Compare commits

...

11 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
Danilo Reyes
3eb3d977f9 phase 10
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 7s
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 15s
CI/CD Pipeline / Nix Flake Check (push) Successful in 41s
CI/CD Pipeline / CI Summary (push) Successful in 1s
2025-11-02 14:26:15 -06:00
Danilo Reyes
ce0b692aee phase 8 2025-11-02 14:13:56 -06:00
138 changed files with 18077 additions and 576 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
@@ -15,6 +15,8 @@ from app.images.schemas import (
BoardImageCreate,
BoardImageResponse,
BoardImageUpdate,
BulkImageUpdate,
BulkUpdateResponse,
ImageListResponse,
ImageResponse,
ImageUploadResponse,
@@ -29,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),
):
"""
@@ -63,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)
@@ -75,7 +77,7 @@ async def upload_image(
mime_type=mime_type,
width=width,
height=height,
metadata=metadata,
image_metadata=image_metadata,
)
return image
@@ -84,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),
):
"""
@@ -119,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,
@@ -134,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)
@@ -154,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),
):
"""
@@ -172,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)
@@ -189,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),
):
"""
@@ -222,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)
@@ -233,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),
):
"""
@@ -242,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()
@@ -283,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),
):
"""
@@ -293,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()
@@ -328,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),
):
"""
@@ -338,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()
@@ -357,10 +410,85 @@ async def remove_image_from_board(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not on this board")
@router.patch("/boards/{board_id}/images/bulk", response_model=BulkUpdateResponse)
async def bulk_update_board_images(
board_id: UUID,
data: BulkImageUpdate,
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
Bulk update multiple images on a board.
Applies the same changes to all specified images. Useful for multi-selection operations.
"""
# Verify board ownership
board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none()
if not board:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
if board.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
# Update each image
repo = ImageRepository(db)
updated_ids = []
failed_count = 0
for image_id in data.image_ids:
try:
# Calculate new position if delta provided
position = None
if data.position_delta:
# Get current position
board_image = await repo.get_board_image(board_id, image_id)
if board_image and board_image.position:
current_pos = board_image.position
position = {
"x": current_pos.get("x", 0) + data.position_delta["dx"],
"y": current_pos.get("y", 0) + data.position_delta["dy"],
}
# Calculate new z-order if delta provided
z_order = None
if data.z_order_delta is not None:
board_image = await repo.get_board_image(board_id, image_id)
if board_image:
z_order = board_image.z_order + data.z_order_delta
# Update the image
updated = await repo.update_board_image(
board_id=board_id,
image_id=image_id,
position=position,
transformations=data.transformations,
z_order=z_order,
group_id=None, # Bulk operations don't change groups
)
if updated:
updated_ids.append(image_id)
else:
failed_count += 1
except Exception as e:
print(f"Error updating image {image_id}: {e}")
failed_count += 1
continue
return BulkUpdateResponse(
updated_count=len(updated_ids),
failed_count=failed_count,
image_ids=updated_ids,
)
@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),
):
"""
@@ -369,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):
@@ -120,6 +122,31 @@ class BoardImageResponse(BaseModel):
from_attributes = True
class BulkImageUpdate(BaseModel):
"""Schema for bulk updating multiple images."""
image_ids: list[UUID] = Field(..., description="List of image IDs to update")
position_delta: dict[str, float] | None = Field(None, description="Position delta to apply")
transformations: dict[str, Any] | None = Field(None, description="Transformations to apply")
z_order_delta: int | None = Field(None, description="Z-order delta to apply")
@field_validator("position_delta")
@classmethod
def validate_position_delta(cls, v: dict[str, float] | None) -> dict[str, float] | None:
"""Validate position delta has dx and dy."""
if v is not None and ("dx" not in v or "dy" not in v):
raise ValueError("Position delta must contain 'dx' and 'dy'")
return v
class BulkUpdateResponse(BaseModel):
"""Response for bulk update operation."""
updated_count: int = Field(..., description="Number of images updated")
failed_count: int = Field(default=0, description="Number of images that failed to update")
image_ids: list[UUID] = Field(..., description="IDs of successfully updated images")
class ImageListResponse(BaseModel):
"""Paginated list of images."""

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

@@ -0,0 +1,378 @@
"""Integration tests for bulk image operations."""
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_bulk_update_position_delta(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test bulk updating positions with delta."""
# 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 = []
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 * i, "y": 100 * i},
transformations={
"scale": 1.0,
"rotation": 0,
"opacity": 1.0,
"flipped_h": False,
"flipped_v": False,
"greyscale": False,
},
z_order=i,
)
db.add(board_image)
board_images.append(board_image)
await db.commit()
# Bulk update position
response = await client.patch(
f"/api/images/boards/{board.id}/images/bulk",
json={
"image_ids": [str(img.id) for img in images[:2]], # First 2 images
"position_delta": {"dx": 50, "dy": 75},
},
)
assert response.status_code == 200
data = response.json()
assert data["updated_count"] == 2
assert data["failed_count"] == 0
@pytest.mark.asyncio
async def test_bulk_update_transformations(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test bulk updating 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)
images = []
for i in range(2):
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=0,
)
db.add(board_image)
await db.commit()
# Bulk update transformations
response = await client.patch(
f"/api/images/boards/{board.id}/images/bulk",
json={
"image_ids": [str(img.id) for img in images],
"transformations": {
"scale": 2.0,
"rotation": 45,
"opacity": 0.8,
},
},
)
assert response.status_code == 200
data = response.json()
assert data["updated_count"] == 2
@pytest.mark.asyncio
async def test_bulk_update_z_order_delta(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test bulk updating Z-order with delta."""
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()
# Bulk update Z-order
response = await client.patch(
f"/api/images/boards/{board.id}/images/bulk",
json={
"image_ids": [str(images[0].id), str(images[1].id)],
"z_order_delta": 10,
},
)
assert response.status_code == 200
data = response.json()
assert data["updated_count"] == 2
@pytest.mark.asyncio
async def test_bulk_update_mixed_operations(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test bulk update with position, transformations, and z-order together."""
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(2):
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=0,
)
db.add(board_image)
await db.commit()
# Bulk update everything
response = await client.patch(
f"/api/images/boards/{board.id}/images/bulk",
json={
"image_ids": [str(img.id) for img in images],
"position_delta": {"dx": 50, "dy": 50},
"transformations": {"scale": 2.0},
"z_order_delta": 5,
},
)
assert response.status_code == 200
data = response.json()
assert data["updated_count"] == 2
assert data["failed_count"] == 0
@pytest.mark.asyncio
async def test_bulk_update_non_existent_image(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test bulk update with some non-existent 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)
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)
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()
# Try to update with one valid and one invalid ID
response = await client.patch(
f"/api/images/boards/{board.id}/images/bulk",
json={
"image_ids": [str(image.id), str(uuid4())], # One valid, one invalid
"transformations": {"scale": 2.0},
},
)
assert response.status_code == 200
data = response.json()
assert data["updated_count"] == 1 # Only valid one updated
assert data["failed_count"] == 1
@pytest.mark.asyncio
async def test_bulk_update_unauthorized(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test bulk update on board not owned by user."""
# 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)
await db.commit()
# Try bulk update as current user
response = await client.patch(
f"/api/images/boards/{board.id}/images/bulk",
json={
"image_ids": [str(uuid4())],
"transformations": {"scale": 2.0},
},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_bulk_update_empty_image_list(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test bulk update with empty image list."""
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)
await db.commit()
response = await client.patch(
f"/api/images/boards/{board.id}/images/bulk",
json={
"image_ids": [],
"transformations": {"scale": 2.0},
},
)
# Should succeed with 0 updated
assert response.status_code == 200
data = response.json()
assert data["updated_count"] == 0

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

