Compare commits

..

16 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
Danilo Reyes
cd8ce33f5e phase 7
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 8s
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 15s
CI/CD Pipeline / Nix Flake Check (push) Successful in 36s
CI/CD Pipeline / CI Summary (push) Successful in 0s
2025-11-02 14:07:13 -06:00
Danilo Reyes
3700ba02ea phase 6 2025-11-02 14:03:01 -06:00
Danilo Reyes
f85ae4d417 feat: add core application constants, ownership verification, and repository utilities
- Introduced application-wide constants for file uploads, image processing, pagination, and authentication in `constants.py`.
- Implemented synchronous and asynchronous board ownership verification functions in `ownership.py`.
- Created a base repository class with common CRUD operations in `repository.py`.
- Added standard response utilities for error and success messages in `responses.py`.
- Refactored image validation to utilize constants for file size and MIME types.
- Enhanced frontend components with consistent styling and validation utilities for forms.
- Established global styles for buttons, forms, loading indicators, and messages to ensure a cohesive UI experience.
2025-11-02 13:44:10 -06:00
Danilo Reyes
ca81729c50 updated dependencies versions
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 9s
CI/CD Pipeline / VM Test - performance (push) Successful in 7s
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 15s
CI/CD Pipeline / Nix Flake Check (push) Successful in 39s
CI/CD Pipeline / CI Summary (push) Successful in 0s
2025-11-02 13:13:36 -06:00
Danilo Reyes
b48adacf51 ci: add clean checkout and verification for frontend linting
All checks were successful
CI/CD Pipeline / Nix Flake Check (push) Successful in 44s
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 1m9s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 32s
CI/CD Pipeline / VM Test - performance (push) Successful in 32s
CI/CD Pipeline / VM Test - security (push) Successful in 31s
CI/CD Pipeline / Backend Linting (push) Successful in 5s
CI/CD Pipeline / Frontend Linting (push) Successful in 48s
CI/CD Pipeline / CI Summary (push) Successful in 0s
- Force clean checkout to avoid stale cached files
- Clean /tmp/frontend-build before copying
- Add verification step to list lib directory contents
- This should resolve persistent frontend linting errors in CI
2025-11-02 12:31:19 -06:00
165 changed files with 24556 additions and 1087 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

View File

@@ -58,12 +58,21 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
clean: true
- name: Install dependencies and run linting - name: Install dependencies and run linting
run: | run: |
# Clean any previous build artifacts
rm -rf /tmp/frontend-build
# Copy frontend to /tmp to avoid noexec issues with DynamicUser # Copy frontend to /tmp to avoid noexec issues with DynamicUser
cp -r frontend /tmp/frontend-build cp -r frontend /tmp/frontend-build
# Verify lib files are present
echo "Verifying frontend lib files..."
ls -la /tmp/frontend-build/src/lib/ || echo "WARNING: lib directory not found!"
# Install dependencies in executable location # Install dependencies in executable location
nix develop --quiet --command bash -c " nix develop --quiet --command bash -c "
cd /tmp/frontend-build cd /tmp/frontend-build

2
.gitignore vendored
View File

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

View File

@@ -1,10 +1,9 @@
from logging.config import fileConfig
import os import os
import sys import sys
from logging.config import fileConfig
from pathlib import Path from pathlib import Path
from sqlalchemy import engine_from_config from sqlalchemy import engine_from_config, pool
from sqlalchemy import pool
from alembic import context 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.repository import UserRepository
from app.auth.schemas import TokenResponse, UserCreate, UserLogin, UserResponse from app.auth.schemas import TokenResponse, UserCreate, UserLogin, UserResponse
from app.auth.security import validate_password_strength, verify_password 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 from app.database.models.user import User
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) @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. 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) @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. Login user and return JWT token.

View File

