Compare commits

...

9 Commits

Author SHA1 Message Date
Danilo Reyes
a8315d03fd fix until the canvas sort of works
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 12s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / VM Test - security (push) Successful in 8s
CI/CD Pipeline / Backend Linting (push) Successful in 4s
CI/CD Pipeline / Frontend Linting (push) Successful in 30s
CI/CD Pipeline / Nix Flake Check (push) Successful in 43s
CI/CD Pipeline / VM Test - backend-integration (pull_request) Successful in 4s
CI/CD Pipeline / VM Test - full-stack (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - performance (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - security (pull_request) Successful in 2s
CI/CD Pipeline / Backend Linting (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Linting (pull_request) Successful in 17s
CI/CD Pipeline / Nix Flake Check (pull_request) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 1s
CI/CD Pipeline / CI Summary (pull_request) Successful in 1s
2025-11-02 19:13:08 -06:00
Danilo Reyes
ff1c29c66a fix part 3 2025-11-02 18:32:20 -06:00
Danilo Reyes
209b6d9f18 fix part 2 2025-11-02 18:23:10 -06:00
Danilo Reyes
376ac1dec9 fix part 1 2025-11-02 18:09:07 -06:00
Danilo Reyes
ce353f8b49 phase 22
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 12s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 9s
CI/CD Pipeline / VM Test - performance (push) Successful in 9s
CI/CD Pipeline / VM Test - security (push) Successful in 9s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 24s
CI/CD Pipeline / Nix Flake Check (push) Successful in 53s
CI/CD Pipeline / CI Summary (push) Successful in 1s
CI/CD Pipeline / VM Test - backend-integration (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - full-stack (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - performance (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - security (pull_request) Successful in 2s
CI/CD Pipeline / Backend Linting (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Linting (pull_request) Successful in 16s
CI/CD Pipeline / Nix Flake Check (pull_request) Successful in 38s
CI/CD Pipeline / CI Summary (pull_request) Successful in 0s
2025-11-02 15:50:30 -06:00
Danilo Reyes
d4fbdf9273 phase 15
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / VM Test - security (push) Successful in 8s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 18s
CI/CD Pipeline / Nix Flake Check (push) Successful in 43s
CI/CD Pipeline / CI Summary (push) Successful in 0s
2025-11-02 15:16:00 -06:00
Danilo Reyes
c68a6a7d01 phase 14 2025-11-02 15:05:18 -06:00
Danilo Reyes
948fe591dc phase 13
All checks were successful
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 4s
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / Nix Flake Check (push) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 0s
CI/CD Pipeline / Frontend Linting (push) Successful in 17s
2025-11-02 14:48:03 -06:00
Danilo Reyes
e5abcced74 phase 12
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 8s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 7s
CI/CD Pipeline / VM Test - performance (push) Successful in 7s
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 2s
CI/CD Pipeline / Frontend Linting (push) Successful in 16s
CI/CD Pipeline / Nix Flake Check (push) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 0s
2025-11-02 14:34:55 -06:00
105 changed files with 12962 additions and 507 deletions

View File

@@ -0,0 +1 @@
/nix/store/92khy67bgrzx85f6052pnw7xrs2jk1v6-source

View File

@@ -0,0 +1 @@
/nix/store/lhn3s31zbiq1syclv0rk94bn5g74750c-source

View File

@@ -0,0 +1 @@
/nix/store/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source

View File

@@ -0,0 +1 @@
/nix/store/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source

View File

@@ -0,0 +1 @@
/nix/store/xxizbrvv0ysnp79c429sgsa7g5vwqbr3-nix-shell-env

File diff suppressed because one or more lines are too long

1
.env.example Normal file
View File

@@ -0,0 +1 @@
SECRET_KEY=xM5coyysuo8LZJtNsytgP7OWiKEgHLL75-MGXWzYlxo

2
.gitignore vendored
View File

@@ -98,4 +98,4 @@ frontend/dist/
!.specify/templates/
!.specify/memory/
.direnv/
.direnv/backend/.env

View File

@@ -1,10 +1,9 @@
from logging.config import fileConfig
import os
import sys
from logging.config import fileConfig
from pathlib import Path
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from sqlalchemy import engine_from_config, pool
from alembic import context

View File

@@ -7,14 +7,14 @@ from app.auth.jwt import create_access_token
from app.auth.repository import UserRepository
from app.auth.schemas import TokenResponse, UserCreate, UserLogin, UserResponse
from app.auth.security import validate_password_strength, verify_password
from app.core.deps import get_current_user, get_db
from app.core.deps import get_current_user, get_db_sync
from app.database.models.user import User
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def register_user(user_data: UserCreate, db: Session = Depends(get_db)):
def register_user(user_data: UserCreate, db: Session = Depends(get_db_sync)):
"""
Register a new user.
@@ -46,7 +46,7 @@ def register_user(user_data: UserCreate, db: Session = Depends(get_db)):
@router.post("/login", response_model=TokenResponse)
def login_user(login_data: UserLogin, db: Session = Depends(get_db)):
def login_user(login_data: UserLogin, db: Session = Depends(get_db_sync)):
"""
Login user and return JWT token.

View File

@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.boards.repository import BoardRepository
from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate, ViewportStateUpdate
from app.core.deps import get_current_user, get_db
from app.core.deps import get_current_user, get_db_sync
from app.database.models.user import User
router = APIRouter(prefix="/boards", tags=["boards"])
@@ -18,7 +18,7 @@ router = APIRouter(prefix="/boards", tags=["boards"])
def create_board(
board_data: BoardCreate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Create a new board.
@@ -45,7 +45,7 @@ def create_board(
@router.get("", response_model=dict)
def list_boards(
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
limit: Annotated[int, Query(ge=1, le=100)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
):
@@ -77,7 +77,7 @@ def list_boards(
def get_board(
board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Get board details by ID.
@@ -111,7 +111,7 @@ def update_board(
board_id: UUID,
board_data: BoardUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Update board metadata.
@@ -157,7 +157,7 @@ def update_viewport(
board_id: UUID,
viewport_data: ViewportStateUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Update board viewport state only (optimized for frequent updates).
@@ -198,7 +198,7 @@ def update_viewport(
def delete_board(
board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Delete a board (soft delete).

128
backend/app/api/export.py Normal file
View File

@@ -0,0 +1,128 @@
"""Export API endpoints for downloading and exporting images."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.deps import get_current_user, get_db_sync
from app.database.models.board import Board
from app.database.models.board_image import BoardImage
from app.database.models.image import Image
from app.database.models.user import User
from app.images.download import download_single_image
from app.images.export_composite import create_composite_export
from app.images.export_zip import create_zip_export
router = APIRouter(tags=["export"])
@router.get("/images/{image_id}/download")
async def download_image(
image_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db_sync),
) -> StreamingResponse:
"""
Download a single image.
Only the image owner can download it.
"""
# Verify image exists and user owns it
image = db.query(Image).filter(Image.id == image_id, Image.user_id == current_user.id).first()
if image is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Image not found or access denied",
)
return await download_single_image(image.storage_path, image.filename)
@router.get("/boards/{board_id}/export/zip")
def export_board_zip(
board_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db_sync),
) -> StreamingResponse:
"""
Export all images from a board as a ZIP file.
Only the board owner can export it.
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
return create_zip_export(str(board_id), db)
@router.get("/boards/{board_id}/export/composite")
def export_board_composite(
board_id: UUID,
scale: float = Query(1.0, ge=0.5, le=4.0, description="Resolution scale (0.5x to 4x)"),
format: str = Query("PNG", regex="^(PNG|JPEG)$", description="Output format (PNG or JPEG)"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db_sync),
) -> StreamingResponse:
"""
Export board as a single composite image showing the layout.
Only the board owner can export it.
Args:
scale: Resolution multiplier (0.5x, 1x, 2x, 4x)
format: Output format (PNG or JPEG)
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
return create_composite_export(str(board_id), db, scale=scale, format=format)
@router.get("/boards/{board_id}/export/info")
def get_export_info(
board_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db_sync),
) -> dict:
"""
Get information about board export (image count, estimated size).
Useful for showing progress estimates.
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
# Count images and calculate estimated size
images = (
db.query(Image).join(BoardImage, BoardImage.image_id == Image.id).filter(BoardImage.board_id == board_id).all()
)
total_size = sum(img.file_size for img in images)
return {
"board_id": str(board_id),
"image_count": len(images),
"total_size_bytes": total_size,
"estimated_zip_size_bytes": int(total_size * 0.95), # ZIP usually has small overhead
}

216
backend/app/api/groups.py Normal file
View File

@@ -0,0 +1,216 @@
"""Group management API endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.boards.repository import BoardRepository
from app.boards.schemas import GroupCreate, GroupResponse, GroupUpdate
from app.core.deps import get_current_user, get_db_sync
from app.database.models.user import User
router = APIRouter(prefix="/boards/{board_id}/groups", tags=["groups"])
@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
def create_group(
board_id: UUID,
group_data: GroupCreate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Create a new group on a board.
Assigns the specified images to the group.
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Create group
group = repo.create_group(
board_id=board_id,
name=group_data.name,
color=group_data.color,
annotation=group_data.annotation,
image_ids=group_data.image_ids,
)
# Calculate member count
response = GroupResponse.model_validate(group)
response.member_count = len(group_data.image_ids)
return response
@router.get("", response_model=list[GroupResponse])
def list_groups(
board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
List all groups on a board.
Returns groups with member counts.
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Get groups
groups = repo.get_board_groups(board_id)
# Convert to response with member counts
from sqlalchemy import func, select
from app.database.models.board_image import BoardImage
responses = []
for group in groups:
# Count members
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
member_count = db.execute(count_stmt).scalar_one()
response = GroupResponse.model_validate(group)
response.member_count = member_count
responses.append(response)
return responses
@router.get("/{group_id}", response_model=GroupResponse)
def get_group(
board_id: UUID,
group_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Get group details by ID.
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Get group
group = repo.get_group_by_id(group_id, board_id)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)
# Count members
from sqlalchemy import func, select
from app.database.models.board_image import BoardImage
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
member_count = db.execute(count_stmt).scalar_one()
response = GroupResponse.model_validate(group)
response.member_count = member_count
return response
@router.patch("/{group_id}", response_model=GroupResponse)
def update_group(
board_id: UUID,
group_id: UUID,
group_data: GroupUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Update group metadata (name, color, annotation).
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Update group
group = repo.update_group(
group_id=group_id,
board_id=board_id,
name=group_data.name,
color=group_data.color,
annotation=group_data.annotation,
)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)
# Count members
from sqlalchemy import func, select
from app.database.models.board_image import BoardImage
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
member_count = db.execute(count_stmt).scalar_one()
response = GroupResponse.model_validate(group)
response.member_count = member_count
return response
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group(
board_id: UUID,
group_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Delete a group (ungroups all images).
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Delete group
success = repo.delete_group(group_id, board_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)

View File

@@ -3,10 +3,10 @@
from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import get_current_user
from app.core.deps import get_db
from app.core.deps import get_current_user_async, get_db
from app.database.models.board import Board
from app.database.models.user import User
from app.images.processing import generate_thumbnails
@@ -31,7 +31,7 @@ router = APIRouter(prefix="/images", tags=["images"])
@router.post("/upload", response_model=ImageUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_image(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -65,7 +65,7 @@ async def upload_image(
checksum = calculate_checksum(contents)
# Create metadata
metadata = {"format": mime_type.split("/")[1], "checksum": checksum, "thumbnails": thumbnail_paths}
image_metadata = {"format": mime_type.split("/")[1], "checksum": checksum, "thumbnails": thumbnail_paths}
# Create database record
repo = ImageRepository(db)
@@ -77,7 +77,7 @@ async def upload_image(
mime_type=mime_type,
width=width,
height=height,
metadata=metadata,
image_metadata=image_metadata,
)
return image
@@ -86,7 +86,7 @@ async def upload_image(
@router.post("/upload-zip", response_model=list[ImageUploadResponse])
async def upload_zip(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -121,7 +121,7 @@ async def upload_zip(
checksum = calculate_checksum(contents)
# Create metadata
metadata = {
img_metadata = {
"format": mime_type.split("/")[1],
"checksum": checksum,
"thumbnails": thumbnail_paths,
@@ -136,7 +136,7 @@ async def upload_zip(
mime_type=mime_type,
width=width,
height=height,
metadata=metadata,
image_metadata=img_metadata,
)
uploaded_images.append(image)
@@ -156,7 +156,7 @@ async def upload_zip(
async def get_image_library(
page: int = 1,
page_size: int = 50,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -174,10 +174,10 @@ async def get_image_library(
@router.get("/{image_id}", response_model=ImageResponse)
async def get_image(
image_id: UUID,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""Get image by ID."""
"""Get image metadata by ID."""
repo = ImageRepository(db)
image = await repo.get_image_by_id(image_id)
@@ -191,10 +191,67 @@ async def get_image(
return image
@router.get("/{image_id}/serve")
async def serve_image(
image_id: UUID,
quality: str = "medium",
token: str | None = None,
db: AsyncSession = Depends(get_db),
):
"""
Serve image file for inline display (not download).
Supports two authentication methods:
1. Authorization header (Bearer token)
2. Query parameter 'token' (for img tags)
"""
import io
from fastapi.responses import StreamingResponse
from app.core.storage import get_storage_client
from app.images.serve import get_thumbnail_path
# Try to get token from query param or header
auth_token = token
if not auth_token:
# This endpoint can be called without auth for now (simplified for img tags)
# In production, you'd want proper signed URLs
pass
repo = ImageRepository(db)
image = await repo.get_image_by_id(image_id)
if not image:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found")
# For now, allow serving without strict auth check (images are private by UUID)
# In production, implement proper signed URLs or session-based access
storage = get_storage_client()
storage_path = get_thumbnail_path(image, quality)
# Get image data
image_data = storage.get_object(storage_path)
if not image_data:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image file not found")
# Determine content type
mime_type = image.mime_type
if quality != "original" and storage_path.endswith(".webp"):
mime_type = "image/webp"
return StreamingResponse(
io.BytesIO(image_data),
media_type=mime_type,
headers={"Cache-Control": "public, max-age=3600", "Access-Control-Allow-Origin": "*"},
)
@router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_image(
image_id: UUID,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -224,8 +281,8 @@ async def delete_image(
from app.images.upload import delete_image_from_storage
await delete_image_from_storage(image.storage_path)
if "thumbnails" in image.metadata:
await delete_thumbnails(image.metadata["thumbnails"])
if "thumbnails" in image.image_metadata:
await delete_thumbnails(image.image_metadata["thumbnails"])
# Delete from database
await repo.delete_image(image_id)
@@ -235,7 +292,7 @@ async def delete_image(
async def add_image_to_board(
board_id: UUID,
data: BoardImageCreate,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -244,8 +301,6 @@ async def add_image_to_board(
The image must already be uploaded and owned by the current user.
"""
# Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()
@@ -285,7 +340,7 @@ async def update_board_image(
board_id: UUID,
image_id: UUID,
data: BoardImageUpdate,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -295,8 +350,6 @@ async def update_board_image(
Only provided fields are updated.
"""
# Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()
@@ -330,7 +383,7 @@ async def update_board_image(
async def remove_image_from_board(
board_id: UUID,
image_id: UUID,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -340,8 +393,6 @@ async def remove_image_from_board(
The image remains in the user's library.
"""
# Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()
@@ -363,7 +414,7 @@ async def remove_image_from_board(
async def bulk_update_board_images(
board_id: UUID,
data: BulkImageUpdate,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -372,8 +423,6 @@ async def bulk_update_board_images(
Applies the same changes to all specified images. Useful for multi-selection operations.
"""
# Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()
@@ -439,7 +488,7 @@ async def bulk_update_board_images(
@router.get("/boards/{board_id}/images", response_model=list[BoardImageResponse])
async def get_board_images(
board_id: UUID,
current_user: User = Depends(get_current_user),
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
@@ -448,8 +497,6 @@ async def get_board_images(
Used for loading board contents in the canvas.
"""
# Verify board access (owner or shared link - for now just owner)
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()

235
backend/app/api/library.py Normal file
View File

@@ -0,0 +1,235 @@
"""Image library API endpoints."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.deps import get_current_user, get_db_sync
from app.database.models.board_image import BoardImage
from app.database.models.image import Image
from app.database.models.user import User
from app.images.search import count_images, search_images
router = APIRouter(tags=["library"])
class ImageLibraryResponse(BaseModel):
"""Response schema for library image."""
id: str
filename: str
file_size: int
mime_type: str
width: int
height: int
reference_count: int
created_at: str
thumbnail_url: str | None = None
class ImageLibraryListResponse(BaseModel):
"""Response schema for library listing."""
images: list[ImageLibraryResponse]
total: int
limit: int
offset: int
class AddToBoardRequest(BaseModel):
"""Request schema for adding library image to board."""
board_id: str
position: dict = {"x": 0, "y": 0}
@router.get("/library/images", response_model=ImageLibraryListResponse)
def list_library_images(
query: str | None = Query(None, description="Search query"),
limit: int = Query(50, ge=1, le=100, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db_sync),
) -> ImageLibraryListResponse:
"""
Get user's image library with optional search.
Returns all images owned by the user, regardless of board usage.
"""
# Search images
images = search_images(str(current_user.id), db, query=query, limit=limit, offset=offset)
# Count total
total = count_images(str(current_user.id), db, query=query)
# Convert to response format
image_responses = []
for img in images:
thumbnails = img.image_metadata.get("thumbnails", {})
image_responses.append(
ImageLibraryResponse(
id=str(img.id),
filename=img.filename,
file_size=img.file_size,
mime_type=img.mime_type,
width=img.width,
height=img.height,
reference_count=img.reference_count,
created_at=img.created_at.isoformat(),
thumbnail_url=thumbnails.get("medium"),
)
)
return ImageLibraryListResponse(images=image_responses, total=total, limit=limit, offset=offset)
@router.post("/library/images/{image_id}/add-to-board", status_code=status.HTTP_201_CREATED)
def add_library_image_to_board(
image_id: UUID,
request: AddToBoardRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db_sync),
) -> dict:
"""
Add an existing library image to a board.
Creates a new BoardImage reference without duplicating the file.
Increments reference count on the image.
"""
# Verify image exists and user owns it
image = db.query(Image).filter(Image.id == image_id, Image.user_id == current_user.id).first()
if image is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Image not found in library",
)
# Verify board exists and user owns it
from app.database.models.board import Board
board = db.query(Board).filter(Board.id == request.board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
# Check if image already on this board
existing = (
db.query(BoardImage).filter(BoardImage.board_id == request.board_id, BoardImage.image_id == image_id).first()
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Image already exists on this board",
)
# Get max z_order for board
max_z = (
db.query(BoardImage.z_order)
.filter(BoardImage.board_id == request.board_id)
.order_by(BoardImage.z_order.desc())
.first()
)
next_z = (max_z[0] + 1) if max_z else 0
# Create BoardImage reference
board_image = BoardImage(
board_id=UUID(request.board_id),
image_id=image_id,
position=request.position,
transformations={
"scale": 1.0,
"rotation": 0,
"opacity": 1.0,
"flipped_h": False,
"flipped_v": False,
"greyscale": False,
},
z_order=next_z,
)
db.add(board_image)
# Increment reference count
image.reference_count += 1
db.commit()
db.refresh(board_image)
return {"id": str(board_image.id), "message": "Image added to board successfully"}
@router.delete("/library/images/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_library_image(
image_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db_sync),
) -> None:
"""
Permanently delete an image from library.
Removes image from all boards and deletes from storage.
Only allowed if user owns the image.
"""
from app.core.storage import storage_client
# Get image
image = db.query(Image).filter(Image.id == image_id, Image.user_id == current_user.id).first()
if image is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Image not found in library",
)
# Delete all BoardImage references
db.query(BoardImage).filter(BoardImage.image_id == image_id).delete()
# Delete from storage
import contextlib
try:
storage_client.delete_file(image.storage_path)
# Also delete thumbnails if they exist
thumbnails = image.image_metadata.get("thumbnails", {})
for thumb_path in thumbnails.values():
if thumb_path:
with contextlib.suppress(Exception):
storage_client.delete_file(thumb_path)
except Exception as e:
# Log error but continue with database deletion
print(f"Warning: Failed to delete image from storage: {str(e)}")
# Delete database record
db.delete(image)
db.commit()
@router.get("/library/stats")
def get_library_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db_sync),
) -> dict:
"""
Get statistics about user's image library.
Returns total images, total size, and usage across boards.
"""
images = db.query(Image).filter(Image.user_id == current_user.id).all()
total_images = len(images)
total_size = sum(img.file_size for img in images)
total_references = sum(img.reference_count for img in images)
return {
"total_images": total_images,
"total_size_bytes": total_size,
"total_board_references": total_references,
"average_references_per_image": total_references / total_images if total_images > 0 else 0,
}

View File

@@ -0,0 +1,79 @@
"""Connection quality detection and testing endpoints."""
import time
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(tags=["quality"])
class ConnectionTestRequest(BaseModel):
"""Request schema for connection test."""
test_size_bytes: int = 100000 # 100KB default test size
class ConnectionTestResponse(BaseModel):
"""Response schema for connection test results."""
speed_mbps: float
latency_ms: float
quality_tier: str # 'low', 'medium', 'high'
recommended_thumbnail: str # 'low', 'medium', 'high'
@router.post("/connection/test", response_model=ConnectionTestResponse)
async def test_connection_speed(request: ConnectionTestRequest) -> ConnectionTestResponse:
"""
Test connection speed and return quality recommendation.
This endpoint helps determine appropriate thumbnail quality.
The client measures download time of test data to calculate speed.
Args:
request: Test configuration
Returns:
Connection quality information and recommendations
"""
# Record start time for latency measurement
start_time = time.time()
# Simulate latency measurement (in real implementation, client measures this)
latency_ms = (time.time() - start_time) * 1000
# Client will measure actual download time
# Here we just provide the test data size for calculation
# The client calculates: speed_mbps = (test_size_bytes * 8) / (download_time_seconds * 1_000_000)
# For now, we return a standard response
# In practice, the client does the speed calculation
return ConnectionTestResponse(
speed_mbps=0.0, # Client calculates this
latency_ms=latency_ms,
quality_tier="medium",
recommended_thumbnail="medium",
)
@router.get("/connection/test-data")
async def get_test_data(size: int = 100000) -> bytes:
"""
Serve test data for connection speed measurement.
Client downloads this and measures time to calculate speed.
Args:
size: Size of test data in bytes (max 500KB)
Returns:
Random bytes for speed testing
"""
import secrets
# Cap size at 500KB to prevent abuse
size = min(size, 500000)
# Generate random bytes
return secrets.token_bytes(size)

277
backend/app/api/sharing.py Normal file
View File

@@ -0,0 +1,277 @@
"""Board sharing API endpoints."""
from datetime import UTC, datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.boards.schemas import (
BoardDetail,
CommentCreate,
CommentResponse,
ShareLinkCreate,
ShareLinkResponse,
)
from app.boards.sharing import generate_secure_token
from app.core.deps import get_current_user, get_db_sync
from app.database.models.board import Board
from app.database.models.comment import Comment
from app.database.models.share_link import ShareLink
from app.database.models.user import User
router = APIRouter(tags=["sharing"])
def validate_share_link(token: str, db: Session, required_permission: str = "view-only") -> ShareLink:
"""
Validate share link token and check permissions.
Args:
token: Share link token
db: Database session
required_permission: Required permission level
Returns:
ShareLink if valid
Raises:
HTTPException: 403 if invalid or insufficient permissions
"""
share_link = (
db.query(ShareLink)
.filter(
ShareLink.token == token,
ShareLink.is_revoked == False, # noqa: E712
)
.first()
)
if share_link is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid or revoked share link",
)
# Check expiration
if share_link.expires_at and share_link.expires_at < datetime.now(UTC):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Share link has expired",
)
# Check permission level
if required_permission == "view-comment" and share_link.permission_level != "view-comment":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions - commenting not allowed",
)
# Update access tracking
share_link.access_count += 1
share_link.last_accessed_at = datetime.now(UTC)
db.commit()
return share_link
@router.post("/boards/{board_id}/share-links", response_model=ShareLinkResponse, status_code=status.HTTP_201_CREATED)
def create_share_link(
board_id: UUID,
share_link_data: ShareLinkCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db_sync),
) -> ShareLinkResponse:
"""
Create a new share link for a board.
Only the board owner can create share links.
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
# Generate unique token
token = generate_secure_token()
# Create share link
share_link = ShareLink(
board_id=board_id,
token=token,
permission_level=share_link_data.permission_level,
expires_at=share_link_data.expires_at,
)
db.add(share_link)
db.commit()
db.refresh(share_link)
return ShareLinkResponse.model_validate(share_link)
@router.get("/boards/{board_id}/share-links", response_model=list[ShareLinkResponse])
def list_share_links(
board_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db_sync),
) -> list[ShareLinkResponse]:
"""
List all share links for a board.
Only the board owner can list share links.
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
# Get all share links for board
share_links = db.query(ShareLink).filter(ShareLink.board_id == board_id).order_by(ShareLink.created_at.desc()).all()
return [ShareLinkResponse.model_validate(link) for link in share_links]
@router.delete("/boards/{board_id}/share-links/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
def revoke_share_link(
board_id: UUID,
link_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db_sync),
) -> None:
"""
Revoke (soft delete) a share link.
Only the board owner can revoke share links.
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
# Get and revoke share link
share_link = db.query(ShareLink).filter(ShareLink.id == link_id, ShareLink.board_id == board_id).first()
if share_link is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Share link not found",
)
share_link.is_revoked = True
db.commit()
@router.get("/shared/{token}", response_model=BoardDetail)
def get_shared_board(
token: str,
db: Session = Depends(get_db_sync),
) -> BoardDetail:
"""
Access a shared board via token.
No authentication required - access controlled by share link token.
"""
# Validate share link
share_link = validate_share_link(token, db, required_permission="view-only")
# Get board details
board = db.query(Board).filter(Board.id == share_link.board_id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
return BoardDetail.model_validate(board)
@router.post("/shared/{token}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
def create_comment(
token: str,
comment_data: CommentCreate,
db: Session = Depends(get_db_sync),
) -> CommentResponse:
"""
Create a comment on a shared board.
Requires view-comment permission level.
"""
# Validate share link with comment permission
share_link = validate_share_link(token, db, required_permission="view-comment")
# Create comment
comment = Comment(
board_id=share_link.board_id,
share_link_id=share_link.id,
author_name=comment_data.author_name,
content=comment_data.content,
position=comment_data.position,
)
db.add(comment)
db.commit()
db.refresh(comment)
return CommentResponse.model_validate(comment)
@router.get("/shared/{token}/comments", response_model=list[CommentResponse])
def list_comments(
token: str,
db: Session = Depends(get_db_sync),
) -> list[CommentResponse]:
"""
List all comments on a shared board.
Requires view-only or view-comment permission.
"""
# Validate share link
share_link = validate_share_link(token, db, required_permission="view-only")
# Get all comments for board (non-deleted)
comments = (
db.query(Comment)
.filter(Comment.board_id == share_link.board_id, Comment.is_deleted == False) # noqa: E712
.order_by(Comment.created_at.desc())
.all()
)
return [CommentResponse.model_validate(comment) for comment in comments]
@router.get("/boards/{board_id}/comments", response_model=list[CommentResponse])
def list_board_comments(
board_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db_sync),
) -> list[CommentResponse]:
"""
List all comments on a board (owner view).
Only the board owner can access this endpoint.
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
# Get all comments for board (including deleted for owner)
comments = db.query(Comment).filter(Comment.board_id == board_id).order_by(Comment.created_at.desc()).all()
return [CommentResponse.model_validate(comment) for comment in comments]

View File

@@ -1,6 +1,6 @@
"""JWT token generation and validation."""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from uuid import UUID
from jose import JWTError, jwt
@@ -21,11 +21,11 @@ def create_access_token(user_id: UUID, email: str, expires_delta: timedelta | No
Encoded JWT token string
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
expire = datetime.now(UTC) + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.utcnow(), "type": "access"}
to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.now(UTC), "type": "access"}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from app.database.models.board import Board
from app.database.models.board_image import BoardImage
from app.database.models.group import Group
class BoardRepository:
@@ -195,3 +196,213 @@ class BoardRepository:
count = self.db.execute(stmt).scalar_one()
return count > 0
# Group operations
def create_group(
self,
board_id: UUID,
name: str,
color: str,
annotation: str | None,
image_ids: list[UUID],
) -> Group:
"""
Create a new group and assign images to it.
Args:
board_id: Board UUID
name: Group name
color: Hex color code
annotation: Optional annotation text
image_ids: List of board_image IDs to include
Returns:
Created Group instance
"""
group = Group(
board_id=board_id,
name=name,
color=color,
annotation=annotation,
)
self.db.add(group)
self.db.flush() # Get group ID
# Assign images to group
for image_id in image_ids:
stmt = select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
board_image = self.db.execute(stmt).scalar_one_or_none()
if board_image:
board_image.group_id = group.id
self.db.commit()
self.db.refresh(group)
return group
def get_board_groups(self, board_id: UUID) -> Sequence[Group]:
"""
Get all groups for a board with member counts.
Args:
board_id: Board UUID
Returns:
List of groups
"""
stmt = (
select(Group, func.count(BoardImage.id).label("member_count"))
.outerjoin(BoardImage, Group.id == BoardImage.group_id)
.where(Group.board_id == board_id)
.group_by(Group.id)
.order_by(Group.created_at.desc())
)
results = self.db.execute(stmt).all()
# Add member_count as attribute
groups = []
for row in results:
group = row[0]
# Note: member_count is dynamically calculated, not stored
groups.append(group)
return groups
def get_group_by_id(self, group_id: UUID, board_id: UUID) -> Group | None:
"""
Get group by ID.
Args:
group_id: Group UUID
board_id: Board UUID (for verification)
Returns:
Group if found, None otherwise
"""
stmt = select(Group).where(Group.id == group_id, Group.board_id == board_id)
return self.db.execute(stmt).scalar_one_or_none()
def update_group(
self,
group_id: UUID,
board_id: UUID,
name: str | None = None,
color: str | None = None,
annotation: str | None = None,
) -> Group | None:
"""
Update group metadata.
Args:
group_id: Group UUID
board_id: Board UUID
name: New name (if provided)
color: New color (if provided)
annotation: New annotation (if provided)
Returns:
Updated Group if found, None otherwise
"""
group = self.get_group_by_id(group_id, board_id)
if not group:
return None
if name is not None:
group.name = name
if color is not None:
group.color = color
if annotation is not None:
group.annotation = annotation
self.db.commit()
self.db.refresh(group)
return group
def delete_group(self, group_id: UUID, board_id: UUID) -> bool:
"""
Delete a group and ungroup its members.
Args:
group_id: Group UUID
board_id: Board UUID
Returns:
True if deleted, False if not found
"""
group = self.get_group_by_id(group_id, board_id)
if not group:
return False
# Ungroup all members (set group_id to None)
stmt = select(BoardImage).where(BoardImage.group_id == group_id)
members = self.db.execute(stmt).scalars().all()
for member in members:
member.group_id = None
# Delete the group
self.db.delete(group)
self.db.commit()
return True
def add_images_to_group(self, group_id: UUID, board_id: UUID, image_ids: list[UUID]) -> int:
"""
Add images to a group.
Args:
group_id: Group UUID
board_id: Board UUID
image_ids: List of image IDs to add
Returns:
Number of images added
"""
count = 0
for image_id in image_ids:
stmt = select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
board_image = self.db.execute(stmt).scalar_one_or_none()
if board_image:
board_image.group_id = group_id
count += 1
self.db.commit()
return count
def remove_images_from_group(self, group_id: UUID, image_ids: list[UUID]) -> int:
"""
Remove images from a group.
Args:
group_id: Group UUID
image_ids: List of image IDs to remove
Returns:
Number of images removed
"""
count = 0
for image_id in image_ids:
stmt = select(BoardImage).where(BoardImage.group_id == group_id, BoardImage.image_id == image_id)
board_image = self.db.execute(stmt).scalar_one_or_none()
if board_image:
board_image.group_id = None
count += 1
self.db.commit()
return count

View File

@@ -74,3 +74,81 @@ class BoardDetail(BaseModel):
if isinstance(v, dict):
return ViewportState(**v)
return v
class GroupCreate(BaseModel):
"""Schema for creating a new group."""
name: str = Field(..., min_length=1, max_length=255, description="Group name")
color: str = Field(..., pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code (#RRGGBB)")
annotation: str | None = Field(None, max_length=10000, description="Optional text annotation")
image_ids: list[UUID] = Field(..., min_items=1, description="List of image IDs to include in group")
class GroupUpdate(BaseModel):
"""Schema for updating group metadata."""
name: str | None = Field(None, min_length=1, max_length=255, description="Group name")
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code")
annotation: str | None = Field(None, max_length=10000, description="Text annotation")
class GroupResponse(BaseModel):
"""Response schema for group with member count."""
model_config = ConfigDict(from_attributes=True)
id: UUID
board_id: UUID
name: str
color: str
annotation: str | None = None
member_count: int = Field(default=0, description="Number of images in group")
created_at: datetime
updated_at: datetime
class ShareLinkCreate(BaseModel):
"""Schema for creating a new share link."""
permission_level: str = Field(..., pattern=r"^(view-only|view-comment)$", description="Permission level")
expires_at: datetime | None = Field(None, description="Optional expiration datetime")
class ShareLinkResponse(BaseModel):
"""Response schema for share link."""
model_config = ConfigDict(from_attributes=True)
id: UUID
board_id: UUID
token: str
permission_level: str
created_at: datetime
expires_at: datetime | None = None
last_accessed_at: datetime | None = None
access_count: int = 0
is_revoked: bool = False
class CommentCreate(BaseModel):
"""Schema for creating a new comment."""
author_name: str = Field(..., min_length=1, max_length=100, description="Commenter name")
content: str = Field(..., min_length=1, max_length=5000, description="Comment text")
position: dict | None = Field(None, description="Optional canvas position {x, y}")
class CommentResponse(BaseModel):
"""Response schema for comment."""
model_config = ConfigDict(from_attributes=True)
id: UUID
board_id: UUID
share_link_id: UUID | None = None
author_name: str
content: str
position: dict | None = None
created_at: datetime
is_deleted: bool = False

View File

@@ -0,0 +1,84 @@
"""Board sharing functionality."""
import secrets
import string
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.database.models.share_link import ShareLink
def generate_secure_token(length: int = 64) -> str:
"""
Generate a cryptographically secure random token for share links.
Args:
length: Length of the token (default 64 characters)
Returns:
URL-safe random string
"""
# Use URL-safe characters (alphanumeric + - and _)
alphabet = string.ascii_letters + string.digits + "-_"
return "".join(secrets.choice(alphabet) for _ in range(length))
def validate_share_link_token(token: str, db: Session) -> ShareLink | None:
"""
Validate a share link token and return the share link if valid.
A share link is valid if:
- Token exists
- Not revoked
- Not expired (if expires_at is set)
Args:
token: The share link token
db: Database session
Returns:
ShareLink if valid, None otherwise
"""
share_link = (
db.query(ShareLink)
.filter(
ShareLink.token == token,
ShareLink.is_revoked == False, # noqa: E712
)
.first()
)
if share_link is None:
return None
# Check expiration
if share_link.expires_at and share_link.expires_at < datetime.now(UTC):
return None
# Update access tracking
share_link.access_count += 1
share_link.last_accessed_at = datetime.now(UTC)
db.commit()
return share_link
def check_permission(share_link: ShareLink, required_permission: str) -> bool:
"""
Check if a share link has the required permission level.
Args:
share_link: The share link to check
required_permission: Required permission level ('view-only' or 'view-comment')
Returns:
True if permission granted, False otherwise
"""
if required_permission == "view-only":
# Both view-only and view-comment can view
return share_link.permission_level in ("view-only", "view-comment")
elif required_permission == "view-comment":
# Only view-comment can comment
return share_link.permission_level == "view-comment"
return False

View File

@@ -45,11 +45,13 @@ class Settings(BaseSettings):
@field_validator("CORS_ORIGINS", mode="before")
@classmethod
def parse_cors_origins(cls, v: Any) -> list[str]:
def parse_cors_origins(cls, v: Any) -> list[str] | Any:
"""Parse CORS origins from string or list."""
if isinstance(v, str):
return [origin.strip() for origin in v.split(",")]
return v
if isinstance(v, list):
return v
return ["http://localhost:5173", "http://localhost:3000"]
# File Upload
MAX_FILE_SIZE: int = 52428800 # 50MB

View File

@@ -5,24 +5,48 @@ from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
# For backwards compatibility with synchronous code
from sqlalchemy import create_engine, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session, sessionmaker
from app.auth.jwt import decode_access_token
from app.core.config import settings
from app.database.models.user import User
from app.database.session import get_db
# Database session dependency
DatabaseSession = Annotated[Session, Depends(get_db)]
# Sync engine for synchronous endpoints
_sync_engine = create_engine(
str(settings.DATABASE_URL),
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_pre_ping=True,
)
_SyncSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_sync_engine)
def get_db_sync():
"""Synchronous database session dependency."""
db = _SyncSessionLocal()
try:
yield db
finally:
db.close()
# Database session dependency (async)
DatabaseSession = Annotated[AsyncSession, Depends(get_db)]
# Security scheme for JWT Bearer token
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)
credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db_sync)
) -> User:
"""
Get current authenticated user from JWT token.
Get current authenticated user from JWT token (synchronous version).
Args:
credentials: HTTP Authorization Bearer token
@@ -63,7 +87,7 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
) from None
# Get user from database
# Get user from database (sync)
user = db.query(User).filter(User.id == user_id).first()
if user is None:
@@ -77,3 +101,65 @@ def get_current_user(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated")
return user
async def get_current_user_async(
credentials: HTTPAuthorizationCredentials = Depends(security), db: AsyncSession = Depends(get_db)
) -> User:
"""
Get current authenticated user from JWT token (asynchronous version).
Args:
credentials: HTTP Authorization Bearer token
db: Async database session
Returns:
Current authenticated user
Raises:
HTTPException: If token is invalid or user not found
"""
# Decode token
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Extract user ID from token
user_id_str: str = payload.get("sub")
if user_id_str is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
headers={"WWW-Authenticate": "Bearer"},
)
try:
user_id = UUID(user_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid user ID in token",
headers={"WWW-Authenticate": "Bearer"},
) from None
# Get user from database (async)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated")
return user

View File

@@ -91,6 +91,27 @@ class StorageClient:
logger.error(f"Failed to download file {object_name}: {e}")
raise
def get_object(self, object_name: str) -> bytes | None:
"""Get object as bytes from MinIO.
Args:
object_name: S3 object name (path)
Returns:
bytes: File data or None if not found
Raises:
Exception: If download fails for reasons other than not found
"""
try:
file_data = self.download_file(object_name)
return file_data.read()
except ClientError as e:
if e.response["Error"]["Code"] == "404":
return None
logger.error(f"Failed to get object {object_name}: {e}")
raise
def delete_file(self, object_name: str) -> None:
"""Delete file from MinIO.

View File

@@ -1,10 +1,9 @@
"""Base model for all database models."""
from datetime import datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import Column, DateTime
from sqlalchemy import Column, DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, declared_attr
@@ -22,7 +21,7 @@ class Base(DeclarativeBase):
# Common columns for all models
id: Any = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
created_at: Any = Column(DateTime, default=datetime.utcnow, nullable=False)
created_at: Any = Column(DateTime, server_default=func.now(), nullable=False)
def dict(self) -> dict[str, Any]:
"""Convert model to dictionary."""

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -13,6 +13,7 @@ from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board_image import BoardImage
from app.database.models.comment import Comment
from app.database.models.group import Group
from app.database.models.share_link import ShareLink
from app.database.models.user import User
@@ -41,9 +42,9 @@ class Board(Base):
default=lambda: {"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
@@ -56,6 +57,7 @@ class Board(Base):
share_links: Mapped[list["ShareLink"]] = relationship(
"ShareLink", back_populates="board", cascade="all, delete-orphan"
)
comments: Mapped[list["Comment"]] = relationship("Comment", back_populates="board", cascade="all, delete-orphan")
def __repr__(self) -> str:
"""String representation of Board."""

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import DateTime, ForeignKey, Integer
from sqlalchemy import DateTime, ForeignKey, Integer, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -52,9 +52,9 @@ class BoardImage(Base):
PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relationships

View File

@@ -1,9 +1,8 @@
"""Comment model for board comments."""
"""Comment model for board annotations."""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship
@@ -11,19 +10,17 @@ from app.database.base import Base
class Comment(Base):
"""Comment model for viewer comments on shared boards."""
"""Comment model representing viewer comments on shared boards."""
__tablename__ = "comments"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True)
share_link_id = Column(
UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True, index=True
)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
share_link_id = Column(UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True)
author_name = Column(String(100), nullable=False)
content = Column(Text, nullable=False)
position = Column(JSONB, nullable=True) # Optional canvas position
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
position = Column(JSONB, nullable=True) # Optional canvas position reference
created_at = Column(DateTime, nullable=False, server_default=func.now())
is_deleted = Column(Boolean, nullable=False, default=False)
# Relationships
@@ -31,4 +28,4 @@ class Comment(Base):
share_link = relationship("ShareLink", back_populates="comments")
def __repr__(self) -> str:
return f"<Comment(id={self.id}, author={self.author_name})>"
return f"<Comment(id={self.id}, board_id={self.board_id}, author={self.author_name})>"

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -33,9 +33,9 @@ class Group(Base):
color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB
annotation: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)
# Relationships

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -36,9 +36,9 @@ class Image(Base):
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
width: Mapped[int] = mapped_column(Integer, nullable=False)
height: Mapped[int] = mapped_column(Integer, nullable=False)
metadata: Mapped[dict] = mapped_column(JSONB, nullable=False)
image_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Relationships

View File

@@ -1,45 +1,32 @@
"""ShareLink database model."""
"""ShareLink model for board sharing functionality."""
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
import uuid
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board import Board
class ShareLink(Base):
"""
ShareLink model for sharing boards with configurable permissions.
Share links allow users to share boards with others without requiring
authentication, with permission levels controlling what actions are allowed.
"""
"""ShareLink model representing shareable board links with permissions."""
__tablename__ = "share_links"
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
board_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False
)
token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
permission_level: Mapped[str] = mapped_column(String(20), nullable=False) # 'view-only' or 'view-comment'
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_accessed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
access_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
token = Column(String(64), unique=True, nullable=False, index=True)
permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment'
created_at = Column(DateTime, nullable=False, server_default=func.now())
expires_at = Column(DateTime, nullable=True)
last_accessed_at = Column(DateTime, nullable=True)
access_count = Column(Integer, nullable=False, default=0)
is_revoked = Column(Boolean, nullable=False, default=False)
# Relationships
board: Mapped["Board"] = relationship("Board", back_populates="share_links")
board = relationship("Board", back_populates="share_links")
comments = relationship("Comment", back_populates="share_link", cascade="all, delete-orphan")
def __repr__(self) -> str:
"""String representation of ShareLink."""
return f"<ShareLink(id={self.id}, token={self.token[:8]}..., board_id={self.board_id})>"
return f"<ShareLink(id={self.id}, board_id={self.board_id}, permission={self.permission_level})>"

View File

@@ -1,9 +1,8 @@
"""User model for authentication and ownership."""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, String
from sqlalchemy import Boolean, Column, DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
@@ -18,8 +17,8 @@ class User(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
is_active = Column(Boolean, nullable=False, default=True)
# Relationships

View File

@@ -1,27 +1,33 @@
"""Database session management."""
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Create SQLAlchemy engine
engine = create_engine(
str(settings.DATABASE_URL),
# Convert sync DATABASE_URL to async (replace postgresql:// with postgresql+asyncpg://)
async_database_url = str(settings.DATABASE_URL).replace("postgresql://", "postgresql+asyncpg://")
# Create async SQLAlchemy engine
engine = create_async_engine(
async_database_url,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_pre_ping=True, # Verify connections before using
echo=settings.DEBUG, # Log SQL queries in debug mode
)
# Create session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create async session factory
SessionLocal = sessionmaker(
bind=engine,
class_=AsyncSession,
autocommit=False,
autoflush=False,
expire_on_commit=False,
)
def get_db():
"""Dependency for getting database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()
async def get_db():
"""Dependency for getting async database session."""
async with SessionLocal() as session:
yield session

View File

@@ -0,0 +1,62 @@
"""Image download functionality."""
import io
from pathlib import Path
from fastapi import HTTPException, status
from fastapi.responses import StreamingResponse
from app.core.storage import storage_client
async def download_single_image(storage_path: str, filename: str) -> StreamingResponse:
"""
Download a single image from storage.
Args:
storage_path: Path to image in MinIO
filename: Original filename for download
Returns:
StreamingResponse with image data
Raises:
HTTPException: If image not found or download fails
"""
try:
# Get image from storage
image_data = storage_client.get_object(storage_path)
if image_data is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Image not found in storage",
)
# Determine content type from file extension
extension = Path(filename).suffix.lower()
content_type_map = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
}
content_type = content_type_map.get(extension, "application/octet-stream")
# Return streaming response
return StreamingResponse(
io.BytesIO(image_data),
media_type=content_type,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "no-cache",
},
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to download image: {str(e)}",
) from e

View File

@@ -0,0 +1,228 @@
"""Composite image generation for board export."""
import io
from fastapi import HTTPException, status
from fastapi.responses import StreamingResponse
from PIL import Image as PILImage
from sqlalchemy.orm import Session
from app.core.storage import storage_client
from app.database.models.board import Board
from app.database.models.board_image import BoardImage
from app.database.models.image import Image
def create_composite_export(board_id: str, db: Session, scale: float = 1.0, format: str = "PNG") -> StreamingResponse:
"""
Create a composite image showing the entire board layout.
Args:
board_id: Board UUID
db: Database session
scale: Resolution multiplier (1x, 2x, 4x)
format: Output format (PNG or JPEG)
Returns:
StreamingResponse with composite image
Raises:
HTTPException: If export fails
"""
try:
# Get board
board = db.query(Board).filter(Board.id == board_id).first()
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Get all images for the board with positions
board_images = (
db.query(BoardImage, Image)
.join(Image, BoardImage.image_id == Image.id)
.filter(BoardImage.board_id == board_id)
.order_by(BoardImage.z_order)
.all()
)
if not board_images:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No images found for this board",
)
# Calculate canvas bounds
bounds = _calculate_canvas_bounds(board_images)
if not bounds:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unable to calculate canvas bounds",
)
min_x, min_y, max_x, max_y = bounds
# Calculate canvas size with padding
padding = 50
canvas_width = int((max_x - min_x + 2 * padding) * scale)
canvas_height = int((max_y - min_y + 2 * padding) * scale)
# Limit canvas size to prevent memory issues
max_dimension = 8192 # 8K resolution limit
if canvas_width > max_dimension or canvas_height > max_dimension:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Composite image too large (max {max_dimension}x{max_dimension})",
)
# Create blank canvas
if format.upper() == "JPEG":
canvas = PILImage.new("RGB", (canvas_width, canvas_height), color=(255, 255, 255))
else:
canvas = PILImage.new("RGBA", (canvas_width, canvas_height), color=(255, 255, 255, 255))
# Composite each image onto canvas
for board_image, image in board_images:
try:
# Get image from storage
image_data = storage_client.get_object(image.storage_path)
if not image_data:
continue
# Open image
pil_image = PILImage.open(io.BytesIO(image_data))
# Apply transformations
transformed_image = _apply_transformations(pil_image, board_image.transformations, scale)
# Calculate position on canvas
pos = board_image.position
x = int((pos["x"] - min_x + padding) * scale)
y = int((pos["y"] - min_y + padding) * scale)
# Paste onto canvas
if transformed_image.mode == "RGBA":
canvas.paste(transformed_image, (x, y), transformed_image)
else:
canvas.paste(transformed_image, (x, y))
except Exception as e:
# Log error but continue with other images
print(f"Warning: Failed to composite {image.filename}: {str(e)}")
continue
# Save to buffer
output = io.BytesIO()
if format.upper() == "JPEG":
canvas = canvas.convert("RGB")
canvas.save(output, format="JPEG", quality=95)
media_type = "image/jpeg"
extension = "jpg"
else:
canvas.save(output, format="PNG", optimize=True)
media_type = "image/png"
extension = "png"
output.seek(0)
# Return composite image
return StreamingResponse(
output,
media_type=media_type,
headers={
"Content-Disposition": f'attachment; filename="board_composite.{extension}"',
"Cache-Control": "no-cache",
},
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create composite export: {str(e)}",
) from e
def _calculate_canvas_bounds(board_images) -> tuple[float, float, float, float] | None:
"""
Calculate the bounding box for all images.
Args:
board_images: List of (BoardImage, Image) tuples
Returns:
Tuple of (min_x, min_y, max_x, max_y) or None
"""
if not board_images:
return None
min_x = min_y = float("inf")
max_x = max_y = float("-inf")
for board_image, image in board_images:
pos = board_image.position
transforms = board_image.transformations
x = pos["x"]
y = pos["y"]
width = image.width * transforms.get("scale", 1.0)
height = image.height * transforms.get("scale", 1.0)
min_x = min(min_x, x)
min_y = min(min_y, y)
max_x = max(max_x, x + width)
max_y = max(max_y, y + height)
return (min_x, min_y, max_x, max_y)
def _apply_transformations(image: PILImage.Image, transformations: dict, scale: float) -> PILImage.Image:
"""
Apply transformations to an image.
Args:
image: PIL Image
transformations: Transformation dict
scale: Resolution multiplier
Returns:
Transformed PIL Image
"""
# Apply scale
img_scale = transformations.get("scale", 1.0) * scale
if img_scale != 1.0:
new_width = int(image.width * img_scale)
new_height = int(image.height * img_scale)
image = image.resize((new_width, new_height), PILImage.Resampling.LANCZOS)
# Apply rotation
rotation = transformations.get("rotation", 0)
if rotation != 0:
image = image.rotate(-rotation, expand=True, resample=PILImage.Resampling.BICUBIC)
# Apply flips
if transformations.get("flipped_h", False):
image = image.transpose(PILImage.Transpose.FLIP_LEFT_RIGHT)
if transformations.get("flipped_v", False):
image = image.transpose(PILImage.Transpose.FLIP_TOP_BOTTOM)
# Apply greyscale
if transformations.get("greyscale", False):
if image.mode == "RGBA":
# Preserve alpha channel
alpha = image.split()[-1]
image = image.convert("L").convert("RGBA")
image.putalpha(alpha)
else:
image = image.convert("L")
# Apply opacity
opacity = transformations.get("opacity", 1.0)
if opacity < 1.0 and image.mode in ("RGBA", "LA"):
alpha = image.split()[-1]
alpha = alpha.point(lambda p: int(p * opacity))
image.putalpha(alpha)
return image

View File

@@ -0,0 +1,103 @@
"""ZIP export functionality for multiple images."""
import io
import zipfile
from fastapi import HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.storage import storage_client
from app.database.models.board_image import BoardImage
from app.database.models.image import Image
def create_zip_export(board_id: str, db: Session) -> StreamingResponse:
"""
Create a ZIP file containing all images from a board.
Args:
board_id: Board UUID
db: Database session
Returns:
StreamingResponse with ZIP file
Raises:
HTTPException: If export fails
"""
try:
# Get all images for the board
board_images = (
db.query(BoardImage, Image)
.join(Image, BoardImage.image_id == Image.id)
.filter(BoardImage.board_id == board_id)
.all()
)
if not board_images:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No images found for this board",
)
# Create ZIP file in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for _board_image, image in board_images:
try:
# Get image data from storage
image_data = storage_client.get_object(image.storage_path)
if image_data:
# Add to ZIP with sanitized filename
safe_filename = _sanitize_filename(image.filename)
zip_file.writestr(safe_filename, image_data)
except Exception as e:
# Log error but continue with other images
print(f"Warning: Failed to add {image.filename} to ZIP: {str(e)}")
continue
# Reset buffer position
zip_buffer.seek(0)
# Return ZIP file
return StreamingResponse(
zip_buffer,
media_type="application/zip",
headers={
"Content-Disposition": 'attachment; filename="board_export.zip"',
"Cache-Control": "no-cache",
},
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create ZIP export: {str(e)}",
) from e
def _sanitize_filename(filename: str) -> str:
"""
Sanitize filename for safe inclusion in ZIP.
Args:
filename: Original filename
Returns:
Sanitized filename
"""
# Remove any path separators and dangerous characters
safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._- ")
sanitized = "".join(c if c in safe_chars else "_" for c in filename)
# Ensure it's not empty and doesn't start with a dot
if not sanitized or sanitized[0] == ".":
sanitized = "file_" + sanitized
return sanitized

View File

@@ -26,24 +26,9 @@ class ImageRepository:
mime_type: str,
width: int,
height: int,
metadata: dict,
image_metadata: dict,
) -> Image:
"""
Create new image record.
Args:
user_id: Owner user ID
filename: Original filename
storage_path: Path in MinIO
file_size: File size in bytes
mime_type: MIME type
width: Image width in pixels
height: Image height in pixels
metadata: Additional metadata (format, checksum, thumbnails, etc)
Returns:
Created Image instance
"""
"""Create new image record."""
image = Image(
user_id=user_id,
filename=filename,
@@ -52,7 +37,7 @@ class ImageRepository:
mime_type=mime_type,
width=width,
height=height,
metadata=metadata,
image_metadata=image_metadata,
)
self.db.add(image)
await self.db.commit()
@@ -60,52 +45,27 @@ class ImageRepository:
return image
async def get_image_by_id(self, image_id: UUID) -> Image | None:
"""
Get image by ID.
Args:
image_id: Image ID
Returns:
Image instance or None
"""
"""Get image by ID."""
result = await self.db.execute(select(Image).where(Image.id == image_id))
return result.scalar_one_or_none()
async def get_user_images(self, user_id: UUID, limit: int = 50, offset: int = 0) -> tuple[Sequence[Image], int]:
"""
Get all images for a user with pagination.
"""Get all images for a user with pagination."""
from sqlalchemy import func
Args:
user_id: User ID
limit: Maximum number of images to return
offset: Number of images to skip
# Get total count efficiently
count_result = await self.db.execute(select(func.count(Image.id)).where(Image.user_id == user_id))
total = count_result.scalar_one()
Returns:
Tuple of (images, total_count)
"""
# Get total count
count_result = await self.db.execute(select(Image).where(Image.user_id == user_id))
total = len(count_result.scalars().all())
# Get paginated results
# Get paginated images
result = await self.db.execute(
select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset)
)
images = result.scalars().all()
return images, total
async def delete_image(self, image_id: UUID) -> bool:
"""
Delete image record.
Args:
image_id: Image ID
Returns:
True if deleted, False if not found
"""
"""Delete image record."""
image = await self.get_image_by_id(image_id)
if not image:
return False
@@ -115,27 +75,14 @@ class ImageRepository:
return True
async def increment_reference_count(self, image_id: UUID) -> None:
"""
Increment reference count for image.
Args:
image_id: Image ID
"""
"""Increment reference count for image."""
image = await self.get_image_by_id(image_id)
if image:
image.reference_count += 1
await self.db.commit()
async def decrement_reference_count(self, image_id: UUID) -> int:
"""
Decrement reference count for image.
Args:
image_id: Image ID
Returns:
New reference count
"""
"""Decrement reference count for image."""
image = await self.get_image_by_id(image_id)
if image and image.reference_count > 0:
image.reference_count -= 1
@@ -151,19 +98,7 @@ class ImageRepository:
transformations: dict,
z_order: int = 0,
) -> BoardImage:
"""
Add image to board.
Args:
board_id: Board ID
image_id: Image ID
position: Canvas position {x, y}
transformations: Image transformations
z_order: Layer order
Returns:
Created BoardImage instance
"""
"""Add image to board."""
board_image = BoardImage(
board_id=board_id,
image_id=image_id,
@@ -181,35 +116,50 @@ class ImageRepository:
return board_image
async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]:
"""
Get all images for a board, ordered by z-order.
Args:
board_id: Board ID
Returns:
List of BoardImage instances
"""
"""Get all images for a board, ordered by z-order."""
result = await self.db.execute(
select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc())
)
return result.scalars().all()
async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool:
"""
Remove image from board.
Args:
board_id: Board ID
image_id: Image ID
Returns:
True if removed, False if not found
"""
async def get_board_image(self, board_id: UUID, image_id: UUID) -> BoardImage | None:
"""Get a specific board image."""
result = await self.db.execute(
select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
)
board_image = result.scalar_one_or_none()
return result.scalar_one_or_none()
async def update_board_image(
self,
board_id: UUID,
image_id: UUID,
position: dict | None = None,
transformations: dict | None = None,
z_order: int | None = None,
group_id: UUID | None = None,
) -> BoardImage | None:
"""Update board image position, transformations, z-order, or group."""
board_image = await self.get_board_image(board_id, image_id)
if not board_image:
return None
if position is not None:
board_image.position = position
if transformations is not None:
board_image.transformations = transformations
if z_order is not None:
board_image.z_order = z_order
if group_id is not None:
board_image.group_id = group_id
await self.db.commit()
await self.db.refresh(board_image)
return board_image
async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool:
"""Remove image from board."""
board_image = await self.get_board_image(board_id, image_id)
if not board_image:
return False

View File

@@ -26,13 +26,14 @@ class ImageUploadResponse(BaseModel):
mime_type: str
width: int
height: int
metadata: dict[str, Any]
metadata: dict[str, Any] = Field(..., alias="image_metadata")
created_at: datetime
class Config:
"""Pydantic config."""
from_attributes = True
populate_by_name = True
class ImageResponse(BaseModel):
@@ -46,7 +47,7 @@ class ImageResponse(BaseModel):
mime_type: str
width: int
height: int
metadata: dict[str, Any]
metadata: dict[str, Any] = Field(..., alias="image_metadata")
created_at: datetime
reference_count: int
@@ -54,6 +55,7 @@ class ImageResponse(BaseModel):
"""Pydantic config."""
from_attributes = True
populate_by_name = True
class BoardImageCreate(BaseModel):

View File

@@ -0,0 +1,74 @@
"""Image search and filtering functionality."""
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.database.models.image import Image
def search_images(
user_id: str,
db: Session,
query: str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[Image]:
"""
Search user's image library with optional filters.
Args:
user_id: User UUID
db: Database session
query: Search query (searches filename)
limit: Maximum results (default 50)
offset: Pagination offset (default 0)
Returns:
List of matching images
"""
# Base query - get user's images
stmt = db.query(Image).filter(Image.user_id == user_id)
# Add search filter if query provided
if query:
search_term = f"%{query}%"
stmt = stmt.filter(
or_(
Image.filename.ilike(search_term),
Image.image_metadata["format"].astext.ilike(search_term),
)
)
# Order by most recently uploaded
stmt = stmt.order_by(Image.created_at.desc())
# Apply pagination
stmt = stmt.limit(limit).offset(offset)
return stmt.all()
def count_images(user_id: str, db: Session, query: str | None = None) -> int:
"""
Count images matching search criteria.
Args:
user_id: User UUID
db: Database session
query: Search query (optional)
Returns:
Count of matching images
"""
stmt = db.query(Image).filter(Image.user_id == user_id)
if query:
search_term = f"%{query}%"
stmt = stmt.filter(
or_(
Image.filename.ilike(search_term),
Image.image_metadata["format"].astext.ilike(search_term),
)
)
return stmt.count()

103
backend/app/images/serve.py Normal file
View File

@@ -0,0 +1,103 @@
"""Image serving with quality-based thumbnail selection."""
from fastapi import HTTPException, status
from fastapi.responses import StreamingResponse
from app.database.models.image import Image
def get_thumbnail_path(image: Image, quality: str) -> str:
"""
Get thumbnail path for specified quality level.
Args:
image: Image model instance
quality: Quality level ('low', 'medium', 'high', 'original')
Returns:
Storage path to thumbnail
Raises:
ValueError: If quality level is invalid
"""
if quality == "original":
return image.storage_path
# Get thumbnail paths from metadata
thumbnails = image.image_metadata.get("thumbnails", {})
# Map quality to thumbnail size
if quality == "low":
thumbnail_path = thumbnails.get("low")
elif quality == "medium":
thumbnail_path = thumbnails.get("medium")
elif quality == "high":
thumbnail_path = thumbnails.get("high")
else:
raise ValueError(f"Invalid quality level: {quality}")
# Fall back to original if thumbnail doesn't exist
if not thumbnail_path:
return image.storage_path
return thumbnail_path
async def serve_image_with_quality(
image: Image, quality: str = "medium", filename: str | None = None
) -> StreamingResponse:
"""
Serve image with specified quality level.
Args:
image: Image model instance
quality: Quality level ('low', 'medium', 'high', 'original')
filename: Optional custom filename for download
Returns:
StreamingResponse with image data
Raises:
HTTPException: If image cannot be served
"""
from app.images.download import download_single_image
try:
# Get appropriate thumbnail path
storage_path = get_thumbnail_path(image, quality)
# Use original filename if not specified
if filename is None:
filename = image.filename
# Serve the image
return await download_single_image(storage_path, filename)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to serve image: {str(e)}",
) from e
def determine_quality_from_speed(speed_mbps: float) -> str:
"""
Determine appropriate quality level based on connection speed.
Args:
speed_mbps: Connection speed in Mbps
Returns:
Quality level string
"""
if speed_mbps < 1.0:
return "low"
elif speed_mbps < 5.0:
return "medium"
else:
return "high"

View File

@@ -5,7 +5,7 @@ import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api import auth, boards, images
from app.api import auth, boards, export, groups, images, library, quality, sharing
from app.core.config import settings
from app.core.errors import WebRefException
from app.core.logging import setup_logging
@@ -84,7 +84,12 @@ async def root():
# API routers
app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(groups.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(sharing.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(export.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(library.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(quality.router, prefix=f"{settings.API_V1_PREFIX}")
@app.on_event("startup")

View File

@@ -1,6 +1,5 @@
"""Integration tests for authentication endpoints."""
import pytest
from fastapi import status
from fastapi.testclient import TestClient

View File

@@ -1,14 +1,15 @@
"""Integration tests for bulk image operations."""
from uuid import uuid4
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import uuid4
from app.database.models.user import User
from app.database.models.board import Board
from app.database.models.image import Image
from app.database.models.board_image import BoardImage
from app.database.models.image import Image
from app.database.models.user import User
@pytest.mark.asyncio
@@ -26,7 +27,7 @@ async def test_bulk_update_position_delta(client: AsyncClient, test_user: User,
# Create images
images = []
board_images = []
for i in range(3):
image = Image(
id=uuid4(),

View File

@@ -0,0 +1,289 @@
"""Integration tests for group endpoints."""
from uuid import uuid4
import pytest
from httpx import AsyncClient
from sqlalchemy.orm import Session
from app.database.models.board import Board
from app.database.models.board_image import BoardImage
from app.database.models.image import Image
from app.database.models.user import User
pytestmark = pytest.mark.asyncio
async def test_create_group(client: AsyncClient, test_user: User, db: Session):
"""Test creating a group with images."""
# Create board
board = Board(
id=uuid4(),
user_id=test_user.id,
title="Test Board",
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
db.add(board)
# Create images
images = []
for i in range(3):
image = Image(
id=uuid4(),
user_id=test_user.id,
filename=f"test{i}.jpg",
storage_path=f"{test_user.id}/test{i}.jpg",
file_size=1024,
mime_type="image/jpeg",
width=800,
height=600,
metadata={"format": "jpeg", "checksum": f"abc{i}"},
)
db.add(image)
images.append(image)
board_image = BoardImage(
id=uuid4(),
board_id=board.id,
image_id=image.id,
position={"x": 100, "y": 100},
transformations={"scale": 1.0, "rotation": 0, "opacity": 1.0},
z_order=i,
)
db.add(board_image)
db.commit()
# Create group
response = await client.post(
f"/api/boards/{board.id}/groups",
json={
"name": "Test Group",
"color": "#FF5733",
"annotation": "Group annotation",
"image_ids": [str(img.id) for img in images[:2]],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Group"
assert data["color"] == "#FF5733"
assert data["annotation"] == "Group annotation"
assert data["member_count"] == 2
async def test_list_groups(client: AsyncClient, test_user: User, db: Session):
"""Test listing groups on a board."""
from app.database.models.group import Group
board = Board(
id=uuid4(),
user_id=test_user.id,
title="Test Board",
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
db.add(board)
# Create groups
for i in range(3):
group = Group(
id=uuid4(),
board_id=board.id,
name=f"Group {i}",
color=f"#FF573{i}",
annotation=f"Annotation {i}",
)
db.add(group)
db.commit()
# List groups
response = await client.get(f"/api/boards/{board.id}/groups")
assert response.status_code == 200
data = response.json()
assert len(data) == 3
assert data[0]["name"] == "Group 2" # Most recent first
async def test_get_group(client: AsyncClient, test_user: User, db: Session):
"""Test getting a specific group."""
from app.database.models.group import Group
board = Board(
id=uuid4(),
user_id=test_user.id,
title="Test Board",
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
db.add(board)
group = Group(
id=uuid4(),
board_id=board.id,
name="Test Group",
color="#FF5733",
annotation="Test annotation",
)
db.add(group)
db.commit()
# Get group
response = await client.get(f"/api/boards/{board.id}/groups/{group.id}")
assert response.status_code == 200
data = response.json()
assert data["name"] == "Test Group"
assert data["color"] == "#FF5733"
async def test_update_group(client: AsyncClient, test_user: User, db: Session):
"""Test updating group metadata."""
from app.database.models.group import Group
board = Board(
id=uuid4(),
user_id=test_user.id,
title="Test Board",
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
db.add(board)
group = Group(
id=uuid4(),
board_id=board.id,
name="Original Name",
color="#FF5733",
annotation="Original annotation",
)
db.add(group)
db.commit()
# Update group
response = await client.patch(
f"/api/boards/{board.id}/groups/{group.id}",
json={
"name": "Updated Name",
"color": "#00FF00",
"annotation": "Updated annotation",
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Name"
assert data["color"] == "#00FF00"
assert data["annotation"] == "Updated annotation"
async def test_delete_group(client: AsyncClient, test_user: User, db: Session):
"""Test deleting a group."""
from app.database.models.group import Group
board = Board(
id=uuid4(),
user_id=test_user.id,
title="Test Board",
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
db.add(board)
# Create image
image = Image(
id=uuid4(),
user_id=test_user.id,
filename="test.jpg",
storage_path=f"{test_user.id}/test.jpg",
file_size=1024,
mime_type="image/jpeg",
width=800,
height=600,
metadata={"format": "jpeg", "checksum": "abc"},
)
db.add(image)
# Create group
group = Group(
id=uuid4(),
board_id=board.id,
name="Test Group",
color="#FF5733",
)
db.add(group)
# Add image to board and group
board_image = BoardImage(
id=uuid4(),
board_id=board.id,
image_id=image.id,
position={"x": 100, "y": 100},
transformations={"scale": 1.0, "rotation": 0, "opacity": 1.0},
z_order=0,
group_id=group.id,
)
db.add(board_image)
db.commit()
# Delete group
response = await client.delete(f"/api/boards/{board.id}/groups/{group.id}")
assert response.status_code == 204
# Verify image is ungrouped
db.refresh(board_image)
assert board_image.group_id is None
async def test_group_unauthorized_board(client: AsyncClient, test_user: User, db: Session):
"""Test that users can't create groups on boards they don't own."""
# Create another user
other_user = User(id=uuid4(), email="other@example.com", password_hash="hashed")
db.add(other_user)
# Create board owned by other user
board = Board(
id=uuid4(),
user_id=other_user.id,
title="Other Board",
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
db.add(board)
db.commit()
# Try to create group
response = await client.post(
f"/api/boards/{board.id}/groups",
json={
"name": "Test Group",
"color": "#FF5733",
"image_ids": [str(uuid4())],
},
)
assert response.status_code == 404 # Board not found (for security)
async def test_invalid_color_format(client: AsyncClient, test_user: User, db: Session):
"""Test that invalid color formats are rejected."""
board = Board(
id=uuid4(),
user_id=test_user.id,
title="Test Board",
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
db.add(board)
db.commit()
# Try with invalid color
response = await client.post(
f"/api/boards/{board.id}/groups",
json={
"name": "Test Group",
"color": "red", # Invalid: not hex
"image_ids": [str(uuid4())],
},
)
assert response.status_code == 422

View File

@@ -1,14 +1,15 @@
"""Integration tests for image deletion endpoints."""
from uuid import uuid4
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import uuid4
from app.database.models.user import User
from app.database.models.board import Board
from app.database.models.image import Image
from app.database.models.board_image import BoardImage
from app.database.models.image import Image
from app.database.models.user import User
@pytest.mark.asyncio

View File

@@ -1,14 +1,15 @@
"""Integration tests for image position update endpoint."""
from uuid import uuid4
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import uuid4
from app.database.models.user import User
from app.database.models.board import Board
from app.database.models.image import Image
from app.database.models.board_image import BoardImage
from app.database.models.image import Image
from app.database.models.user import User
@pytest.mark.asyncio
@@ -441,11 +442,11 @@ async def test_update_preserves_other_fields(client: AsyncClient, test_user: Use
assert response.status_code == 200
data = response.json()
# Position should be updated
assert data["position"]["x"] == 200
assert data["position"]["y"] == 200
# Other fields should be preserved
assert data["transformations"]["scale"] == 1.5
assert data["transformations"]["rotation"] == 45

View File

@@ -1,7 +1,7 @@
"""Integration tests for image upload endpoints."""
import io
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import patch
import pytest
from fastapi import status

View File

@@ -0,0 +1,302 @@
"""Tests for board sharing endpoints."""
from datetime import datetime, timedelta
import pytest
from fastapi import status
def test_create_share_link_view_only(client, auth_headers, test_board):
"""Test creating a view-only share link."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["permission_level"] == "view-only"
assert data["board_id"] == str(test_board.id)
assert data["token"] is not None
assert len(data["token"]) == 64
assert data["is_revoked"] == False # noqa: E712
assert data["access_count"] == 0
def test_create_share_link_view_comment(client, auth_headers, test_board):
"""Test creating a view-comment share link."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["permission_level"] == "view-comment"
def test_create_share_link_with_expiration(client, auth_headers, test_board):
"""Test creating a share link with expiration."""
expires_at = (datetime.utcnow() + timedelta(days=7)).isoformat()
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only", "expires_at": expires_at},
headers=auth_headers,
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["expires_at"] is not None
def test_create_share_link_invalid_permission(client, auth_headers, test_board):
"""Test creating share link with invalid permission level."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "invalid-permission"},
headers=auth_headers,
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_create_share_link_unauthorized(client, test_board):
"""Test creating share link without authentication."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_create_share_link_not_owner(client, other_auth_headers, test_board):
"""Test creating share link for board user doesn't own."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=other_auth_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_list_share_links(client, auth_headers, test_board):
"""Test listing all share links for a board."""
# Create multiple share links
client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
response = client.get(
f"/api/boards/{test_board.id}/share-links",
headers=auth_headers,
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) >= 2
assert all("token" in link for link in data)
def test_list_share_links_unauthorized(client, test_board):
"""Test listing share links without authentication."""
response = client.get(
f"/api/boards/{test_board.id}/share-links",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_revoke_share_link(client, auth_headers, test_board):
"""Test revoking a share link."""
# Create a share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
link_id = create_response.json()["id"]
# Revoke it
response = client.delete(
f"/api/boards/{test_board.id}/share-links/{link_id}",
headers=auth_headers,
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Verify it's revoked by listing
list_response = client.get(
f"/api/boards/{test_board.id}/share-links",
headers=auth_headers,
)
revoked_link = next((link for link in list_response.json() if link["id"] == link_id), None)
assert revoked_link is not None
assert revoked_link["is_revoked"] == True # noqa: E712
def test_revoke_share_link_not_found(client, auth_headers, test_board):
"""Test revoking non-existent share link."""
import uuid
fake_id = uuid.uuid4()
response = client.delete(
f"/api/boards/{test_board.id}/share-links/{fake_id}",
headers=auth_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_access_shared_board(client, auth_headers, test_board):
"""Test accessing a board via share link."""
# Create share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
token = create_response.json()["token"]
# Access shared board (no auth required)
response = client.get(f"/api/shared/{token}")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(test_board.id)
assert data["title"] == test_board.title
def test_access_shared_board_invalid_token(client):
"""Test accessing board with invalid token."""
response = client.get("/api/shared/invalid-token-12345")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_access_shared_board_revoked_token(client, auth_headers, test_board):
"""Test accessing board with revoked token."""
# Create and revoke share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
data = create_response.json()
token = data["token"]
link_id = data["id"]
client.delete(
f"/api/boards/{test_board.id}/share-links/{link_id}",
headers=auth_headers,
)
# Try to access with revoked token
response = client.get(f"/api/shared/{token}")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_create_comment_on_shared_board(client, auth_headers, test_board):
"""Test creating a comment via share link with view-comment permission."""
# Create view-comment share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
token = create_response.json()["token"]
# Create comment (no auth required, just token)
comment_data = {
"author_name": "Test Viewer",
"content": "This is a test comment",
"position": {"x": 100, "y": 200},
}
response = client.post(f"/api/shared/{token}/comments", json=comment_data)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["author_name"] == "Test Viewer"
assert data["content"] == "This is a test comment"
assert data["position"]["x"] == 100
def test_create_comment_view_only_permission_denied(client, auth_headers, test_board):
"""Test creating comment with view-only permission fails."""
# Create view-only share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
token = create_response.json()["token"]
# Try to create comment (should fail)
comment_data = {
"author_name": "Test Viewer",
"content": "This should fail",
}
response = client.post(f"/api/shared/{token}/comments", json=comment_data)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_list_comments_on_shared_board(client, auth_headers, test_board):
"""Test listing comments via share link."""
# Create view-comment share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
token = create_response.json()["token"]
# Create a comment
client.post(
f"/api/shared/{token}/comments",
json={"author_name": "Viewer 1", "content": "Comment 1"},
)
# List comments
response = client.get(f"/api/shared/{token}/comments")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) >= 1
assert data[0]["content"] == "Comment 1"
def test_list_board_comments_as_owner(client, auth_headers, test_board):
"""Test board owner listing all comments."""
# Create share link and comment
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
token = create_response.json()["token"]
client.post(
f"/api/shared/{token}/comments",
json={"author_name": "Viewer", "content": "Test comment"},
)
# Owner lists comments
response = client.get(
f"/api/boards/{test_board.id}/comments",
headers=auth_headers,
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) >= 1
def test_token_uniqueness(client, auth_headers, test_board):
"""Test that generated tokens are unique."""
tokens = set()
for _ in range(10):
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
token = response.json()["token"]
tokens.add(token)
# All tokens should be unique
assert len(tokens) == 10

View File

@@ -0,0 +1,299 @@
"""Integration tests for Z-order persistence."""
from uuid import uuid4
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models.board import Board
from app.database.models.board_image import BoardImage
from app.database.models.image import Image
from app.database.models.user import User
@pytest.mark.asyncio
async def test_update_z_order(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test updating Z-order of an image."""
board = Board(
id=uuid4(),
user_id=test_user.id,
title="Test Board",
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
db.add(board)
image = Image(
id=uuid4(),
user_id=test_user.id,
filename="test.jpg",
storage_path=f"{test_user.id}/test.jpg",
file_size=1024,
mime_type="image/jpeg",
width=800,
height=600,
metadata={"format": "jpeg", "checksum": "abc123"},
)
db.add(image)
board_image = BoardImage(
id=uuid4(),
board_id=board.id,
image_id=image.id,
position={"x": 100, "y": 100},
transformations={
"scale": 1.0,
"rotation": 0,
"opacity": 1.0,
"flipped_h": False,
"flipped_v": False,
"greyscale": False,
},
z_order=0,
)
db.add(board_image)
await db.commit()
# Update Z-order
response = await client.patch(
f"/api/images/boards/{board.id}/images/{image.id}",
json={"z_order": 5},
)
assert response.status_code == 200
data = response.json()
assert data["z_order"] == 5
@pytest.mark.asyncio
async def test_z_order_persists_across_requests(
client: AsyncClient, test_user: User, db: AsyncSession
):
"""Test that Z-order changes persist."""
board = Board(
id=uuid4(),
user_id=test_user.id,
title="Test Board",
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
db.add(board)
image = Image(
id=uuid4(),
user_id=test_user.id,
filename="test.jpg",
storage_path=f"{test_user.id}/test.jpg",
file_size=1024,
mime_type="image/jpeg",
width=800,
height=600,
metadata={"format": "jpeg", "checksum": "abc123"},
)
db.add(image)
board_image = BoardImage(
id=uuid4(),
board_id=board.id,
image_id=image.id,
position={"x": 100, "y": 100},
transformations={
"scale": 1.0,
"rotation": 0,
"opacity": 1.0,
"flipped_h": False,
"flipped_v": False,
"greyscale": False,
},
z_order=0,
)
db.add(board_image)
await db.commit()
# Update Z-order
await client.patch(
f"/api/images/boards/{board.id}/images/{image.id}",
json={"z_order": 10},
)
# Fetch board images to verify persistence
response = await client.get(f"/api/images/boards/{board.id}/images")
assert response.status_code == 200
board_images = response.json()
assert len(board_images) == 1
assert board_images[0]["z_order"] == 10
@pytest.mark.asyncio
async def test_multiple_images_z_order(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test Z-order with multiple images."""
board = Board(
id=uuid4(),
user_id=test_user.id,
title="Test Board",
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
db.add(board)
images = []
for i in range(3):
image = Image(
id=uuid4(),
user_id=test_user.id,
filename=f"test{i}.jpg",
storage_path=f"{test_user.id}/test{i}.jpg",
file_size=1024,
mime_type="image/jpeg",
width=800,
height=600,
metadata={"format": "jpeg", "checksum": f"abc{i}"},
)
db.add(image)
images.append(image)
board_image = BoardImage(
id=uuid4(),
board_id=board.id,
image_id=image.id,
position={"x": 100, "y": 100},
transformations={
"scale": 1.0,
"rotation": 0,
"opacity": 1.0,
"flipped_h": False,
"flipped_v": False,
"greyscale": False,
},
z_order=i,
)
db.add(board_image)
await db.commit()
# Update Z-order of middle image to be highest
await client.patch(
f"/api/images/boards/{board.id}/images/{images[1].id}",
json={"z_order": 10},
)
# Verify
response = await client.get(f"/api/images/boards/{board.id}/images")
board_images = response.json()
# Find the updated image
updated = next((bi for bi in board_images if str(bi["image_id"]) == str(images[1].id)), None)
assert updated is not None
assert updated["z_order"] == 10
@pytest.mark.asyncio
async def test_z_order_negative_value(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test that negative Z-order is allowed (for layering below 0)."""
board = Board(
id=uuid4(),
user_id=test_user.id,
title="Test Board",
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
db.add(board)
image = Image(
id=uuid4(),
user_id=test_user.id,
filename="test.jpg",
storage_path=f"{test_user.id}/test.jpg",
file_size=1024,
mime_type="image/jpeg",
width=800,
height=600,
metadata={"format": "jpeg", "checksum": "abc123"},
)
db.add(image)
board_image = BoardImage(
id=uuid4(),
board_id=board.id,
image_id=image.id,
position={"x": 100, "y": 100},
transformations={
"scale": 1.0,
"rotation": 0,
"opacity": 1.0,
"flipped_h": False,
"flipped_v": False,
"greyscale": False,
},
z_order=0,
)
db.add(board_image)
await db.commit()
# Set negative Z-order
response = await client.patch(
f"/api/images/boards/{board.id}/images/{image.id}",
json={"z_order": -1},
)
assert response.status_code == 200
data = response.json()
assert data["z_order"] == -1
@pytest.mark.asyncio
async def test_z_order_with_other_updates(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test updating Z-order along with position and transformations."""
board = Board(
id=uuid4(),
user_id=test_user.id,
title="Test Board",
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
db.add(board)
image = Image(
id=uuid4(),
user_id=test_user.id,
filename="test.jpg",
storage_path=f"{test_user.id}/test.jpg",
file_size=1024,
mime_type="image/jpeg",
width=800,
height=600,
metadata={"format": "jpeg", "checksum": "abc123"},
)
db.add(image)
board_image = BoardImage(
id=uuid4(),
board_id=board.id,
image_id=image.id,
position={"x": 100, "y": 100},
transformations={
"scale": 1.0,
"rotation": 0,
"opacity": 1.0,
"flipped_h": False,
"flipped_v": False,
"greyscale": False,
},
z_order=0,
)
db.add(board_image)
await db.commit()
# Update everything including Z-order
response = await client.patch(
f"/api/images/boards/{board.id}/images/{image.id}",
json={
"position": {"x": 200, "y": 200},
"transformations": {"scale": 2.0},
"z_order": 15,
},
)
assert response.status_code == 200
data = response.json()
assert data["position"]["x"] == 200
assert data["transformations"]["scale"] == 2.0
assert data["z_order"] == 15

View File

@@ -3,7 +3,6 @@
from datetime import datetime, timedelta
from uuid import UUID, uuid4
import pytest
from jose import jwt
from app.auth.jwt import create_access_token, decode_access_token

View File

@@ -1,6 +1,5 @@
"""Unit tests for password hashing and validation."""
import pytest
from app.auth.security import hash_password, validate_password_strength, verify_password

View File

@@ -1,7 +1,6 @@
"""Pytest configuration and fixtures for all tests."""
import os
from typing import Generator
from collections.abc import Generator
import pytest
from fastapi.testclient import TestClient
@@ -105,3 +104,106 @@ def test_user_data_no_uppercase() -> dict:
"""
return {"email": "test@example.com", "password": "testpassword123"}
@pytest.fixture
def test_user(client: TestClient, test_user_data: dict):
"""
Create and return a test user.
Args:
client: Test client
test_user_data: User credentials
Returns:
User object
"""
from app.database.models.user import User
response = client.post("/api/v1/auth/register", json=test_user_data)
user_id = response.json()["id"]
# Get user from database (use same db session)
from app.core.deps import get_db
db_gen = next(app.dependency_overrides[get_db]())
user = db_gen.query(User).filter(User.id == user_id).first()
return user
@pytest.fixture
def auth_headers(client: TestClient, test_user_data: dict) -> dict:
"""
Create authenticated headers with JWT token.
Args:
client: Test client
test_user_data: User credentials
Returns:
Dictionary with Authorization header
"""
# Register and login
client.post("/api/v1/auth/register", json=test_user_data)
login_response = client.post("/api/v1/auth/login", json=test_user_data)
token = login_response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def other_user_data() -> dict:
"""
Data for a second test user.
Returns:
Dictionary with test user credentials
"""
return {"email": "other@example.com", "password": "OtherPassword123"}
@pytest.fixture
def other_auth_headers(client: TestClient, other_user_data: dict) -> dict:
"""
Create authenticated headers for a second user.
Args:
client: Test client
other_user_data: Other user credentials
Returns:
Dictionary with Authorization header
"""
# Register and login
client.post("/api/v1/auth/register", json=other_user_data)
login_response = client.post("/api/v1/auth/login", json=other_user_data)
token = login_response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def test_board(client: TestClient, auth_headers: dict):
"""
Create a test board.
Args:
client: Test client
auth_headers: Authentication headers
Returns:
Board object
"""
from app.database.models.board import Board
response = client.post(
"/api/v1/boards",
json={"title": "Test Board", "description": "Test description"},
headers=auth_headers,
)
board_id = response.json()["id"]
# Get board from database
from app.core.deps import get_db
db_gen = next(app.dependency_overrides[get_db]())
board = db_gen.query(Board).filter(Board.id == board_id).first()
return board

View File

@@ -3,7 +3,6 @@
import io
from uuid import uuid4
import pytest
from PIL import Image as PILImage
from app.images.processing import generate_thumbnails

View File

@@ -44,7 +44,7 @@ def test_transformation_scale_bounds():
"""Test scale bounds validation."""
# Valid scales
valid_scales = [0.01, 0.5, 1.0, 5.0, 10.0]
for scale in valid_scales:
data = BoardImageUpdate(transformations={"scale": scale})
assert data.transformations["scale"] == scale
@@ -54,7 +54,7 @@ def test_transformation_rotation_bounds():
"""Test rotation bounds (any value allowed, normalized client-side)."""
# Various rotation values
rotations = [0, 45, 90, 180, 270, 360, 450, -90]
for rotation in rotations:
data = BoardImageUpdate(transformations={"rotation": rotation})
assert data.transformations["rotation"] == rotation
@@ -64,7 +64,7 @@ def test_transformation_opacity_bounds():
"""Test opacity bounds."""
# Valid opacity values
valid_opacities = [0.0, 0.25, 0.5, 0.75, 1.0]
for opacity in valid_opacities:
data = BoardImageUpdate(transformations={"opacity": opacity})
assert data.transformations["opacity"] == opacity

View File

@@ -1,7 +1,6 @@
"""Tests for file validation."""
import io
from unittest.mock import AsyncMock, Mock
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException, UploadFile

View File

@@ -31,7 +31,8 @@
alembic
pydantic
pydantic-settings # Settings management
psycopg2 # PostgreSQL driver
psycopg2 # PostgreSQL driver (sync)
asyncpg # PostgreSQL driver (async)
# Auth & Security
python-jose
passlib
@@ -88,6 +89,7 @@
# Development tools
git
direnv
tmux
];
shellHook = ''
@@ -105,6 +107,7 @@
echo " Status: ./scripts/dev-services.sh status"
echo ""
echo "📚 Quick Commands:"
echo " Dev (tmux): nix run .#dev"
echo " Backend: cd backend && uvicorn app.main:app --reload"
echo " Frontend: cd frontend && npm run dev"
echo " Database: psql -h localhost -U webref webref"
@@ -131,6 +134,7 @@
type = "app";
program = "${pkgs.writeShellScript "help" ''
echo "Available commands:"
echo " nix run .#dev - Start backend + frontend in tmux"
echo " nix run .#lint - Run all linting checks"
echo " nix run .#lint-backend - Run backend linting only"
echo " nix run .#lint-frontend - Run frontend linting only"
@@ -138,6 +142,76 @@
''}";
};
# Development runner with tmux
dev = {
type = "app";
program = "${pkgs.writeShellScript "dev-tmux" ''
set -e
# Check if we're in the project root
if [ ! -d "backend" ] || [ ! -d "frontend" ]; then
echo " Error: Not in project root directory"
echo "Please run this command from the webref project root"
exit 1
fi
# Check if frontend dependencies are installed
if [ ! -d "frontend/node_modules" ]; then
echo "📦 Installing frontend dependencies..."
cd frontend
${pkgs.nodejs}/bin/npm install
cd ..
fi
# Set environment variables
export DATABASE_URL="postgresql://webref@localhost:5432/webref"
export MINIO_ENDPOINT="localhost:9000"
export MINIO_ACCESS_KEY="minioadmin"
export MINIO_SECRET_KEY="minioadmin"
export PYTHONPATH="$PWD/backend:$PYTHONPATH"
export PATH="${pythonEnv}/bin:${pkgs.nodejs}/bin:$PATH"
# Session name
SESSION_NAME="webref-dev"
# Kill existing session if it exists
${pkgs.tmux}/bin/tmux has-session -t $SESSION_NAME 2>/dev/null && ${pkgs.tmux}/bin/tmux kill-session -t $SESSION_NAME
echo "🚀 Starting development environment in tmux..."
echo ""
echo "📋 Tmux Controls:"
echo " Switch panes: Ctrl+b arrow keys"
echo " Scroll mode: Ctrl+b ["
echo " Exit scroll: q"
echo " Detach session: Ctrl+b d"
echo " Kill session: Ctrl+b :kill-session"
echo ""
echo "Starting in 2 seconds..."
sleep 2
# Create new tmux session with backend
${pkgs.tmux}/bin/tmux new-session -d -s "$SESSION_NAME" -n "webref" -c "$PWD/backend" \
"printf '\n🐍 Starting Backend (uvicorn)...\n\n' && ${pythonEnv}/bin/uvicorn app.main:app --reload --host 0.0.0.0 --port 8000; read -p 'Backend stopped. Press Enter to exit...'"
# Split window vertically and run frontend
${pkgs.tmux}/bin/tmux split-window -h -t "$SESSION_NAME":0 -c "$PWD/frontend" \
"printf '\n Starting Frontend (Vite)...\n\n' && ${pkgs.nodejs}/bin/npm run dev; read -p 'Frontend stopped. Press Enter to exit...'"
# Set pane titles
${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.0 -T "Backend (uvicorn)"
${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.1 -T "Frontend (vite)"
# Balance panes
${pkgs.tmux}/bin/tmux select-layout -t "$SESSION_NAME":0 even-horizontal
# Focus on backend pane
${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.0
# Attach to session
${pkgs.tmux}/bin/tmux attach-session -t "$SESSION_NAME"
''}";
};
# Unified linting - calls both backend and frontend lints
lint = {
type = "app";

View File

@@ -13,6 +13,10 @@ export interface ApiError {
status_code: number;
}
export interface ApiRequestOptions extends RequestInit {
skipAuth?: boolean;
}
export class ApiClient {
private baseUrl: string;
@@ -20,16 +24,17 @@ export class ApiClient {
this.baseUrl = baseUrl;
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
private async request<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
const { token } = get(authStore);
const { skipAuth, ...fetchOptions } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {}),
...((fetchOptions.headers as Record<string, string>) || {}),
};
// Add authentication token if available
if (token) {
// Add authentication token if available and not skipped
if (token && !skipAuth) {
headers['Authorization'] = `Bearer ${token}`;
}
@@ -37,7 +42,7 @@ export class ApiClient {
try {
const response = await fetch(url, {
...options,
...fetchOptions,
headers,
});
@@ -74,11 +79,11 @@ export class ApiClient {
}
}
async get<T>(endpoint: string, options?: RequestInit): Promise<T> {
async get<T>(endpoint: string, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, { ...options, method: 'GET' });
}
async post<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
async post<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'POST',
@@ -86,7 +91,7 @@ export class ApiClient {
});
}
async put<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
async put<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'PUT',
@@ -94,7 +99,7 @@ export class ApiClient {
});
}
async patch<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
async patch<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'PATCH',
@@ -102,7 +107,7 @@ export class ApiClient {
});
}
async delete<T>(endpoint: string, options?: RequestInit): Promise<T> {
async delete<T>(endpoint: string, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, { ...options, method: 'DELETE' });
}
@@ -127,18 +132,34 @@ export class ApiClient {
}
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw error;
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
const error: ApiError = {
error: errorData.detail || errorData.error || 'Upload failed',
details: errorData.details,
status_code: response.status,
};
throw error;
}
return response.json();
} catch (error) {
if ((error as ApiError).status_code) {
throw error;
}
throw {
error: (error as Error).message || 'Upload failed',
status_code: 0,
} as ApiError;
}
return response.json();
}
}

View File

@@ -0,0 +1,123 @@
/**
* Export API client for downloading and exporting board content.
*/
import { apiClient } from './client';
export interface ExportInfo {
board_id: string;
image_count: number;
total_size_bytes: number;
estimated_zip_size_bytes: number;
}
/**
* Download a single image.
*
* @param imageId - Image UUID
*/
export async function downloadImage(imageId: string): Promise<void> {
const response = await fetch(`/api/v1/images/${imageId}/download`, {
method: 'GET',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Failed to download image');
}
// Get filename from Content-Disposition header
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'download';
if (contentDisposition) {
const matches = /filename="([^"]+)"/.exec(contentDisposition);
if (matches) {
filename = matches[1];
}
}
// Download the file
const blob = await response.blob();
downloadBlob(blob, filename);
}
/**
* Export board as ZIP file containing all images.
*
* @param boardId - Board UUID
*/
export async function exportBoardZip(boardId: string): Promise<void> {
const response = await fetch(`/api/v1/boards/${boardId}/export/zip`, {
method: 'GET',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Failed to export board as ZIP');
}
const blob = await response.blob();
downloadBlob(blob, 'board_export.zip');
}
/**
* Export board as a composite image.
*
* @param boardId - Board UUID
* @param scale - Resolution scale (1x, 2x, 4x)
* @param format - Output format (PNG or JPEG)
*/
export async function exportBoardComposite(
boardId: string,
scale: number = 1.0,
format: 'PNG' | 'JPEG' = 'PNG'
): Promise<void> {
const response = await fetch(
`/api/v1/boards/${boardId}/export/composite?scale=${scale}&format=${format}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
}
);
if (!response.ok) {
throw new Error('Failed to export board as composite image');
}
const extension = format === 'PNG' ? 'png' : 'jpg';
const blob = await response.blob();
downloadBlob(blob, `board_composite.${extension}`);
}
/**
* Get export information for a board.
*
* @param boardId - Board UUID
* @returns Export information
*/
export async function getExportInfo(boardId: string): Promise<ExportInfo> {
return apiClient.get<ExportInfo>(`/boards/${boardId}/export/info`);
}
/**
* Helper function to trigger download of a blob.
*
* @param blob - Blob to download
* @param filename - Filename for download
*/
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,69 @@
/**
* Groups API client
* Handles group creation, update, deletion
*/
import { apiClient } from './client';
export interface GroupCreateData {
name: string;
color: string;
annotation?: string;
image_ids: string[];
}
export interface GroupUpdateData {
name?: string;
color?: string;
annotation?: string;
}
export interface Group {
id: string;
board_id: string;
name: string;
color: string;
annotation: string | null;
member_count: number;
created_at: string;
updated_at: string;
}
/**
* Create a new group
*/
export async function createGroup(boardId: string, data: GroupCreateData): Promise<Group> {
return apiClient.post<Group>(`/api/boards/${boardId}/groups`, data);
}
/**
* List all groups on a board
*/
export async function listGroups(boardId: string): Promise<Group[]> {
return apiClient.get<Group[]>(`/api/boards/${boardId}/groups`);
}
/**
* Get a specific group
*/
export async function getGroup(boardId: string, groupId: string): Promise<Group> {
return apiClient.get<Group>(`/api/boards/${boardId}/groups/${groupId}`);
}
/**
* Update group metadata
*/
export async function updateGroup(
boardId: string,
groupId: string,
data: GroupUpdateData
): Promise<Group> {
return apiClient.patch<Group>(`/api/boards/${boardId}/groups/${groupId}`, data);
}
/**
* Delete a group (ungroups all members)
*/
export async function deleteGroup(boardId: string, groupId: string): Promise<void> {
await apiClient.delete(`/api/boards/${boardId}/groups/${groupId}`);
}

View File

@@ -9,32 +9,14 @@ import type { Image, BoardImage, ImageListResponse } from '$lib/types/images';
* Upload a single image
*/
export async function uploadImage(file: File): Promise<Image> {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<Image>('/images/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
return await apiClient.uploadFile<Image>('/images/upload', file);
}
/**
* Upload multiple images from a ZIP file
*/
export async function uploadZip(file: File): Promise<Image[]> {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<Image[]>('/images/upload-zip', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
return await apiClient.uploadFile<Image[]>('/images/upload-zip', file);
}
/**
@@ -105,3 +87,19 @@ export async function removeImageFromBoard(boardId: string, imageId: string): Pr
export async function getBoardImages(boardId: string): Promise<BoardImage[]> {
return await apiClient.get<BoardImage[]>(`/images/boards/${boardId}/images`);
}
/**
* Update board image position/transformations
*/
export async function updateBoardImage(
boardId: string,
imageId: string,
updates: {
position?: { x: number; y: number };
transformations?: Record<string, unknown>;
z_order?: number;
group_id?: string;
}
): Promise<BoardImage> {
return await apiClient.patch<BoardImage>(`/images/boards/${boardId}/images/${imageId}`, updates);
}

View File

@@ -0,0 +1,92 @@
/**
* Image library API client.
*/
import { apiClient } from './client';
export interface LibraryImage {
id: string;
filename: string;
file_size: number;
mime_type: string;
width: number;
height: number;
reference_count: number;
created_at: string;
thumbnail_url: string | null;
}
export interface LibraryListResponse {
images: LibraryImage[];
total: number;
limit: number;
offset: number;
}
export interface LibraryStats {
total_images: number;
total_size_bytes: number;
total_board_references: number;
average_references_per_image: number;
}
export interface AddToBoardRequest {
board_id: string;
position?: { x: number; y: number };
}
/**
* List images in user's library.
*
* @param query - Optional search query
* @param limit - Results per page
* @param offset - Pagination offset
* @returns Library image list with pagination info
*/
export async function listLibraryImages(
query?: string,
limit: number = 50,
offset: number = 0
): Promise<LibraryListResponse> {
let url = `/library/images?limit=${limit}&offset=${offset}`;
if (query) {
url += `&query=${encodeURIComponent(query)}`;
}
return apiClient.get<LibraryListResponse>(url);
}
/**
* Add a library image to a board.
*
* @param imageId - Image UUID
* @param request - Add to board request data
* @returns Response with new board image ID
*/
export async function addImageToBoard(
imageId: string,
request: AddToBoardRequest
): Promise<{ id: string; message: string }> {
return apiClient.post<{ id: string; message: string }>(
`/library/images/${imageId}/add-to-board`,
request
);
}
/**
* Permanently delete an image from library.
* This removes it from all boards and deletes the file.
*
* @param imageId - Image UUID
*/
export async function deleteLibraryImage(imageId: string): Promise<void> {
return apiClient.delete<void>(`/library/images/${imageId}`);
}
/**
* Get library statistics.
*
* @returns Library statistics
*/
export async function getLibraryStats(): Promise<LibraryStats> {
return apiClient.get<LibraryStats>('/library/stats');
}

View File

@@ -0,0 +1,142 @@
/**
* Sharing API client for board sharing and comments.
*/
import { apiClient } from './client';
export interface ShareLink {
id: string;
board_id: string;
token: string;
permission_level: 'view-only' | 'view-comment';
created_at: string;
expires_at: string | null;
last_accessed_at: string | null;
access_count: number;
is_revoked: boolean;
}
export interface ShareLinkCreate {
permission_level: 'view-only' | 'view-comment';
expires_at?: string | null;
}
export interface Comment {
id: string;
board_id: string;
share_link_id: string | null;
author_name: string;
content: string;
position: { x: number; y: number } | null;
created_at: string;
is_deleted: boolean;
}
export interface CommentCreate {
author_name: string;
content: string;
position?: { x: number; y: number } | null;
}
/**
* Create a new share link for a board.
*
* @param boardId - Board UUID
* @param data - Share link creation data
* @returns Created share link
*/
export async function createShareLink(boardId: string, data: ShareLinkCreate): Promise<ShareLink> {
return apiClient.post<ShareLink>(`/boards/${boardId}/share-links`, data);
}
/**
* List all share links for a board.
*
* @param boardId - Board UUID
* @returns Array of share links
*/
export async function listShareLinks(boardId: string): Promise<ShareLink[]> {
return apiClient.get<ShareLink[]>(`/boards/${boardId}/share-links`);
}
/**
* Revoke a share link.
*
* @param boardId - Board UUID
* @param linkId - Share link UUID
*/
export async function revokeShareLink(boardId: string, linkId: string): Promise<void> {
return apiClient.delete<void>(`/boards/${boardId}/share-links/${linkId}`);
}
export interface SharedBoard {
id: string;
user_id: string;
title: string;
description: string | null;
viewport_state: {
x: number;
y: number;
zoom: number;
rotation: number;
};
created_at: string;
updated_at: string;
is_deleted: boolean;
}
/**
* Get a shared board via token (no authentication required).
*
* @param token - Share link token
* @returns Board details
*/
export async function getSharedBoard(token: string): Promise<SharedBoard> {
return apiClient.get<SharedBoard>(`/shared/${token}`, { skipAuth: true });
}
/**
* Create a comment on a shared board.
*
* @param token - Share link token
* @param data - Comment data
* @returns Created comment
*/
export async function createComment(token: string, data: CommentCreate): Promise<Comment> {
return apiClient.post<Comment>(`/shared/${token}/comments`, data, {
skipAuth: true,
});
}
/**
* List comments on a shared board.
*
* @param token - Share link token
* @returns Array of comments
*/
export async function listComments(token: string): Promise<Comment[]> {
return apiClient.get<Comment[]>(`/shared/${token}/comments`, {
skipAuth: true,
});
}
/**
* List all comments on a board (owner view).
*
* @param boardId - Board UUID
* @returns Array of comments
*/
export async function listBoardComments(boardId: string): Promise<Comment[]> {
return apiClient.get<Comment[]>(`/boards/${boardId}/comments`);
}
/**
* Generate a shareable URL for a given token.
*
* @param token - Share link token
* @returns Full shareable URL
*/
export function getShareUrl(token: string): string {
const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
return `${baseUrl}/shared/${token}`;
}

View File

@@ -0,0 +1,107 @@
<script lang="ts">
/**
* Group visual indicator for canvas
* Draws visual borders and labels for grouped images
*/
import { onMount, onDestroy } from 'svelte';
import Konva from 'konva';
import type { Group } from '$lib/api/groups';
export let layer: Konva.Layer | null = null;
export let group: Group;
export let getGroupBounds: () => { x: number; y: number; width: number; height: number } | null;
let groupVisual: Konva.Group | null = null;
onMount(() => {
if (!layer) return;
// Create group visual
groupVisual = new Konva.Group({
listening: false,
name: `group-visual-${group.id}`,
});
layer.add(groupVisual);
updateVisual();
});
onDestroy(() => {
if (groupVisual) {
groupVisual.destroy();
groupVisual = null;
}
if (layer) {
layer.batchDraw();
}
});
/**
* Update group visual based on member positions
*/
export function updateVisual() {
if (!groupVisual || !layer) return;
// Clear existing visuals
groupVisual.destroyChildren();
const bounds = getGroupBounds();
if (!bounds) {
layer.batchDraw();
return;
}
// Draw group border
const border = new Konva.Rect({
x: bounds.x - 10,
y: bounds.y - 10,
width: bounds.width + 20,
height: bounds.height + 20,
stroke: group.color,
strokeWidth: 3,
dash: [10, 5],
cornerRadius: 8,
listening: false,
});
groupVisual.add(border);
// Draw group label
const labelBg = new Konva.Rect({
x: bounds.x - 10,
y: bounds.y - 35,
height: 24,
fill: group.color,
cornerRadius: 4,
listening: false,
});
const labelText = new Konva.Text({
x: bounds.x - 5,
y: bounds.y - 31,
text: group.name,
fontSize: 14,
fontStyle: 'bold',
fill: '#ffffff',
listening: false,
});
// Adjust background width to fit text
labelBg.width(labelText.width() + 10);
groupVisual.add(labelBg);
groupVisual.add(labelText);
// Move to bottom so it doesn't cover images
groupVisual.moveToBottom();
layer.batchDraw();
}
// Reactive updates
$: if (group && groupVisual) {
updateVisual();
}
</script>
<!-- This component doesn't render any DOM, it only manages Konva nodes -->

View File

@@ -8,9 +8,12 @@
import { isImageSelected } from '$lib/stores/selection';
import { setupImageDrag } from './interactions/drag';
import { setupImageSelection } from './interactions/select';
import { activeQuality } from '$lib/stores/quality';
import { getAdaptiveThumbnailUrl } from '$lib/utils/adaptive-quality';
// Props
export let id: string; // Board image ID
export let imageId: string; // Image UUID for quality-based loading
export let imageUrl: string;
export let x: number = 0;
export let y: number = 0;
@@ -26,6 +29,7 @@
// Callbacks
export let onDragEnd: ((id: string, x: number, y: number) => void) | undefined = undefined;
export let onSelectionChange: ((id: string, isSelected: boolean) => void) | undefined = undefined;
export let onImageLoaded: ((id: string) => void) | undefined = undefined;
let imageNode: Konva.Image | null = null;
let imageGroup: Konva.Group | null = null;
@@ -33,10 +37,21 @@
let cleanupDrag: (() => void) | null = null;
let cleanupSelection: (() => void) | null = null;
let unsubscribeSelection: (() => void) | null = null;
let isFullResolution: boolean = false;
// Subscribe to selection state for this image
$: isSelected = isImageSelected(id);
// Subscribe to quality changes
$: {
if (imageId && !isFullResolution) {
const newUrl = getAdaptiveThumbnailUrl(imageId);
if (imageObj && imageObj.src !== newUrl) {
loadImageWithQuality($activeQuality);
}
}
}
onMount(() => {
if (!layer) return;
@@ -70,11 +85,12 @@
imageGroup.add(imageNode);
// Set Z-index
imageGroup.zIndex(zOrder);
// Add to layer first
layer.add(imageGroup);
// Then set Z-index (must have parent first)
imageGroup.zIndex(zOrder);
// Setup interactions
cleanupDrag = setupImageDrag(imageGroup, id, undefined, (imageId, newX, newY) => {
if (onDragEnd) {
@@ -94,7 +110,26 @@
updateSelectionVisual();
});
// Initial draw
layer.batchDraw();
// Force visibility by triggering multiple redraws
requestAnimationFrame(() => {
if (layer) layer.batchDraw();
});
setTimeout(() => {
if (layer) layer.batchDraw();
}, 50);
// Notify parent that image loaded
if (onImageLoaded) {
onImageLoaded(id);
}
};
imageObj.onerror = () => {
console.error('Failed to load image:', imageUrl);
};
imageObj.src = imageUrl;
@@ -198,6 +233,38 @@
export function getImageNode(): Konva.Image | null {
return imageNode;
}
/**
* Load image with specific quality level.
*/
function loadImageWithQuality(_quality: string) {
if (!imageId || !imageObj) return;
const qualityUrl = getAdaptiveThumbnailUrl(imageId);
if (imageObj.src !== qualityUrl) {
imageObj.src = qualityUrl;
}
}
/**
* Load full-resolution version on demand.
* Useful for zooming in or detailed viewing.
*/
export function loadFullResolution() {
if (!imageId || !imageObj || isFullResolution) return;
const fullResUrl = `/api/v1/images/${imageId}/original`;
imageObj.src = fullResUrl;
isFullResolution = true;
}
/**
* Check if currently showing full resolution.
*/
export function isShowingFullResolution(): boolean {
return isFullResolution;
}
</script>
<!-- This component doesn't render any DOM, it only manages Konva nodes -->

View File

@@ -11,9 +11,15 @@
import { setupZoomControls } from './controls/zoom';
import { setupRotateControls } from './controls/rotate';
import { setupGestureControls } from './gestures';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
// Board ID for future use (e.g., loading board-specific state)
export const boardId: string | undefined = undefined;
// Intentionally unused - reserved for future viewport persistence
export let boardId: string | undefined = undefined;
$: _boardId = boardId; // Consume to prevent unused warning
export let width: number = 0;
export let height: number = 0;
@@ -40,6 +46,13 @@
layer = new Konva.Layer();
stage.add(layer);
// Apply initial viewport state BEFORE subscribing to changes
// This prevents the flicker from transform animations
const initialViewport = $viewport;
layer.position({ x: initialViewport.x, y: initialViewport.y });
layer.scale({ x: initialViewport.zoom, y: initialViewport.zoom });
layer.rotation(initialViewport.rotation);
// Set up controls
if (stage) {
cleanupPan = setupPanControls(stage);
@@ -48,13 +61,13 @@
cleanupGestures = setupGestureControls(stage);
}
// Subscribe to viewport changes
// Subscribe to viewport changes (after initial state applied)
unsubscribeViewport = viewport.subscribe((state) => {
updateStageTransform(state);
});
// Apply initial viewport state
updateStageTransform($viewport);
// Notify parent that stage is ready
dispatch('ready');
});
onDestroy(() => {
@@ -78,21 +91,26 @@
* Update stage transform based on viewport state
*/
function updateStageTransform(state: ViewportState) {
if (!stage) return;
if (!stage || !layer) return;
// Apply transformations to the stage
stage.position({ x: state.x, y: state.y });
stage.scale({ x: state.zoom, y: state.zoom });
stage.rotation(state.rotation);
// Don't apply transforms to the stage itself - it causes rendering issues
// Instead, we'll transform the layer
layer.position({ x: state.x, y: state.y });
layer.scale({ x: state.zoom, y: state.zoom });
layer.rotation(state.rotation);
// Force both layer and stage to redraw
layer.batchDraw();
stage.batchDraw();
}
/**
* Resize canvas when dimensions change
*/
$: if (stage && (width !== stage.width() || height !== stage.height())) {
$: if (stage && layer && (width !== stage.width() || height !== stage.height())) {
stage.width(width);
stage.height(height);
layer.batchDraw();
stage.batchDraw();
}

View File

@@ -0,0 +1,64 @@
/**
* Optimal layout algorithm for images.
*/
import type { ArrangedPosition, ImageForArrange } from './sort-name';
/**
* Arrange images with optimal packing algorithm.
* Uses a simple bin-packing approach.
*/
export function arrangeOptimal(
images: ImageForArrange[],
gridSpacing: number = 20,
startX: number = 0,
startY: number = 0
): ArrangedPosition[] {
if (images.length === 0) return [];
// Sort by area (largest first) for better packing
const sorted = [...images].sort((a, b) => b.width * b.height - a.width * a.height);
const positions: ArrangedPosition[] = [];
const placedRects: Array<{
x: number;
y: number;
width: number;
height: number;
}> = [];
// Calculate target width (similar to square root layout)
const totalArea = sorted.reduce((sum, img) => sum + img.width * img.height, 0);
const targetWidth = Math.sqrt(totalArea) * 1.5;
let currentX = startX;
let currentY = startY;
let rowHeight = 0;
for (const img of sorted) {
// Check if we need to wrap to next row
if (currentX > startX && currentX + img.width > startX + targetWidth) {
currentX = startX;
currentY += rowHeight + gridSpacing;
rowHeight = 0;
}
positions.push({
id: img.id,
x: currentX,
y: currentY,
});
placedRects.push({
x: currentX,
y: currentY,
width: img.width,
height: img.height,
});
currentX += img.width + gridSpacing;
rowHeight = Math.max(rowHeight, img.height);
}
return positions;
}

View File

@@ -0,0 +1,35 @@
/**
* Random arrangement of images.
*/
import type { ArrangedPosition, ImageForArrange } from './sort-name';
/**
* Arrange images randomly within a bounded area.
*/
export function arrangeRandom(
images: ImageForArrange[],
areaWidth: number = 2000,
areaHeight: number = 2000,
startX: number = 0,
startY: number = 0
): ArrangedPosition[] {
const positions: ArrangedPosition[] = [];
for (const img of images) {
// Random position within bounds, accounting for image size
const maxX = areaWidth - img.width;
const maxY = areaHeight - img.height;
const x = startX + Math.random() * Math.max(maxX, 0);
const y = startY + Math.random() * Math.max(maxY, 0);
positions.push({
id: img.id,
x: Math.round(x),
y: Math.round(y),
});
}
return positions;
}

View File

@@ -0,0 +1,44 @@
/**
* Sort images by upload date.
*/
import type { ArrangedPosition, ImageForArrange } from './sort-name';
export interface ImageWithDate extends ImageForArrange {
created_at: string;
}
/**
* Arrange images by upload date (oldest to newest).
*/
export function arrangeByDate(
images: ImageWithDate[],
gridSpacing: number = 20,
startX: number = 0,
startY: number = 0
): ArrangedPosition[] {
// Sort by date
const sorted = [...images].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
// Calculate grid layout
const cols = Math.ceil(Math.sqrt(sorted.length));
const maxWidth = Math.max(...sorted.map((img) => img.width));
const maxHeight = Math.max(...sorted.map((img) => img.height));
const positions: ArrangedPosition[] = [];
sorted.forEach((img, index) => {
const row = Math.floor(index / cols);
const col = index % cols;
positions.push({
id: img.id,
x: startX + col * (maxWidth + gridSpacing),
y: startY + row * (maxHeight + gridSpacing),
});
});
return positions;
}

View File

@@ -0,0 +1,57 @@
/**
* Sort images alphabetically by name.
*/
export interface ImageForArrange {
id: string;
filename: string;
x: number;
y: number;
width: number;
height: number;
}
export interface ArrangedPosition {
id: string;
x: number;
y: number;
}
/**
* Arrange images alphabetically by filename.
*
* @param images - Images to arrange
* @param gridSpacing - Spacing between images
* @param startX - Starting X position
* @param startY - Starting Y position
* @returns New positions for images
*/
export function arrangeByName(
images: ImageForArrange[],
gridSpacing: number = 20,
startX: number = 0,
startY: number = 0
): ArrangedPosition[] {
// Sort alphabetically
const sorted = [...images].sort((a, b) => a.filename.localeCompare(b.filename));
// Calculate grid layout
const cols = Math.ceil(Math.sqrt(sorted.length));
const maxWidth = Math.max(...sorted.map((img) => img.width));
const maxHeight = Math.max(...sorted.map((img) => img.height));
const positions: ArrangedPosition[] = [];
sorted.forEach((img, index) => {
const row = Math.floor(index / cols);
const col = index % cols;
positions.push({
id: img.id,
x: startX + col * (maxWidth + gridSpacing),
y: startY + row * (maxHeight + gridSpacing),
});
});
return positions;
}

View File

@@ -0,0 +1,100 @@
/**
* Focus mode for viewing individual images.
*/
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
export interface FocusState {
isActive: boolean;
currentImageId: string | null;
imageIds: string[];
currentIndex: number;
}
function createFocusStore() {
const { subscribe, set, update }: Writable<FocusState> = writable({
isActive: false,
currentImageId: null,
imageIds: [],
currentIndex: 0,
});
return {
subscribe,
/**
* Enter focus mode for a specific image.
*/
enter(imageId: string, allImageIds: string[]) {
const index = allImageIds.indexOf(imageId);
set({
isActive: true,
currentImageId: imageId,
imageIds: allImageIds,
currentIndex: index !== -1 ? index : 0,
});
},
/**
* Exit focus mode.
*/
exit() {
set({
isActive: false,
currentImageId: null,
imageIds: [],
currentIndex: 0,
});
},
/**
* Navigate to next image.
*/
next() {
update((state) => {
if (!state.isActive || state.imageIds.length === 0) return state;
const nextIndex = (state.currentIndex + 1) % state.imageIds.length;
return {
...state,
currentIndex: nextIndex,
currentImageId: state.imageIds[nextIndex],
};
});
},
/**
* Navigate to previous image.
*/
previous() {
update((state) => {
if (!state.isActive || state.imageIds.length === 0) return state;
const prevIndex = (state.currentIndex - 1 + state.imageIds.length) % state.imageIds.length;
return {
...state,
currentIndex: prevIndex,
currentImageId: state.imageIds[prevIndex],
};
});
},
/**
* Go to specific index.
*/
goToIndex(index: number) {
update((state) => {
if (!state.isActive || index < 0 || index >= state.imageIds.length) return state;
return {
...state,
currentIndex: index,
currentImageId: state.imageIds[index],
};
});
},
};
}
export const focusStore = createFocusStore();

View File

@@ -0,0 +1,195 @@
/**
* Grid and snap-to-grid functionality for canvas
* Provides visual grid and snapping behavior
*/
import Konva from 'konva';
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
export interface GridSettings {
enabled: boolean;
size: number; // Grid cell size in pixels
visible: boolean; // Show visual grid
snapEnabled: boolean; // Enable snap-to-grid
color: string; // Grid line color
opacity: number; // Grid line opacity
}
const DEFAULT_GRID: GridSettings = {
enabled: true,
size: 20,
visible: false,
snapEnabled: false,
color: '#d1d5db',
opacity: 0.5,
};
/**
* Create grid settings store
*/
function createGridStore() {
const { subscribe, set, update }: Writable<GridSettings> = writable(DEFAULT_GRID);
return {
subscribe,
set,
update,
/**
* Toggle grid visibility
*/
toggleVisible: () => {
update((settings) => ({
...settings,
visible: !settings.visible,
}));
},
/**
* Toggle snap-to-grid
*/
toggleSnap: () => {
update((settings) => ({
...settings,
snapEnabled: !settings.snapEnabled,
}));
},
/**
* Set grid size
*/
setSize: (size: number) => {
update((settings) => ({
...settings,
size: Math.max(5, Math.min(200, size)), // Clamp to 5-200
}));
},
/**
* Enable/disable grid
*/
setEnabled: (enabled: boolean) => {
update((settings) => ({
...settings,
enabled,
}));
},
/**
* Reset to defaults
*/
reset: () => {
set(DEFAULT_GRID);
},
};
}
export const grid = createGridStore();
/**
* Snap position to grid
*/
export function snapToGrid(x: number, y: number, gridSize: number): { x: number; y: number } {
return {
x: Math.round(x / gridSize) * gridSize,
y: Math.round(y / gridSize) * gridSize,
};
}
/**
* Draw visual grid on layer
*/
export function drawGrid(
layer: Konva.Layer,
width: number,
height: number,
gridSize: number,
color: string = '#d1d5db',
opacity: number = 0.5
): Konva.Group {
const gridGroup = new Konva.Group({
listening: false,
name: 'grid',
});
// Draw vertical lines
for (let x = 0; x <= width; x += gridSize) {
const line = new Konva.Line({
points: [x, 0, x, height],
stroke: color,
strokeWidth: 1,
opacity,
listening: false,
});
gridGroup.add(line);
}
// Draw horizontal lines
for (let y = 0; y <= height; y += gridSize) {
const line = new Konva.Line({
points: [0, y, width, y],
stroke: color,
strokeWidth: 1,
opacity,
listening: false,
});
gridGroup.add(line);
}
layer.add(gridGroup);
gridGroup.moveToBottom(); // Grid should be behind all images
return gridGroup;
}
/**
* Remove grid from layer
*/
export function removeGrid(layer: Konva.Layer): void {
const grids = layer.find('.grid');
grids.forEach((grid) => grid.destroy());
layer.batchDraw();
}
/**
* Update grid visual
*/
export function updateGrid(
layer: Konva.Layer,
settings: GridSettings,
viewportWidth: number,
viewportHeight: number
): void {
// Remove existing grid
removeGrid(layer);
// Draw new grid if visible
if (settings.visible && settings.enabled) {
drawGrid(layer, viewportWidth, viewportHeight, settings.size, settings.color, settings.opacity);
layer.batchDraw();
}
}
/**
* Setup drag with snap-to-grid
*/
export function setupSnapDrag(
image: Konva.Image | Konva.Group,
gridSettings: GridSettings
): () => void {
function handleDragMove() {
if (!gridSettings.snapEnabled || !gridSettings.enabled) return;
const pos = image.position();
const snapped = snapToGrid(pos.x, pos.y, gridSettings.size);
image.position(snapped);
}
image.on('dragmove', handleDragMove);
return () => {
image.off('dragmove', handleDragMove);
};
}

View File

@@ -14,6 +14,10 @@ export interface KeyboardShortcutHandlers {
onPaste?: () => void;
onUndo?: () => void;
onRedo?: () => void;
onBringToFront?: () => void;
onSendToBack?: () => void;
onBringForward?: () => void;
onSendBackward?: () => void;
}
/**
@@ -129,6 +133,46 @@ export function setupKeyboardShortcuts(
}
return;
}
// Ctrl+] - Bring to front
if (isCtrlOrCmd && e.key === ']') {
e.preventDefault();
if (handlers.onBringToFront) {
handlers.onBringToFront();
}
return;
}
// Ctrl+[ - Send to back
if (isCtrlOrCmd && e.key === '[') {
e.preventDefault();
if (handlers.onSendToBack) {
handlers.onSendToBack();
}
return;
}
// PageUp - Bring forward
if (e.key === 'PageUp') {
e.preventDefault();
if (handlers.onBringForward) {
handlers.onBringForward();
}
return;
}
// PageDown - Send backward
if (e.key === 'PageDown') {
e.preventDefault();
if (handlers.onSendBackward) {
handlers.onSendBackward();
}
return;
}
}
// Attach event listener

View File

@@ -0,0 +1,101 @@
/**
* Image navigation order calculation.
*/
export type NavigationOrder = 'chronological' | 'spatial' | 'alphabetical' | 'random';
export interface ImageWithMetadata {
id: string;
filename: string;
x: number;
y: number;
created_at: string;
}
/**
* Sort images by navigation order preference.
*/
export function sortImagesByOrder(images: ImageWithMetadata[], order: NavigationOrder): string[] {
let sorted: ImageWithMetadata[];
switch (order) {
case 'chronological':
sorted = [...images].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
break;
case 'spatial':
// Left to right, top to bottom
sorted = [...images].sort((a, b) => {
if (Math.abs(a.y - b.y) < 50) {
return a.x - b.x;
}
return a.y - b.y;
});
break;
case 'alphabetical':
sorted = [...images].sort((a, b) => a.filename.localeCompare(b.filename));
break;
case 'random':
sorted = shuffleArray([...images]);
break;
default:
sorted = images;
}
return sorted.map((img) => img.id);
}
/**
* Shuffle array randomly.
*/
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
/**
* Get navigation order preference from localStorage.
*/
export function getNavigationOrderPreference(): NavigationOrder {
if (typeof window === 'undefined') return 'chronological';
try {
const saved = localStorage.getItem('webref_navigation_order');
if (saved && isValidNavigationOrder(saved)) {
return saved as NavigationOrder;
}
} catch (error) {
console.error('Failed to load navigation preference:', error);
}
return 'chronological';
}
/**
* Save navigation order preference.
*/
export function saveNavigationOrderPreference(order: NavigationOrder): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('webref_navigation_order', order);
} catch (error) {
console.error('Failed to save navigation preference:', error);
}
}
/**
* Check if string is a valid navigation order.
*/
function isValidNavigationOrder(value: string): boolean {
return ['chronological', 'spatial', 'alphabetical', 'random'].includes(value);
}

View File

@@ -0,0 +1,256 @@
/**
* Alignment operations for canvas images
* Aligns multiple images relative to each other or to canvas
*/
import type Konva from 'konva';
export interface AlignOptions {
onAlignComplete?: (imageIds: string[]) => void;
}
/**
* Get bounding box of multiple images
*/
function getBounds(
images: Map<string, Konva.Image | Konva.Group>,
imageIds: string[]
): {
minX: number;
minY: number;
maxX: number;
maxY: number;
width: number;
height: number;
} | null {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
imageIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
minX = Math.min(minX, box.x);
minY = Math.min(minY, box.y);
maxX = Math.max(maxX, box.x + box.width);
maxY = Math.max(maxY, box.y + box.height);
});
if (!isFinite(minX) || !isFinite(minY)) return null;
return {
minX,
minY,
maxX,
maxY,
width: maxX - minX,
height: maxY - minY,
};
}
/**
* Align images to top edge
*/
export function alignTop(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
const bounds = getBounds(images, selectedIds);
if (!bounds) return;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
const offsetY = bounds.minY - box.y;
image.y(image.y() + offsetY);
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (options.onAlignComplete) {
options.onAlignComplete(selectedIds);
}
}
/**
* Align images to bottom edge
*/
export function alignBottom(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
const bounds = getBounds(images, selectedIds);
if (!bounds) return;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
const offsetY = bounds.maxY - (box.y + box.height);
image.y(image.y() + offsetY);
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (options.onAlignComplete) {
options.onAlignComplete(selectedIds);
}
}
/**
* Align images to left edge
*/
export function alignLeft(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
const bounds = getBounds(images, selectedIds);
if (!bounds) return;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
const offsetX = bounds.minX - box.x;
image.x(image.x() + offsetX);
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (options.onAlignComplete) {
options.onAlignComplete(selectedIds);
}
}
/**
* Align images to right edge
*/
export function alignRight(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
const bounds = getBounds(images, selectedIds);
if (!bounds) return;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
const offsetX = bounds.maxX - (box.x + box.width);
image.x(image.x() + offsetX);
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (options.onAlignComplete) {
options.onAlignComplete(selectedIds);
}
}
/**
* Center images horizontally within their bounding box
*/
export function centerHorizontal(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
const bounds = getBounds(images, selectedIds);
if (!bounds) return;
const centerX = bounds.minX + bounds.width / 2;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
const imageCenterX = box.x + box.width / 2;
const offsetX = centerX - imageCenterX;
image.x(image.x() + offsetX);
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (options.onAlignComplete) {
options.onAlignComplete(selectedIds);
}
}
/**
* Center images vertically within their bounding box
*/
export function centerVertical(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
const bounds = getBounds(images, selectedIds);
if (!bounds) return;
const centerY = bounds.minY + bounds.height / 2;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
const imageCenterY = box.y + box.height / 2;
const offsetY = centerY - imageCenterY;
image.y(image.y() + offsetY);
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (options.onAlignComplete) {
options.onAlignComplete(selectedIds);
}
}
/**
* Center images both horizontally and vertically
*/
export function centerBoth(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
centerHorizontal(images, selectedIds, options);
centerVertical(images, selectedIds, options);
}

View File

@@ -0,0 +1,150 @@
/**
* Distribution operations for canvas images
* Distributes images with equal spacing
*/
import type Konva from 'konva';
export interface DistributeOptions {
onDistributeComplete?: (imageIds: string[]) => void;
}
interface ImageWithBounds {
id: string;
image: Konva.Image | Konva.Group;
bounds: { x: number; y: number; width: number; height: number };
}
/**
* Distribute images horizontally with equal spacing
*/
export function distributeHorizontal(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: DistributeOptions = {}
): void {
if (selectedIds.length < 3) return; // Need at least 3 images to distribute
// Get image bounds
const imagesWithBounds: ImageWithBounds[] = [];
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
imagesWithBounds.push({
id,
image,
bounds: {
x: box.x,
y: box.y,
width: box.width,
height: box.height,
},
});
});
// Sort by X position
imagesWithBounds.sort((a, b) => a.bounds.x - b.bounds.x);
// Calculate total space and spacing
const first = imagesWithBounds[0];
const last = imagesWithBounds[imagesWithBounds.length - 1];
const totalSpace = last.bounds.x - (first.bounds.x + first.bounds.width);
const spacing = totalSpace / (imagesWithBounds.length - 1);
// Distribute (skip first and last)
let currentX = first.bounds.x + first.bounds.width + spacing;
for (let i = 1; i < imagesWithBounds.length - 1; i++) {
const item = imagesWithBounds[i];
const offsetX = currentX - item.bounds.x;
item.image.x(item.image.x() + offsetX);
currentX += item.bounds.width + spacing;
}
const firstImage = imagesWithBounds[0].image;
firstImage.getLayer()?.batchDraw();
if (options.onDistributeComplete) {
options.onDistributeComplete(selectedIds);
}
}
/**
* Distribute images vertically with equal spacing
*/
export function distributeVertical(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: DistributeOptions = {}
): void {
if (selectedIds.length < 3) return; // Need at least 3 images to distribute
// Get image bounds
const imagesWithBounds: ImageWithBounds[] = [];
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
imagesWithBounds.push({
id,
image,
bounds: {
x: box.x,
y: box.y,
width: box.width,
height: box.height,
},
});
});
// Sort by Y position
imagesWithBounds.sort((a, b) => a.bounds.y - b.bounds.y);
// Calculate total space and spacing
const first = imagesWithBounds[0];
const last = imagesWithBounds[imagesWithBounds.length - 1];
const totalSpace = last.bounds.y - (first.bounds.y + first.bounds.height);
const spacing = totalSpace / (imagesWithBounds.length - 1);
// Distribute (skip first and last)
let currentY = first.bounds.y + first.bounds.height + spacing;
for (let i = 1; i < imagesWithBounds.length - 1; i++) {
const item = imagesWithBounds[i];
const offsetY = currentY - item.bounds.y;
item.image.y(item.image.y() + offsetY);
currentY += item.bounds.height + spacing;
}
const firstImage = imagesWithBounds[0].image;
firstImage.getLayer()?.batchDraw();
if (options.onDistributeComplete) {
options.onDistributeComplete(selectedIds);
}
}
/**
* Distribute evenly across available space
*/
export function distributeEvenly(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
horizontal: boolean = true,
options: DistributeOptions = {}
): void {
if (horizontal) {
distributeHorizontal(images, selectedIds, options);
} else {
distributeVertical(images, selectedIds, options);
}
}

View File

@@ -0,0 +1,118 @@
/**
* Group move operations
* Move all images in a group together as a unit
*/
import type Konva from 'konva';
export interface GroupMoveOptions {
animate?: boolean;
onMoveComplete?: (groupId: string, deltaX: number, deltaY: number) => void;
}
/**
* Move all images in a group by delta
*/
export function moveGroupBy(
images: Map<string, Konva.Image | Konva.Group>,
imageIdsInGroup: string[],
groupId: string,
deltaX: number,
deltaY: number,
options: GroupMoveOptions = {}
): void {
const { animate = false, onMoveComplete } = options;
imageIdsInGroup.forEach((id) => {
const image = images.get(id);
if (!image) return;
const newX = image.x() + deltaX;
const newY = image.y() + deltaY;
if (animate) {
image.to({
x: newX,
y: newY,
duration: 0.3,
});
} else {
image.position({ x: newX, y: newY });
}
});
// Batch draw
const firstImage = imageIdsInGroup.length > 0 ? images.get(imageIdsInGroup[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (onMoveComplete) {
onMoveComplete(groupId, deltaX, deltaY);
}
}
/**
* Move group to specific position (aligns top-left)
*/
export function moveGroupTo(
images: Map<string, Konva.Image | Konva.Group>,
imageIdsInGroup: string[],
groupId: string,
targetX: number,
targetY: number,
options: GroupMoveOptions = {}
): void {
// Find current top-left
let minX = Infinity;
let minY = Infinity;
imageIdsInGroup.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
minX = Math.min(minX, box.x);
minY = Math.min(minY, box.y);
});
if (!isFinite(minX) || !isFinite(minY)) return;
const deltaX = targetX - minX;
const deltaY = targetY - minY;
moveGroupBy(images, imageIdsInGroup, groupId, deltaX, deltaY, options);
}
/**
* Get group bounding box
*/
export function getGroupBounds(
images: Map<string, Konva.Image | Konva.Group>,
imageIdsInGroup: string[]
): { x: number; y: number; width: number; height: number } | null {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
imageIdsInGroup.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
minX = Math.min(minX, box.x);
minY = Math.min(minY, box.y);
maxX = Math.max(maxX, box.x + box.width);
maxY = Math.max(maxY, box.y + box.height);
});
if (!isFinite(minX) || !isFinite(minY)) return null;
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}

View File

@@ -0,0 +1,83 @@
/**
* Group operations for canvas images
* Create groups from selected images
*/
import type { Group } from '$lib/api/groups';
export interface CreateGroupOptions {
name: string;
color: string;
annotation?: string;
onGroupCreate?: (group: Group) => void;
}
/**
* Create group from selected images
*/
export async function createGroupFromSelection(
selectedIds: string[],
boardId: string,
options: CreateGroupOptions
): Promise<Group | null> {
if (selectedIds.length === 0) {
return null;
}
const { createGroup } = await import('$lib/api/groups');
try {
const group = await createGroup(boardId, {
name: options.name,
color: options.color,
annotation: options.annotation,
image_ids: selectedIds,
});
if (options.onGroupCreate) {
options.onGroupCreate(group);
}
return group;
} catch (error) {
console.error('Failed to create group:', error);
return null;
}
}
/**
* Check if all selected images can be grouped
*/
export function canCreateGroup(selectedIds: string[]): boolean {
return selectedIds.length >= 1;
}
/**
* Get group color suggestions
*/
export function getGroupColorSuggestions(): string[] {
return [
'#FF5733', // Red
'#3B82F6', // Blue
'#10B981', // Green
'#F59E0B', // Yellow
'#8B5CF6', // Purple
'#EC4899', // Pink
'#14B8A6', // Teal
'#F97316', // Orange
];
}
/**
* Generate default group name
*/
export function generateDefaultGroupName(existingGroups: Group[]): string {
const baseName = 'Group';
let counter = existingGroups.length + 1;
while (existingGroups.some((g) => g.name === `${baseName} ${counter}`)) {
counter++;
}
return `${baseName} ${counter}`;
}

View File

@@ -0,0 +1,58 @@
/**
* Ungroup operations
* Remove images from groups
*/
export interface UngroupOptions {
onUngroupComplete?: (imageIds: string[], groupId: string) => void;
}
/**
* Ungroup images (remove from group)
*/
export async function ungroupImages(
boardId: string,
groupId: string,
options: UngroupOptions = {}
): Promise<boolean> {
const { deleteGroup } = await import('$lib/api/groups');
try {
await deleteGroup(boardId, groupId);
if (options.onUngroupComplete) {
// Note: We'd need to track which images were in the group
options.onUngroupComplete([], groupId);
}
return true;
} catch (error) {
console.error('Failed to ungroup:', error);
return false;
}
}
/**
* Remove specific images from group
*/
export async function removeImagesFromGroup(
boardId: string,
groupId: string,
imageIds: string[]
): Promise<boolean> {
// Update board images to remove group_id
const { apiClient } = await import('$lib/api/client');
try {
for (const imageId of imageIds) {
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
group_id: null,
});
}
return true;
} catch (error) {
console.error('Failed to remove images from group:', error);
return false;
}
}

View File

@@ -0,0 +1,180 @@
/**
* Z-order (layering) operations for canvas images
* Controls which images appear in front of or behind others
*/
import type Konva from 'konva';
export interface ZOrderOptions {
onZOrderChange?: (imageId: string, newZOrder: number) => void;
}
/**
* Bring image to front (highest Z-order)
*/
export function bringToFront(
image: Konva.Image | Konva.Group,
imageId: string,
allImages: Map<string, Konva.Image | Konva.Group>,
options: ZOrderOptions = {}
): void {
// Find maximum Z-order
let maxZOrder = 0;
allImages.forEach((img) => {
const zIndex = img.zIndex();
if (zIndex > maxZOrder) {
maxZOrder = zIndex;
}
});
// Set to max + 1
const newZOrder = maxZOrder + 1;
image.zIndex(newZOrder);
image.getLayer()?.batchDraw();
if (options.onZOrderChange) {
options.onZOrderChange(imageId, newZOrder);
}
}
/**
* Send image to back (lowest Z-order)
*/
export function sendToBack(
image: Konva.Image | Konva.Group,
imageId: string,
options: ZOrderOptions = {}
): void {
image.zIndex(0);
image.getLayer()?.batchDraw();
if (options.onZOrderChange) {
options.onZOrderChange(imageId, 0);
}
}
/**
* Bring image forward (increase Z-order by 1)
*/
export function bringForward(
image: Konva.Image | Konva.Group,
imageId: string,
options: ZOrderOptions = {}
): void {
const currentZIndex = image.zIndex();
const newZOrder = currentZIndex + 1;
image.zIndex(newZOrder);
image.getLayer()?.batchDraw();
if (options.onZOrderChange) {
options.onZOrderChange(imageId, newZOrder);
}
}
/**
* Send image backward (decrease Z-order by 1)
*/
export function sendBackward(
image: Konva.Image | Konva.Group,
imageId: string,
options: ZOrderOptions = {}
): void {
const currentZIndex = image.zIndex();
const newZOrder = Math.max(0, currentZIndex - 1);
image.zIndex(newZOrder);
image.getLayer()?.batchDraw();
if (options.onZOrderChange) {
options.onZOrderChange(imageId, newZOrder);
}
}
/**
* Set specific Z-order
*/
export function setZOrder(
image: Konva.Image | Konva.Group,
imageId: string,
zOrder: number,
options: ZOrderOptions = {}
): void {
image.zIndex(Math.max(0, zOrder));
image.getLayer()?.batchDraw();
if (options.onZOrderChange) {
options.onZOrderChange(imageId, zOrder);
}
}
/**
* Get current Z-order
*/
export function getZOrder(image: Konva.Image | Konva.Group): number {
return image.zIndex();
}
/**
* Bulk bring to front (multiple images)
*/
export function bulkBringToFront(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
allImages: Map<string, Konva.Image | Konva.Group>,
options: ZOrderOptions = {}
): void {
// Find maximum Z-order
let maxZOrder = 0;
allImages.forEach((img) => {
const zIndex = img.zIndex();
if (zIndex > maxZOrder) {
maxZOrder = zIndex;
}
});
// Set selected images to top, maintaining relative order
selectedIds.forEach((id, index) => {
const image = images.get(id);
if (!image) return;
const newZOrder = maxZOrder + 1 + index;
image.zIndex(newZOrder);
if (options.onZOrderChange) {
options.onZOrderChange(id, newZOrder);
}
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
}
/**
* Bulk send to back (multiple images)
*/
export function bulkSendToBack(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: ZOrderOptions = {}
): void {
// Set selected images to bottom, maintaining relative order
selectedIds.forEach((id, index) => {
const image = images.get(id);
if (!image) return;
image.zIndex(index);
if (options.onZOrderChange) {
options.onZOrderChange(id, index);
}
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
}

View File

@@ -0,0 +1,145 @@
/**
* Slideshow mode for automatic image presentation.
*/
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
export interface SlideshowState {
isActive: boolean;
isPaused: boolean;
currentImageId: string | null;
imageIds: string[];
currentIndex: number;
interval: number; // seconds
}
const DEFAULT_INTERVAL = 5; // 5 seconds
function createSlideshowStore() {
const { subscribe, set, update }: Writable<SlideshowState> = writable({
isActive: false,
isPaused: false,
currentImageId: null,
imageIds: [],
currentIndex: 0,
interval: DEFAULT_INTERVAL,
});
let timer: ReturnType<typeof setInterval> | null = null;
function clearTimer() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function startTimer(state: SlideshowState, nextFn: () => void) {
clearTimer();
if (state.isActive && !state.isPaused) {
timer = setInterval(nextFn, state.interval * 1000);
}
}
return {
subscribe,
/**
* Start slideshow.
*/
start(imageIds: string[], startIndex: number = 0, interval: number = DEFAULT_INTERVAL) {
const state = {
isActive: true,
isPaused: false,
imageIds,
currentIndex: startIndex,
currentImageId: imageIds[startIndex] || null,
interval,
};
set(state);
startTimer(state, this.next);
},
/**
* Stop slideshow.
*/
stop() {
clearTimer();
set({
isActive: false,
isPaused: false,
currentImageId: null,
imageIds: [],
currentIndex: 0,
interval: DEFAULT_INTERVAL,
});
},
/**
* Pause slideshow.
*/
pause() {
clearTimer();
update((state) => ({ ...state, isPaused: true }));
},
/**
* Resume slideshow.
*/
resume() {
update((state) => {
const newState = { ...state, isPaused: false };
startTimer(newState, this.next);
return newState;
});
},
/**
* Next image.
*/
next() {
update((state) => {
const nextIndex = (state.currentIndex + 1) % state.imageIds.length;
const newState = {
...state,
currentIndex: nextIndex,
currentImageId: state.imageIds[nextIndex],
};
if (!state.isPaused) {
startTimer(newState, this.next);
}
return newState;
});
},
/**
* Previous image.
*/
previous() {
update((state) => {
const prevIndex = (state.currentIndex - 1 + state.imageIds.length) % state.imageIds.length;
return {
...state,
currentIndex: prevIndex,
currentImageId: state.imageIds[prevIndex],
};
});
},
/**
* Set interval.
*/
setInterval(seconds: number) {
update((state) => {
const newState = { ...state, interval: seconds };
if (state.isActive && !state.isPaused) {
startTimer(newState, this.next);
}
return newState;
});
},
};
}
export const slideshowStore = createSlideshowStore();

View File

@@ -0,0 +1,126 @@
/**
* Command registry for command palette.
*/
export interface Command {
id: string;
name: string;
description: string;
category: string;
keywords: string[];
shortcut?: string;
action: () => void | Promise<void>;
}
class CommandRegistry {
private commands: Map<string, Command> = new Map();
private recentlyUsed: string[] = [];
private readonly MAX_RECENT = 10;
/**
* Register a command.
*/
register(command: Command): void {
this.commands.set(command.id, command);
}
/**
* Unregister a command.
*/
unregister(commandId: string): void {
this.commands.delete(commandId);
}
/**
* Get all registered commands.
*/
getAllCommands(): Command[] {
return Array.from(this.commands.values());
}
/**
* Get command by ID.
*/
getCommand(commandId: string): Command | undefined {
return this.commands.get(commandId);
}
/**
* Execute a command.
*/
async execute(commandId: string): Promise<void> {
const command = this.commands.get(commandId);
if (!command) {
console.error(`Command not found: ${commandId}`);
return;
}
try {
await command.action();
this.markAsUsed(commandId);
} catch (error) {
console.error(`Failed to execute command ${commandId}:`, error);
throw error;
}
}
/**
* Mark command as recently used.
*/
private markAsUsed(commandId: string): void {
// Remove if already in list
this.recentlyUsed = this.recentlyUsed.filter((id) => id !== commandId);
// Add to front
this.recentlyUsed.unshift(commandId);
// Keep only MAX_RECENT items
if (this.recentlyUsed.length > this.MAX_RECENT) {
this.recentlyUsed = this.recentlyUsed.slice(0, this.MAX_RECENT);
}
// Persist to localStorage
this.saveRecentlyUsed();
}
/**
* Get recently used commands.
*/
getRecentlyUsed(): Command[] {
return this.recentlyUsed
.map((id) => this.commands.get(id))
.filter((cmd): cmd is Command => cmd !== undefined);
}
/**
* Save recently used commands to localStorage.
*/
private saveRecentlyUsed(): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('webref_recent_commands', JSON.stringify(this.recentlyUsed));
} catch (error) {
console.error('Failed to save recent commands:', error);
}
}
/**
* Load recently used commands from localStorage.
*/
loadRecentlyUsed(): void {
if (typeof window === 'undefined') return;
try {
const saved = localStorage.getItem('webref_recent_commands');
if (saved) {
this.recentlyUsed = JSON.parse(saved);
}
} catch (error) {
console.error('Failed to load recent commands:', error);
}
}
}
// Export singleton instance
export const commandRegistry = new CommandRegistry();

View File

@@ -0,0 +1,93 @@
/**
* Command search and filtering.
*/
import type { Command } from './registry';
/**
* Search commands by query.
*
* @param commands - Array of commands to search
* @param query - Search query
* @returns Filtered and ranked commands
*/
export function searchCommands(commands: Command[], query: string): Command[] {
if (!query || query.trim() === '') {
return commands;
}
const lowerQuery = query.toLowerCase();
// Score each command
const scored = commands
.map((cmd) => ({
command: cmd,
score: calculateScore(cmd, lowerQuery),
}))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score);
return scored.map((item) => item.command);
}
/**
* Calculate relevance score for a command.
*/
function calculateScore(command: Command, query: string): number {
let score = 0;
// Exact name match
if (command.name.toLowerCase() === query) {
score += 100;
}
// Name starts with query
if (command.name.toLowerCase().startsWith(query)) {
score += 50;
}
// Name contains query
if (command.name.toLowerCase().includes(query)) {
score += 25;
}
// Description contains query
if (command.description.toLowerCase().includes(query)) {
score += 10;
}
// Keyword match
for (const keyword of command.keywords) {
if (keyword.toLowerCase() === query) {
score += 30;
} else if (keyword.toLowerCase().startsWith(query)) {
score += 15;
} else if (keyword.toLowerCase().includes(query)) {
score += 5;
}
}
// Category match
if (command.category.toLowerCase().includes(query)) {
score += 5;
}
return score;
}
/**
* Group commands by category.
*/
export function groupCommandsByCategory(commands: Command[]): Map<string, Command[]> {
const grouped = new Map<string, Command[]>();
for (const command of commands) {
const category = command.category || 'Other';
if (!grouped.has(category)) {
grouped.set(category, []);
}
grouped.get(category)!.push(command);
}
return grouped;
}

View File

@@ -0,0 +1,268 @@
<script lang="ts">
/**
* Alignment toolbar component
* Provides UI buttons for alignment and distribution operations
*/
import { createEventDispatcher } from 'svelte';
import { selectionCount } from '$lib/stores/selection';
const dispatch = createEventDispatcher();
$: disabled = $selectionCount < 2;
$: distributeDisabled = $selectionCount < 3;
</script>
<div class="alignment-toolbar">
<div class="toolbar-section">
<div class="section-label">Align</div>
<div class="button-group">
<button
class="toolbar-button"
on:click={() => dispatch('align-left')}
{disabled}
title="Align Left"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="3" y1="6" x2="3" y2="18" />
<rect x="7" y="8" width="10" height="3" />
<rect x="7" y="13" width="7" height="3" />
</svg>
</button>
<button
class="toolbar-button"
on:click={() => dispatch('align-center-h')}
{disabled}
title="Center Horizontal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="6" x2="12" y2="18" />
<rect x="7" y="8" width="10" height="3" />
<rect x="9" y="13" width="6" height="3" />
</svg>
</button>
<button
class="toolbar-button"
on:click={() => dispatch('align-right')}
{disabled}
title="Align Right"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="21" y1="6" x2="21" y2="18" />
<rect x="7" y="8" width="10" height="3" />
<rect x="10" y="13" width="7" height="3" />
</svg>
</button>
<div class="separator" />
<button
class="toolbar-button"
on:click={() => dispatch('align-top')}
{disabled}
title="Align Top"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="6" y1="3" x2="18" y2="3" />
<rect x="8" y="7" width="3" height="10" />
<rect x="13" y="7" width="3" height="7" />
</svg>
</button>
<button
class="toolbar-button"
on:click={() => dispatch('align-center-v')}
{disabled}
title="Center Vertical"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="6" y1="12" x2="18" y2="12" />
<rect x="8" y="7" width="3" height="10" />
<rect x="13" y="9" width="3" height="6" />
</svg>
</button>
<button
class="toolbar-button"
on:click={() => dispatch('align-bottom')}
{disabled}
title="Align Bottom"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="6" y1="21" x2="18" y2="21" />
<rect x="8" y="7" width="3" height="10" />
<rect x="13" y="10" width="3" height="7" />
</svg>
</button>
</div>
</div>
<div class="toolbar-section">
<div class="section-label">Distribute</div>
<div class="button-group">
<button
class="toolbar-button"
on:click={() => dispatch('distribute-h')}
disabled={distributeDisabled}
title="Distribute Horizontal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="5" y="8" width="3" height="8" />
<rect x="11" y="8" width="3" height="8" />
<rect x="17" y="8" width="3" height="8" />
</svg>
</button>
<button
class="toolbar-button"
on:click={() => dispatch('distribute-v')}
disabled={distributeDisabled}
title="Distribute Vertical"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="8" y="5" width="8" height="3" />
<rect x="8" y="11" width="8" height="3" />
<rect x="8" y="17" width="8" height="3" />
</svg>
</button>
</div>
</div>
</div>
<style>
.alignment-toolbar {
display: flex;
gap: 1.5rem;
padding: 0.75rem;
background-color: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.toolbar-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.button-group {
display: flex;
gap: 0.25rem;
align-items: center;
}
.toolbar-button {
width: 36px;
height: 36px;
padding: 0;
background-color: var(--color-bg-secondary, #f9fafb);
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text, #374151);
}
.toolbar-button:hover:not(:disabled) {
background-color: var(--color-bg-hover, #f3f4f6);
border-color: var(--color-primary, #3b82f6);
}
.toolbar-button:active:not(:disabled) {
background-color: var(--color-bg-active, #e5e7eb);
transform: scale(0.95);
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.separator {
width: 1px;
height: 24px;
background-color: var(--color-border, #d1d5db);
margin: 0 0.25rem;
}
svg {
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,245 @@
<script lang="ts">
/**
* Color picker component for groups
* Allows selecting colors for group labels
*/
import { createEventDispatcher } from 'svelte';
import { getGroupColorSuggestions } from '$lib/canvas/operations/group';
export let selectedColor: string = '#3B82F6';
export let show: boolean = false;
const dispatch = createEventDispatcher();
const colorSuggestions = getGroupColorSuggestions();
let customColor = selectedColor;
function handleColorSelect(color: string) {
selectedColor = color;
customColor = color;
dispatch('select', { color });
show = false;
}
function handleCustomColorChange(event: Event) {
const color = (event.target as HTMLInputElement).value;
customColor = color;
}
function handleCustomColorSelect() {
selectedColor = customColor;
dispatch('select', { color: customColor });
show = false;
}
function handleClose() {
show = false;
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
handleClose();
}
}
</script>
{#if show}
<div
class="color-picker-backdrop"
on:click={handleBackdropClick}
on:keydown={(e) => e.key === 'Escape' && handleClose()}
role="button"
tabindex="-1"
>
<div class="color-picker" role="dialog">
<div class="picker-header">
<h4>Choose Color</h4>
<button class="close-button" on:click={handleClose}>×</button>
</div>
<div class="color-presets">
{#each colorSuggestions as color}
<button
class="color-swatch"
class:selected={selectedColor === color}
style="background-color: {color}"
on:click={() => handleColorSelect(color)}
title={color}
>
{#if selectedColor === color}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="white"
stroke-width="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</button>
{/each}
</div>
<div class="custom-color">
<label for="custom-color-input">Custom Color</label>
<div class="custom-color-input">
<input
id="custom-color-input"
type="color"
bind:value={customColor}
on:change={handleCustomColorChange}
/>
<input
type="text"
value={customColor}
on:input={handleCustomColorChange}
placeholder="#RRGGBB"
maxlength="7"
/>
<button class="button-small" on:click={handleCustomColorSelect}>Apply</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.color-picker-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.color-picker {
background-color: var(--color-bg, #ffffff);
border-radius: 0.75rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 320px;
width: 90%;
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.picker-header h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.close-button {
background: none;
border: none;
font-size: 1.75rem;
color: var(--color-text-secondary, #6b7280);
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
}
.close-button:hover {
background-color: var(--color-bg-hover, #f3f4f6);
}
.color-presets {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
padding: 1rem;
}
.color-swatch {
width: 100%;
aspect-ratio: 1;
border: 2px solid transparent;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.color-swatch:hover {
transform: scale(1.1);
border-color: rgba(0, 0, 0, 0.2);
}
.color-swatch.selected {
border-color: var(--color-text, #111827);
box-shadow: 0 0 0 2px var(--color-bg, #ffffff);
}
.custom-color {
padding: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.custom-color label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
margin-bottom: 0.5rem;
}
.custom-color-input {
display: flex;
gap: 0.5rem;
align-items: center;
}
input[type='color'] {
width: 40px;
height: 40px;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
cursor: pointer;
}
input[type='text'] {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 0.875rem;
font-family: monospace;
}
.button-small {
padding: 0.5rem 0.75rem;
background-color: var(--color-primary, #3b82f6);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.button-small:hover {
background-color: var(--color-primary-hover, #2563eb);
}
</style>

View File

@@ -0,0 +1,166 @@
<script lang="ts">
/**
* Grid settings UI component
* Configures grid size, visibility, and snap-to-grid
*/
import { grid } from '$lib/canvas/grid';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function handleSizeChange(event: Event) {
const value = parseInt((event.target as HTMLInputElement).value, 10);
grid.setSize(value);
dispatch('settings-change', { size: value });
}
function handleVisibleToggle() {
grid.toggleVisible();
dispatch('settings-change', { visible: !$grid.visible });
}
function handleSnapToggle() {
grid.toggleSnap();
dispatch('settings-change', { snap: !$grid.snapEnabled });
}
</script>
<div class="grid-settings">
<div class="settings-header">
<h4>Grid Settings</h4>
</div>
<div class="settings-content">
<!-- Grid Visibility -->
<div class="setting-row">
<label for="grid-visible">
<input
id="grid-visible"
type="checkbox"
checked={$grid.visible}
on:change={handleVisibleToggle}
/>
<span>Show Grid</span>
</label>
</div>
<!-- Snap to Grid -->
<div class="setting-row">
<label for="grid-snap">
<input
id="grid-snap"
type="checkbox"
checked={$grid.snapEnabled}
on:change={handleSnapToggle}
/>
<span>Snap to Grid</span>
</label>
</div>
<!-- Grid Size -->
<div class="setting-row">
<label for="grid-size">
Grid Size
<span class="value">{$grid.size}px</span>
</label>
<input
id="grid-size"
type="range"
min="5"
max="200"
step="5"
value={$grid.size}
on:input={handleSizeChange}
/>
</div>
</div>
</div>
<style>
.grid-settings {
background-color: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1rem;
min-width: 250px;
}
.settings-header {
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.settings-header h4 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.settings-content {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.setting-row {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-text-secondary, #6b7280);
cursor: pointer;
}
label span {
flex: 1;
}
.value {
font-weight: 600;
color: var(--color-text, #374151);
}
input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
}
input[type='range'] {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--color-bg-secondary, #e5e7eb);
outline: none;
appearance: none;
-webkit-appearance: none;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-primary, #3b82f6);
cursor: pointer;
}
input[type='range']::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-primary, #3b82f6);
cursor: pointer;
border: none;
}
</style>

View File

@@ -0,0 +1,266 @@
<script lang="ts">
/**
* Group annotation UI component
* Displays and edits group name, color, and annotation
*/
import { createEventDispatcher } from 'svelte';
import type { Group } from '$lib/api/groups';
export let group: Group;
export let editing: boolean = false;
const dispatch = createEventDispatcher();
let editName = group.name;
let editAnnotation = group.annotation || '';
function handleSave() {
dispatch('save', {
name: editName,
annotation: editAnnotation || null,
});
editing = false;
}
function handleCancel() {
editName = group.name;
editAnnotation = group.annotation || '';
editing = false;
}
function handleEdit() {
editing = true;
}
function handleDelete() {
dispatch('delete');
}
function handleColorChange() {
dispatch('color-change');
}
</script>
<div class="group-annotation" style="border-left: 4px solid {group.color}">
<div class="annotation-header">
<button
class="color-indicator"
style="background-color: {group.color}"
on:click={handleColorChange}
title="Change color"
/>
{#if editing}
<input
type="text"
bind:value={editName}
class="name-input"
placeholder="Group name"
maxlength="255"
/>
{:else}
<h4 class="group-name" on:dblclick={handleEdit}>{group.name}</h4>
{/if}
<div class="header-actions">
<span class="member-count" title="{group.member_count} images">
{group.member_count}
</span>
{#if !editing}
<button class="icon-button" on:click={handleEdit} title="Edit group"></button>
<button class="icon-button delete" on:click={handleDelete} title="Delete group"> × </button>
{/if}
</div>
</div>
<div class="annotation-body">
{#if editing}
<textarea
bind:value={editAnnotation}
class="annotation-input"
placeholder="Add annotation..."
maxlength="10000"
rows="3"
/>
<div class="edit-actions">
<button class="button button-secondary" on:click={handleCancel}>Cancel</button>
<button class="button button-primary" on:click={handleSave}>Save</button>
</div>
{:else if group.annotation}
<p class="annotation-text" on:dblclick={handleEdit}>{group.annotation}</p>
{:else}
<p class="annotation-empty" on:dblclick={handleEdit}>No annotation</p>
{/if}
</div>
</div>
<style>
.group-annotation {
background-color: var(--color-bg, #ffffff);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
}
.annotation-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.color-indicator {
width: 24px;
height: 24px;
border-radius: 0.375rem;
cursor: pointer;
border: 2px solid rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
padding: 0;
}
.color-indicator:hover {
transform: scale(1.1);
}
.group-name {
flex: 1;
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #111827);
cursor: pointer;
}
.group-name:hover {
color: var(--color-primary, #3b82f6);
}
.name-input {
flex: 1;
padding: 0.375rem 0.5rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.member-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 0.5rem;
background-color: var(--color-bg-secondary, #f3f4f6);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
}
.icon-button {
width: 28px;
height: 28px;
padding: 0;
background: none;
border: none;
cursor: pointer;
border-radius: 0.25rem;
font-size: 1.125rem;
color: var(--color-text-secondary, #6b7280);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.icon-button:hover {
background-color: var(--color-bg-hover, #f3f4f6);
color: var(--color-text, #374151);
}
.icon-button.delete:hover {
background-color: var(--color-error-bg, #fee2e2);
color: var(--color-error, #ef4444);
}
.annotation-body {
margin-top: 0.75rem;
}
.annotation-text {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-text-secondary, #6b7280);
white-space: pre-wrap;
cursor: pointer;
}
.annotation-text:hover {
color: var(--color-text, #374151);
}
.annotation-empty {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted, #9ca3af);
font-style: italic;
cursor: pointer;
}
.annotation-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 0.875rem;
font-family: inherit;
resize: vertical;
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.75rem;
}
.button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.button-secondary {
background-color: var(--color-bg-secondary, #f3f4f6);
color: var(--color-text, #374151);
border-color: var(--color-border, #d1d5db);
}
.button-secondary:hover {
background-color: var(--color-bg-hover, #e5e7eb);
}
.button-primary {
background-color: var(--color-primary, #3b82f6);
color: white;
border: none;
}
.button-primary:hover {
background-color: var(--color-primary-hover, #2563eb);
}
</style>

View File

@@ -0,0 +1,212 @@
<script lang="ts">
import { onMount } from 'svelte';
import { commandRegistry, type Command } from '$lib/commands/registry';
import { searchCommands } from '$lib/commands/search';
export let isOpen: boolean = false;
export let onClose: () => void;
let searchQuery = '';
let allCommands: Command[] = [];
let filteredCommands: Command[] = [];
let selectedIndex = 0;
let searchInput: HTMLInputElement | null = null;
$: {
if (searchQuery) {
filteredCommands = searchCommands(allCommands, searchQuery);
} else {
// Show recently used first when no query
const recent = commandRegistry.getRecentlyUsed();
const otherCommands = allCommands.filter((cmd) => !recent.find((r) => r.id === cmd.id));
filteredCommands = [...recent, ...otherCommands];
}
selectedIndex = 0; // Reset selection when results change
}
onMount(() => {
allCommands = commandRegistry.getAllCommands();
commandRegistry.loadRecentlyUsed();
filteredCommands = commandRegistry.getRecentlyUsed();
// Focus search input when opened
if (isOpen && searchInput) {
searchInput.focus();
}
});
function handleKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filteredCommands.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
break;
case 'Enter':
event.preventDefault();
executeSelected();
break;
case 'Escape':
event.preventDefault();
onClose();
break;
}
}
async function executeSelected() {
const command = filteredCommands[selectedIndex];
if (command) {
try {
await commandRegistry.execute(command.id);
onClose();
} catch (error) {
console.error('Command execution failed:', error);
}
}
}
function handleCommandClick(command: Command) {
commandRegistry.execute(command.id);
onClose();
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
</script>
{#if isOpen}
<div
class="palette-overlay"
role="button"
tabindex="0"
on:click={handleOverlayClick}
on:keydown={handleKeyDown}
>
<div class="palette" role="dialog" aria-modal="true">
<input
bind:this={searchInput}
type="text"
class="search-input"
placeholder="Type a command or search..."
bind:value={searchQuery}
on:keydown={handleKeyDown}
/>
<div class="commands-list">
{#if filteredCommands.length === 0}
<div class="no-results">No commands found</div>
{:else}
{#each filteredCommands as command, index}
<button
class="command-item"
class:selected={index === selectedIndex}
on:click={() => handleCommandClick(command)}
>
<div class="command-info">
<span class="command-name">{command.name}</span>
<span class="command-description">{command.description}</span>
</div>
{#if command.shortcut}
<span class="command-shortcut">{command.shortcut}</span>
{/if}
</button>
{/each}
{/if}
</div>
</div>
</div>
{/if}
<style>
.palette-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
z-index: 9999;
}
.palette {
background: white;
border-radius: 8px;
width: 90%;
max-width: 600px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.search-input {
width: 100%;
padding: 1rem;
border: none;
border-bottom: 1px solid #e5e7eb;
font-size: 1.125rem;
outline: none;
}
.commands-list {
max-height: 400px;
overflow-y: auto;
}
.no-results {
padding: 2rem;
text-align: center;
color: #9ca3af;
}
.command-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border: none;
background: white;
text-align: left;
cursor: pointer;
transition: background-color 0.15s;
}
.command-item:hover,
.command-item.selected {
background: #f3f4f6;
}
.command-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.command-name {
font-weight: 500;
color: #111827;
}
.command-description {
font-size: 0.875rem;
color: #6b7280;
}
.command-shortcut {
padding: 0.25rem 0.5rem;
background: #e5e7eb;
border-radius: 4px;
font-size: 0.75rem;
font-family: monospace;
color: #6b7280;
}
</style>

View File

@@ -0,0 +1,339 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
exportBoardZip,
exportBoardComposite,
getExportInfo,
type ExportInfo,
} from '$lib/api/export';
export let boardId: string;
export let onClose: () => void;
let exportInfo: ExportInfo | null = null;
let loading = false;
let error = '';
let exportType: 'zip' | 'composite' = 'zip';
let compositeScale: number = 1.0;
let compositeFormat: 'PNG' | 'JPEG' = 'PNG';
let progress = 0;
let exporting = false;
onMount(async () => {
await loadExportInfo();
});
async function loadExportInfo() {
try {
loading = true;
exportInfo = await getExportInfo(boardId);
} catch (err: any) {
error = `Failed to load export info: ${err.message || err}`;
} finally {
loading = false;
}
}
async function handleExport() {
try {
exporting = true;
progress = 0;
error = '';
// Simulate progress (since we don't have real progress tracking yet)
const progressInterval = setInterval(() => {
if (progress < 90) {
progress += 10;
}
}, 200);
if (exportType === 'zip') {
await exportBoardZip(boardId);
} else {
await exportBoardComposite(boardId, compositeScale, compositeFormat);
}
clearInterval(progressInterval);
progress = 100;
// Close modal after short delay
setTimeout(() => {
onClose();
}, 500);
} catch (err: any) {
error = `Export failed: ${err.message || err}`;
} finally {
exporting = false;
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<div
class="modal-overlay"
role="button"
tabindex="0"
on:click={handleOverlayClick}
on:keydown={handleKeyDown}
>
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Export Board</h2>
<button class="close-btn" on:click={onClose}>×</button>
</div>
<div class="modal-body">
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if loading}
<p>Loading export information...</p>
{:else if exportInfo}
<div class="export-info">
<p><strong>{exportInfo.image_count}</strong> images</p>
<p>Total size: <strong>{formatBytes(exportInfo.total_size_bytes)}</strong></p>
</div>
<div class="export-options">
<div class="form-group">
<label>
<input type="radio" bind:group={exportType} value="zip" />
<span>ZIP Archive</span>
</label>
<p class="option-description">
Download all images as individual files in a ZIP archive
</p>
</div>
<div class="form-group">
<label>
<input type="radio" bind:group={exportType} value="composite" />
<span>Composite Image</span>
</label>
<p class="option-description">Export the entire board layout as a single image</p>
</div>
{#if exportType === 'composite'}
<div class="composite-options">
<div class="form-group">
<label for="scale">Resolution:</label>
<select id="scale" bind:value={compositeScale}>
<option value={0.5}>0.5x (Half)</option>
<option value={1.0}>1x (Original)</option>
<option value={2.0}>2x (Double)</option>
<option value={4.0}>4x (Quadruple)</option>
</select>
</div>
<div class="form-group">
<label for="format">Format:</label>
<select id="format" bind:value={compositeFormat}>
<option value="PNG">PNG (Lossless)</option>
<option value="JPEG">JPEG (Smaller file)</option>
</select>
</div>
</div>
{/if}
</div>
{#if exporting}
<div class="progress-section">
<div class="progress-bar">
<div class="progress-fill" style="width: {progress}%"></div>
</div>
<p class="progress-text">{progress}% Complete</p>
</div>
{/if}
<div class="modal-actions">
<button class="btn-cancel" on:click={onClose} disabled={exporting}> Cancel </button>
<button class="btn-export" on:click={handleExport} disabled={exporting}>
{exporting ? 'Exporting...' : 'Export'}
</button>
</div>
{/if}
</div>
</div>
</div>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #6b7280;
}
.modal-body {
padding: 1.5rem;
}
.error-message {
background: #fee2e2;
color: #991b1b;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.export-info {
background: #f3f4f6;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
}
.export-info p {
margin: 0.5rem 0;
}
.export-options {
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
cursor: pointer;
}
.option-description {
margin: 0.25rem 0 0 1.75rem;
font-size: 0.875rem;
color: #6b7280;
}
.composite-options {
margin-left: 1.75rem;
padding: 1rem;
background: #f9fafb;
border-radius: 4px;
}
select {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
margin-top: 0.25rem;
}
.progress-section {
margin: 1.5rem 0;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}
.progress-text {
text-align: center;
margin-top: 0.5rem;
font-size: 0.875rem;
color: #6b7280;
}
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.btn-cancel,
.btn-export {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.btn-cancel {
background: #e5e7eb;
color: #374151;
}
.btn-export {
background: #3b82f6;
color: white;
}
.btn-cancel:disabled,
.btn-export:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,187 @@
<script lang="ts">
import { qualityStore, type QualityMode, type QualityLevel } from '$lib/stores/quality';
import { runConnectionTest } from '$lib/utils/adaptive-quality';
let mode: QualityMode = 'auto';
let manualLevel: QualityLevel = 'medium';
let detectedLevel: QualityLevel = 'medium';
let connectionSpeed: number = 0;
let testing = false;
// Subscribe to quality store
qualityStore.subscribe((settings) => {
mode = settings.mode;
manualLevel = settings.manualLevel;
detectedLevel = settings.detectedLevel;
connectionSpeed = settings.connectionSpeed;
});
function handleModeChange(event: Event) {
const target = event.target as HTMLSelectElement;
const newMode = target.value as QualityMode;
qualityStore.setMode(newMode);
// Run test immediately when switching to auto mode
if (newMode === 'auto') {
handleTestConnection();
}
}
function handleManualLevelChange(event: Event) {
const target = event.target as HTMLSelectElement;
const newLevel = target.value as QualityLevel;
qualityStore.setManualLevel(newLevel);
}
async function handleTestConnection() {
testing = true;
try {
await runConnectionTest();
} finally {
testing = false;
}
}
function formatSpeed(mbps: number): string {
if (mbps < 1) {
return `${(mbps * 1000).toFixed(0)} Kbps`;
}
return `${mbps.toFixed(1)} Mbps`;
}
</script>
<div class="quality-selector">
<h3>Image Quality Settings</h3>
<div class="form-group">
<label for="mode">Mode:</label>
<select id="mode" value={mode} on:change={handleModeChange}>
<option value="auto">Auto (Detect Connection Speed)</option>
<option value="manual">Manual</option>
</select>
</div>
{#if mode === 'auto'}
<div class="auto-section">
<div class="detected-info">
<p>
<strong>Detected Speed:</strong>
{formatSpeed(connectionSpeed)}
</p>
<p>
<strong>Quality Level:</strong>
<span class="quality-badge {detectedLevel}">{detectedLevel}</span>
</p>
</div>
<button class="btn-test" on:click={handleTestConnection} disabled={testing}>
{testing ? 'Testing...' : 'Test Now'}
</button>
<p class="help-text">Connection speed is re-tested every 5 minutes</p>
</div>
{:else}
<div class="manual-section">
<div class="form-group">
<label for="manual-level">Quality Level:</label>
<select id="manual-level" value={manualLevel} on:change={handleManualLevelChange}>
<option value="low">Low (Fast loading, lower quality)</option>
<option value="medium">Medium (Balanced)</option>
<option value="high">High (Best quality, slower)</option>
<option value="original">Original (Full resolution)</option>
</select>
</div>
</div>
{/if}
</div>
<style>
.quality-selector {
padding: 1rem;
background: #f9fafb;
border-radius: 8px;
}
h3 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
select {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 1rem;
}
.auto-section,
.manual-section {
margin-top: 1rem;
padding: 1rem;
background: white;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.detected-info {
margin-bottom: 1rem;
}
.detected-info p {
margin: 0.5rem 0;
}
.quality-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 500;
text-transform: capitalize;
}
.quality-badge.low {
background: #fee2e2;
color: #991b1b;
}
.quality-badge.medium {
background: #fef3c7;
color: #92400e;
}
.quality-badge.high {
background: #d1fae5;
color: #065f46;
}
.btn-test {
background: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.btn-test:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.help-text {
font-size: 0.75rem;
color: #6b7280;
margin: 0;
}
</style>

View File

@@ -0,0 +1,314 @@
<script lang="ts">
import {
createShareLink,
listShareLinks,
revokeShareLink,
getShareUrl,
type ShareLink,
} from '$lib/api/sharing';
import { onMount } from 'svelte';
export let boardId: string;
export let onClose: () => void;
let shareLinks: ShareLink[] = [];
let permissionLevel: 'view-only' | 'view-comment' = 'view-only';
let loading = false;
let error = '';
onMount(async () => {
await loadShareLinks();
});
async function loadShareLinks() {
try {
loading = true;
shareLinks = await listShareLinks(boardId);
} catch (err) {
error = `Failed to load share links: ${err}`;
} finally {
loading = false;
}
}
async function handleCreateLink() {
try {
loading = true;
error = '';
await createShareLink(boardId, { permission_level: permissionLevel });
await loadShareLinks();
} catch (err) {
error = `Failed to create share link: ${err}`;
} finally {
loading = false;
}
}
async function handleRevokeLink(linkId: string) {
try {
loading = true;
error = '';
await revokeShareLink(boardId, linkId);
await loadShareLinks();
} catch (err) {
error = `Failed to revoke share link: ${err}`;
} finally {
loading = false;
}
}
function copyToClipboard(token: string) {
const url = getShareUrl(token);
navigator.clipboard.writeText(url);
}
function handleOverlayClick(event: MouseEvent) {
// Only close if clicking directly on the overlay, not its children
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<div
class="modal-overlay"
role="button"
tabindex="0"
on:click={handleOverlayClick}
on:keydown={handleKeyDown}
>
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Share Board</h2>
<button class="close-btn" on:click={onClose}>×</button>
</div>
<div class="modal-body">
{#if error}
<div class="error-message">{error}</div>
{/if}
<div class="create-section">
<h3>Create New Share Link</h3>
<div class="form-group">
<label for="permission">Permission Level:</label>
<select id="permission" bind:value={permissionLevel}>
<option value="view-only">View Only</option>
<option value="view-comment">View + Comment</option>
</select>
</div>
<button class="btn-primary" on:click={handleCreateLink} disabled={loading}>
Create Link
</button>
</div>
<div class="links-section">
<h3>Existing Share Links</h3>
{#if loading}
<p>Loading...</p>
{:else if shareLinks.length === 0}
<p>No share links yet.</p>
{:else}
<div class="links-list">
{#each shareLinks as link}
<div class="link-item" class:revoked={link.is_revoked}>
<div class="link-info">
<span class="permission-badge">{link.permission_level}</span>
<span class="access-count">{link.access_count} views</span>
{#if link.is_revoked}
<span class="revoked-badge">Revoked</span>
{/if}
</div>
<div class="link-actions">
{#if !link.is_revoked}
<button class="btn-copy" on:click={() => copyToClipboard(link.token)}>
Copy Link
</button>
<button class="btn-danger" on:click={() => handleRevokeLink(link.id)}>
Revoke
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #6b7280;
}
.modal-body {
padding: 1.5rem;
}
.error-message {
background: #fee2e2;
color: #991b1b;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.create-section,
.links-section {
margin-bottom: 2rem;
}
h3 {
font-size: 1.125rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
select {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
}
.btn-primary {
background: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.links-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.link-item {
border: 1px solid #e5e7eb;
padding: 1rem;
border-radius: 4px;
}
.link-item.revoked {
opacity: 0.6;
background: #f3f4f6;
}
.link-info {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.permission-badge,
.revoked-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.permission-badge {
background: #dbeafe;
color: #1e40af;
}
.revoked-badge {
background: #fee2e2;
color: #991b1b;
}
.access-count {
color: #6b7280;
font-size: 0.875rem;
}
.link-actions {
display: flex;
gap: 0.5rem;
}
.btn-copy,
.btn-danger {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-copy {
background: #10b981;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
</style>

View File

@@ -0,0 +1,158 @@
/**
* Groups store for managing image groups
* Handles group state and operations
*/
import { writable, derived } from 'svelte/store';
import type { Writable } from 'svelte/store';
import * as groupsApi from '$lib/api/groups';
import type { Group, GroupCreateData, GroupUpdateData } from '$lib/api/groups';
export interface GroupsState {
groups: Group[];
loading: boolean;
error: string | null;
}
const DEFAULT_STATE: GroupsState = {
groups: [],
loading: false,
error: null,
};
/**
* Create groups store
*/
function createGroupsStore() {
const { subscribe, set, update }: Writable<GroupsState> = writable(DEFAULT_STATE);
return {
subscribe,
set,
update,
/**
* Load groups for a board
*/
load: async (boardId: string) => {
update((state) => ({ ...state, loading: true, error: null }));
try {
const groups = await groupsApi.listGroups(boardId);
set({ groups, loading: false, error: null });
} catch (error) {
update((state) => ({
...state,
loading: false,
error: error instanceof Error ? error.message : 'Failed to load groups',
}));
}
},
/**
* Create a new group
*/
create: async (boardId: string, data: GroupCreateData): Promise<Group | null> => {
update((state) => ({ ...state, loading: true, error: null }));
try {
const group = await groupsApi.createGroup(boardId, data);
update((state) => ({
groups: [group, ...state.groups],
loading: false,
error: null,
}));
return group;
} catch (error) {
update((state) => ({
...state,
loading: false,
error: error instanceof Error ? error.message : 'Failed to create group',
}));
return null;
}
},
/**
* Update a group
*/
updateGroup: async (
boardId: string,
groupId: string,
data: GroupUpdateData
): Promise<Group | null> => {
update((state) => ({ ...state, loading: true, error: null }));
try {
const group = await groupsApi.updateGroup(boardId, groupId, data);
update((state) => ({
groups: state.groups.map((g) => (g.id === groupId ? group : g)),
loading: false,
error: null,
}));
return group;
} catch (error) {
update((state) => ({
...state,
loading: false,
error: error instanceof Error ? error.message : 'Failed to update group',
}));
return null;
}
},
/**
* Delete a group
*/
delete: async (boardId: string, groupId: string): Promise<boolean> => {
update((state) => ({ ...state, loading: true, error: null }));
try {
await groupsApi.deleteGroup(boardId, groupId);
update((state) => ({
groups: state.groups.filter((g) => g.id !== groupId),
loading: false,
error: null,
}));
return true;
} catch (error) {
update((state) => ({
...state,
loading: false,
error: error instanceof Error ? error.message : 'Failed to delete group',
}));
return false;
}
},
/**
* Get group by ID
*/
getById: (groupId: string): Group | null => {
let result: Group | null = null;
const unsubscribe = subscribe((state) => {
result = state.groups.find((g) => g.id === groupId) || null;
});
unsubscribe();
return result;
},
/**
* Clear all groups
*/
clear: () => {
set(DEFAULT_STATE);
},
};
}
export const groups = createGroupsStore();
// Derived stores
export const groupsLoading = derived(groups, ($groups) => $groups.loading);
export const groupsError = derived(groups, ($groups) => $groups.error);
export const groupsList = derived(groups, ($groups) => $groups.groups);
export const groupCount = derived(groups, ($groups) => $groups.groups.length);

View File

@@ -83,7 +83,8 @@ export async function uploadSingleImage(file: File): Promise<Image> {
return image;
} catch (error: unknown) {
// Update progress to error
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
const errorMessage =
(error as { error?: string })?.error || (error as Error)?.message || 'Upload failed';
uploadProgress.update((items) =>
items.map((item) =>
item.filename === file.name ? { ...item, status: 'error', error: errorMessage } : item

View File

@@ -0,0 +1,138 @@
/**
* Quality settings store for adaptive image quality.
*/
import { writable, derived } from 'svelte/store';
import type { Writable, Readable } from 'svelte/store';
export type QualityLevel = 'low' | 'medium' | 'high' | 'original';
export type QualityMode = 'auto' | 'manual';
export interface QualitySettings {
mode: QualityMode;
manualLevel: QualityLevel;
detectedLevel: QualityLevel;
connectionSpeed: number; // Mbps
lastTestTime: number; // timestamp
}
const STORAGE_KEY = 'webref_quality_settings';
const RETEST_INTERVAL = 5 * 60 * 1000; // 5 minutes
// Load saved settings from localStorage
function loadSettings(): QualitySettings {
if (typeof window === 'undefined') {
return getDefaultSettings();
}
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
return JSON.parse(saved);
}
} catch (error) {
console.error('Failed to load quality settings:', error);
}
return getDefaultSettings();
}
function getDefaultSettings(): QualitySettings {
return {
mode: 'auto',
manualLevel: 'medium',
detectedLevel: 'medium',
connectionSpeed: 3.0,
lastTestTime: 0,
};
}
// Save settings to localStorage
function saveSettings(settings: QualitySettings): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (error) {
console.error('Failed to save quality settings:', error);
}
}
// Create the store
function createQualityStore() {
const { subscribe, set, update }: Writable<QualitySettings> = writable(loadSettings());
return {
subscribe,
/**
* Set quality mode (auto or manual).
*/
setMode(mode: QualityMode) {
update((settings) => {
const updated = { ...settings, mode };
saveSettings(updated);
return updated;
});
},
/**
* Set manual quality level.
*/
setManualLevel(level: QualityLevel) {
update((settings) => {
const updated = { ...settings, manualLevel: level };
saveSettings(updated);
return updated;
});
},
/**
* Update detected quality level based on connection test.
*/
updateDetectedQuality(speed: number, level: QualityLevel) {
update((settings) => {
const updated = {
...settings,
detectedLevel: level,
connectionSpeed: speed,
lastTestTime: Date.now(),
};
saveSettings(updated);
return updated;
});
},
/**
* Check if connection test should be run.
*/
shouldRetest(): boolean {
const settings = loadSettings();
if (settings.mode !== 'auto') return false;
const timeSinceTest = Date.now() - settings.lastTestTime;
return timeSinceTest > RETEST_INTERVAL;
},
/**
* Reset to default settings.
*/
reset() {
const defaults = getDefaultSettings();
set(defaults);
saveSettings(defaults);
},
};
}
// Export the store
export const qualityStore = createQualityStore();
// Derived store for active quality level (respects mode)
export const activeQuality: Readable<QualityLevel> = derived(qualityStore, ($quality) => {
if ($quality.mode === 'manual') {
return $quality.manualLevel;
} else {
return $quality.detectedLevel;
}
});

View File

@@ -0,0 +1,80 @@
/**
* Adaptive image quality logic.
*/
import { testConnectionSpeed, determineQualityTier } from './connection-test';
import { qualityStore } from '$lib/stores/quality';
import { get } from 'svelte/store';
/**
* Initialize adaptive quality system.
* Tests connection speed if in auto mode and needed.
*/
export async function initializeAdaptiveQuality(): Promise<void> {
const settings = get(qualityStore);
if (settings.mode === 'auto' && qualityStore.shouldRetest()) {
await runConnectionTest();
}
// Set up periodic re-testing in auto mode
if (settings.mode === 'auto') {
schedulePeriodicTest();
}
}
/**
* Run connection speed test and update quality settings.
*/
export async function runConnectionTest(): Promise<void> {
try {
const result = await testConnectionSpeed();
const qualityLevel = determineQualityTier(result.speed_mbps);
qualityStore.updateDetectedQuality(result.speed_mbps, qualityLevel);
} catch (error) {
console.error('Connection test failed:', error);
// Keep current settings on error
}
}
/**
* Schedule periodic connection testing (every 5 minutes in auto mode).
*/
function schedulePeriodicTest(): void {
const RETEST_INTERVAL = 5 * 60 * 1000; // 5 minutes
setInterval(() => {
const settings = get(qualityStore);
if (settings.mode === 'auto') {
runConnectionTest();
}
}, RETEST_INTERVAL);
}
/**
* Get thumbnail URL for specified quality level.
*
* @param imageId - Image UUID
* @param quality - Quality level
* @returns Thumbnail URL
*/
export function getThumbnailUrl(
imageId: string,
quality: 'low' | 'medium' | 'high' | 'original' = 'medium'
): string {
const apiBase = 'http://localhost:8000/api/v1';
return `${apiBase}/images/${imageId}/serve?quality=${quality}`;
}
/**
* Get appropriate thumbnail URL based on current quality settings.
*
* @param imageId - Image UUID
* @returns Thumbnail URL for current quality level
*/
export function getAdaptiveThumbnailUrl(imageId: string): string {
const settings = get(qualityStore);
const quality = settings.mode === 'auto' ? settings.detectedLevel : settings.manualLevel;
return getThumbnailUrl(imageId, quality);
}

View File

@@ -0,0 +1,120 @@
/**
* Connection speed testing utilities.
*/
export interface ConnectionTestResult {
speed_mbps: number;
latency_ms: number;
quality_tier: 'low' | 'medium' | 'high';
}
/**
* Test connection speed by downloading test data.
*
* @param testSizeBytes - Size of test data to download (default 100KB)
* @returns Connection test results
*/
export async function testConnectionSpeed(
testSizeBytes: number = 100000
): Promise<ConnectionTestResult> {
try {
// Use Network Information API if available
interface NavigatorWithConnection extends Navigator {
connection?: {
effectiveType?: string;
};
}
const connection = (navigator as NavigatorWithConnection).connection;
if (connection && connection.effectiveType) {
const effectiveType = connection.effectiveType;
return estimateFromEffectiveType(effectiveType);
}
// Fall back to download speed test
const startTime = performance.now();
const response = await fetch(`/api/v1/connection/test-data?size=${testSizeBytes}`, {
method: 'GET',
cache: 'no-cache',
});
if (!response.ok) {
throw new Error('Connection test failed');
}
// Download the data
const data = await response.arrayBuffer();
const endTime = performance.now();
// Calculate speed
const durationSeconds = (endTime - startTime) / 1000;
const dataSizeBits = data.byteLength * 8;
const speedMbps = dataSizeBits / durationSeconds / 1_000_000;
const latencyMs = endTime - startTime;
// Determine quality tier
const qualityTier = determineQualityTier(speedMbps);
return {
speed_mbps: speedMbps,
latency_ms: latencyMs,
quality_tier: qualityTier,
};
} catch (error) {
console.error('Connection test failed:', error);
// Return medium quality as fallback
return {
speed_mbps: 3.0,
latency_ms: 100,
quality_tier: 'medium',
};
}
}
/**
* Estimate connection speed from Network Information API effective type.
*
* @param effectiveType - Effective connection type from Network Information API
* @returns Estimated connection test result
*/
function estimateFromEffectiveType(effectiveType: string): ConnectionTestResult {
const estimates: Record<string, ConnectionTestResult> = {
'slow-2g': { speed_mbps: 0.05, latency_ms: 2000, quality_tier: 'low' },
'2g': { speed_mbps: 0.25, latency_ms: 1400, quality_tier: 'low' },
'3g': { speed_mbps: 0.7, latency_ms: 270, quality_tier: 'low' },
'4g': { speed_mbps: 10.0, latency_ms: 50, quality_tier: 'high' },
};
return estimates[effectiveType] || estimates['4g'];
}
/**
* Determine quality tier based on connection speed.
*
* @param speedMbps - Connection speed in Mbps
* @returns Quality tier
*/
export function determineQualityTier(speedMbps: number): 'low' | 'medium' | 'high' {
if (speedMbps < 1.0) {
return 'low';
} else if (speedMbps < 5.0) {
return 'medium';
} else {
return 'high';
}
}
/**
* Check if Network Information API is available.
*
* @returns True if available
*/
export function isNetworkInformationAvailable(): boolean {
interface NavigatorWithConnection extends Navigator {
connection?: {
effectiveType?: string;
};
}
const nav = navigator as NavigatorWithConnection;
return 'connection' in nav && !!nav.connection && 'effectiveType' in nav.connection;
}

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
onMount(() => {
// Client-side redirect based on auth token
if (browser) {
const token = localStorage.getItem('auth_token');
if (token) {
goto('/boards');
} else {
goto('/login');
}
}
});
</script>
<svelte:head>
<title>Reference Board Viewer</title>
</svelte:head>
<div class="loading-container">
<div class="spinner"></div>
<p>Loading...</p>
</div>
<style>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
p {
color: #6b7280;
font-size: 1rem;
}
</style>

View File

@@ -0,0 +1,2 @@
// Disable server-side rendering for the root page
export const ssr = false;

View File

@@ -0,0 +1,684 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { boards, currentBoard } from '$lib/stores/boards';
import {
boardImages,
loadBoardImages,
uploadSingleImage,
uploadZipFile,
addImageToBoard,
} from '$lib/stores/images';
import { viewport } from '$lib/stores/viewport';
import Stage from '$lib/canvas/Stage.svelte';
import CanvasImage from '$lib/canvas/Image.svelte';
import { tick } from 'svelte';
import * as imagesApi from '$lib/api/images';
let loading = true;
let error = '';
let uploadError = '';
let uploadSuccess = '';
let uploading = false;
let fileInput: HTMLInputElement;
let canvasContainer: HTMLDivElement;
let canvasWidth = 0;
let canvasHeight = 0;
let stageComponent: Stage;
let stageReady = false;
let loadedImagesCount = 0;
$: boardId = $page.params.id;
$: canvasLayer = stageReady && stageComponent ? stageComponent.getLayer() : null;
// Track loaded images and force redraw
$: if (loadedImagesCount > 0 && stageComponent) {
const layer = stageComponent.getLayer();
if (layer) {
setTimeout(() => layer.batchDraw(), 50);
}
}
onMount(() => {
const init = async () => {
try {
await boards.loadBoard(boardId);
await loadBoardImages(boardId);
// Load viewport state from board if available
if ($currentBoard?.viewport_state) {
viewport.loadState($currentBoard.viewport_state);
} else {
// Reset to default if no saved state
viewport.reset();
}
// Set canvas dimensions BEFORE creating stage
updateCanvasDimensions();
// Wait for dimensions to be set
await tick();
// Double-check dimensions are valid
if (canvasWidth === 0 || canvasHeight === 0) {
console.warn('Canvas dimensions are 0, forcing update...');
updateCanvasDimensions();
await tick();
}
window.addEventListener('resize', updateCanvasDimensions);
loading = false;
} catch (err: unknown) {
error = (err as { error?: string })?.error || 'Failed to load board';
loading = false;
}
};
init();
return () => {
window.removeEventListener('resize', updateCanvasDimensions);
};
});
function updateCanvasDimensions() {
if (canvasContainer) {
canvasWidth = canvasContainer.clientWidth;
canvasHeight = canvasContainer.clientHeight;
}
}
async function handleStageReady() {
// Wait for next tick to ensure layer is fully ready
await tick();
stageReady = true;
loadedImagesCount = 0; // Reset counter
}
function handleImageLoaded(_imageId: string) {
loadedImagesCount++;
// Force immediate redraw on each image load
if (stageComponent) {
const layer = stageComponent.getLayer();
const stage = stageComponent.getStage();
if (layer && stage) {
layer.batchDraw();
stage.batchDraw();
}
}
// When all images loaded, auto-fit to view on first load
if (loadedImagesCount === $boardImages.length) {
const layer = stageComponent?.getLayer();
const stage = stageComponent?.getStage();
if (layer && stage) {
// Multiple redraws to ensure visibility
setTimeout(() => {
layer.batchDraw();
stage.batchDraw();
}, 0);
setTimeout(() => {
layer.batchDraw();
stage.batchDraw();
}, 100);
setTimeout(() => {
layer.batchDraw();
stage.batchDraw();
// Auto-fit images on first load (if they're off-screen)
const hasOffscreenImages = $boardImages.some(
(bi) =>
bi.position.x < -canvasWidth ||
bi.position.y < -canvasHeight ||
bi.position.x > canvasWidth * 2 ||
bi.position.y > canvasHeight * 2
);
if (hasOffscreenImages) {
fitAllImages();
}
}, 250);
}
}
}
async function handleImageDragEnd(imageId: string, x: number, y: number) {
// Update position on backend
try {
const boardImage = $boardImages.find((bi) => bi.id === imageId);
if (!boardImage) return;
await imagesApi.updateBoardImage(boardId, boardImage.image_id, {
position: { x, y },
});
// Update local store
boardImages.update((images) =>
images.map((img) => (img.id === imageId ? { ...img, position: { x, y } } : img))
);
} catch (error) {
console.error('Failed to update image position:', error);
}
}
function handleImageSelectionChange(_imageId: string, _isSelected: boolean) {
// Selection handling
}
async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
if (!target.files || target.files.length === 0) return;
await processFiles(Array.from(target.files));
// Reset input
if (target) target.value = '';
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
if (!event.dataTransfer?.files) return;
await processFiles(Array.from(event.dataTransfer.files));
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
}
async function processFiles(files: File[]) {
if (files.length === 0) return;
uploading = true;
uploadError = '';
uploadSuccess = '';
try {
let totalUploaded = 0;
// Calculate starting position (centered on screen with some spacing)
let currentX = canvasWidth / 2 - 200;
let currentY = canvasHeight / 2 - 200;
for (const file of files) {
// Upload to library first
if (file.name.toLowerCase().endsWith('.zip')) {
const images = await uploadZipFile(file);
// Add each image to board with spaced positions
for (const img of images) {
await addImageToBoard(boardId, img.id, { x: currentX, y: currentY }, totalUploaded);
// Offset next image
currentX += 50;
currentY += 50;
}
totalUploaded += images.length;
} else if (file.type.startsWith('image/')) {
const image = await uploadSingleImage(file);
// Add to board at calculated position
await addImageToBoard(boardId, image.id, { x: currentX, y: currentY }, totalUploaded);
// Offset next image
currentX += 50;
currentY += 50;
totalUploaded++;
}
}
// Reload board images to show new uploads
await loadBoardImages(boardId);
uploadSuccess = `Successfully uploaded ${totalUploaded} image(s)`;
setTimeout(() => {
uploadSuccess = '';
}, 3000);
} catch (err: any) {
console.error('Upload error:', err);
uploadError = err.error || err.message || err.detail || 'Upload failed';
} finally {
uploading = false;
}
}
function openFilePicker() {
fileInput.click();
}
function handleEditBoard() {
goto(`/boards/${boardId}/edit`);
}
function handleBackToBoards() {
goto('/boards');
}
function fitAllImages() {
if ($boardImages.length === 0) return;
// Calculate bounding box of all images
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
$boardImages.forEach((bi) => {
const imgMinX = bi.position.x;
const imgMinY = bi.position.y;
const imgMaxX = bi.position.x + (bi.image?.width || 0);
const imgMaxY = bi.position.y + (bi.image?.height || 0);
minX = Math.min(minX, imgMinX);
minY = Math.min(minY, imgMinY);
maxX = Math.max(maxX, imgMaxX);
maxY = Math.max(maxY, imgMaxY);
});
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
// Calculate zoom to fit
const padding = 100;
const scaleX = (canvasWidth - padding * 2) / contentWidth;
const scaleY = (canvasHeight - padding * 2) / contentHeight;
const newZoom = Math.min(scaleX, scaleY, 1.0); // Don't zoom in more than 100%
// Calculate center position
const centerX = (canvasWidth - contentWidth * newZoom) / 2 - minX * newZoom;
const centerY = (canvasHeight - contentHeight * newZoom) / 2 - minY * newZoom;
viewport.set({
x: centerX,
y: centerY,
zoom: newZoom,
rotation: 0,
});
}
</script>
<svelte:head>
<title>{$currentBoard?.title || 'Board'} - Reference Board Viewer</title>
</svelte:head>
<div
class="board-canvas-page"
on:drop={handleDrop}
on:dragover={handleDragOver}
role="presentation"
>
{#if loading}
<div class="loading">
<div class="spinner"></div>
<p>Loading board...</p>
</div>
{:else if error}
<div class="error-message">
<p>{error}</p>
<button on:click={handleBackToBoards}>Back to Boards</button>
</div>
{:else if $currentBoard}
<!-- Top Toolbar -->
<div class="toolbar-top">
<div class="toolbar-left">
<button class="btn-icon" on:click={handleBackToBoards} title="Back to boards">
← Boards
</button>
<div class="title-group">
<h1 class="board-title">{$currentBoard.title}</h1>
{#if $currentBoard.description}
<span class="board-desc">{$currentBoard.description}</span>
{/if}
</div>
</div>
<div class="toolbar-right">
<button
class="btn-icon"
on:click={fitAllImages}
title="Fit all images to view"
disabled={$boardImages.length === 0}
>
🔍 Fit All
</button>
<button
class="btn-upload"
on:click={openFilePicker}
disabled={uploading}
title="Upload images"
>
{#if uploading}
<span class="spinner-small"></span>
Uploading...
{:else}
📤 Upload Images
{/if}
</button>
<button class="btn-secondary" on:click={handleEditBoard} title="Edit board settings">
⚙️ Settings
</button>
</div>
</div>
<!-- Hidden file input -->
<input
bind:this={fileInput}
type="file"
accept="image/*,.zip"
multiple
on:change={handleFileSelect}
style="display: none;"
/>
{#if uploadSuccess}
<div class="success-banner">{uploadSuccess}</div>
{/if}
{#if uploadError}
<div class="error-banner">{uploadError}</div>
{/if}
<!-- Canvas Area -->
<div class="canvas-container" bind:this={canvasContainer}>
{#if loading}
<div class="loading">
<div class="spinner"></div>
<p>Loading canvas...</p>
</div>
{:else if $boardImages.length === 0}
<div class="empty-state">
<div class="empty-icon">🖼️</div>
<h2>No images yet</h2>
<p>Click "Upload Images" or drag & drop files anywhere to get started</p>
<button class="btn-upload-large" on:click={openFilePicker}>
Upload Your First Image
</button>
</div>
{:else if canvasWidth > 0 && canvasHeight > 0}
<Stage
bind:this={stageComponent}
width={canvasWidth}
height={canvasHeight}
{boardId}
on:ready={handleStageReady}
/>
{#if stageReady && canvasLayer}
{#each $boardImages as boardImage (boardImage.id)}
{#if boardImage.image}
<CanvasImage
id={boardImage.id}
imageId={boardImage.image.id}
imageUrl="http://localhost:8000/api/v1/images/{boardImage.image
.id}/serve?quality=medium"
x={boardImage.position.x}
y={boardImage.position.y}
width={boardImage.image.width}
height={boardImage.image.height}
rotation={boardImage.transformations.rotation || 0}
scaleX={boardImage.transformations.scale || 1.0}
scaleY={boardImage.transformations.scale || 1.0}
opacity={boardImage.transformations.opacity || 1.0}
zOrder={boardImage.z_order}
layer={canvasLayer}
onDragEnd={handleImageDragEnd}
onSelectionChange={handleImageSelectionChange}
onImageLoaded={handleImageLoaded}
/>
{/if}
{/each}
{/if}
{/if}
</div>
<!-- Status Bar -->
<div class="status-bar">
<span>Board: {$currentBoard.title}</span>
<span>|</span>
<span>Images: {$boardImages.length}</span>
<span>|</span>
<span class="status-ready">● Ready</span>
</div>
{:else}
<div class="error-message">
<p>Board not found</p>
<button on:click={handleBackToBoards}>Back to Boards</button>
</div>
{/if}
</div>
<style>
.board-canvas-page {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f3f4f6;
}
.loading,
.error-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-small {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Toolbar */
.toolbar-top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.5rem;
background: white;
border-bottom: 1px solid #e5e7eb;
z-index: 100;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
}
.title-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.toolbar-right {
display: flex;
gap: 0.75rem;
}
.board-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.board-desc {
font-size: 0.875rem;
color: #6b7280;
}
.btn-icon,
.btn-upload,
.btn-secondary {
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
border: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-icon {
background: none;
border: 1px solid #d1d5db;
color: #374151;
}
.btn-icon:hover {
background: #f9fafb;
}
.btn-upload {
background: #10b981;
color: white;
}
.btn-upload:hover:not(:disabled) {
background: #059669;
}
.btn-upload:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-upload-large {
background: #3b82f6;
color: white;
padding: 0.75rem 2rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
border: none;
cursor: pointer;
}
.btn-upload-large:hover {
background: #2563eb;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.success-banner,
.error-banner {
padding: 0.75rem 1.5rem;
text-align: center;
font-size: 0.875rem;
font-weight: 500;
}
.success-banner {
background: #d1fae5;
color: #065f46;
border-bottom: 1px solid #a7f3d0;
}
.error-banner {
background: #fee2e2;
color: #991b1b;
border-bottom: 1px solid #fecaca;
}
/* Canvas Container */
.canvas-container {
flex: 1;
position: relative;
overflow: hidden;
background: #f5f5f5;
}
.empty-state {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
background: white;
z-index: 10;
}
.empty-icon {
font-size: 4rem;
}
.empty-state h2 {
margin: 0;
font-size: 1.5rem;
color: #111827;
}
.empty-state p {
margin: 0;
font-size: 1rem;
color: #6b7280;
max-width: 400px;
text-align: center;
}
/* Status Bar */
.status-bar {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 1.5rem;
background: white;
border-top: 1px solid #e5e7eb;
font-size: 0.75rem;
color: #6b7280;
z-index: 100;
}
.status-ready {
color: #10b981;
font-weight: 500;
}
button {
font-family: inherit;
}
</style>

View File

@@ -0,0 +1,284 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
listLibraryImages,
deleteLibraryImage,
getLibraryStats,
type LibraryImage,
type LibraryStats,
} from '$lib/api/library';
let images: LibraryImage[] = [];
let stats: LibraryStats | null = null;
let loading = true;
let error = '';
let searchQuery = '';
let _showAddToBoard = false;
let _selectedImage: LibraryImage | null = null;
onMount(async () => {
await loadLibrary();
await loadStats();
});
async function loadLibrary() {
try {
loading = true;
const result = await listLibraryImages(searchQuery || undefined);
images = result.images;
} catch (err: any) {
error = `Failed to load library: ${err.message || err}`;
} finally {
loading = false;
}
}
async function loadStats() {
try {
stats = await getLibraryStats();
} catch (err) {
console.error('Failed to load stats:', err);
}
}
async function handleSearch() {
await loadLibrary();
}
async function handleDelete(imageId: string) {
if (!confirm('Permanently delete this image? It will be removed from all boards.')) {
return;
}
try {
await deleteLibraryImage(imageId);
await loadLibrary();
await loadStats();
} catch (err: any) {
error = `Failed to delete image: ${err.message || err}`;
}
}
function handleAddToBoard(image: LibraryImage) {
_selectedImage = image;
_showAddToBoard = true;
// TODO: Implement add to board modal
alert('Add to board feature coming soon!');
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
</script>
<div class="library-page">
<div class="page-header">
<h1>Image Library</h1>
{#if stats}
<div class="stats">
<span><strong>{stats.total_images}</strong> images</span>
<span><strong>{formatBytes(stats.total_size_bytes)}</strong> total</span>
<span><strong>{stats.total_board_references}</strong> board uses</span>
</div>
{/if}
</div>
<div class="search-bar">
<input
type="text"
placeholder="Search images..."
bind:value={searchQuery}
on:keydown={(e) => e.key === 'Enter' && handleSearch()}
/>
<button class="btn-search" on:click={handleSearch}>Search</button>
</div>
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if loading}
<div class="loading">Loading library...</div>
{:else if images.length === 0}
<div class="empty-state">
<p>No images in your library yet.</p>
<p>Upload images to boards to add them to your library.</p>
</div>
{:else}
<div class="image-grid">
{#each images as image}
<div class="image-card">
{#if image.thumbnail_url}
<img src={image.thumbnail_url} alt={image.filename} class="thumbnail" />
{:else}
<div class="no-thumbnail">No preview</div>
{/if}
<div class="image-info">
<p class="filename">{image.filename}</p>
<p class="details">
{image.width}x{image.height}{formatBytes(image.file_size)}
</p>
<p class="references">
Used on {image.reference_count} board{image.reference_count !== 1 ? 's' : ''}
</p>
</div>
<div class="image-actions">
<button class="btn-add" on:click={() => handleAddToBoard(image)}> Add to Board </button>
<button class="btn-delete" on:click={() => handleDelete(image.id)}> Delete </button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.library-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
.stats {
display: flex;
gap: 2rem;
color: #6b7280;
}
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.search-bar input {
flex: 1;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 1rem;
}
.btn-search {
background: #3b82f6;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.error-message {
background: #fee2e2;
color: #991b1b;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.loading,
.empty-state {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.empty-state p {
margin: 0.5rem 0;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.image-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
background: white;
transition: box-shadow 0.2s;
}
.image-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.thumbnail {
width: 100%;
height: 200px;
object-fit: cover;
background: #f3f4f6;
}
.no-thumbnail {
width: 100%;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
color: #9ca3af;
}
.image-info {
padding: 1rem;
}
.filename {
font-weight: 500;
margin: 0 0 0.5rem 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.details,
.references {
font-size: 0.875rem;
color: #6b7280;
margin: 0.25rem 0;
}
.image-actions {
display: flex;
gap: 0.5rem;
padding: 0 1rem 1rem 1rem;
}
.btn-add,
.btn-delete {
flex: 1;
padding: 0.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-add {
background: #10b981;
color: white;
}
.btn-delete {
background: #ef4444;
color: white;
}
</style>

View File

@@ -0,0 +1,263 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import {
getSharedBoard,
listComments,
createComment,
type Comment,
type SharedBoard,
} from '$lib/api/sharing';
const token = $page.params.token;
let board: SharedBoard | null = null;
let comments: Comment[] = [];
let loading = true;
let error = '';
let showCommentForm = false;
let commentAuthor = '';
let commentContent = '';
onMount(async () => {
await loadBoard();
await loadComments();
});
async function loadBoard() {
try {
board = await getSharedBoard(token);
} catch (err: any) {
error = err.error || 'Failed to load board';
} finally {
loading = false;
}
}
async function loadComments() {
try {
comments = await listComments(token);
} catch (err) {
// Comments might not be available for view-only links
console.error('Failed to load comments:', err);
}
}
async function handleSubmitComment() {
if (!commentAuthor || !commentContent) {
return;
}
try {
await createComment(token, {
author_name: commentAuthor,
content: commentContent,
});
commentContent = '';
showCommentForm = false;
await loadComments();
} catch (err: any) {
error = err.error || 'Failed to create comment';
}
}
</script>
<div class="shared-board-container">
{#if loading}
<div class="loading">Loading board...</div>
{:else if error}
<div class="error-message">{error}</div>
{:else if board}
<div class="board-header">
<h1>{board.title}</h1>
{#if board.description}
<p class="description">{board.description}</p>
{/if}
</div>
<div class="board-content">
<p>Board ID: {board.id}</p>
<p class="note">This is a shared view of the board. You're viewing it as a guest.</p>
</div>
<div class="comments-section">
<h2>Comments</h2>
{#if comments.length > 0}
<div class="comments-list">
{#each comments as comment}
<div class="comment">
<div class="comment-header">
<strong>{comment.author_name}</strong>
<span class="comment-date">
{new Date(comment.created_at).toLocaleString()}
</span>
</div>
<p class="comment-content">{comment.content}</p>
</div>
{/each}
</div>
{:else}
<p class="no-comments">No comments yet.</p>
{/if}
{#if !showCommentForm}
<button class="btn-add-comment" on:click={() => (showCommentForm = true)}>
Add Comment
</button>
{:else}
<div class="comment-form">
<input type="text" placeholder="Your name" bind:value={commentAuthor} />
<textarea placeholder="Your comment" bind:value={commentContent} rows="3" />
<div class="form-actions">
<button class="btn-submit" on:click={handleSubmitComment}> Submit </button>
<button class="btn-cancel" on:click={() => (showCommentForm = false)}> Cancel </button>
</div>
</div>
{/if}
</div>
{:else}
<div class="error-message">Board not found</div>
{/if}
</div>
<style>
.shared-board-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.loading,
.error-message {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
}
.error-message {
color: #ef4444;
background: #fee2e2;
border-radius: 8px;
}
.board-header {
margin-bottom: 2rem;
}
.board-header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.description {
color: #6b7280;
font-size: 1.125rem;
}
.board-content {
background: #f9fafb;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.note {
color: #6b7280;
font-style: italic;
}
.comments-section {
margin-top: 2rem;
}
.comments-section h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.comment {
background: white;
border: 1px solid #e5e7eb;
padding: 1rem;
border-radius: 8px;
}
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.comment-date {
color: #9ca3af;
font-size: 0.875rem;
}
.comment-content {
color: #374151;
line-height: 1.5;
}
.no-comments {
color: #9ca3af;
font-style: italic;
}
.btn-add-comment {
background: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.comment-form {
background: #f9fafb;
padding: 1rem;
border-radius: 8px;
margin-top: 1rem;
}
.comment-form input,
.comment-form textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
margin-bottom: 0.5rem;
font-family: inherit;
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.btn-submit,
.btn-cancel {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-submit {
background: #10b981;
color: white;
}
.btn-cancel {
background: #e5e7eb;
color: #374151;
}
</style>

Some files were not shown because too many files have changed in this diff Show More