@@ -0,0 +1,221 @@
"""Integration tests for image deletion endpoints."""
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_remove_image_from_board(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test removing image from board (not deleting)."""
# Create board and 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"},
reference_count=1,
)
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()
# Remove from board
response = await client.delete(f"/api/images/boards/{board.id}/images/{image.id}")
assert response.status_code == 204
@pytest.mark.asyncio
async def test_remove_image_not_on_board(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test removing image that's not on the 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)
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)
await db.commit()
# Try to remove (image not on board)
response = await client.delete(f"/api/images/boards/{board.id}/images/{image.id}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_remove_image_unauthorized(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test removing image from board not owned by user."""
# 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)
image = Image(
id=uuid4(),
user_id=other_user.id,
filename="test.jpg",
storage_path=f"{other_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()
# Try to remove as current user
response = await client.delete(f"/api/images/boards/{board.id}/images/{image.id}")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_permanent_delete_image(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test permanently deleting image from library."""
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"},
reference_count=0, # Not used on any boards
)
db.add(image)
await db.commit()
# Delete permanently
response = await client.delete(f"/api/images/{image.id}")
assert response.status_code == 204
@pytest.mark.asyncio
async def test_cannot_delete_image_in_use(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test that images in use cannot be permanently deleted."""
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"},
reference_count=1, # Used on a board
)
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()
# Try to delete
response = await client.delete(f"/api/images/{image.id}")
assert response.status_code == 400
assert "still used" in response.json()["detail"].lower()

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

@@ -0,0 +1,236 @@
"""Tests for image transformation validation."""
import pytest
from pydantic import ValidationError
from app.images.schemas import BoardImageUpdate
def test_valid_transformations():
"""Test that valid transformations are accepted."""
data = BoardImageUpdate(
transformations={
"scale": 1.5,
"rotation": 45,
"opacity": 0.8,
"flipped_h": True,
"flipped_v": False,
"greyscale": False,
}
)
assert data.transformations is not None
assert data.transformations["scale"] == 1.5
assert data.transformations["rotation"] == 45
assert data.transformations["opacity"] == 0.8
assert data.transformations["flipped_h"] is True
assert data.transformations["greyscale"] is False
def test_minimal_transformations():
"""Test that minimal transformation data is accepted."""
data = BoardImageUpdate(
transformations={
"scale": 1.0,
"rotation": 0,
"opacity": 1.0,
}
)
assert data.transformations is not None
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
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
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
def test_transformation_boolean_flags():
"""Test boolean transformation flags."""
data = BoardImageUpdate(
transformations={
"flipped_h": True,
"flipped_v": True,
"greyscale": True,
}
)
assert data.transformations["flipped_h"] is True
assert data.transformations["flipped_v"] is True
assert data.transformations["greyscale"] is True
def test_transformation_crop_data():
"""Test crop transformation data."""
data = BoardImageUpdate(
transformations={
"crop": {
"x": 10,
"y": 10,
"width": 100,
"height": 100,
}
}
)
assert data.transformations["crop"] is not None
assert data.transformations["crop"]["x"] == 10
assert data.transformations["crop"]["width"] == 100
def test_transformation_null_crop():
"""Test that crop can be null (no crop)."""
data = BoardImageUpdate(
transformations={
"crop": None,
}
)
assert data.transformations["crop"] is None
def test_partial_transformation_update():
"""Test updating only some transformation fields."""
# Only update scale
data = BoardImageUpdate(transformations={"scale": 2.0})
assert data.transformations["scale"] == 2.0
# Only update rotation
data = BoardImageUpdate(transformations={"rotation": 90})
assert data.transformations["rotation"] == 90
# Only update opacity
data = BoardImageUpdate(transformations={"opacity": 0.5})
assert data.transformations["opacity"] == 0.5
def test_complete_transformation_update():
"""Test updating all transformation fields."""
data = BoardImageUpdate(
transformations={
"scale": 1.5,
"rotation": 45,
"opacity": 0.8,
"flipped_h": True,
"flipped_v": False,
"greyscale": True,
"crop": {
"x": 20,
"y": 20,
"width": 150,
"height": 150,
},
}
)
assert data.transformations is not None
assert len(data.transformations) == 7
def test_position_validation_with_transformations():
"""Test that position and transformations can be updated together."""
data = BoardImageUpdate(
position={"x": 100, "y": 200},
transformations={"scale": 1.5, "rotation": 45},
)
assert data.position == {"x": 100, "y": 200}
assert data.transformations["scale"] == 1.5
assert data.transformations["rotation"] == 45
def test_invalid_position_missing_x():
"""Test that position without x coordinate is rejected."""
with pytest.raises(ValidationError) as exc_info:
BoardImageUpdate(position={"y": 100})
assert "must contain 'x' and 'y'" in str(exc_info.value)
def test_invalid_position_missing_y():
"""Test that position without y coordinate is rejected."""
with pytest.raises(ValidationError) as exc_info:
BoardImageUpdate(position={"x": 100})
assert "must contain 'x' and 'y'" in str(exc_info.value)
def test_z_order_update():
"""Test Z-order update."""
data = BoardImageUpdate(z_order=5)
assert data.z_order == 5
# Negative Z-order allowed (layering)
data = BoardImageUpdate(z_order=-1)
assert data.z_order == -1
# Large Z-order allowed
data = BoardImageUpdate(z_order=999999)
assert data.z_order == 999999
def test_group_id_update():
"""Test group ID update."""
from uuid import uuid4
group_id = uuid4()
data = BoardImageUpdate(group_id=group_id)
assert data.group_id == group_id
# Null group ID (remove from group)
data = BoardImageUpdate(group_id=None)
assert data.group_id is None
def test_empty_update():
"""Test that empty update (no fields) is valid."""
data = BoardImageUpdate()
assert data.position is None
assert data.transformations is None
assert data.z_order is None
assert data.group_id is None
def test_transformation_data_types():
"""Test that transformation data types are validated."""
# Valid types
data = BoardImageUpdate(
transformations={
"scale": 1.5, # float
"rotation": 45, # int (converted to float)
"opacity": 0.8, # float
"flipped_h": True, # bool
"flipped_v": False, # bool
"greyscale": True, # bool
}
)
assert isinstance(data.transformations["scale"], (int, float))
assert isinstance(data.transformations["flipped_h"], bool)

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

@@ -4,28 +4,28 @@ module.exports = {
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
'prettier',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
extraFileExtensions: ['.svelte'],
},
env: {
browser: true,
es2017: true,
node: true
node: true,
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
parser: '@typescript-eslint/parser',
},
},
],
rules: {
// TypeScript rules
@@ -33,18 +33,18 @@ module.exports = {
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'warn',
// General rules
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'error',
'no-var': 'error',
// Svelte specific
'svelte/no-at-html-tags': 'error',
'svelte/no-target-blank': 'error'
}
'svelte/no-target-blank': 'error',
},
};