@@ -7,8 +7,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.boards.repository import BoardRepository from app.boards.repository import BoardRepository
from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate 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 from app.database.models.user import User
router = APIRouter(prefix="/boards", tags=["boards"]) router = APIRouter(prefix="/boards", tags=["boards"])
@@ -18,7 +18,7 @@ router = APIRouter(prefix="/boards", tags=["boards"])
def create_board( def create_board(
board_data: BoardCreate, board_data: BoardCreate,
current_user: Annotated[User, Depends(get_current_user)], 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. Create a new board.
@@ -45,7 +45,7 @@ def create_board(
@router.get("", response_model=dict) @router.get("", response_model=dict)
def list_boards( def list_boards(
current_user: Annotated[User, Depends(get_current_user)], 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, limit: Annotated[int, Query(ge=1, le=100)] = 50,
offset: Annotated[int, Query(ge=0)] = 0, offset: Annotated[int, Query(ge=0)] = 0,
): ):
@@ -77,7 +77,7 @@ def list_boards(
def get_board( def get_board(
board_id: UUID, board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)], 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. Get board details by ID.
@@ -111,7 +111,7 @@ def update_board(
board_id: UUID, board_id: UUID,
board_data: BoardUpdate, board_data: BoardUpdate,
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db_sync)],
): ):
""" """
Update board metadata. Update board metadata.
@@ -152,11 +152,53 @@ def update_board(
return BoardDetail.model_validate(board) return BoardDetail.model_validate(board)
@router.patch("/{board_id}/viewport", status_code=status.HTTP_204_NO_CONTENT)
def update_viewport(
board_id: UUID,
viewport_data: ViewportStateUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db_sync)],
):
"""
Update board viewport state only (optimized for frequent updates).
This endpoint is designed for high-frequency viewport state updates
(debounced pan/zoom/rotate changes) with minimal overhead.
Args:
board_id: Board UUID
viewport_data: Viewport state data
current_user: Current authenticated user
db: Database session
Raises:
HTTPException: 404 if board not found or not owned by user
"""
repo = BoardRepository(db)
# Convert viewport data to dict
viewport_dict = viewport_data.model_dump()
board = repo.update_board(
board_id=board_id,
user_id=current_user.id,
title=None,
description=None,
viewport_state=viewport_dict,
)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Board {board_id} not found",
)
@router.delete("/{board_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{board_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_board( def delete_board(
board_id: UUID, board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)], 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). 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 uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import get_current_user from app.core.deps import get_current_user_async, get_db
from app.core.deps import get_db
from app.database.models.board import Board from app.database.models.board import Board
from app.database.models.user import User from app.database.models.user import User
from app.images.processing import generate_thumbnails from app.images.processing import generate_thumbnails
@@ -14,6 +14,9 @@ from app.images.repository import ImageRepository
from app.images.schemas import ( from app.images.schemas import (
BoardImageCreate, BoardImageCreate,
BoardImageResponse, BoardImageResponse,
BoardImageUpdate,
BulkImageUpdate,
BulkUpdateResponse,
ImageListResponse, ImageListResponse,
ImageResponse, ImageResponse,
ImageUploadResponse, ImageUploadResponse,
@@ -28,7 +31,7 @@ router = APIRouter(prefix="/images", tags=["images"])
@router.post("/upload", response_model=ImageUploadResponse, status_code=status.HTTP_201_CREATED) @router.post("/upload", response_model=ImageUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_image( async def upload_image(
file: UploadFile = File(...), file: UploadFile = File(...),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -62,7 +65,7 @@ async def upload_image(
checksum = calculate_checksum(contents) checksum = calculate_checksum(contents)
# Create metadata # 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 # Create database record
repo = ImageRepository(db) repo = ImageRepository(db)
@@ -74,7 +77,7 @@ async def upload_image(
mime_type=mime_type, mime_type=mime_type,
width=width, width=width,
height=height, height=height,
metadata=metadata, image_metadata=image_metadata,
) )
return image return image
@@ -83,7 +86,7 @@ async def upload_image(
@router.post("/upload-zip", response_model=list[ImageUploadResponse]) @router.post("/upload-zip", response_model=list[ImageUploadResponse])
async def upload_zip( async def upload_zip(
file: UploadFile = File(...), file: UploadFile = File(...),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -118,7 +121,7 @@ async def upload_zip(
checksum = calculate_checksum(contents) checksum = calculate_checksum(contents)
# Create metadata # Create metadata
metadata = { img_metadata = {
"format": mime_type.split("/")[1], "format": mime_type.split("/")[1],
"checksum": checksum, "checksum": checksum,
"thumbnails": thumbnail_paths, "thumbnails": thumbnail_paths,
@@ -133,7 +136,7 @@ async def upload_zip(
mime_type=mime_type, mime_type=mime_type,
width=width, width=width,
height=height, height=height,
metadata=metadata, image_metadata=img_metadata,
) )
uploaded_images.append(image) uploaded_images.append(image)
@@ -153,7 +156,7 @@ async def upload_zip(
async def get_image_library( async def get_image_library(
page: int = 1, page: int = 1,
page_size: int = 50, page_size: int = 50,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -171,10 +174,10 @@ async def get_image_library(
@router.get("/{image_id}", response_model=ImageResponse) @router.get("/{image_id}", response_model=ImageResponse)
async def get_image( async def get_image(
image_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), db: AsyncSession = Depends(get_db),
): ):
"""Get image by ID.""" """Get image metadata by ID."""
repo = ImageRepository(db) repo = ImageRepository(db)
image = await repo.get_image_by_id(image_id) image = await repo.get_image_by_id(image_id)
@@ -188,10 +191,67 @@ async def get_image(
return 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) @router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_image( async def delete_image(
image_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), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -221,8 +281,8 @@ async def delete_image(
from app.images.upload import delete_image_from_storage from app.images.upload import delete_image_from_storage
await delete_image_from_storage(image.storage_path) await delete_image_from_storage(image.storage_path)
if "thumbnails" in image.metadata: if "thumbnails" in image.image_metadata:
await delete_thumbnails(image.metadata["thumbnails"]) await delete_thumbnails(image.image_metadata["thumbnails"])
# Delete from database # Delete from database
await repo.delete_image(image_id) await repo.delete_image(image_id)
@@ -232,7 +292,7 @@ async def delete_image(
async def add_image_to_board( async def add_image_to_board(
board_id: UUID, board_id: UUID,
data: BoardImageCreate, data: BoardImageCreate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -241,8 +301,6 @@ async def add_image_to_board(
The image must already be uploaded and owned by the current user. The image must already be uploaded and owned by the current user.
""" """
# Verify board ownership # Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id)) board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none() board = board_result.scalar_one_or_none()
@@ -277,11 +335,55 @@ async def add_image_to_board(
return board_image return board_image
@router.patch("/boards/{board_id}/images/{image_id}", response_model=BoardImageResponse)
async def update_board_image(
board_id: UUID,
image_id: UUID,
data: BoardImageUpdate,
current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db),
):
"""
Update board image position, transformations, z-order, or group.
This endpoint is optimized for frequent position updates (debounced from frontend).
Only provided fields are updated.
"""
# 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 board image
repo = ImageRepository(db)
board_image = await repo.update_board_image(
board_id=board_id,
image_id=image_id,
position=data.position,
transformations=data.transformations,
z_order=data.z_order,
group_id=data.group_id,
)
if not board_image:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not on this board")
# Load image relationship for response
await db.refresh(board_image, ["image"])
return board_image
@router.delete("/boards/{board_id}/images/{image_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/boards/{board_id}/images/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_image_from_board( async def remove_image_from_board(
board_id: UUID, board_id: UUID,
image_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), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -291,8 +393,6 @@ async def remove_image_from_board(
The image remains in the user's library. The image remains in the user's library.
""" """
# Verify board ownership # Verify board ownership
from sqlalchemy import select
board_result = await db.execute(select(Board).where(Board.id == board_id)) board_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none() board = board_result.scalar_one_or_none()
@@ -310,10 +410,85 @@ async def remove_image_from_board(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not on this 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]) @router.get("/boards/{board_id}/images", response_model=list[BoardImageResponse])
async def get_board_images( async def get_board_images(
board_id: UUID, board_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -322,8 +497,6 @@ async def get_board_images(
Used for loading board contents in the canvas. Used for loading board contents in the canvas.
""" """
# Verify board access (owner or shared link - for now just owner) # 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_result = await db.execute(select(Board).where(Board.id == board_id))
board = board_result.scalar_one_or_none() 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.""" """JWT token generation and validation."""
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from uuid import UUID from uuid import UUID
from jose import JWTError, jwt 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 Encoded JWT token string
""" """
if expires_delta: if expires_delta:
expire = datetime.utcnow() + expires_delta expire = datetime.now(UTC) + expires_delta
else: 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) encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt 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 import Board
from app.database.models.board_image import BoardImage from app.database.models.board_image import BoardImage
from app.database.models.group import Group
class BoardRepository: class BoardRepository:
@@ -195,3 +196,213 @@ class BoardRepository:
count = self.db.execute(stmt).scalar_one() count = self.db.execute(stmt).scalar_one()
return count > 0 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

@@ -22,6 +22,15 @@ class BoardCreate(BaseModel):
description: str | None = Field(default=None, description="Optional board description") description: str | None = Field(default=None, description="Optional board description")
class ViewportStateUpdate(BaseModel):
"""Schema for updating viewport state only."""
x: float = Field(..., description="Horizontal pan position")
y: float = Field(..., description="Vertical pan position")
zoom: float = Field(..., ge=0.1, le=5.0, description="Zoom level (0.1 to 5.0)")
rotation: float = Field(..., ge=0, le=360, description="Canvas rotation in degrees (0 to 360)")
class BoardUpdate(BaseModel): class BoardUpdate(BaseModel):
"""Schema for updating board metadata.""" """Schema for updating board metadata."""
@@ -65,3 +74,81 @@ class BoardDetail(BaseModel):
if isinstance(v, dict): if isinstance(v, dict):
return ViewportState(**v) return ViewportState(**v)
return 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") @field_validator("CORS_ORIGINS", mode="before")
@classmethod @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.""" """Parse CORS origins from string or list."""
if isinstance(v, str): if isinstance(v, str):
return [origin.strip() for origin in v.split(",")] return [origin.strip() for origin in v.split(",")]
if isinstance(v, list):
return v return v
return ["http://localhost:5173", "http://localhost:3000"]
# File Upload # File Upload
MAX_FILE_SIZE: int = 52428800 # 50MB MAX_FILE_SIZE: int = 52428800 # 50MB

View File

@@ -0,0 +1,38 @@
"""Application-wide constants."""
# File upload limits
MAX_IMAGE_SIZE = 52_428_800 # 50MB in bytes
MAX_ZIP_SIZE = 209_715_200 # 200MB in bytes
# Image processing
MAX_IMAGE_DIMENSION = 10_000 # Max width or height in pixels
THUMBNAIL_SIZES = {
"low": 800, # For slow connections (<1 Mbps)
"medium": 1600, # For medium connections (1-5 Mbps)
"high": 3200, # For fast connections (>5 Mbps)
}
# Pagination defaults
DEFAULT_PAGE_SIZE = 50
MAX_PAGE_SIZE = 100
# Board limits
MAX_BOARD_TITLE_LENGTH = 255
MAX_BOARD_DESCRIPTION_LENGTH = 1000
MAX_IMAGES_PER_BOARD = 1000
# Authentication
TOKEN_EXPIRE_HOURS = 168 # 7 days
PASSWORD_MIN_LENGTH = 8
# Supported image formats
ALLOWED_MIME_TYPES = {
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
}
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}

View File

@@ -5,24 +5,48 @@ from uuid import UUID
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer 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.auth.jwt import decode_access_token
from app.core.config import settings
from app.database.models.user import User from app.database.models.user import User
from app.database.session import get_db from app.database.session import get_db
# Database session dependency # Sync engine for synchronous endpoints
DatabaseSession = Annotated[Session, Depends(get_db)] _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 scheme for JWT Bearer token
security = HTTPBearer() security = HTTPBearer()
def get_current_user( def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db) credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db_sync)
) -> User: ) -> User:
""" """
Get current authenticated user from JWT token. Get current authenticated user from JWT token (synchronous version).
Args: Args:
credentials: HTTP Authorization Bearer token credentials: HTTP Authorization Bearer token
@@ -63,7 +87,7 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) from None ) from None
# Get user from database # Get user from database (sync)
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if user is None: 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") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated")
return user 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

@@ -0,0 +1,69 @@
"""Ownership verification utilities."""
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.database.models.board import Board
def verify_board_ownership_sync(db: Session, board_id: UUID, user_id: UUID) -> Board:
"""
Verify board ownership (synchronous).
Args:
db: Database session
board_id: Board UUID
user_id: User UUID
Returns:
Board instance if owned by user
Raises:
HTTPException: 404 if board not found or not owned by user
"""
stmt = select(Board).where(
Board.id == board_id,
Board.user_id == user_id,
Board.is_deleted == False, # noqa: E712
)
board = db.execute(stmt).scalar_one_or_none()
if not board:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Board {board_id} not found")
return board
async def verify_board_ownership_async(db: AsyncSession, board_id: UUID, user_id: UUID) -> Board:
"""
Verify board ownership (asynchronous).
Args:
db: Async database session
board_id: Board UUID
user_id: User UUID
Returns:
Board instance if owned by user
Raises:
HTTPException: 404 if board not found or not owned by user
"""
stmt = select(Board).where(
Board.id == board_id,
Board.user_id == user_id,
Board.is_deleted == False, # noqa: E712
)
result = await db.execute(stmt)
board = result.scalar_one_or_none()
if not board:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Board {board_id} not found")
return board

View File

@@ -0,0 +1,119 @@
"""Base repository with common database operations."""
from typing import TypeVar
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
# Type variable for model classes
ModelType = TypeVar("ModelType")
class BaseRepository[ModelType]:
"""Base repository with common CRUD operations."""
def __init__(self, model: type[ModelType], db: Session | AsyncSession):
"""
Initialize repository.
Args:
model: SQLAlchemy model class
db: Database session (sync or async)
"""
self.model = model
self.db = db
def get_by_id_sync(self, id: UUID) -> ModelType | None:
"""
Get entity by ID (synchronous).
Args:
id: Entity UUID
Returns:
Entity if found, None otherwise
"""
return self.db.query(self.model).filter(self.model.id == id).first()
async def get_by_id_async(self, id: UUID) -> ModelType | None:
"""
Get entity by ID (asynchronous).
Args:
id: Entity UUID
Returns:
Entity if found, None otherwise
"""
stmt = select(self.model).where(self.model.id == id)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
def count_sync(self, **filters) -> int:
"""
Count entities with optional filters (synchronous).
Args:
**filters: Column filters (column_name=value)
Returns:
Count of matching entities
"""
query = self.db.query(func.count(self.model.id))
for key, value in filters.items():
query = query.filter(getattr(self.model, key) == value)
return query.scalar()
async def count_async(self, **filters) -> int:
"""
Count entities with optional filters (asynchronous).
Args:
**filters: Column filters (column_name=value)
Returns:
Count of matching entities
"""
stmt = select(func.count(self.model.id))
for key, value in filters.items():
stmt = stmt.where(getattr(self.model, key) == value)
result = await self.db.execute(stmt)
return result.scalar_one()
def delete_sync(self, id: UUID) -> bool:
"""
Delete entity by ID (synchronous).
Args:
id: Entity UUID
Returns:
True if deleted, False if not found
"""
entity = self.get_by_id_sync(id)
if not entity:
return False
self.db.delete(entity)
self.db.commit()
return True
async def delete_async(self, id: UUID) -> bool:
"""
Delete entity by ID (asynchronous).
Args:
id: Entity UUID
Returns:
True if deleted, False if not found
"""
entity = await self.get_by_id_async(id)
if not entity:
return False
await self.db.delete(entity)
await self.db.commit()
return True

View File

@@ -0,0 +1,75 @@
"""Standard response utilities."""
from typing import Any
from fastapi import status
class ErrorResponse:
"""Standard error response formats."""
@staticmethod
def not_found(resource: str = "Resource") -> dict[str, Any]:
"""404 Not Found response."""
return {
"status_code": status.HTTP_404_NOT_FOUND,
"detail": f"{resource} not found",
}
@staticmethod
def forbidden(message: str = "Access denied") -> dict[str, Any]:
"""403 Forbidden response."""
return {
"status_code": status.HTTP_403_FORBIDDEN,
"detail": message,
}
@staticmethod
def unauthorized(message: str = "Authentication required") -> dict[str, Any]:
"""401 Unauthorized response."""
return {
"status_code": status.HTTP_401_UNAUTHORIZED,
"detail": message,
"headers": {"WWW-Authenticate": "Bearer"},
}
@staticmethod
def bad_request(message: str) -> dict[str, Any]:
"""400 Bad Request response."""
return {
"status_code": status.HTTP_400_BAD_REQUEST,
"detail": message,
}
@staticmethod
def conflict(message: str) -> dict[str, Any]:
"""409 Conflict response."""
return {
"status_code": status.HTTP_409_CONFLICT,
"detail": message,
}
class SuccessResponse:
"""Standard success response formats."""
@staticmethod
def created(data: dict[str, Any], message: str = "Created successfully") -> dict[str, Any]:
"""201 Created response."""
return {
"message": message,
"data": data,
}
@staticmethod
def ok(data: dict[str, Any] | None = None, message: str = "Success") -> dict[str, Any]:
"""200 OK response."""
response = {"message": message}
if data:
response["data"] = data
return response
@staticmethod
def no_content() -> None:
"""204 No Content response."""
return None

View File

@@ -91,6 +91,27 @@ class StorageClient:
logger.error(f"Failed to download file {object_name}: {e}") logger.error(f"Failed to download file {object_name}: {e}")
raise 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: def delete_file(self, object_name: str) -> None:
"""Delete file from MinIO. """Delete file from MinIO.

View File

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

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID, uuid4 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 JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -13,6 +13,7 @@ from app.database.base import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from app.database.models.board_image import BoardImage 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.group import Group
from app.database.models.share_link import ShareLink from app.database.models.share_link import ShareLink
from app.database.models.user import User 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}, 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( 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) is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
@@ -56,6 +57,7 @@ class Board(Base):
share_links: Mapped[list["ShareLink"]] = relationship( share_links: Mapped[list["ShareLink"]] = relationship(
"ShareLink", back_populates="board", cascade="all, delete-orphan" "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: def __repr__(self) -> str:
"""String representation of Board.""" """String representation of Board."""

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID, uuid4 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 JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship 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 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( 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 # Relationships

View File

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

View File

@@ -4,7 +4,7 @@ from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID, uuid4 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 JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship 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) mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
width: Mapped[int] = mapped_column(Integer, nullable=False) width: Mapped[int] = mapped_column(Integer, nullable=False)
height: 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) reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Relationships # Relationships

View File

@@ -1,45 +1,32 @@
"""ShareLink database model.""" """ShareLink model for board sharing functionality."""
from datetime import datetime import uuid
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import relationship
from app.database.base import Base from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board import Board
class ShareLink(Base): class ShareLink(Base):
""" """ShareLink model representing shareable board links with permissions."""
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.
"""
__tablename__ = "share_links" __tablename__ = "share_links"
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
board_id: Mapped[UUID] = mapped_column( board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
PGUUID(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'
token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) created_at = Column(DateTime, nullable=False, server_default=func.now())
permission_level: Mapped[str] = mapped_column(String(20), nullable=False) # 'view-only' or 'view-comment' expires_at = Column(DateTime, nullable=True)
last_accessed_at = Column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) access_count = Column(Integer, nullable=False, default=0)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) is_revoked = Column(Boolean, nullable=False, default=False)
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)
# Relationships # 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: def __repr__(self) -> str:
"""String representation of ShareLink.""" return f"<ShareLink(id={self.id}, board_id={self.board_id}, permission={self.permission_level})>"
return f"<ShareLink(id={self.id}, token={self.token[:8]}..., board_id={self.board_id})>"

View File

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

View File

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

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, mime_type: str,
width: int, width: int,
height: int, height: int,
metadata: dict, image_metadata: dict,
) -> Image: ) -> Image:
""" """Create new image record."""
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
"""
image = Image( image = Image(
user_id=user_id, user_id=user_id,
filename=filename, filename=filename,
@@ -52,7 +37,7 @@ class ImageRepository:
mime_type=mime_type, mime_type=mime_type,
width=width, width=width,
height=height, height=height,
metadata=metadata, image_metadata=image_metadata,
) )
self.db.add(image) self.db.add(image)
await self.db.commit() await self.db.commit()
@@ -60,52 +45,27 @@ class ImageRepository:
return image return image
async def get_image_by_id(self, image_id: UUID) -> Image | None: async def get_image_by_id(self, image_id: UUID) -> Image | None:
""" """Get image by ID."""
Get image by ID.
Args:
image_id: Image ID
Returns:
Image instance or None
"""
result = await self.db.execute(select(Image).where(Image.id == image_id)) result = await self.db.execute(select(Image).where(Image.id == image_id))
return result.scalar_one_or_none() 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]: 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: # Get total count efficiently
user_id: User ID count_result = await self.db.execute(select(func.count(Image.id)).where(Image.user_id == user_id))
limit: Maximum number of images to return total = count_result.scalar_one()
offset: Number of images to skip
Returns: # Get paginated images
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
result = await self.db.execute( result = await self.db.execute(
select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset) select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset)
) )
images = result.scalars().all() images = result.scalars().all()
return images, total return images, total
async def delete_image(self, image_id: UUID) -> bool: async def delete_image(self, image_id: UUID) -> bool:
""" """Delete image record."""
Delete image record.
Args:
image_id: Image ID
Returns:
True if deleted, False if not found
"""
image = await self.get_image_by_id(image_id) image = await self.get_image_by_id(image_id)
if not image: if not image:
return False return False
@@ -115,27 +75,14 @@ class ImageRepository:
return True return True
async def increment_reference_count(self, image_id: UUID) -> None: async def increment_reference_count(self, image_id: UUID) -> None:
""" """Increment reference count for image."""
Increment reference count for image.
Args:
image_id: Image ID
"""
image = await self.get_image_by_id(image_id) image = await self.get_image_by_id(image_id)
if image: if image:
image.reference_count += 1 image.reference_count += 1
await self.db.commit() await self.db.commit()
async def decrement_reference_count(self, image_id: UUID) -> int: async def decrement_reference_count(self, image_id: UUID) -> int:
""" """Decrement reference count for image."""
Decrement reference count for image.
Args:
image_id: Image ID
Returns:
New reference count
"""
image = await self.get_image_by_id(image_id) image = await self.get_image_by_id(image_id)
if image and image.reference_count > 0: if image and image.reference_count > 0:
image.reference_count -= 1 image.reference_count -= 1
@@ -151,19 +98,7 @@ class ImageRepository:
transformations: dict, transformations: dict,
z_order: int = 0, z_order: int = 0,
) -> BoardImage: ) -> BoardImage:
""" """Add image to board."""
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
"""
board_image = BoardImage( board_image = BoardImage(
board_id=board_id, board_id=board_id,
image_id=image_id, image_id=image_id,
@@ -181,35 +116,50 @@ class ImageRepository:
return board_image return board_image
async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]: async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]:
""" """Get all images for a board, ordered by z-order."""
Get all images for a board, ordered by z-order.
Args:
board_id: Board ID
Returns:
List of BoardImage instances
"""
result = await self.db.execute( result = await self.db.execute(
select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc()) select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc())
) )
return result.scalars().all() return result.scalars().all()
async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool: async def get_board_image(self, board_id: UUID, image_id: UUID) -> BoardImage | None:
""" """Get a specific board image."""
Remove image from board.
Args:
board_id: Board ID
image_id: Image ID
Returns:
True if removed, False if not found
"""
result = await self.db.execute( result = await self.db.execute(
select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id) 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: if not board_image:
return False return False

View File

@@ -26,13 +26,14 @@ class ImageUploadResponse(BaseModel):
mime_type: str mime_type: str
width: int width: int
height: int height: int
metadata: dict[str, Any] metadata: dict[str, Any] = Field(..., alias="image_metadata")
created_at: datetime created_at: datetime
class Config: class Config:
"""Pydantic config.""" """Pydantic config."""
from_attributes = True from_attributes = True
populate_by_name = True
class ImageResponse(BaseModel): class ImageResponse(BaseModel):
@@ -46,7 +47,7 @@ class ImageResponse(BaseModel):
mime_type: str mime_type: str
width: int width: int
height: int height: int
metadata: dict[str, Any] metadata: dict[str, Any] = Field(..., alias="image_metadata")
created_at: datetime created_at: datetime
reference_count: int reference_count: int
@@ -54,6 +55,7 @@ class ImageResponse(BaseModel):
"""Pydantic config.""" """Pydantic config."""
from_attributes = True from_attributes = True
populate_by_name = True
class BoardImageCreate(BaseModel): class BoardImageCreate(BaseModel):
@@ -83,6 +85,23 @@ class BoardImageCreate(BaseModel):
return v return v
class BoardImageUpdate(BaseModel):
"""Schema for updating board image position/transformations."""
position: dict[str, float] | None = Field(None, description="Canvas position")
transformations: dict[str, Any] | None = Field(None, description="Image transformations")
z_order: int | None = Field(None, description="Layer order")
group_id: UUID | None = Field(None, description="Group membership")
@field_validator("position")
@classmethod
def validate_position(cls, v: dict[str, float] | None) -> dict[str, float] | None:
"""Validate position has x and y if provided."""
if v is not None and ("x" not in v or "y" not in v):
raise ValueError("Position must contain 'x' and 'y' coordinates")
return v
class BoardImageResponse(BaseModel): class BoardImageResponse(BaseModel):
"""Response for board image with all metadata.""" """Response for board image with all metadata."""
@@ -103,6 +122,31 @@ class BoardImageResponse(BaseModel):
from_attributes = True 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): class ImageListResponse(BaseModel):
"""Paginated list of images.""" """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

@@ -3,21 +3,11 @@
import magic import magic
from fastapi import HTTPException, UploadFile, status from fastapi import HTTPException, UploadFile, status
# Maximum file size: 50MB from app.core.constants import (
MAX_FILE_SIZE = 52_428_800 ALLOWED_EXTENSIONS,
ALLOWED_MIME_TYPES,
# Allowed MIME types MAX_IMAGE_SIZE,
ALLOWED_MIME_TYPES = { )
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
}
# Allowed file extensions
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}
async def validate_image_file(file: UploadFile) -> bytes: async def validate_image_file(file: UploadFile) -> bytes:
@@ -50,10 +40,10 @@ async def validate_image_file(file: UploadFile) -> bytes:
if file_size == 0: if file_size == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file uploaded") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file uploaded")
if file_size > MAX_FILE_SIZE: if file_size > MAX_IMAGE_SIZE:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File too large. Maximum size is {MAX_FILE_SIZE / 1_048_576:.1f}MB", detail=f"File too large. Maximum size is {MAX_IMAGE_SIZE / 1_048_576:.1f}MB",
) )
# Validate file extension # Validate file extension

View File

@@ -5,7 +5,7 @@ import logging
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse 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.config import settings
from app.core.errors import WebRefException from app.core.errors import WebRefException
from app.core.logging import setup_logging from app.core.logging import setup_logging
@@ -84,7 +84,12 @@ async def root():
# API routers # API routers
app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}") 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(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(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") @app.on_event("startup")

View File

@@ -1,6 +1,5 @@
"""Integration tests for authentication endpoints.""" """Integration tests for authentication endpoints."""
import pytest
from fastapi import status from fastapi import status
from fastapi.testclient import TestClient 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

@@ -0,0 +1,455 @@
"""Integration tests for image position update endpoint."""
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_image_position(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test updating image position on board."""
# Create a 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 an 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": "abc123"},
)
db.add(image)
# Add image to board
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 position
response = await client.patch(
f"/api/images/boards/{board.id}/images/{image.id}",
json={"position": {"x": 200, "y": 250}},
)
assert response.status_code == 200
data = response.json()
assert data["position"]["x"] == 200
assert data["position"]["y"] == 250
@pytest.mark.asyncio
async def test_update_image_transformations(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test updating image transformations."""
# Create board, image, and board_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 transformations
response = await client.patch(
f"/api/images/boards/{board.id}/images/{image.id}",
json={
"transformations": {
"scale": 1.5,
"rotation": 45,
"opacity": 0.8,
"flipped_h": True,
"flipped_v": False,
"greyscale": True,
}
},
)
assert response.status_code == 200
data = response.json()
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 True
@pytest.mark.asyncio
async def test_update_image_z_order(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test updating image Z-order."""
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_update_multiple_fields(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test updating 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)
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
response = await client.patch(
f"/api/images/boards/{board.id}/images/{image.id}",
json={
"position": {"x": 300, "y": 400},
"transformations": {"scale": 2.0, "rotation": 90},
"z_order": 10,
},
)
assert response.status_code == 200
data = response.json()
assert data["position"]["x"] == 300
assert data["position"]["y"] == 400
assert data["transformations"]["scale"] == 2.0
assert data["transformations"]["rotation"] == 90
assert data["z_order"] == 10
@pytest.mark.asyncio
async def test_update_image_not_on_board(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test updating image that's not on the specified 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 update image that's not on board
response = await client.patch(
f"/api/images/boards/{board.id}/images/{image.id}",
json={"position": {"x": 200, "y": 200}},
)
assert response.status_code == 404
assert "not on this board" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_update_image_invalid_position(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test updating with invalid position data."""
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()
# Try to update with missing y coordinate
response = await client.patch(
f"/api/images/boards/{board.id}/images/{image.id}",
json={"position": {"x": 200}},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_update_image_unauthorized(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test that other users cannot update images 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 User's 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 update as current user (should fail)
response = await client.patch(
f"/api/images/boards/{board.id}/images/{image.id}",
json={"position": {"x": 200, "y": 200}},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_update_preserves_other_fields(client: AsyncClient, test_user: User, db: AsyncSession):
"""Test that updating one field preserves others."""
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.5,
"rotation": 45,
"opacity": 0.9,
"flipped_h": True,
"flipped_v": False,
"greyscale": False,
},
z_order=3,
)
db.add(board_image)
await db.commit()
# Update only position
response = await client.patch(
f"/api/images/boards/{board.id}/images/{image.id}",
json={"position": {"x": 200, "y": 200}},
)
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
assert data["transformations"]["opacity"] == 0.9
assert data["z_order"] == 3

View File

@@ -1,7 +1,7 @@
"""Integration tests for image upload endpoints.""" """Integration tests for image upload endpoints."""
import io import io
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import patch
import pytest import pytest
from fastapi import status 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 datetime import datetime, timedelta
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import pytest
from jose import jwt from jose import jwt
from app.auth.jwt import create_access_token, decode_access_token from app.auth.jwt import create_access_token, decode_access_token

View File

@@ -1,6 +1,5 @@
"""Unit tests for password hashing and validation.""" """Unit tests for password hashing and validation."""
import pytest
from app.auth.security import hash_password, validate_password_strength, verify_password 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.""" """Pytest configuration and fixtures for all tests."""
import os from collections.abc import Generator
from typing import Generator
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -105,3 +104,106 @@ def test_user_data_no_uppercase() -> dict:
""" """
return {"email": "test@example.com", "password": "testpassword123"} 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 import io
from uuid import uuid4 from uuid import uuid4
import pytest
from PIL import Image as PILImage from PIL import Image as PILImage
from app.images.processing import generate_thumbnails 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.""" """Tests for file validation."""
import io from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
import pytest import pytest
from fastapi import HTTPException, UploadFile from fastapi import HTTPException, UploadFile

View File

@@ -31,7 +31,8 @@
alembic alembic
pydantic pydantic
pydantic-settings # Settings management pydantic-settings # Settings management
psycopg2 # PostgreSQL driver psycopg2 # PostgreSQL driver (sync)
asyncpg # PostgreSQL driver (async)
# Auth & Security # Auth & Security
python-jose python-jose
passlib passlib
@@ -88,6 +89,7 @@
# Development tools # Development tools
git git
direnv direnv
tmux
]; ];
shellHook = '' shellHook = ''
@@ -105,6 +107,7 @@
echo " Status: ./scripts/dev-services.sh status" echo " Status: ./scripts/dev-services.sh status"
echo "" echo ""
echo "📚 Quick Commands:" echo "📚 Quick Commands:"
echo " Dev (tmux): nix run .#dev"
echo " Backend: cd backend && uvicorn app.main:app --reload" echo " Backend: cd backend && uvicorn app.main:app --reload"
echo " Frontend: cd frontend && npm run dev" echo " Frontend: cd frontend && npm run dev"
echo " Database: psql -h localhost -U webref webref" echo " Database: psql -h localhost -U webref webref"
@@ -131,6 +134,7 @@
type = "app"; type = "app";
program = "${pkgs.writeShellScript "help" '' program = "${pkgs.writeShellScript "help" ''
echo "Available commands:" echo "Available commands:"
echo " nix run .#dev - Start backend + frontend in tmux"
echo " nix run .#lint - Run all linting checks" echo " nix run .#lint - Run all linting checks"
echo " nix run .#lint-backend - Run backend linting only" echo " nix run .#lint-backend - Run backend linting only"
echo " nix run .#lint-frontend - Run frontend 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 # Unified linting - calls both backend and frontend lints
lint = { lint = {
type = "app"; type = "app";

View File

@@ -4,28 +4,28 @@ module.exports = {
'eslint:recommended', 'eslint:recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended', 'plugin:svelte/recommended',
'prettier' 'prettier',
], ],
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'], plugins: ['@typescript-eslint'],
parserOptions: { parserOptions: {
sourceType: 'module', sourceType: 'module',
ecmaVersion: 2020, ecmaVersion: 2020,
extraFileExtensions: ['.svelte'] extraFileExtensions: ['.svelte'],
}, },
env: { env: {
browser: true, browser: true,
es2017: true, es2017: true,
node: true node: true,
}, },
overrides: [ overrides: [
{ {
files: ['*.svelte'], files: ['*.svelte'],
parser: 'svelte-eslint-parser', parser: 'svelte-eslint-parser',
parserOptions: { parserOptions: {
parser: '@typescript-eslint/parser' parser: '@typescript-eslint/parser',
} },
} },
], ],
rules: { rules: {
// TypeScript rules // TypeScript rules
@@ -33,8 +33,8 @@ module.exports = {
'error', 'error',
{ {
argsIgnorePattern: '^_', argsIgnorePattern: '^_',
varsIgnorePattern: '^_' varsIgnorePattern: '^_',
} },
], ],
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
@@ -45,6 +45,6 @@ module.exports = {
// Svelte specific // Svelte specific
'svelte/no-at-html-tags': 'error', '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 [
}, },
}, },
]; ];

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@testing-library/svelte": "^5.2.8",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
@@ -26,6 +27,7 @@
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1", "eslint-plugin-svelte": "^2.35.1",
"jsdom": "^27.1.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.1.2", "prettier-plugin-svelte": "^3.1.2",
"svelte": "^4.2.0", "svelte": "^4.2.0",
@@ -38,8 +40,14 @@
"vitest": "^2.0.0" "vitest": "^2.0.0"
}, },
"dependencies": { "dependencies": {
"konva": "^9.3.0", "globals": "^15.0.0",
"globals": "^15.0.0" "konva": "^9.3.0"
},
"overrides": {
"cookie": ">=0.7.0",
"inflight": "npm:@apteryxxyz/inflight@^2.0.0",
"glob": "^11.0.0",
"rimraf": "^6.0.0",
"esbuild": "^0.25.12"
} }
} }

12
frontend/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -13,6 +13,10 @@ export interface ApiError {
status_code: number; status_code: number;
} }
export interface ApiRequestOptions extends RequestInit {
skipAuth?: boolean;
}
export class ApiClient { export class ApiClient {
private baseUrl: string; private baseUrl: string;
@@ -20,16 +24,17 @@ export class ApiClient {
this.baseUrl = baseUrl; 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 { token } = get(authStore);
const { skipAuth, ...fetchOptions } = options;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {}), ...((fetchOptions.headers as Record<string, string>) || {}),
}; };
// Add authentication token if available // Add authentication token if available and not skipped
if (token) { if (token && !skipAuth) {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
} }
@@ -37,7 +42,7 @@ export class ApiClient {
try { try {
const response = await fetch(url, { const response = await fetch(url, {
...options, ...fetchOptions,
headers, 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' }); 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, { return this.request<T>(endpoint, {
...options, ...options,
method: 'POST', 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, { return this.request<T>(endpoint, {
...options, ...options,
method: 'PUT', 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, { return this.request<T>(endpoint, {
...options, ...options,
method: 'PATCH', 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' }); return this.request<T>(endpoint, { ...options, method: 'DELETE' });
} }
@@ -127,6 +132,8 @@ export class ApiClient {
} }
const url = `${this.baseUrl}${endpoint}`; const url = `${this.baseUrl}${endpoint}`;
try {
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers, headers,
@@ -134,11 +141,25 @@ export class ApiClient {
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); 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; throw error;
} }
return response.json(); 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;
}
} }
} }

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 * Upload a single image
*/ */
export async function uploadImage(file: File): Promise<Image> { export async function uploadImage(file: File): Promise<Image> {
const formData = new FormData(); return await apiClient.uploadFile<Image>('/images/upload', file);
formData.append('file', file);
const response = await apiClient.post<Image>('/images/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
} }
/** /**
* Upload multiple images from a ZIP file * Upload multiple images from a ZIP file
*/ */
export async function uploadZip(file: File): Promise<Image[]> { export async function uploadZip(file: File): Promise<Image[]> {
const formData = new FormData(); return await apiClient.uploadFile<Image[]>('/images/upload-zip', file);
formData.append('file', file);
const response = await apiClient.post<Image[]>('/images/upload-zip', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
} }
/** /**
@@ -105,3 +87,19 @@ export async function removeImageFromBoard(boardId: string, imageId: string): Pr
export async function getBoardImages(boardId: string): Promise<BoardImage[]> { export async function getBoardImages(boardId: string): Promise<BoardImage[]> {
return await apiClient.get<BoardImage[]>(`/images/boards/${boardId}/images`); 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

@@ -0,0 +1,270 @@
<script lang="ts">
/**
* Konva Image wrapper component for canvas images
* Wraps a Konva.Image with selection, dragging, and transformation support
*/
import { onMount, onDestroy } from 'svelte';
import Konva from 'konva';
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;
export let width: number = 100;
export let height: number = 100;
export let rotation: number = 0;
export let scaleX: number = 1;
export let scaleY: number = 1;
export let opacity: number = 1;
export let layer: Konva.Layer | null = null;
export let zOrder: number = 0;
// 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;
let imageObj: HTMLImageElement | null = null;
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;
// Load image
imageObj = new Image();
imageObj.crossOrigin = 'Anonymous';
imageObj.onload = () => {
if (!layer || !imageObj) return;
// Create Konva image
imageNode = new Konva.Image({
image: imageObj!,
x: 0,
y: 0,
width,
height,
listening: true,
});
// Create group for image and selection box
imageGroup = new Konva.Group({
x,
y,
rotation,
scaleX,
scaleY,
opacity,
draggable: true,
id: `image-group-${id}`,
});
imageGroup.add(imageNode);
// 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) {
onDragEnd(imageId, newX, newY);
}
});
cleanupSelection = setupImageSelection(imageGroup, id, undefined, (imageId, _selected) => {
updateSelectionVisual();
if (onSelectionChange) {
onSelectionChange(imageId, _selected);
}
});
// Subscribe to selection changes for visual updates
unsubscribeSelection = isSelected.subscribe((_selected) => {
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;
});
onDestroy(() => {
// Clean up event listeners
if (cleanupDrag) cleanupDrag();
if (cleanupSelection) cleanupSelection();
if (unsubscribeSelection) unsubscribeSelection();
// Destroy Konva nodes
if (imageNode) imageNode.destroy();
if (imageGroup) imageGroup.destroy();
// Redraw layer
if (layer) layer.batchDraw();
});
/**
* Update selection visual (highlight border)
*/
function updateSelectionVisual() {
if (!imageGroup || !$isSelected) return;
// Remove existing selection box
const existingBox = imageGroup.findOne('.selection-box');
if (existingBox) existingBox.destroy();
if ($isSelected && imageNode) {
// Add selection box
const selectionBox = new Konva.Rect({
x: 0,
y: 0,
width: imageNode.width(),
height: imageNode.height(),
stroke: '#3b82f6',
strokeWidth: 2,
listening: false,
name: 'selection-box',
});
imageGroup.add(selectionBox);
}
if (layer) layer.batchDraw();
}
/**
* Update image position
*/
$: if (imageGroup && (imageGroup.x() !== x || imageGroup.y() !== y)) {
imageGroup.position({ x, y });
if (layer) layer.batchDraw();
}
/**
* Update image transformations
*/
$: if (imageGroup) {
if (imageGroup.rotation() !== rotation) {
imageGroup.rotation(rotation);
if (layer) layer.batchDraw();
}
if (imageGroup.scaleX() !== scaleX || imageGroup.scaleY() !== scaleY) {
imageGroup.scale({ x: scaleX, y: scaleY });
if (layer) layer.batchDraw();
}
if (imageGroup.opacity() !== opacity) {
imageGroup.opacity(opacity);
if (layer) layer.batchDraw();
}
if (imageGroup.zIndex() !== zOrder) {
imageGroup.zIndex(zOrder);
if (layer) layer.batchDraw();
}
}
/**
* Update image dimensions
*/
$: if (imageNode && (imageNode.width() !== width || imageNode.height() !== height)) {
imageNode.size({ width, height });
updateSelectionVisual();
if (layer) layer.batchDraw();
}
/**
* Expose image group for external manipulation
*/
export function getImageGroup(): Konva.Group | null {
return imageGroup;
}
/**
* Expose image node for external manipulation
*/
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

@@ -0,0 +1,179 @@
<script lang="ts">
/**
* Selection box visual indicator for canvas
* Displays a border and resize handles around selected images
*/
import { onMount, onDestroy } from 'svelte';
import Konva from 'konva';
import { selection, selectionCount } from '$lib/stores/selection';
export let layer: Konva.Layer | null = null;
export let getImageBounds: (
id: string
) => { x: number; y: number; width: number; height: number } | null;
let selectionGroup: Konva.Group | null = null;
let unsubscribe: (() => void) | null = null;
onMount(() => {
if (!layer) return;
// Create group for selection visuals
selectionGroup = new Konva.Group({
listening: false,
name: 'selection-group',
});
layer.add(selectionGroup);
// Subscribe to selection changes
unsubscribe = selection.subscribe(() => {
updateSelectionVisuals();
});
layer.batchDraw();
});
onDestroy(() => {
if (unsubscribe) unsubscribe();
if (selectionGroup) {
selectionGroup.destroy();
selectionGroup = null;
}
if (layer) layer.batchDraw();
});
/**
* Update selection visual indicators
*/
function updateSelectionVisuals() {
if (!selectionGroup || !layer) return;
// Clear existing visuals
selectionGroup.destroyChildren();
const selectedIds = selection.getSelectedIds();
if (selectedIds.length === 0) {
layer.batchDraw();
return;
}
// Calculate bounding box of all selected images
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
selectedIds.forEach((id) => {
const bounds = getImageBounds(id);
if (bounds) {
minX = Math.min(minX, bounds.x);
minY = Math.min(minY, bounds.y);
maxX = Math.max(maxX, bounds.x + bounds.width);
maxY = Math.max(maxY, bounds.y + bounds.height);
}
});
if (!isFinite(minX) || !isFinite(minY)) {
layer.batchDraw();
return;
}
const width = maxX - minX;
const height = maxY - minY;
// Draw selection border
const border = new Konva.Rect({
x: minX,
y: minY,
width,
height,
stroke: '#3b82f6',
strokeWidth: 2,
dash: [8, 4],
listening: false,
});
selectionGroup.add(border);
// Draw resize handles if single selection
if ($selectionCount === 1) {
const handleSize = 8;
const handlePositions = [
{ x: minX, y: minY, cursor: 'nw-resize' }, // Top-left
{ x: minX + width / 2, y: minY, cursor: 'n-resize' }, // Top-center
{ x: maxX, y: minY, cursor: 'ne-resize' }, // Top-right
{ x: maxX, y: minY + height / 2, cursor: 'e-resize' }, // Right-center
{ x: maxX, y: maxY, cursor: 'se-resize' }, // Bottom-right
{ x: minX + width / 2, y: maxY, cursor: 's-resize' }, // Bottom-center
{ x: minX, y: maxY, cursor: 'sw-resize' }, // Bottom-left
{ x: minX, y: minY + height / 2, cursor: 'w-resize' }, // Left-center
];
handlePositions.forEach((pos) => {
const handle = new Konva.Rect({
x: pos.x - handleSize / 2,
y: pos.y - handleSize / 2,
width: handleSize,
height: handleSize,
fill: '#3b82f6',
stroke: '#ffffff',
strokeWidth: 1,
listening: false,
});
selectionGroup!.add(handle);
});
}
// Draw selection count badge if multiple selection
if ($selectionCount > 1) {
const badgeX = maxX - 30;
const badgeY = minY - 30;
const badge = new Konva.Group({
x: badgeX,
y: badgeY,
listening: false,
});
const badgeBackground = new Konva.Rect({
x: 0,
y: 0,
width: 30,
height: 24,
fill: '#3b82f6',
cornerRadius: 4,
listening: false,
});
const badgeText = new Konva.Text({
x: 0,
y: 0,
width: 30,
height: 24,
text: $selectionCount.toString(),
fontSize: 14,
fill: '#ffffff',
align: 'center',
verticalAlign: 'middle',
listening: false,
});
badge.add(badgeBackground);
badge.add(badgeText);
selectionGroup!.add(badge);
}
layer.batchDraw();
}
/**
* Force update of selection visuals (for external calls)
*/
export function update() {
updateSelectionVisuals();
}
</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 { setupZoomControls } from './controls/zoom';
import { setupRotateControls } from './controls/rotate'; import { setupRotateControls } from './controls/rotate';
import { setupGestureControls } from './gestures'; import { setupGestureControls } from './gestures';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
// Board ID for future use (e.g., loading board-specific state) // 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 width: number = 0;
export let height: number = 0; export let height: number = 0;
@@ -40,6 +46,13 @@
layer = new Konva.Layer(); layer = new Konva.Layer();
stage.add(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 // Set up controls
if (stage) { if (stage) {
cleanupPan = setupPanControls(stage); cleanupPan = setupPanControls(stage);
@@ -48,13 +61,13 @@
cleanupGestures = setupGestureControls(stage); cleanupGestures = setupGestureControls(stage);
} }
// Subscribe to viewport changes // Subscribe to viewport changes (after initial state applied)
unsubscribeViewport = viewport.subscribe((state) => { unsubscribeViewport = viewport.subscribe((state) => {
updateStageTransform(state); updateStageTransform(state);
}); });
// Apply initial viewport state // Notify parent that stage is ready
updateStageTransform($viewport); dispatch('ready');
}); });
onDestroy(() => { onDestroy(() => {
@@ -78,21 +91,26 @@
* Update stage transform based on viewport state * Update stage transform based on viewport state
*/ */
function updateStageTransform(state: ViewportState) { function updateStageTransform(state: ViewportState) {
if (!stage) return; if (!stage || !layer) return;
// Apply transformations to the stage // Don't apply transforms to the stage itself - it causes rendering issues
stage.position({ x: state.x, y: state.y }); // Instead, we'll transform the layer
stage.scale({ x: state.zoom, y: state.zoom }); layer.position({ x: state.x, y: state.y });
stage.rotation(state.rotation); layer.scale({ x: state.zoom, y: state.zoom });
layer.rotation(state.rotation);
// Force both layer and stage to redraw
layer.batchDraw();
stage.batchDraw(); stage.batchDraw();
} }
/** /**
* Resize canvas when dimensions change * 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.width(width);
stage.height(height); stage.height(height);
layer.batchDraw();
stage.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,184 @@
/**
* Image dragging interactions for canvas
* Handles dragging images to reposition them
*/
import Konva from 'konva';
import { selection } from '$lib/stores/selection';
import { get } from 'svelte/store';
export interface DragState {
isDragging: boolean;
startPos: { x: number; y: number } | null;
draggedImageId: string | null;
}
const dragState: DragState = {
isDragging: false,
startPos: null,
draggedImageId: null,
};
/**
* Setup drag interactions for an image
*/
export function setupImageDrag(
image: Konva.Image | Konva.Group,
imageId: string,
onDragMove?: (imageId: string, x: number, y: number) => void,
onDragEnd?: (imageId: string, x: number, y: number) => void
): () => void {
/**
* Handle drag start
*/
function handleDragStart(e: Konva.KonvaEventObject<DragEvent>) {
dragState.isDragging = true;
dragState.startPos = { x: image.x(), y: image.y() };
dragState.draggedImageId = imageId;
// If dragged image is not selected, select it
const selectionState = get(selection);
if (!selectionState.selectedIds.has(imageId)) {
// Check if Ctrl/Cmd key is pressed
if (e.evt.ctrlKey || e.evt.metaKey) {
selection.addToSelection(imageId);
} else {
selection.selectOne(imageId);
}
}
// Set dragging cursor
const stage = image.getStage();
if (stage) {
stage.container().style.cursor = 'grabbing';
}
}
/**
* Handle drag move
*/
function handleDragMove(_e: Konva.KonvaEventObject<DragEvent>) {
if (!dragState.isDragging) return;
const x = image.x();
const y = image.y();
// Call callback if provided
if (onDragMove) {
onDragMove(imageId, x, y);
}
// If multiple images are selected, move them together
const selectionState = get(selection);
if (selectionState.selectedIds.size > 1 && dragState.startPos) {
const deltaX = x - dragState.startPos.x;
const deltaY = y - dragState.startPos.y;
// Update start position for next delta calculation
dragState.startPos = { x, y };
// Dispatch custom event to move other selected images
const stage = image.getStage();
if (stage) {
stage.fire('multiDragMove', {
draggedImageId: imageId,
deltaX,
deltaY,
selectedIds: Array.from(selectionState.selectedIds),
});
}
}
}
/**
* Handle drag end
*/
function handleDragEnd(_e: Konva.KonvaEventObject<DragEvent>) {
if (!dragState.isDragging) return;
const x = image.x();
const y = image.y();
// Call callback if provided
if (onDragEnd) {
onDragEnd(imageId, x, y);
}
// Reset drag state
dragState.isDragging = false;
dragState.startPos = null;
dragState.draggedImageId = null;
// Reset cursor
const stage = image.getStage();
if (stage) {
stage.container().style.cursor = 'default';
}
}
// Enable dragging
image.draggable(true);
// Attach event listeners
image.on('dragstart', handleDragStart);
image.on('dragmove', handleDragMove);
image.on('dragend', handleDragEnd);
// Return cleanup function
return () => {
image.off('dragstart', handleDragStart);
image.off('dragmove', handleDragMove);
image.off('dragend', handleDragEnd);
image.draggable(false);
};
}
/**
* Move image to specific position (programmatic)
*/
export function moveImageTo(
image: Konva.Image | Konva.Group,
x: number,
y: number,
animate: boolean = false
): void {
if (animate) {
// TODO: Add animation support using Konva.Tween
image.to({
x,
y,
duration: 0.3,
easing: Konva.Easings.EaseOut,
});
} else {
image.position({ x, y });
}
}
/**
* Move image by delta (programmatic)
*/
export function moveImageBy(
image: Konva.Image | Konva.Group,
deltaX: number,
deltaY: number,
animate: boolean = false
): void {
const currentX = image.x();
const currentY = image.y();
moveImageTo(image, currentX + deltaX, currentY + deltaY, animate);
}
/**
* Get current drag state (useful for debugging)
*/
export function getDragState(): DragState {
return { ...dragState };
}
/**
* Check if currently dragging
*/
export function isDragging(): boolean {
return dragState.isDragging;
}

View File

@@ -0,0 +1,234 @@
/**
* Rectangle selection (drag-to-select multiple images)
* Allows selecting multiple images by dragging a selection rectangle
*/
import Konva from 'konva';
import { selection } from '$lib/stores/selection';
export interface SelectionRectangle {
x1: number;
y1: number;
x2: number;
y2: number;
}
export interface MultiSelectState {
isSelecting: boolean;
startPos: { x: number; y: number } | null;
currentRect: SelectionRectangle | null;
}
const multiSelectState: MultiSelectState = {
isSelecting: false,
startPos: null,
currentRect: null,
};
/**
* Setup rectangle selection on stage
*/
export function setupRectangleSelection(
stage: Konva.Stage,
layer: Konva.Layer,
getImageBounds: () => Array<{
id: string;
bounds: { x: number; y: number; width: number; height: number };
}>,
onSelectionChange?: (selectedIds: string[]) => void
): () => void {
let selectionRect: Konva.Rect | null = null;
/**
* Handle mouse/touch down to start selection
*/
function handleMouseDown(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
// Only start rectangle selection if clicking on stage background
if (e.target !== stage) return;
// Only if not pressing Ctrl (that's for pan)
const isModifierPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false;
if (isModifierPressed) return;
const pos = stage.getPointerPosition();
if (!pos) return;
// Transform pointer position to account for stage transformations
const transform = stage.getAbsoluteTransform().copy().invert();
const localPos = transform.point(pos);
multiSelectState.isSelecting = true;
multiSelectState.startPos = localPos;
multiSelectState.currentRect = {
x1: localPos.x,
y1: localPos.y,
x2: localPos.x,
y2: localPos.y,
};
// Create visual selection rectangle
selectionRect = new Konva.Rect({
x: localPos.x,
y: localPos.y,
width: 0,
height: 0,
fill: 'rgba(0, 120, 255, 0.1)',
stroke: 'rgba(0, 120, 255, 0.8)',
strokeWidth: 1 / stage.scaleX(), // Adjust for zoom
listening: false,
});
layer.add(selectionRect);
layer.batchDraw();
}
/**
* Handle mouse/touch move to update selection rectangle
*/
function handleMouseMove(_e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
if (!multiSelectState.isSelecting || !multiSelectState.startPos || !selectionRect) return;
const pos = stage.getPointerPosition();
if (!pos) return;
// Transform pointer position
const transform = stage.getAbsoluteTransform().copy().invert();
const localPos = transform.point(pos);
multiSelectState.currentRect = {
x1: multiSelectState.startPos.x,
y1: multiSelectState.startPos.y,
x2: localPos.x,
y2: localPos.y,
};
// Update visual rectangle
const x = Math.min(multiSelectState.startPos.x, localPos.x);
const y = Math.min(multiSelectState.startPos.y, localPos.y);
const width = Math.abs(localPos.x - multiSelectState.startPos.x);
const height = Math.abs(localPos.y - multiSelectState.startPos.y);
selectionRect.x(x);
selectionRect.y(y);
selectionRect.width(width);
selectionRect.height(height);
layer.batchDraw();
}
/**
* Handle mouse/touch up to complete selection
*/
function handleMouseUp(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
if (!multiSelectState.isSelecting || !multiSelectState.currentRect) {
return;
}
// Get all images that intersect with selection rectangle
const selectedIds = getImagesInRectangle(multiSelectState.currentRect, getImageBounds());
// Check if Ctrl/Cmd is pressed for additive selection
const isModifierPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false;
if (isModifierPressed && selectedIds.length > 0) {
// Add to existing selection
selection.addMultipleToSelection(selectedIds);
} else if (selectedIds.length > 0) {
// Replace selection
selection.selectMultiple(selectedIds);
} else {
// Empty selection - clear
selection.clearSelection();
}
// Call callback
if (onSelectionChange) {
onSelectionChange(selectedIds);
}
// Clean up
if (selectionRect) {
selectionRect.destroy();
selectionRect = null;
layer.batchDraw();
}
multiSelectState.isSelecting = false;
multiSelectState.startPos = null;
multiSelectState.currentRect = null;
}
// Attach event listeners
stage.on('mousedown touchstart', handleMouseDown);
stage.on('mousemove touchmove', handleMouseMove);
stage.on('mouseup touchend', handleMouseUp);
// Return cleanup function
return () => {
stage.off('mousedown touchstart', handleMouseDown);
stage.off('mousemove touchmove', handleMouseMove);
stage.off('mouseup touchend', handleMouseUp);
if (selectionRect) {
selectionRect.destroy();
selectionRect = null;
}
multiSelectState.isSelecting = false;
multiSelectState.startPos = null;
multiSelectState.currentRect = null;
};
}
/**
* Get images that intersect with selection rectangle
*/
function getImagesInRectangle(
rect: SelectionRectangle,
imageBounds: Array<{
id: string;
bounds: { x: number; y: number; width: number; height: number };
}>
): string[] {
const x1 = Math.min(rect.x1, rect.x2);
const y1 = Math.min(rect.y1, rect.y2);
const x2 = Math.max(rect.x1, rect.x2);
const y2 = Math.max(rect.y1, rect.y2);
return imageBounds
.filter((item) => {
const { x, y, width, height } = item.bounds;
// Check if rectangles intersect
return !(x + width < x1 || x > x2 || y + height < y1 || y > y2);
})
.map((item) => item.id);
}
/**
* Check if currently in rectangle selection mode
*/
export function isRectangleSelecting(): boolean {
return multiSelectState.isSelecting;
}
/**
* Get current selection rectangle
*/
export function getCurrentSelectionRect(): SelectionRectangle | null {
return multiSelectState.currentRect ? { ...multiSelectState.currentRect } : null;
}
/**
* Cancel ongoing rectangle selection
*/
export function cancelRectangleSelection(layer: Konva.Layer): void {
multiSelectState.isSelecting = false;
multiSelectState.startPos = null;
multiSelectState.currentRect = null;
// Remove any active selection rectangle
const rects = layer.find('.selection-rect');
rects.forEach((rect) => rect.destroy());
layer.batchDraw();
}

View File

@@ -0,0 +1,157 @@
/**
* Click selection interactions for canvas
* Handles single and multi-select (Ctrl+Click)
*/
import type Konva from 'konva';
import { selection } from '$lib/stores/selection';
import { get } from 'svelte/store';
export interface SelectOptions {
multiSelectKey?: boolean; // Enable Ctrl/Cmd+Click for multi-select
deselectOnBackground?: boolean; // Deselect when clicking empty canvas
}
const DEFAULT_OPTIONS: SelectOptions = {
multiSelectKey: true,
deselectOnBackground: true,
};
/**
* Setup click selection for an image
*/
export function setupImageSelection(
image: Konva.Image | Konva.Group,
imageId: string,
options: SelectOptions = DEFAULT_OPTIONS,
onSelectionChange?: (imageId: string, isSelected: boolean) => void
): () => void {
/**
* Handle click/tap on image
*/
function handleClick(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
e.cancelBubble = true; // Prevent event from reaching stage
const isMultiSelectPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false;
const selectionState = get(selection);
const isCurrentlySelected = selectionState.selectedIds.has(imageId);
if (options.multiSelectKey && isMultiSelectPressed) {
// Multi-select mode (Ctrl+Click)
if (isCurrentlySelected) {
selection.removeFromSelection(imageId);
if (onSelectionChange) {
onSelectionChange(imageId, false);
}
} else {
selection.addToSelection(imageId);
if (onSelectionChange) {
onSelectionChange(imageId, true);
}
}
} else {
// Single select mode
if (!isCurrentlySelected) {
selection.selectOne(imageId);
if (onSelectionChange) {
onSelectionChange(imageId, true);
}
}
}
}
// Attach click/tap listener
image.on('click tap', handleClick);
// Return cleanup function
return () => {
image.off('click tap', handleClick);
};
}
/**
* Setup background deselection (clicking empty canvas clears selection)
*/
export function setupBackgroundDeselect(stage: Konva.Stage, onDeselect?: () => void): () => void {
/**
* Handle click on stage background
*/
function handleStageClick(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
// Only deselect if clicking on the stage itself (not on any shape)
if (e.target === stage) {
selection.clearSelection();
if (onDeselect) {
onDeselect();
}
}
}
// Attach listener
stage.on('click tap', handleStageClick);
// Return cleanup function
return () => {
stage.off('click tap', handleStageClick);
};
}
/**
* Select image programmatically
*/
export function selectImage(imageId: string, multiSelect: boolean = false): void {
if (multiSelect) {
selection.addToSelection(imageId);
} else {
selection.selectOne(imageId);
}
}
/**
* Deselect image programmatically
*/
export function deselectImage(imageId: string): void {
selection.removeFromSelection(imageId);
}
/**
* Toggle image selection programmatically
*/
export function toggleImageSelection(imageId: string): void {
selection.toggleSelection(imageId);
}
/**
* Select all images programmatically
*/
export function selectAllImages(allImageIds: string[]): void {
selection.selectAll(allImageIds);
}
/**
* Clear all selection programmatically
*/
export function clearAllSelection(): void {
selection.clearSelection();
}
/**
* Get selected images count
*/
export function getSelectedCount(): number {
return selection.getSelectionCount();
}
/**
* Get array of selected image IDs
*/
export function getSelectedImageIds(): string[] {
return selection.getSelectedIds();
}
/**
* Check if an image is selected
*/
export function isImageSelected(imageId: string): boolean {
return selection.isSelected(imageId);
}

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}`;
}

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