View File

@@ -15,4 +15,3 @@
}
]
}

View File

@@ -60,4 +60,3 @@ export default [
},
},
];

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,86 @@
/**
* Copy operation for canvas images
* Copies selected images to clipboard
*/
import { clipboard, type ClipboardImageData } from '$lib/stores/clipboard';
import { selection } from '$lib/stores/selection';
/**
* Copy selected images to clipboard
*/
export function copySelectedImages(
getImageData: (id: string) => ClipboardImageData | null
): number {
const selectedIds = selection.getSelectedIds();
if (selectedIds.length === 0) {
return 0;
}
const imagesToCopy: ClipboardImageData[] = [];
selectedIds.forEach((id) => {
const imageData = getImageData(id);
if (imageData) {
imagesToCopy.push(imageData);
}
});
clipboard.copy(imagesToCopy);
return imagesToCopy.length;
}
/**
* Copy specific images to clipboard
*/
export function copyImages(
imageIds: string[],
getImageData: (id: string) => ClipboardImageData | null
): number {
const imagesToCopy: ClipboardImageData[] = [];
imageIds.forEach((id) => {
const imageData = getImageData(id);
if (imageData) {
imagesToCopy.push(imageData);
}
});
clipboard.copy(imagesToCopy);
return imagesToCopy.length;
}
/**
* Copy single image to clipboard
*/
export function copySingleImage(
getImageData: (id: string) => ClipboardImageData | null,
imageId: string
): boolean {
const imageData = getImageData(imageId);
if (!imageData) {
return false;
}
clipboard.copy([imageData]);
return true;
}
/**
* Check if clipboard has content
*/
export function hasClipboardContent(): boolean {
return clipboard.hasContent();
}
/**
* Get clipboard count
*/
export function getClipboardCount(): number {
const state = clipboard.getClipboard();
return state.images.length;
}

View File

@@ -0,0 +1,69 @@
/**
* Cut operation for canvas images
* Cuts selected images to clipboard (copy + mark for deletion)
*/
import { clipboard, type ClipboardImageData } from '$lib/stores/clipboard';
import { selection } from '$lib/stores/selection';
/**
* Cut selected images to clipboard
*/
export function cutSelectedImages(getImageData: (id: string) => ClipboardImageData | null): number {
const selectedIds = selection.getSelectedIds();
if (selectedIds.length === 0) {
return 0;
}
const imagesToCut: ClipboardImageData[] = [];
selectedIds.forEach((id) => {
const imageData = getImageData(id);
if (imageData) {
imagesToCut.push(imageData);
}
});
clipboard.cut(imagesToCut);
return imagesToCut.length;
}
/**
* Cut specific images to clipboard
*/
export function cutImages(
imageIds: string[],
getImageData: (id: string) => ClipboardImageData | null
): number {
const imagesToCut: ClipboardImageData[] = [];
imageIds.forEach((id) => {
const imageData = getImageData(id);
if (imageData) {
imagesToCut.push(imageData);
}
});
clipboard.cut(imagesToCut);
return imagesToCut.length;
}
/**
* Cut single image to clipboard
*/
export function cutSingleImage(
getImageData: (id: string) => ClipboardImageData | null,
imageId: string
): boolean {
const imageData = getImageData(imageId);
if (!imageData) {
return false;
}
clipboard.cut([imageData]);
return true;
}

View File

@@ -0,0 +1,139 @@
/**
* Paste operation for canvas images
* Pastes clipboard images at viewport center or specific position
*/
import { clipboard, type ClipboardImageData } from '$lib/stores/clipboard';
import { viewport } from '$lib/stores/viewport';
import { get } from 'svelte/store';
export interface PasteOptions {
position?: { x: number; y: number }; // Override default center position
clearClipboardAfter?: boolean; // Clear clipboard after paste (default: false for copy, true for cut)
onPasteComplete?: (pastedIds: string[]) => void;
}
export interface PastedImageData extends ClipboardImageData {
newPosition: { x: number; y: number };
}
/**
* Paste clipboard images at viewport center
*/
export function pasteFromClipboard(
viewportWidth: number,
viewportHeight: number,
options: PasteOptions = {}
): PastedImageData[] {
const clipboardState = clipboard.getClipboard();
if (clipboardState.images.length === 0) {
return [];
}
// Determine paste position
let pastePosition: { x: number; y: number };
if (options.position) {
pastePosition = options.position;
} else {
// Use viewport center
const viewportState = get(viewport);
pastePosition = {
x: -viewportState.x + viewportWidth / 2,
y: -viewportState.y + viewportHeight / 2,
};
}
// Calculate offset to paste at center
const pastedImages: PastedImageData[] = [];
// Calculate bounding box of clipboard images
let minX = Infinity;
let minY = Infinity;
clipboardState.images.forEach((img) => {
minX = Math.min(minX, img.position.x);
minY = Math.min(minY, img.position.y);
});
// Create pasted images with new positions
clipboardState.images.forEach((img) => {
const offsetX = img.position.x - minX;
const offsetY = img.position.y - minY;
pastedImages.push({
...img,
newPosition: {
x: pastePosition.x + offsetX,
y: pastePosition.y + offsetY,
},
});
});
// Clear clipboard if requested (default for cut operation)
const shouldClear = options.clearClipboardAfter ?? clipboardState.operation === 'cut';
if (shouldClear) {
clipboard.clear();
}
// Call callback if provided
if (options.onPasteComplete) {
options.onPasteComplete(pastedImages.map((img) => img.boardImageId));
}
return pastedImages;
}
/**
* Paste at specific position
*/
export function pasteAtPosition(
x: number,
y: number,
options: PasteOptions = {}
): PastedImageData[] {
return pasteFromClipboard(0, 0, {
...options,
position: { x, y },
});
}
/**
* Check if can paste (clipboard has content)
*/
export function canPaste(): boolean {
return clipboard.hasContent();
}
/**
* Get paste preview (positions where images will be pasted)
*/
export function getPastePreview(
viewportWidth: number,
viewportHeight: number
): Array<{ x: number; y: number }> {
const clipboardState = clipboard.getClipboard();
if (clipboardState.images.length === 0) {
return [];
}
const viewportState = get(viewport);
const centerX = -viewportState.x + viewportWidth / 2;
const centerY = -viewportState.y + viewportHeight / 2;
// Calculate offsets
let minX = Infinity;
let minY = Infinity;
clipboardState.images.forEach((img) => {
minX = Math.min(minX, img.position.x);
minY = Math.min(minY, img.position.y);
});
return clipboardState.images.map((img) => ({
x: centerX + (img.position.x - minX),
y: centerY + (img.position.y - minY),
}));
}

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

@@ -0,0 +1,225 @@
/**
* Keyboard shortcuts for canvas operations
* Handles Ctrl+A (select all), Escape (deselect), and other shortcuts
*/
import { selection } from '$lib/stores/selection';
export interface KeyboardShortcutHandlers {
onSelectAll?: (allImageIds: string[]) => void;
onDeselectAll?: () => void;
onDelete?: () => void;
onCopy?: () => void;
onCut?: () => void;
onPaste?: () => void;
onUndo?: () => void;
onRedo?: () => void;
onBringToFront?: () => void;
onSendToBack?: () => void;
onBringForward?: () => void;
onSendBackward?: () => void;
}
/**
* Setup keyboard shortcuts for canvas
*/
export function setupKeyboardShortcuts(
getAllImageIds: () => string[],
handlers: KeyboardShortcutHandlers = {}
): () => void {
/**
* Handle keyboard shortcuts
*/
function handleKeyDown(e: KeyboardEvent) {
// Ignore if typing in input/textarea
if (
document.activeElement?.tagName === 'INPUT' ||
document.activeElement?.tagName === 'TEXTAREA'
) {
return;
}
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
// Ctrl+A / Cmd+A - Select all
if (isCtrlOrCmd && e.key === 'a') {
e.preventDefault();
const allIds = getAllImageIds();
selection.selectAll(allIds);
if (handlers.onSelectAll) {
handlers.onSelectAll(allIds);
}
return;
}
// Escape - Deselect all
if (e.key === 'Escape') {
e.preventDefault();
selection.clearSelection();
if (handlers.onDeselectAll) {
handlers.onDeselectAll();
}
return;
}
// Delete / Backspace - Delete selected
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
if (handlers.onDelete) {
handlers.onDelete();
}
return;
}
// Ctrl+C / Cmd+C - Copy
if (isCtrlOrCmd && e.key === 'c') {
e.preventDefault();
if (handlers.onCopy) {
handlers.onCopy();
}
return;
}
// Ctrl+X / Cmd+X - Cut
if (isCtrlOrCmd && e.key === 'x') {
e.preventDefault();
if (handlers.onCut) {
handlers.onCut();
}
return;
}
// Ctrl+V / Cmd+V - Paste
if (isCtrlOrCmd && e.key === 'v') {
e.preventDefault();
if (handlers.onPaste) {
handlers.onPaste();
}
return;
}
// Ctrl+Z / Cmd+Z - Undo
if (isCtrlOrCmd && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
if (handlers.onUndo) {
handlers.onUndo();
}
return;
}
// Ctrl+Shift+Z / Cmd+Shift+Z - Redo
if (isCtrlOrCmd && e.key === 'z' && e.shiftKey) {
e.preventDefault();
if (handlers.onRedo) {
handlers.onRedo();
}
return;
}
// Ctrl+Y / Cmd+Y - Alternative Redo
if (isCtrlOrCmd && e.key === 'y') {
e.preventDefault();
if (handlers.onRedo) {
handlers.onRedo();
}
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
window.addEventListener('keydown', handleKeyDown);
// Return cleanup function
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}
/**
* Select all images programmatically
*/
export function selectAllImages(allImageIds: string[]): void {
selection.selectAll(allImageIds);
}
/**
* Deselect all images programmatically
*/
export function deselectAllImages(): void {
selection.clearSelection();
}
/**
* Check if modifier key is pressed
*/
export function isModifierPressed(e: KeyboardEvent): boolean {
return e.ctrlKey || e.metaKey;
}
/**
* Check if shift key is pressed
*/
export function isShiftPressed(e: KeyboardEvent): boolean {
return e.shiftKey;
}
/**
* Get keyboard shortcut display string
*/
export function getShortcutDisplay(shortcut: string): string {
const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.platform);
return shortcut
.replace('Ctrl', isMac ? '⌘' : 'Ctrl')
.replace('Alt', isMac ? '⌥' : 'Alt')
.replace('Shift', isMac ? '⇧' : 'Shift');
}

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,160 @@
/**
* Bulk move operations for multiple selected images
* Moves all selected images together by the same delta
*/
import type Konva from 'konva';
export interface BulkMoveOptions {
animate?: boolean;
onMoveComplete?: (imageIds: string[], deltaX: number, deltaY: number) => void;
}
/**
* Move multiple images by delta
*/
export function bulkMove(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
deltaX: number,
deltaY: number,
options: BulkMoveOptions = {}
): void {
const { animate = false, onMoveComplete } = options;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const currentX = image.x();
const currentY = image.y();
const newX = currentX + deltaX;
const newY = currentY + deltaY;
if (animate) {
image.to({
x: newX,
y: newY,
duration: 0.3,
});
} else {
image.position({ x: newX, y: newY });
}
});
// Batch draw if layer exists
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (onMoveComplete) {
onMoveComplete(selectedIds, deltaX, deltaY);
}
}
/**
* Move multiple images to specific position (aligns top-left corners)
*/
export function bulkMoveTo(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
targetX: number,
targetY: number,
options: BulkMoveOptions = {}
): void {
const { animate = false } = options;
// Calculate current bounding box
let minX = Infinity;
let minY = Infinity;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
minX = Math.min(minX, image.x());
minY = Math.min(minY, image.y());
});
if (!isFinite(minX) || !isFinite(minY)) return;
// Calculate delta to move top-left to target
const deltaX = targetX - minX;
const deltaY = targetY - minY;
bulkMove(images, selectedIds, deltaX, deltaY, { ...options, animate });
}
/**
* Center multiple images at specific point
*/
export function bulkCenterAt(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
centerX: number,
centerY: number,
options: BulkMoveOptions = {}
): void {
const { animate = false } = options;
// Calculate current bounding box
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
selectedIds.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;
const currentCenterX = (minX + maxX) / 2;
const currentCenterY = (minY + maxY) / 2;
const deltaX = centerX - currentCenterX;
const deltaY = centerY - currentCenterY;
bulkMove(images, selectedIds, deltaX, deltaY, { ...options, animate });
}
/**
* Get bounding box of multiple images
*/
export function getBulkBounds(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[]
): { x: number; y: number; width: number; height: number } | null {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
selectedIds.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,117 @@
/**
* Bulk rotate operations for multiple selected images
* Rotates all selected images together
*/
import type Konva from 'konva';
import { rotateImageTo, rotateImageBy } from '../transforms/rotate';
export interface BulkRotateOptions {
animate?: boolean;
onRotateComplete?: (imageIds: string[], rotation: number) => void;
}
/**
* Rotate multiple images to same angle
*/
export function bulkRotateTo(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
degrees: number,
options: BulkRotateOptions = {}
): void {
const { animate = false, onRotateComplete } = options;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
rotateImageTo(image, degrees, animate);
});
// Batch draw
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (onRotateComplete) {
onRotateComplete(selectedIds, degrees);
}
}
/**
* Rotate multiple images by delta
*/
export function bulkRotateBy(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
degrees: number,
options: BulkRotateOptions = {}
): void {
const { animate = false, onRotateComplete } = options;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
rotateImageBy(image, degrees, animate);
});
// Batch draw
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (onRotateComplete) {
// Get average rotation for callback (or first image rotation)
const firstImage = images.get(selectedIds[0]);
const rotation = firstImage ? firstImage.rotation() : 0;
onRotateComplete(selectedIds, rotation);
}
}
/**
* Rotate multiple images 90° clockwise
*/
export function bulkRotate90CW(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkRotateOptions = {}
): void {
bulkRotateBy(images, selectedIds, 90, options);
}
/**
* Rotate multiple images 90° counter-clockwise
*/
export function bulkRotate90CCW(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkRotateOptions = {}
): void {
bulkRotateBy(images, selectedIds, -90, options);
}
/**
* Rotate multiple images 180°
*/
export function bulkRotate180(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkRotateOptions = {}
): void {
bulkRotateBy(images, selectedIds, 180, options);
}
/**
* Reset rotation for multiple images
*/
export function bulkResetRotation(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkRotateOptions = {}
): void {
bulkRotateTo(images, selectedIds, 0, options);
}

View File

@@ -0,0 +1,151 @@
/**
* Bulk scale operations for multiple selected images
* Scales all selected images together
*/
import type Konva from 'konva';
import { scaleImageTo, scaleImageBy } from '../transforms/scale';
export interface BulkScaleOptions {
animate?: boolean;
onScaleComplete?: (imageIds: string[], scale: number) => void;
}
/**
* Scale multiple images to same factor
*/
export function bulkScaleTo(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
scale: number,
options: BulkScaleOptions = {}
): void {
const { animate = false, onScaleComplete } = options;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
scaleImageTo(image, scale, animate);
});
// Batch draw
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (onScaleComplete) {
onScaleComplete(selectedIds, scale);
}
}
/**
* Scale multiple images by factor
*/
export function bulkScaleBy(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
factor: number,
options: BulkScaleOptions = {}
): void {
const { animate = false, onScaleComplete } = options;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
scaleImageBy(image, factor, animate);
});
// Batch draw
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (onScaleComplete) {
// Get average scale for callback (or first image scale)
const firstImage = images.get(selectedIds[0]);
const scale = firstImage ? Math.abs(firstImage.scaleX()) : 1.0;
onScaleComplete(selectedIds, scale);
}
}
/**
* Double size of multiple images
*/
export function bulkDoubleSize(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkScaleOptions = {}
): void {
bulkScaleBy(images, selectedIds, 2.0, options);
}
/**
* Half size of multiple images
*/
export function bulkHalfSize(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkScaleOptions = {}
): void {
bulkScaleBy(images, selectedIds, 0.5, options);
}
/**
* Reset scale for multiple images
*/
export function bulkResetScale(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkScaleOptions = {}
): void {
bulkScaleTo(images, selectedIds, 1.0, options);
}
/**
* Scale uniformly while maintaining relative positions
*/
export function bulkScaleUniform(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
factor: number,
centerX: number,
centerY: number,
options: BulkScaleOptions = {}
): void {
const { animate = false } = options;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
// Scale the image
scaleImageBy(image, factor, animate);
// Adjust position to scale around center point
const x = image.x();
const y = image.y();
const newX = centerX + (x - centerX) * factor;
const newY = centerY + (y - centerY) * factor;
if (animate) {
image.to({
x: newX,
y: newY,
duration: 0.3,
});
} else {
image.position({ x: newX, y: newY });
}
});
// Batch draw
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
}

View File

@@ -0,0 +1,100 @@
/**
* Delete operation for canvas images
* Handles deletion with confirmation for large selections
*/
import { selection } from '$lib/stores/selection';
export interface DeleteOptions {
confirmationThreshold?: number; // Show confirmation if deleting more than this (default: 10)
onDeleteConfirm?: (imageIds: string[]) => Promise<boolean>; // Return true to proceed
onDeleteComplete?: (deletedIds: string[]) => void;
}
const DEFAULT_CONFIRMATION_THRESHOLD = 10;
/**
* Delete selected images
*/
export async function deleteSelectedImages(
options: DeleteOptions = {}
): Promise<{ deleted: string[]; cancelled: boolean }> {
const selectedIds = selection.getSelectedIds();
if (selectedIds.length === 0) {
return { deleted: [], cancelled: false };
}
return deleteImages(selectedIds, options);
}
/**
* Delete specific images
*/
export async function deleteImages(
imageIds: string[],
options: DeleteOptions = {}
): Promise<{ deleted: string[]; cancelled: boolean }> {
const {
confirmationThreshold = DEFAULT_CONFIRMATION_THRESHOLD,
onDeleteConfirm,
onDeleteComplete,
} = options;
// Check if confirmation needed
const needsConfirmation = imageIds.length > confirmationThreshold;
if (needsConfirmation && onDeleteConfirm) {
const confirmed = await onDeleteConfirm(imageIds);
if (!confirmed) {
return { deleted: [], cancelled: true };
}
}
// Proceed with deletion
const deletedIds = [...imageIds];
// Clear selection of deleted images
deletedIds.forEach((id) => {
selection.removeFromSelection(id);
});
// Call completion callback
if (onDeleteComplete) {
onDeleteComplete(deletedIds);
}
return { deleted: deletedIds, cancelled: false };
}
/**
* Delete single image
*/
export async function deleteSingleImage(
imageId: string,
options: DeleteOptions = {}
): Promise<boolean> {
const result = await deleteImages([imageId], options);
return !result.cancelled && result.deleted.length > 0;
}
/**
* Get delete confirmation message
*/
export function getDeleteConfirmationMessage(count: number): string {
if (count === 1) {
return 'Are you sure you want to delete this image from the board?';
}
return `Are you sure you want to delete ${count} images from the board?`;
}
/**
* Check if deletion needs confirmation
*/
export function needsDeleteConfirmation(
count: number,
threshold: number = DEFAULT_CONFIRMATION_THRESHOLD
): boolean {
return count > threshold;
}

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,180 @@
/**
* Image crop transformations
* Non-destructive rectangular cropping
*/
import Konva from 'konva';
export interface CropRegion {
x: number;
y: number;
width: number;
height: number;
}
/**
* Apply crop to image
*/
export function cropImage(image: Konva.Image | Konva.Group, cropRegion: CropRegion): void {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
const img = imageNode as Konva.Image;
// Validate crop region
const imageWidth = img.width();
const imageHeight = img.height();
const validCrop = {
x: Math.max(0, Math.min(cropRegion.x, imageWidth)),
y: Math.max(0, Math.min(cropRegion.y, imageHeight)),
width: Math.max(1, Math.min(cropRegion.width, imageWidth - cropRegion.x)),
height: Math.max(1, Math.min(cropRegion.height, imageHeight - cropRegion.y)),
};
// Apply crop using Konva's crop property
img.crop(validCrop);
}
/**
* Remove crop (reset to full image)
*/
export function removeCrop(image: Konva.Image | Konva.Group): void {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
(imageNode as Konva.Image).crop(undefined);
}
/**
* Get current crop region
*/
export function getCropRegion(image: Konva.Image | Konva.Group): CropRegion | null {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return null;
const crop = (imageNode as Konva.Image).crop();
if (!crop) return null;
return {
x: crop.x || 0,
y: crop.y || 0,
width: crop.width || 0,
height: crop.height || 0,
};
}
/**
* Check if image is cropped
*/
export function isCropped(image: Konva.Image | Konva.Group): boolean {
const crop = getCropRegion(image);
return crop !== null;
}
/**
* Crop to square (centered)
*/
export function cropToSquare(image: Konva.Image | Konva.Group): void {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
const img = imageNode as Konva.Image;
const width = img.width();
const height = img.height();
const size = Math.min(width, height);
const cropRegion: CropRegion = {
x: (width - size) / 2,
y: (height - size) / 2,
width: size,
height: size,
};
cropImage(image, cropRegion);
}
/**
* Create interactive crop tool (returns cleanup function)
*/
export function enableCropTool(
image: Konva.Image | Konva.Group,
layer: Konva.Layer,
onCropComplete: (cropRegion: CropRegion) => void
): () => void {
let cropRect: Konva.Rect | null = null;
let isDragging = false;
let startPos: { x: number; y: number } | null = null;
function handleMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
const pos = e.target.getStage()?.getPointerPosition();
if (!pos) return;
isDragging = true;
startPos = pos;
cropRect = new Konva.Rect({
x: pos.x,
y: pos.y,
width: 0,
height: 0,
stroke: '#3b82f6',
strokeWidth: 2,
dash: [4, 2],
listening: false,
});
layer.add(cropRect);
}
function handleMouseMove(e: Konva.KonvaEventObject<MouseEvent>) {
if (!isDragging || !startPos || !cropRect) return;
const pos = e.target.getStage()?.getPointerPosition();
if (!pos) return;
const width = pos.x - startPos.x;
const height = pos.y - startPos.y;
cropRect.width(width);
cropRect.height(height);
layer.batchDraw();
}
function handleMouseUp() {
if (!isDragging || !startPos || !cropRect) return;
const cropRegion: CropRegion = {
x: Math.min(startPos.x, cropRect.x() + cropRect.width()),
y: Math.min(startPos.y, cropRect.y() + cropRect.height()),
width: Math.abs(cropRect.width()),
height: Math.abs(cropRect.height()),
};
if (cropRegion.width > 10 && cropRegion.height > 10) {
onCropComplete(cropRegion);
}
cropRect.destroy();
cropRect = null;
isDragging = false;
startPos = null;
layer.batchDraw();
}
image.on('mousedown', handleMouseDown);
image.on('mousemove', handleMouseMove);
image.on('mouseup', handleMouseUp);
return () => {
image.off('mousedown', handleMouseDown);
image.off('mousemove', handleMouseMove);
image.off('mouseup', handleMouseUp);
if (cropRect) {
cropRect.destroy();
layer.batchDraw();
}
};
}

View File

@@ -0,0 +1,100 @@
/**
* Image flip transformations
* Non-destructive horizontal and vertical flipping
*/
import type Konva from 'konva';
/**
* Flip image horizontally
*/
export function flipImageHorizontal(
image: Konva.Image | Konva.Group,
animate: boolean = false
): void {
const currentScaleX = image.scaleX();
const newScaleX = -currentScaleX;
if (animate) {
image.to({
scaleX: newScaleX,
duration: 0.3,
});
} else {
image.scaleX(newScaleX);
}
}
/**
* Flip image vertically
*/
export function flipImageVertical(
image: Konva.Image | Konva.Group,
animate: boolean = false
): void {
const currentScaleY = image.scaleY();
const newScaleY = -currentScaleY;
if (animate) {
image.to({
scaleY: newScaleY,
duration: 0.3,
});
} else {
image.scaleY(newScaleY);
}
}
/**
* Check if image is flipped horizontally
*/
export function isFlippedHorizontal(image: Konva.Image | Konva.Group): boolean {
return image.scaleX() < 0;
}
/**
* Check if image is flipped vertically
*/
export function isFlippedVertical(image: Konva.Image | Konva.Group): boolean {
return image.scaleY() < 0;
}
/**
* Reset horizontal flip
*/
export function resetFlipHorizontal(image: Konva.Image | Konva.Group): void {
const scale = Math.abs(image.scaleX());
image.scaleX(scale);
}
/**
* Reset vertical flip
*/
export function resetFlipVertical(image: Konva.Image | Konva.Group): void {
const scale = Math.abs(image.scaleY());
image.scaleY(scale);
}
/**
* Reset both flips
*/
export function resetAllFlips(image: Konva.Image | Konva.Group): void {
const scaleX = Math.abs(image.scaleX());
const scaleY = Math.abs(image.scaleY());
image.scale({ x: scaleX, y: scaleY });
}
/**
* Set flip state explicitly
*/
export function setFlipState(
image: Konva.Image | Konva.Group,
horizontal: boolean,
vertical: boolean
): void {
const currentScaleX = Math.abs(image.scaleX());
const currentScaleY = Math.abs(image.scaleY());
image.scaleX(horizontal ? -currentScaleX : currentScaleX);
image.scaleY(vertical ? -currentScaleY : currentScaleY);
}

View File

@@ -0,0 +1,70 @@
/**
* Image greyscale filter transformation
* Non-destructive greyscale conversion
*/
import Konva from 'konva';
/**
* Apply greyscale filter to image
*/
export function applyGreyscale(image: Konva.Image | Konva.Group): void {
// Find the actual image node
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
// Apply greyscale filter using Konva.Filters
(imageNode as Konva.Image).filters([Konva.Filters.Grayscale]);
(imageNode as Konva.Image).cache();
}
/**
* Remove greyscale filter from image
*/
export function removeGreyscale(image: Konva.Image | Konva.Group): void {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
(imageNode as Konva.Image).filters([]);
(imageNode as Konva.Image).clearCache();
}
/**
* Toggle greyscale filter
*/
export function toggleGreyscale(image: Konva.Image | Konva.Group): void {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
const filters = (imageNode as Konva.Image).filters() || [];
if (filters.length > 0 && filters.some((f) => f.name === 'Grayscale')) {
removeGreyscale(image);
} else {
applyGreyscale(image);
}
}
/**
* Check if greyscale is applied
*/
export function isGreyscaleApplied(image: Konva.Image | Konva.Group): boolean {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return false;
const filters = (imageNode as Konva.Image).filters() || [];
return filters.some((f) => f.name === 'Grayscale');
}
/**
* Set greyscale state explicitly
*/
export function setGreyscale(image: Konva.Image | Konva.Group, enabled: boolean): void {
const isCurrentlyGreyscale = isGreyscaleApplied(image);
if (enabled && !isCurrentlyGreyscale) {
applyGreyscale(image);
} else if (!enabled && isCurrentlyGreyscale) {
removeGreyscale(image);
}
}

View File

@@ -0,0 +1,96 @@
/**
* Image opacity transformations
* Non-destructive opacity adjustment (0-100%)
*/
import type Konva from 'konva';
const MIN_OPACITY = 0.0;
const MAX_OPACITY = 1.0;
/**
* Set image opacity (0.0 to 1.0)
*/
export function setImageOpacity(
image: Konva.Image | Konva.Group,
opacity: number,
animate: boolean = false
): void {
// Clamp to 0.0-1.0
const clampedOpacity = Math.max(MIN_OPACITY, Math.min(MAX_OPACITY, opacity));
if (animate) {
image.to({
opacity: clampedOpacity,
duration: 0.3,
});
} else {
image.opacity(clampedOpacity);
}
}
/**
* Set opacity by percentage (0-100)
*/
export function setImageOpacityPercent(
image: Konva.Image | Konva.Group,
percent: number,
animate: boolean = false
): void {
const opacity = Math.max(0, Math.min(100, percent)) / 100;
setImageOpacity(image, opacity, animate);
}
/**
* Increase opacity by delta
*/
export function increaseOpacity(image: Konva.Image | Konva.Group, delta: number = 0.1): void {
const currentOpacity = image.opacity();
setImageOpacity(image, currentOpacity + delta);
}
/**
* Decrease opacity by delta
*/
export function decreaseOpacity(image: Konva.Image | Konva.Group, delta: number = 0.1): void {
const currentOpacity = image.opacity();
setImageOpacity(image, currentOpacity - delta);
}
/**
* Reset opacity to 100% (1.0)
*/
export function resetImageOpacity(
image: Konva.Image | Konva.Group,
animate: boolean = false
): void {
setImageOpacity(image, 1.0, animate);
}
/**
* Get current opacity
*/
export function getImageOpacity(image: Konva.Image | Konva.Group): number {
return image.opacity();
}
/**
* Get opacity as percentage (0-100)
*/
export function getImageOpacityPercent(image: Konva.Image | Konva.Group): number {
return Math.round(image.opacity() * 100);
}
/**
* Check if image is fully opaque
*/
export function isFullyOpaque(image: Konva.Image | Konva.Group): boolean {
return image.opacity() >= MAX_OPACITY;
}
/**
* Check if image is fully transparent
*/
export function isFullyTransparent(image: Konva.Image | Konva.Group): boolean {
return image.opacity() <= MIN_OPACITY;
}

View File

@@ -0,0 +1,106 @@
/**
* Reset transformations to original state
* Resets all non-destructive transformations
*/
import Konva from 'konva';
import { resetImageRotation } from './rotate';
import { resetImageScale } from './scale';
import { resetAllFlips } from './flip';
import { resetImageOpacity } from './opacity';
import { removeCrop } from './crop';
import { removeGreyscale } from './greyscale';
/**
* Reset all transformations to original state
*/
export function resetAllTransformations(
image: Konva.Image | Konva.Group,
animate: boolean = false
): void {
// Reset rotation
resetImageRotation(image, animate);
// Reset scale
resetImageScale(image, animate);
// Reset flips
resetAllFlips(image);
// Reset opacity
resetImageOpacity(image, animate);
// Remove crop
removeCrop(image);
// Remove greyscale
removeGreyscale(image);
// Redraw
image.getLayer()?.batchDraw();
}
/**
* Reset only geometric transformations (position, scale, rotation)
*/
export function resetGeometricTransformations(
image: Konva.Image | Konva.Group,
animate: boolean = false
): void {
resetImageRotation(image, animate);
resetImageScale(image, animate);
resetAllFlips(image);
image.getLayer()?.batchDraw();
}
/**
* Reset only visual transformations (opacity, greyscale, crop)
*/
export function resetVisualTransformations(image: Konva.Image | Konva.Group): void {
resetImageOpacity(image);
removeCrop(image);
removeGreyscale(image);
image.getLayer()?.batchDraw();
}
/**
* Check if image has any transformations applied
*/
export function hasTransformations(image: Konva.Image | Konva.Group): boolean {
const hasRotation = image.rotation() !== 0;
const hasScale = Math.abs(image.scaleX()) !== 1.0 || Math.abs(image.scaleY()) !== 1.0;
const hasOpacity = image.opacity() !== 1.0;
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
const hasCrop = imageNode ? (imageNode as Konva.Image).crop() !== undefined : false;
const hasGreyscale = imageNode ? ((imageNode as Konva.Image).filters() || []).length > 0 : false;
return hasRotation || hasScale || hasOpacity || hasCrop || hasGreyscale;
}
/**
* Get transformation summary
*/
export function getTransformationSummary(image: Konva.Image | Konva.Group): {
rotation: number;
scale: number;
opacity: number;
flippedH: boolean;
flippedV: boolean;
cropped: boolean;
greyscale: boolean;
} {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
return {
rotation: image.rotation(),
scale: Math.abs(image.scaleX()),
opacity: image.opacity(),
flippedH: image.scaleX() < 0,
flippedV: image.scaleY() < 0,
cropped: imageNode ? (imageNode as Konva.Image).crop() !== undefined : false,
greyscale: imageNode ? ((imageNode as Konva.Image).filters() || []).length > 0 : false,
};
}

View File

@@ -0,0 +1,79 @@
/**
* Image rotation transformations
* Non-destructive rotation of canvas images
*/
import type Konva from 'konva';
/**
* Rotate image to specific angle (0-360 degrees)
*/
export function rotateImageTo(
image: Konva.Image | Konva.Group,
degrees: number,
animate: boolean = false
): void {
// Normalize to 0-360
const normalizedDegrees = ((degrees % 360) + 360) % 360;
if (animate) {
image.to({
rotation: normalizedDegrees,
duration: 0.3,
});
} else {
image.rotation(normalizedDegrees);
}
}
/**
* Rotate image by delta degrees
*/
export function rotateImageBy(
image: Konva.Image | Konva.Group,
degrees: number,
animate: boolean = false
): void {
const currentRotation = image.rotation();
const newRotation = (((currentRotation + degrees) % 360) + 360) % 360;
rotateImageTo(image, newRotation, animate);
}
/**
* Rotate image by 90 degrees clockwise
*/
export function rotateImage90CW(image: Konva.Image | Konva.Group): void {
rotateImageBy(image, 90);
}
/**
* Rotate image by 90 degrees counter-clockwise
*/
export function rotateImage90CCW(image: Konva.Image | Konva.Group): void {
rotateImageBy(image, -90);
}
/**
* Flip image to 180 degrees
*/
export function rotateImage180(image: Konva.Image | Konva.Group): void {
rotateImageTo(image, 180);
}
/**
* Reset rotation to 0 degrees
*/
export function resetImageRotation(
image: Konva.Image | Konva.Group,
animate: boolean = false
): void {
rotateImageTo(image, 0, animate);
}
/**
* Get current rotation angle
*/
export function getImageRotation(image: Konva.Image | Konva.Group): number {
return image.rotation();
}

View File

@@ -0,0 +1,109 @@
/**
* Image scaling transformations
* Non-destructive scaling with resize handles
*/
import Konva from 'konva';
const MIN_SCALE = 0.01;
const MAX_SCALE = 10.0;
/**
* Scale image to specific factor
*/
export function scaleImageTo(
image: Konva.Image | Konva.Group,
scale: number,
animate: boolean = false
): void {
// Clamp to min/max
const clampedScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale));
if (animate) {
image.to({
scaleX: clampedScale,
scaleY: clampedScale,
duration: 0.3,
});
} else {
image.scale({ x: clampedScale, y: clampedScale });
}
}
/**
* Scale image by factor (multiply current scale)
*/
export function scaleImageBy(
image: Konva.Image | Konva.Group,
factor: number,
animate: boolean = false
): void {
const currentScale = image.scaleX();
const newScale = currentScale * factor;
scaleImageTo(image, newScale, animate);
}
/**
* Scale image to fit specific dimensions
*/
export function scaleImageToFit(
image: Konva.Image | Konva.Group,
maxWidth: number,
maxHeight: number,
animate: boolean = false
): void {
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
if (!imageNode) return;
const width = (imageNode as Konva.Image).width();
const height = (imageNode as Konva.Image).height();
const scaleX = maxWidth / width;
const scaleY = maxHeight / height;
const scale = Math.min(scaleX, scaleY);
scaleImageTo(image, scale, animate);
}
/**
* Reset scale to 1.0 (original size)
*/
export function resetImageScale(image: Konva.Image | Konva.Group, animate: boolean = false): void {
scaleImageTo(image, 1.0, animate);
}
/**
* Double image size
*/
export function doubleImageSize(image: Konva.Image | Konva.Group): void {
scaleImageBy(image, 2.0);
}
/**
* Half image size
*/
export function halfImageSize(image: Konva.Image | Konva.Group): void {
scaleImageBy(image, 0.5);
}
/**
* Get current scale
*/
export function getImageScale(image: Konva.Image | Konva.Group): number {
return image.scaleX();
}
/**
* Check if image is at minimum scale
*/
export function isAtMinScale(image: Konva.Image | Konva.Group): boolean {
return image.scaleX() <= MIN_SCALE;
}
/**
* Check if image is at maximum scale
*/
export function isAtMaxScale(image: Konva.Image | Konva.Group): boolean {
return image.scaleX() >= MAX_SCALE;
}

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>

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