Compare commits

..

3 Commits

Author SHA1 Message Date
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
34 changed files with 5463 additions and 273 deletions

View File

@@ -7,7 +7,7 @@ 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
from app.database.models.user import User from app.database.models.user import User
@@ -152,6 +152,48 @@ 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)],
):
"""
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,

View File

@@ -14,6 +14,7 @@ from app.images.repository import ImageRepository
from app.images.schemas import ( from app.images.schemas import (
BoardImageCreate, BoardImageCreate,
BoardImageResponse, BoardImageResponse,
BoardImageUpdate,
ImageListResponse, ImageListResponse,
ImageResponse, ImageResponse,
ImageUploadResponse, ImageUploadResponse,
@@ -277,6 +278,52 @@ 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),
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
from sqlalchemy import select
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,

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."""

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

@@ -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

@@ -83,6 +83,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."""

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

@@ -0,0 +1,454 @@
"""Integration tests for image position update endpoint."""
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import uuid4
from app.database.models.user import User
from app.database.models.board import Board
from app.database.models.image import Image
from app.database.models.board_image import BoardImage
@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

@@ -0,0 +1,203 @@
<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';
// Props
export let id: string; // Board image ID
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;
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;
// Subscribe to selection state for this image
$: isSelected = isImageSelected(id);
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);
// Set Z-index
imageGroup.zIndex(zOrder);
layer.add(imageGroup);
// 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();
});
layer.batchDraw();
};
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;
}
</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

@@ -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,188 @@
/**
* Position and transformation sync with backend
* Handles debounced persistence of image position changes
*/
import { apiClient } from '$lib/api/client';
// Debounce timeout for position sync (ms)
const SYNC_DEBOUNCE_MS = 500;
interface PendingUpdate {
boardId: string;
imageId: string;
position: { x: number; y: number };
timeout: ReturnType<typeof setTimeout>;
}
// Track pending updates by image ID
const pendingUpdates = new Map<string, PendingUpdate>();
/**
* Schedule position sync for an image (debounced)
*/
export function syncImagePosition(boardId: string, imageId: string, x: number, y: number): void {
// Cancel existing timeout if any
const existing = pendingUpdates.get(imageId);
if (existing) {
clearTimeout(existing.timeout);
}
// Schedule new sync
const timeout = setTimeout(async () => {
await performPositionSync(boardId, imageId, x, y);
pendingUpdates.delete(imageId);
}, SYNC_DEBOUNCE_MS);
pendingUpdates.set(imageId, {
boardId,
imageId,
position: { x, y },
timeout,
});
}
/**
* Perform actual position sync to backend
*/
async function performPositionSync(
boardId: string,
imageId: string,
x: number,
y: number
): Promise<void> {
try {
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
position: { x, y },
});
} catch (error) {
console.error('Failed to sync image position:', error);
// Don't throw - this is a background operation
}
}
/**
* Force immediate sync of all pending updates
*/
export async function forceSync(): Promise<void> {
const promises: Promise<void>[] = [];
pendingUpdates.forEach((update) => {
clearTimeout(update.timeout);
promises.push(
performPositionSync(update.boardId, update.imageId, update.position.x, update.position.y)
);
});
pendingUpdates.clear();
await Promise.all(promises);
}
/**
* Force immediate sync for specific image
*/
export async function forceSyncImage(imageId: string): Promise<void> {
const update = pendingUpdates.get(imageId);
if (!update) return;
clearTimeout(update.timeout);
await performPositionSync(update.boardId, update.imageId, update.position.x, update.position.y);
pendingUpdates.delete(imageId);
}
/**
* Cancel pending sync for specific image
*/
export function cancelSync(imageId: string): void {
const update = pendingUpdates.get(imageId);
if (update) {
clearTimeout(update.timeout);
pendingUpdates.delete(imageId);
}
}
/**
* Cancel all pending syncs
*/
export function cancelAllSync(): void {
pendingUpdates.forEach((update) => {
clearTimeout(update.timeout);
});
pendingUpdates.clear();
}
/**
* Get count of pending syncs
*/
export function getPendingSyncCount(): number {
return pendingUpdates.size;
}
/**
* Check if image has pending sync
*/
export function hasPendingSync(imageId: string): boolean {
return pendingUpdates.has(imageId);
}
/**
* Sync image transformations (scale, rotation, etc.)
*/
export async function syncImageTransformations(
boardId: string,
imageId: string,
transformations: {
scale?: number;
rotation?: number;
opacity?: number;
flipped_h?: boolean;
flipped_v?: boolean;
greyscale?: boolean;
}
): Promise<void> {
try {
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
transformations,
});
} catch (error) {
console.error('Failed to sync image transformations:', error);
throw error;
}
}
/**
* Sync image Z-order
*/
export async function syncImageZOrder(
boardId: string,
imageId: string,
zOrder: number
): Promise<void> {
try {
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
z_order: zOrder,
});
} catch (error) {
console.error('Failed to sync image Z-order:', error);
throw error;
}
}
/**
* Sync image group membership
*/
export async function syncImageGroup(
boardId: string,
imageId: string,
groupId: string | null
): Promise<void> {
try {
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
group_id: groupId,
});
} catch (error) {
console.error('Failed to sync image group:', error);
throw error;
}
}

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { validateEmail, validateRequired } from '$lib/utils/validation';
export let isLoading = false; export let isLoading = false;
@@ -14,14 +15,14 @@
function validateForm(): boolean { function validateForm(): boolean {
errors = {}; errors = {};
if (!email) { const emailValidation = validateEmail(email);
errors.email = 'Email is required'; if (!emailValidation.valid) {
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { errors.email = emailValidation.message;
errors.email = 'Please enter a valid email address';
} }
if (!password) { const passwordValidation = validateRequired(password, 'Password');
errors.password = 'Password is required'; if (!passwordValidation.valid) {
errors.password = passwordValidation.message;
} }
return Object.keys(errors).length === 0; return Object.keys(errors).length === 0;
@@ -40,40 +41,42 @@
<form on:submit={handleSubmit} class="login-form"> <form on:submit={handleSubmit} class="login-form">
<div class="form-group"> <div class="form-group">
<label for="email">Email</label> <label for="email" class="form-label">Email</label>
<input <input
id="email" id="email"
type="email" type="email"
bind:value={email} bind:value={email}
disabled={isLoading} disabled={isLoading}
placeholder="you@example.com" placeholder="you@example.com"
class="form-input"
class:error={errors.email} class:error={errors.email}
autocomplete="email" autocomplete="email"
/> />
{#if errors.email} {#if errors.email}
<span class="error-text">{errors.email}</span> <span class="form-error-text">{errors.email}</span>
{/if} {/if}
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="password" class="form-label">Password</label>
<input <input
id="password" id="password"
type="password" type="password"
bind:value={password} bind:value={password}
disabled={isLoading} disabled={isLoading}
placeholder="••••••••" placeholder="••••••••"
class="form-input"
class:error={errors.password} class:error={errors.password}
autocomplete="current-password" autocomplete="current-password"
/> />
{#if errors.password} {#if errors.password}
<span class="error-text">{errors.password}</span> <span class="form-error-text">{errors.password}</span>
{/if} {/if}
</div> </div>
<button type="submit" disabled={isLoading} class="submit-button"> <button type="submit" disabled={isLoading} class="btn btn-primary submit-button">
{#if isLoading} {#if isLoading}
<span class="spinner"></span> <span class="spinner-small"></span>
Logging in... Logging in...
{:else} {:else}
Login Login
@@ -88,87 +91,16 @@
gap: 1.25rem; gap: 1.25rem;
} }
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
font-weight: 500;
color: #374151;
font-size: 0.95rem;
}
input {
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
transition: all 0.2s;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
input.error {
border-color: #ef4444;
}
input:disabled {
background-color: #f9fafb;
cursor: not-allowed;
}
.error-text {
color: #ef4444;
font-size: 0.875rem;
}
.submit-button { .submit-button {
padding: 0.875rem 1.5rem; padding: 0.875rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
color: white;
border: none; .form-group :global(label) {
margin-bottom: 0;
}
.form-group :global(input) {
padding: 0.75rem 1rem;
border-radius: 6px; border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
} }
</style> </style>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { validateEmail, validatePassword, validatePasswordsMatch } from '$lib/utils/validation';
export let isLoading = false; export let isLoading = false;
@@ -12,44 +13,22 @@
submit: { email: string; password: string }; submit: { email: string; password: string };
}>(); }>();
function validatePassword(pwd: string): { valid: boolean; message: string } {
if (pwd.length < 8) {
return { valid: false, message: 'Password must be at least 8 characters' };
}
if (!/[A-Z]/.test(pwd)) {
return { valid: false, message: 'Password must contain an uppercase letter' };
}
if (!/[a-z]/.test(pwd)) {
return { valid: false, message: 'Password must contain a lowercase letter' };
}
if (!/\d/.test(pwd)) {
return { valid: false, message: 'Password must contain a number' };
}
return { valid: true, message: '' };
}
function validateForm(): boolean { function validateForm(): boolean {
errors = {}; errors = {};
if (!email) { const emailValidation = validateEmail(email);
errors.email = 'Email is required'; if (!emailValidation.valid) {
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { errors.email = emailValidation.message;
errors.email = 'Please enter a valid email address';
} }
if (!password) { const passwordValidation = validatePassword(password);
errors.password = 'Password is required'; if (!passwordValidation.valid) {
} else { errors.password = passwordValidation.message;
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
errors.password = passwordValidation.message;
}
} }
if (!confirmPassword) { const confirmValidation = validatePasswordsMatch(password, confirmPassword);
errors.confirmPassword = 'Please confirm your password'; if (!confirmValidation.valid) {
} else if (password !== confirmPassword) { errors.confirmPassword = confirmValidation.message;
errors.confirmPassword = 'Passwords do not match';
} }
return Object.keys(errors).length === 0; return Object.keys(errors).length === 0;
@@ -68,58 +47,63 @@
<form on:submit={handleSubmit} class="register-form"> <form on:submit={handleSubmit} class="register-form">
<div class="form-group"> <div class="form-group">
<label for="email">Email</label> <label for="email" class="form-label">Email</label>
<input <input
id="email" id="email"
type="email" type="email"
bind:value={email} bind:value={email}
disabled={isLoading} disabled={isLoading}
placeholder="you@example.com" placeholder="you@example.com"
class="form-input"
class:error={errors.email} class:error={errors.email}
autocomplete="email" autocomplete="email"
/> />
{#if errors.email} {#if errors.email}
<span class="error-text">{errors.email}</span> <span class="form-error-text">{errors.email}</span>
{/if} {/if}
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password</label> <label for="password" class="form-label">Password</label>
<input <input
id="password" id="password"
type="password" type="password"
bind:value={password} bind:value={password}
disabled={isLoading} disabled={isLoading}
placeholder="••••••••" placeholder="••••••••"
class="form-input"
class:error={errors.password} class:error={errors.password}
autocomplete="new-password" autocomplete="new-password"
/> />
{#if errors.password} {#if errors.password}
<span class="error-text">{errors.password}</span> <span class="form-error-text">{errors.password}</span>
{:else} {:else}
<span class="help-text"> Must be 8+ characters with uppercase, lowercase, and number </span> <span class="form-help-text">
Must be 8+ characters with uppercase, lowercase, and number
</span>
{/if} {/if}
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="confirmPassword">Confirm Password</label> <label for="confirmPassword" class="form-label">Confirm Password</label>
<input <input
id="confirmPassword" id="confirmPassword"
type="password" type="password"
bind:value={confirmPassword} bind:value={confirmPassword}
disabled={isLoading} disabled={isLoading}
placeholder="••••••••" placeholder="••••••••"
class="form-input"
class:error={errors.confirmPassword} class:error={errors.confirmPassword}
autocomplete="new-password" autocomplete="new-password"
/> />
{#if errors.confirmPassword} {#if errors.confirmPassword}
<span class="error-text">{errors.confirmPassword}</span> <span class="form-error-text">{errors.confirmPassword}</span>
{/if} {/if}
</div> </div>
<button type="submit" disabled={isLoading} class="submit-button"> <button type="submit" disabled={isLoading} class="btn btn-primary submit-button">
{#if isLoading} {#if isLoading}
<span class="spinner"></span> <span class="spinner-small"></span>
Creating account... Creating account...
{:else} {:else}
Create Account Create Account
@@ -134,92 +118,16 @@
gap: 1.25rem; gap: 1.25rem;
} }
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
font-weight: 500;
color: #374151;
font-size: 0.95rem;
}
input {
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
transition: all 0.2s;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
input.error {
border-color: #ef4444;
}
input:disabled {
background-color: #f9fafb;
cursor: not-allowed;
}
.error-text {
color: #ef4444;
font-size: 0.875rem;
}
.help-text {
color: #6b7280;
font-size: 0.875rem;
}
.submit-button { .submit-button {
padding: 0.875rem 1.5rem; padding: 0.875rem 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
color: white;
border: none; .form-group :global(label) {
margin-bottom: 0;
}
.form-group :global(input) {
padding: 0.75rem 1rem;
border-radius: 6px; border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s,
box-shadow 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
} }
</style> </style>

View File

@@ -2,6 +2,7 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { BoardSummary } from '$lib/types/boards'; import type { BoardSummary } from '$lib/types/boards';
import { formatDate } from '$lib/utils/format';
export let board: BoardSummary; export let board: BoardSummary;
@@ -15,11 +16,6 @@
event.stopPropagation(); event.stopPropagation();
dispatch('delete'); dispatch('delete');
} }
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
</script> </script>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let type: 'error' | 'success' | 'warning' | 'info' = 'info';
export let message: string;
export let dismissible: boolean = false;
const dispatch = createEventDispatcher<{ dismiss: void }>();
const icons = {
error: '⚠',
success: '✓',
warning: '⚠',
info: '',
};
function handleDismiss() {
dispatch('dismiss');
}
</script>
<div class="message-banner message-{type}" role="alert">
<span class="message-icon">{icons[type]}</span>
<span class="message-text">{message}</span>
{#if dismissible}
<button class="close-btn" on:click={handleDismiss} aria-label="Dismiss">×</button>
{/if}
</div>
<style>
.message-text {
flex: 1;
}
</style>

View File

@@ -0,0 +1,200 @@
/**
* Selection store for canvas image selection management
* Tracks selected images and provides selection operations
*/
import { writable, derived } from 'svelte/store';
import type { Writable } from 'svelte/store';
export interface SelectedImage {
id: string;
boardImageId: string; // The junction table ID
}
export interface SelectionState {
selectedIds: Set<string>; // Set of board_image IDs
lastSelectedId: string | null; // For shift-click range selection
}
const DEFAULT_SELECTION: SelectionState = {
selectedIds: new Set(),
lastSelectedId: null,
};
/**
* Create selection store with operations
*/
function createSelectionStore() {
const { subscribe, set, update }: Writable<SelectionState> = writable(DEFAULT_SELECTION);
return {
subscribe,
set,
update,
/**
* Select a single image (clears previous selection)
*/
selectOne: (id: string) => {
update(() => ({
selectedIds: new Set([id]),
lastSelectedId: id,
}));
},
/**
* Add image to selection (for Ctrl+Click)
*/
addToSelection: (id: string) => {
update((state) => {
const newSelectedIds = new Set(state.selectedIds);
newSelectedIds.add(id);
return {
selectedIds: newSelectedIds,
lastSelectedId: id,
};
});
},
/**
* Remove image from selection (for Ctrl+Click on selected)
*/
removeFromSelection: (id: string) => {
update((state) => {
const newSelectedIds = new Set(state.selectedIds);
newSelectedIds.delete(id);
return {
selectedIds: newSelectedIds,
lastSelectedId: state.lastSelectedId === id ? null : state.lastSelectedId,
};
});
},
/**
* Toggle selection of an image
*/
toggleSelection: (id: string) => {
update((state) => {
const newSelectedIds = new Set(state.selectedIds);
if (newSelectedIds.has(id)) {
newSelectedIds.delete(id);
return {
selectedIds: newSelectedIds,
lastSelectedId: state.lastSelectedId === id ? null : state.lastSelectedId,
};
} else {
newSelectedIds.add(id);
return {
selectedIds: newSelectedIds,
lastSelectedId: id,
};
}
});
},
/**
* Select multiple images (e.g., from rectangle selection)
*/
selectMultiple: (ids: string[]) => {
update((_state) => ({
selectedIds: new Set(ids),
lastSelectedId: ids.length > 0 ? ids[ids.length - 1] : null,
}));
},
/**
* Add multiple images to selection
*/
addMultipleToSelection: (ids: string[]) => {
update((state) => {
const newSelectedIds = new Set(state.selectedIds);
ids.forEach((id) => newSelectedIds.add(id));
return {
selectedIds: newSelectedIds,
lastSelectedId: ids.length > 0 ? ids[ids.length - 1] : state.lastSelectedId,
};
});
},
/**
* Select all images
*/
selectAll: (allIds: string[]) => {
update(() => ({
selectedIds: new Set(allIds),
lastSelectedId: allIds.length > 0 ? allIds[allIds.length - 1] : null,
}));
},
/**
* Clear all selection
*/
clearSelection: () => {
set(DEFAULT_SELECTION);
},
/**
* Check if an image is selected
*/
isSelected: (id: string): boolean => {
let result = false;
const unsubscribe = subscribe((_state) => {
result = _state.selectedIds.has(id);
});
unsubscribe();
return result;
},
/**
* Get count of selected images
*/
getSelectionCount: (): number => {
let count = 0;
const unsubscribe = subscribe((state) => {
count = state.selectedIds.size;
});
unsubscribe();
return count;
},
/**
* Get array of selected IDs
*/
getSelectedIds: (): string[] => {
let ids: string[] = [];
const unsubscribe = subscribe((state) => {
ids = Array.from(state.selectedIds);
});
unsubscribe();
return ids;
},
};
}
export const selection = createSelectionStore();
// Derived stores for common queries
export const hasSelection = derived(selection, ($selection) => {
return $selection.selectedIds.size > 0;
});
export const selectionCount = derived(selection, ($selection) => {
return $selection.selectedIds.size;
});
export const isSingleSelection = derived(selection, ($selection) => {
return $selection.selectedIds.size === 1;
});
export const isMultiSelection = derived(selection, ($selection) => {
return $selection.selectedIds.size > 1;
});
/**
* Helper to check if an ID is in the selection (reactive)
*/
export function isImageSelected(id: string) {
return derived(selection, ($selection) => {
return $selection.selectedIds.has(id);
});
}

View File

@@ -0,0 +1,76 @@
/**
* Shared button styles
* Consistent button styling across the application
*/
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
}
.btn-danger:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.btn-large {
padding: 1rem 2rem;
font-size: 1.125rem;
}

View File

@@ -0,0 +1,75 @@
/**
* Shared form styles
* Used across all forms to maintain consistency and reduce duplication
*/
/* Form containers */
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
/* Labels */
.form-label {
display: block;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.form-label .required {
color: #ef4444;
}
/* Inputs and textareas */
.form-input,
.form-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
transition: all 0.2s;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-input.error,
.form-textarea.error {
border-color: #ef4444;
}
.form-input:disabled,
.form-textarea:disabled {
background-color: #f9fafb;
cursor: not-allowed;
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
/* Help and error text */
.form-help-text {
display: block;
color: #6b7280;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form-error-text {
display: block;
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
}

View File

@@ -0,0 +1,278 @@
/**
* Global styles - Import this once in app layout
* Contains all shared form, button, loading, and message styles
*/
/* ============================================
FORM STYLES
============================================ */
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.form-label .required {
color: #ef4444;
}
.form-input,
.form-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
transition: all 0.2s;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-input.error,
.form-textarea.error {
border-color: #ef4444;
}
.form-input:disabled,
.form-textarea:disabled {
background-color: #f9fafb;
cursor: not-allowed;
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.form-help-text {
display: block;
color: #6b7280;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form-error-text {
display: block;
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* ============================================
BUTTON STYLES
============================================ */
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
}
.btn-danger:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.btn-large {
padding: 1rem 2rem;
font-size: 1.125rem;
}
/* ============================================
LOADING STYLES
============================================ */
.spinner {
width: 48px;
height: 48px;
border: 4px solid #e5e7eb;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-small {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-medium {
width: 32px;
height: 32px;
border: 3px solid #e5e7eb;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem;
color: #6b7280;
}
.loading-container .spinner {
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ============================================
MESSAGE / ALERT STYLES
============================================ */
.message-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.message-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.message-banner .close-btn {
margin-left: auto;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
line-height: 1;
transition: opacity 0.2s;
}
.message-banner .close-btn:hover {
opacity: 0.7;
}
.message-error {
background: #fee2e2;
border: 1px solid #fca5a5;
color: #991b1b;
}
.message-error .message-icon,
.message-error .close-btn {
color: #991b1b;
}
.message-success {
background: #d1fae5;
border: 1px solid #a7f3d0;
color: #065f46;
}
.message-success .message-icon,
.message-success .close-btn {
color: #065f46;
}
.message-warning {
background: #fef3c7;
border: 1px solid #fde68a;
color: #92400e;
}
.message-warning .message-icon,
.message-warning .close-btn {
color: #92400e;
}
.message-info {
background: #dbeafe;
border: 1px solid #bfdbfe;
color: #1e40af;
}
.message-info .message-icon,
.message-info .close-btn {
color: #1e40af;
}

View File

@@ -0,0 +1,49 @@
/**
* Shared loading/spinner styles
*/
.spinner {
width: 48px;
height: 48px;
border: 4px solid #e5e7eb;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-small {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-medium {
width: 32px;
height: 32px;
border: 3px solid #e5e7eb;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem;
color: #6b7280;
}
.loading-container .spinner {
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,80 @@
/**
* Shared message/alert/banner styles
*/
.message-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.message-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.message-banner .close-btn {
margin-left: auto;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
line-height: 1;
transition: opacity 0.2s;
}
.message-banner .close-btn:hover {
opacity: 0.7;
}
/* Error message */
.message-error {
background: #fee2e2;
border: 1px solid #fca5a5;
color: #991b1b;
}
.message-error .message-icon,
.message-error .close-btn {
color: #991b1b;
}
/* Success message */
.message-success {
background: #d1fae5;
border: 1px solid #a7f3d0;
color: #065f46;
}
.message-success .message-icon,
.message-success .close-btn {
color: #065f46;
}
/* Warning message */
.message-warning {
background: #fef3c7;
border: 1px solid #fde68a;
color: #92400e;
}
.message-warning .message-icon,
.message-warning .close-btn {
color: #92400e;
}
/* Info message */
.message-info {
background: #dbeafe;
border: 1px solid #bfdbfe;
color: #1e40af;
}
.message-info .message-icon,
.message-info .close-btn {
color: #1e40af;
}

View File

@@ -0,0 +1,78 @@
/**
* Formatting utilities
*/
/**
* Format date to readable string
*/
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
/**
* Format date with time
*/
export function formatDateTime(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
/**
* Format relative time (e.g., "2 hours ago")
*/
export function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins} ${diffMins === 1 ? 'minute' : 'minutes'} ago`;
if (diffHours < 24) return `${diffHours} ${diffHours === 1 ? 'hour' : 'hours'} ago`;
if (diffDays < 7) return `${diffDays} ${diffDays === 1 ? 'day' : 'days'} ago`;
return formatDate(dateString);
}
/**
* Format file size to human-readable string
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
/**
* Pluralize word based on count
*/
export function pluralize(count: number, singular: string, plural?: string): string {
if (count === 1) return singular;
return plural || `${singular}s`;
}
/**
* Truncate text with ellipsis
*/
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - 3) + '...';
}

View File

@@ -0,0 +1,125 @@
/**
* Shared validation utilities
*/
export interface ValidationResult {
valid: boolean;
message: string;
}
/**
* Validate email format
*/
export function validateEmail(email: string): ValidationResult {
if (!email) {
return { valid: false, message: 'Email is required' };
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return { valid: false, message: 'Please enter a valid email address' };
}
return { valid: true, message: '' };
}
/**
* Validate password strength
* Requirements: 8+ chars, uppercase, lowercase, number
*/
export function validatePassword(password: string): ValidationResult {
if (!password) {
return { valid: false, message: 'Password is required' };
}
if (password.length < 8) {
return { valid: false, message: 'Password must be at least 8 characters' };
}
if (!/[A-Z]/.test(password)) {
return { valid: false, message: 'Password must contain an uppercase letter' };
}
if (!/[a-z]/.test(password)) {
return { valid: false, message: 'Password must contain a lowercase letter' };
}
if (!/\d/.test(password)) {
return { valid: false, message: 'Password must contain a number' };
}
return { valid: true, message: '' };
}
/**
* Validate passwords match
*/
export function validatePasswordsMatch(
password: string,
confirmPassword: string
): ValidationResult {
if (!confirmPassword) {
return { valid: false, message: 'Please confirm your password' };
}
if (password !== confirmPassword) {
return { valid: false, message: 'Passwords do not match' };
}
return { valid: true, message: '' };
}
/**
* Validate board title
*/
export function validateBoardTitle(title: string): ValidationResult {
if (!title || !title.trim()) {
return { valid: false, message: 'Title is required' };
}
if (title.length > 255) {
return { valid: false, message: 'Title must be 255 characters or less' };
}
return { valid: true, message: '' };
}
/**
* Validate board description
*/
export function validateBoardDescription(description: string): ValidationResult {
if (description && description.length > 1000) {
return { valid: false, message: 'Description must be 1000 characters or less' };
}
return { valid: true, message: '' };
}
/**
* Validate required field
*/
export function validateRequired(value: string, fieldName: string = 'Field'): ValidationResult {
if (!value || !value.trim()) {
return { valid: false, message: `${fieldName} is required` };
}
return { valid: true, message: '' };
}
/**
* Validate max length
*/
export function validateMaxLength(
value: string,
maxLength: number,
fieldName: string = 'Field'
): ValidationResult {
if (value && value.length > maxLength) {
return {
valid: false,
message: `${fieldName} must be ${maxLength} characters or less`,
};
}
return { valid: true, message: '' };
}

View File

@@ -1 +1,5 @@
<script>
import '$lib/styles/global.css';
</script>
<slot /> <slot />

View File

@@ -0,0 +1,627 @@
/**
* Tests for canvas controls (pan, zoom, rotate, reset, fit)
* Tests viewport store and control functions
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { get } from 'svelte/store';
import { viewport, isViewportDefault, isZoomMin, isZoomMax } from '$lib/stores/viewport';
import { panTo, panBy } from '$lib/canvas/controls/pan';
import { zoomTo, zoomBy, zoomIn, zoomOut } from '$lib/canvas/controls/zoom';
import {
rotateTo,
rotateBy,
rotateClockwise,
rotateCounterClockwise,
resetRotation,
rotateTo90,
rotateTo180,
rotateTo270,
} from '$lib/canvas/controls/rotate';
import { resetCamera, resetPan, resetZoom } from '$lib/canvas/controls/reset';
describe('Viewport Store', () => {
beforeEach(() => {
// Reset viewport to default state before each test
viewport.reset();
});
describe('Initialization', () => {
it('starts with default values', () => {
const state = get(viewport);
expect(state).toEqual({
x: 0,
y: 0,
zoom: 1.0,
rotation: 0,
});
});
it('isViewportDefault is true at initialization', () => {
expect(get(isViewportDefault)).toBe(true);
});
it('provides viewport bounds', () => {
const bounds = viewport.getBounds();
expect(bounds).toEqual({
minZoom: 0.1,
maxZoom: 5.0,
minRotation: 0,
maxRotation: 360,
});
});
});
describe('Pan Operations', () => {
it('sets pan position', () => {
viewport.setPan(100, 200);
const state = get(viewport);
expect(state.x).toBe(100);
expect(state.y).toBe(200);
});
it('pans by delta', () => {
viewport.setPan(50, 50);
viewport.panBy(25, 30);
const state = get(viewport);
expect(state.x).toBe(75);
expect(state.y).toBe(80);
});
it('allows negative pan values', () => {
viewport.setPan(-100, -200);
const state = get(viewport);
expect(state.x).toBe(-100);
expect(state.y).toBe(-200);
});
it('handles large pan values', () => {
viewport.setPan(100000, 100000);
const state = get(viewport);
expect(state.x).toBe(100000);
expect(state.y).toBe(100000);
});
});
describe('Zoom Operations', () => {
it('sets zoom level', () => {
viewport.setZoom(2.0);
const state = get(viewport);
expect(state.zoom).toBe(2.0);
});
it('clamps zoom to minimum', () => {
viewport.setZoom(0.05);
const state = get(viewport);
expect(state.zoom).toBe(0.1);
});
it('clamps zoom to maximum', () => {
viewport.setZoom(10.0);
const state = get(viewport);
expect(state.zoom).toBe(5.0);
});
it('zooms by factor', () => {
viewport.setZoom(1.0);
viewport.zoomBy(2.0);
const state = get(viewport);
expect(state.zoom).toBe(2.0);
});
it('zooms to center point', () => {
viewport.setZoom(1.0, 100, 100);
const state = get(viewport);
expect(state.zoom).toBe(1.0);
// Position should remain at center
});
it('isZoomMin reflects minimum zoom', () => {
viewport.setZoom(0.1);
expect(get(isZoomMin)).toBe(true);
viewport.setZoom(1.0);
expect(get(isZoomMin)).toBe(false);
});
it('isZoomMax reflects maximum zoom', () => {
viewport.setZoom(5.0);
expect(get(isZoomMax)).toBe(true);
viewport.setZoom(1.0);
expect(get(isZoomMax)).toBe(false);
});
});
describe('Rotation Operations', () => {
it('sets rotation', () => {
viewport.setRotation(45);
const state = get(viewport);
expect(state.rotation).toBe(45);
});
it('normalizes rotation to 0-360', () => {
viewport.setRotation(450);
expect(get(viewport).rotation).toBe(90);
viewport.setRotation(-90);
expect(get(viewport).rotation).toBe(270);
});
it('rotates by delta', () => {
viewport.setRotation(45);
viewport.rotateBy(15);
expect(get(viewport).rotation).toBe(60);
});
it('handles negative rotation delta', () => {
viewport.setRotation(45);
viewport.rotateBy(-15);
expect(get(viewport).rotation).toBe(30);
});
it('wraps rotation around 360', () => {
viewport.setRotation(350);
viewport.rotateBy(20);
expect(get(viewport).rotation).toBe(10);
});
});
describe('Reset Operations', () => {
it('resets viewport to default', () => {
viewport.setPan(100, 100);
viewport.setZoom(2.0);
viewport.setRotation(45);
viewport.reset();
const state = get(viewport);
expect(state).toEqual({
x: 0,
y: 0,
zoom: 1.0,
rotation: 0,
});
});
it('reset makes isViewportDefault true', () => {
viewport.setPan(100, 100);
expect(get(isViewportDefault)).toBe(false);
viewport.reset();
expect(get(isViewportDefault)).toBe(true);
});
});
describe('Fit to Screen', () => {
it('fits content to screen with default padding', () => {
viewport.fitToScreen(800, 600, 1024, 768);
const state = get(viewport);
expect(state.zoom).toBeGreaterThan(0);
expect(state.rotation).toBe(0); // Rotation reset when fitting
});
it('fits content with custom padding', () => {
viewport.fitToScreen(800, 600, 1024, 768, 100);
const state = get(viewport);
expect(state.zoom).toBeGreaterThan(0);
});
it('handles oversized content', () => {
viewport.fitToScreen(2000, 1500, 1024, 768);
const state = get(viewport);
expect(state.zoom).toBeLessThan(1.0);
});
it('handles undersized content', () => {
viewport.fitToScreen(100, 100, 1024, 768);
const state = get(viewport);
expect(state.zoom).toBeGreaterThan(1.0);
});
it('respects maximum zoom when fitting', () => {
// Very small content that would zoom beyond max
viewport.fitToScreen(10, 10, 1024, 768);
const state = get(viewport);
expect(state.zoom).toBeLessThanOrEqual(5.0);
});
});
describe('Load State', () => {
it('loads partial state', () => {
viewport.loadState({ x: 100, y: 200 });
const state = get(viewport);
expect(state.x).toBe(100);
expect(state.y).toBe(200);
expect(state.zoom).toBe(1.0); // Unchanged
expect(state.rotation).toBe(0); // Unchanged
});
it('loads complete state', () => {
viewport.loadState({
x: 100,
y: 200,
zoom: 2.5,
rotation: 90,
});
const state = get(viewport);
expect(state).toEqual({
x: 100,
y: 200,
zoom: 2.5,
rotation: 90,
});
});
it('clamps loaded zoom to bounds', () => {
viewport.loadState({ zoom: 10.0 });
expect(get(viewport).zoom).toBe(5.0);
viewport.loadState({ zoom: 0.01 });
expect(get(viewport).zoom).toBe(0.1);
});
it('normalizes loaded rotation', () => {
viewport.loadState({ rotation: 450 });
expect(get(viewport).rotation).toBe(90);
viewport.loadState({ rotation: -45 });
expect(get(viewport).rotation).toBe(315);
});
});
describe('State Subscription', () => {
it('notifies subscribers on pan changes', () => {
const subscriber = vi.fn();
const unsubscribe = viewport.subscribe(subscriber);
viewport.setPan(100, 100);
expect(subscriber).toHaveBeenCalled();
unsubscribe();
});
it('notifies subscribers on zoom changes', () => {
const subscriber = vi.fn();
const unsubscribe = viewport.subscribe(subscriber);
viewport.setZoom(2.0);
expect(subscriber).toHaveBeenCalled();
unsubscribe();
});
it('notifies subscribers on rotation changes', () => {
const subscriber = vi.fn();
const unsubscribe = viewport.subscribe(subscriber);
viewport.setRotation(45);
expect(subscriber).toHaveBeenCalled();
unsubscribe();
});
});
});
describe('Pan Controls', () => {
beforeEach(() => {
viewport.reset();
});
describe('Programmatic Pan', () => {
it('panTo sets absolute position', () => {
panTo(100, 200);
const state = get(viewport);
expect(state.x).toBe(100);
expect(state.y).toBe(200);
});
it('panBy moves relative to current position', () => {
panTo(50, 50);
panBy(25, 30);
const state = get(viewport);
expect(state.x).toBe(75);
expect(state.y).toBe(80);
});
it('panBy with negative deltas', () => {
panTo(100, 100);
panBy(-50, -50);
const state = get(viewport);
expect(state.x).toBe(50);
expect(state.y).toBe(50);
});
});
});
describe('Zoom Controls', () => {
beforeEach(() => {
viewport.reset();
});
describe('Programmatic Zoom', () => {
it('zoomTo sets absolute zoom level', () => {
zoomTo(2.5);
expect(get(viewport).zoom).toBe(2.5);
});
it('zoomBy multiplies current zoom', () => {
zoomTo(2.0);
zoomBy(1.5);
expect(get(viewport).zoom).toBe(3.0);
});
it('zoomIn increases zoom', () => {
const initialZoom = get(viewport).zoom;
zoomIn();
expect(get(viewport).zoom).toBeGreaterThan(initialZoom);
});
it('zoomOut decreases zoom', () => {
zoomTo(2.0);
const initialZoom = get(viewport).zoom;
zoomOut();
expect(get(viewport).zoom).toBeLessThan(initialZoom);
});
it('zoomIn respects maximum zoom', () => {
zoomTo(4.9);
zoomIn();
expect(get(viewport).zoom).toBeLessThanOrEqual(5.0);
});
it('zoomOut respects minimum zoom', () => {
zoomTo(0.15);
zoomOut();
expect(get(viewport).zoom).toBeGreaterThanOrEqual(0.1);
});
});
});
describe('Rotate Controls', () => {
beforeEach(() => {
viewport.reset();
});
describe('Programmatic Rotation', () => {
it('rotateTo sets absolute rotation', () => {
rotateTo(90);
expect(get(viewport).rotation).toBe(90);
});
it('rotateBy adds to current rotation', () => {
rotateTo(45);
rotateBy(15);
expect(get(viewport).rotation).toBe(60);
});
it('rotateClockwise rotates by step', () => {
rotateClockwise();
// Default step is 15 degrees
expect(get(viewport).rotation).toBe(15);
});
it('rotateCounterClockwise rotates by negative step', () => {
rotateTo(30);
rotateCounterClockwise();
// Default step is 15 degrees
expect(get(viewport).rotation).toBe(15);
});
it('resetRotation sets to 0', () => {
rotateTo(90);
resetRotation();
expect(get(viewport).rotation).toBe(0);
});
it('rotateTo90 sets to 90 degrees', () => {
rotateTo90();
expect(get(viewport).rotation).toBe(90);
});
it('rotateTo180 sets to 180 degrees', () => {
rotateTo180();
expect(get(viewport).rotation).toBe(180);
});
it('rotateTo270 sets to 270 degrees', () => {
rotateTo270();
expect(get(viewport).rotation).toBe(270);
});
});
});
describe('Reset Controls', () => {
beforeEach(() => {
// Set non-default values
viewport.setPan(100, 200);
viewport.setZoom(2.5);
viewport.setRotation(90);
});
describe('Selective Reset', () => {
it('resetPan only resets position', () => {
resetPan();
const state = get(viewport);
expect(state.x).toBe(0);
expect(state.y).toBe(0);
expect(state.zoom).toBe(2.5); // Unchanged
expect(state.rotation).toBe(90); // Unchanged
});
it('resetZoom only resets zoom', () => {
resetZoom();
const state = get(viewport);
expect(state.x).toBe(100); // Unchanged
expect(state.y).toBe(200); // Unchanged
expect(state.zoom).toBe(1.0);
expect(state.rotation).toBe(90); // Unchanged
});
it('resetRotation (from reset controls) only resets rotation', () => {
resetRotation();
const state = get(viewport);
expect(state.x).toBe(100); // Unchanged
expect(state.y).toBe(200); // Unchanged
expect(state.zoom).toBe(2.5); // Unchanged
expect(state.rotation).toBe(0);
});
it('resetCamera resets everything', () => {
resetCamera();
const state = get(viewport);
expect(state).toEqual({
x: 0,
y: 0,
zoom: 1.0,
rotation: 0,
});
});
});
});
describe('Viewport State Serialization', () => {
beforeEach(() => {
viewport.reset();
});
it('serializes viewport state to JSON', async () => {
const { serializeViewportState } = await import('$lib/stores/viewport');
viewport.setPan(100, 200);
viewport.setZoom(2.0);
viewport.setRotation(45);
const state = get(viewport);
const serialized = serializeViewportState(state);
expect(serialized).toBe(JSON.stringify({ x: 100, y: 200, zoom: 2, rotation: 45 }));
});
it('deserializes viewport state from JSON', async () => {
const { deserializeViewportState } = await import('$lib/stores/viewport');
const json = JSON.stringify({ x: 100, y: 200, zoom: 2.5, rotation: 90 });
const state = deserializeViewportState(json);
expect(state).toEqual({
x: 100,
y: 200,
zoom: 2.5,
rotation: 90,
});
});
it('handles invalid JSON gracefully', async () => {
const { deserializeViewportState } = await import('$lib/stores/viewport');
const state = deserializeViewportState('invalid json');
// Should return default state
expect(state).toEqual({
x: 0,
y: 0,
zoom: 1.0,
rotation: 0,
});
});
it('validates deserialized values', async () => {
const { deserializeViewportState } = await import('$lib/stores/viewport');
const json = JSON.stringify({ x: 100, y: 200, zoom: 10.0, rotation: 450 });
const state = deserializeViewportState(json);
// Zoom should be clamped to max
expect(state.zoom).toBe(5.0);
// Rotation should be normalized to 0-360
expect(state.rotation).toBe(90);
});
it('handles missing fields in JSON', async () => {
const { deserializeViewportState } = await import('$lib/stores/viewport');
const json = JSON.stringify({ x: 100 });
const state = deserializeViewportState(json);
expect(state.x).toBe(100);
expect(state.y).toBe(0); // Default
expect(state.zoom).toBe(1.0); // Default
expect(state.rotation).toBe(0); // Default
});
});
describe('Integration Tests', () => {
beforeEach(() => {
viewport.reset();
});
it('complex viewport manipulation sequence', () => {
// Pan
viewport.setPan(100, 100);
// Zoom
viewport.setZoom(2.0);
// Rotate
viewport.setRotation(45);
// Pan by delta
viewport.panBy(50, 50);
const state = get(viewport);
expect(state.x).toBe(150);
expect(state.y).toBe(150);
expect(state.zoom).toBe(2.0);
expect(state.rotation).toBe(45);
});
it('reset after complex manipulation', () => {
viewport.setPan(100, 100);
viewport.setZoom(3.0);
viewport.setRotation(180);
viewport.reset();
expect(get(isViewportDefault)).toBe(true);
});
it('multiple zoom operations maintain center', () => {
viewport.setZoom(2.0, 500, 500);
viewport.setZoom(1.5, 500, 500);
// Position should adjust to keep point at 500,500 centered
const state = get(viewport);
expect(state.zoom).toBe(1.5);
});
});

View File

@@ -0,0 +1,303 @@
/**
* Tests for canvas image dragging functionality
* Tests drag interactions, position updates, and multi-drag
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Konva from 'konva';
import { get } from 'svelte/store';
import { selection } from '$lib/stores/selection';
import { setupImageDrag, moveImageTo, moveImageBy, isDragging } from '$lib/canvas/interactions/drag';
describe('Image Dragging', () => {
let stage: Konva.Stage;
let layer: Konva.Layer;
let image: Konva.Image;
let imageId: string;
beforeEach(() => {
// Create container
const container = document.createElement('div');
container.id = 'test-container';
document.body.appendChild(container);
// Create stage and layer
stage = new Konva.Stage({
container: 'test-container',
width: 800,
height: 600,
});
layer = new Konva.Layer();
stage.add(layer);
// Create test image
const imageElement = new Image();
imageElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
image = new Konva.Image({
image: imageElement,
x: 100,
y: 100,
width: 200,
height: 200,
});
layer.add(image);
layer.draw();
imageId = 'test-image-1';
// Reset selection
selection.clearSelection();
});
afterEach(() => {
stage.destroy();
document.body.innerHTML = '';
});
describe('Setup and Initialization', () => {
it('sets up drag handlers on image', () => {
const cleanup = setupImageDrag(image, imageId);
expect(image.draggable()).toBe(true);
expect(typeof cleanup).toBe('function');
cleanup();
});
it('cleanup function removes drag handlers', () => {
const cleanup = setupImageDrag(image, imageId);
cleanup();
expect(image.draggable()).toBe(false);
});
it('allows custom drag callbacks', () => {
const onDragMove = vi.fn();
const onDragEnd = vi.fn();
setupImageDrag(image, imageId, onDragMove, onDragEnd);
// Callbacks should be set up
expect(onDragMove).not.toHaveBeenCalled();
expect(onDragEnd).not.toHaveBeenCalled();
});
});
describe('Drag Start', () => {
it('selects image on drag start if not selected', () => {
setupImageDrag(image, imageId);
// Simulate drag start
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(true);
});
it('adds to selection with Ctrl key', () => {
const otherId = 'other-image';
selection.selectOne(otherId);
setupImageDrag(image, imageId);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: true, metaKey: false },
});
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(true);
expect(selectionState.selectedIds.has(otherId)).toBe(true);
expect(selectionState.selectedIds.size).toBe(2);
});
it('updates drag state', () => {
setupImageDrag(image, imageId);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
// isDragging should return true during drag
// Note: In actual implementation, this would be checked during dragmove
});
});
describe('Drag Move', () => {
it('calls onDragMove callback with current position', () => {
const onDragMove = vi.fn();
setupImageDrag(image, imageId, onDragMove);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
image.position({ x: 150, y: 150 });
image.fire('dragmove', {
evt: {},
});
expect(onDragMove).toHaveBeenCalledWith(imageId, 150, 150);
});
it('handles multi-drag when multiple images selected', () => {
selection.selectMultiple([imageId, 'other-image']);
const multiDragHandler = vi.fn();
stage.on('multiDragMove', multiDragHandler);
setupImageDrag(image, imageId);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
image.position({ x: 150, y: 150 });
image.fire('dragmove', {
evt: {},
});
// Should fire multiDragMove event for other selected images
expect(multiDragHandler).toHaveBeenCalled();
});
});
describe('Drag End', () => {
it('calls onDragEnd callback with final position', () => {
const onDragEnd = vi.fn();
setupImageDrag(image, imageId, undefined, onDragEnd);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
image.position({ x: 200, y: 200 });
image.fire('dragend', {
evt: {},
});
expect(onDragEnd).toHaveBeenCalledWith(imageId, 200, 200);
});
it('resets drag state after drag ends', () => {
setupImageDrag(image, imageId);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
image.fire('dragend', {
evt: {},
});
expect(isDragging()).toBe(false);
});
});
describe('Programmatic Movement', () => {
it('moveImageTo sets absolute position', () => {
moveImageTo(image, 300, 300);
expect(image.x()).toBe(300);
expect(image.y()).toBe(300);
});
it('moveImageBy moves relative to current position', () => {
image.position({ x: 100, y: 100 });
moveImageBy(image, 50, 50);
expect(image.x()).toBe(150);
expect(image.y()).toBe(150);
});
it('handles negative delta in moveImageBy', () => {
image.position({ x: 100, y: 100 });
moveImageBy(image, -25, -25);
expect(image.x()).toBe(75);
expect(image.y()).toBe(75);
});
it('moveImageTo works with large values', () => {
moveImageTo(image, 10000, 10000);
expect(image.x()).toBe(10000);
expect(image.y()).toBe(10000);
});
});
describe('Edge Cases', () => {
it('handles dragging to negative coordinates', () => {
moveImageTo(image, -100, -100);
expect(image.x()).toBe(-100);
expect(image.y()).toBe(-100);
});
it('handles zero movement', () => {
const initialX = image.x();
const initialY = image.y();
moveImageBy(image, 0, 0);
expect(image.x()).toBe(initialX);
expect(image.y()).toBe(initialY);
});
it('handles rapid position changes', () => {
for (let i = 0; i < 100; i++) {
moveImageBy(image, 1, 1);
}
expect(image.x()).toBeGreaterThan(100);
expect(image.y()).toBeGreaterThan(100);
});
});
describe('Integration with Selection', () => {
it('maintains selection during drag', () => {
selection.selectOne(imageId);
setupImageDrag(image, imageId);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
image.fire('dragend', {
evt: {},
});
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(true);
});
it('clears other selections when dragging unselected image', () => {
selection.selectMultiple(['other-image-1', 'other-image-2']);
setupImageDrag(image, imageId);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(true);
expect(selectionState.selectedIds.has('other-image-1')).toBe(false);
expect(selectionState.selectedIds.has('other-image-2')).toBe(false);
expect(selectionState.selectedIds.size).toBe(1);
});
});
});

View File

@@ -0,0 +1,423 @@
/**
* Tests for canvas image selection functionality
* Tests click selection, multi-select, and background deselection
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import Konva from 'konva';
import { get } from 'svelte/store';
import { selection } from '$lib/stores/selection';
import {
setupImageSelection,
setupBackgroundDeselect,
selectImage,
deselectImage,
toggleImageSelection,
selectAllImages,
clearAllSelection,
getSelectedCount,
getSelectedImageIds,
isImageSelected,
} from '$lib/canvas/interactions/select';
describe('Image Selection', () => {
let stage: Konva.Stage;
let layer: Konva.Layer;
let image: Konva.Image;
let imageId: string;
beforeEach(() => {
// Create container
const container = document.createElement('div');
container.id = 'test-container';
document.body.appendChild(container);
// Create stage and layer
stage = new Konva.Stage({
container: 'test-container',
width: 800,
height: 600,
});
layer = new Konva.Layer();
stage.add(layer);
// Create test image
const imageElement = new Image();
imageElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
image = new Konva.Image({
image: imageElement,
x: 100,
y: 100,
width: 200,
height: 200,
});
layer.add(image);
layer.draw();
imageId = 'test-image-1';
// Reset selection
selection.clearSelection();
});
afterEach(() => {
stage.destroy();
document.body.innerHTML = '';
});
describe('Setup', () => {
it('sets up click handler on image', () => {
const cleanup = setupImageSelection(image, imageId);
expect(typeof cleanup).toBe('function');
cleanup();
});
it('cleanup function removes click handlers', () => {
const cleanup = setupImageSelection(image, imageId);
// Select the image
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
expect(get(selection).selectedIds.has(imageId)).toBe(true);
// Clean up
cleanup();
// Clear selection
selection.clearSelection();
// Click should no longer work
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
expect(get(selection).selectedIds.has(imageId)).toBe(false);
});
});
describe('Single Click Selection', () => {
it('selects image on click', () => {
setupImageSelection(image, imageId);
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(true);
expect(selectionState.selectedIds.size).toBe(1);
});
it('replaces selection when clicking different image', () => {
selection.selectOne('other-image');
setupImageSelection(image, imageId);
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(true);
expect(selectionState.selectedIds.has('other-image')).toBe(false);
expect(selectionState.selectedIds.size).toBe(1);
});
it('calls onSelectionChange callback', () => {
const callback = vi.fn();
setupImageSelection(image, imageId, undefined, callback);
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
expect(callback).toHaveBeenCalledWith(imageId, true);
});
it('does not deselect on second click without Ctrl', () => {
setupImageSelection(image, imageId);
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(true);
});
});
describe('Multi-Select (Ctrl+Click)', () => {
it('adds to selection with Ctrl+Click', () => {
selection.selectOne('other-image');
setupImageSelection(image, imageId);
image.fire('click', { evt: { ctrlKey: true, metaKey: false } });
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(true);
expect(selectionState.selectedIds.has('other-image')).toBe(true);
expect(selectionState.selectedIds.size).toBe(2);
});
it('removes from selection with Ctrl+Click on selected image', () => {
selection.selectMultiple([imageId, 'other-image']);
setupImageSelection(image, imageId);
image.fire('click', { evt: { ctrlKey: true, metaKey: false } });
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(false);
expect(selectionState.selectedIds.has('other-image')).toBe(true);
expect(selectionState.selectedIds.size).toBe(1);
});
it('works with Cmd key (metaKey) on Mac', () => {
selection.selectOne('other-image');
setupImageSelection(image, imageId);
image.fire('click', { evt: { ctrlKey: false, metaKey: true } });
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(true);
expect(selectionState.selectedIds.size).toBe(2);
});
it('calls callback with correct state when adding to selection', () => {
const callback = vi.fn();
setupImageSelection(image, imageId, undefined, callback);
image.fire('click', { evt: { ctrlKey: true, metaKey: false } });
expect(callback).toHaveBeenCalledWith(imageId, true);
});
it('calls callback with correct state when removing from selection', () => {
const callback = vi.fn();
selection.selectOne(imageId);
setupImageSelection(image, imageId, undefined, callback);
image.fire('click', { evt: { ctrlKey: true, metaKey: false } });
expect(callback).toHaveBeenCalledWith(imageId, false);
});
});
describe('Background Deselection', () => {
it('clears selection when clicking stage background', () => {
selection.selectMultiple([imageId, 'other-image']);
setupBackgroundDeselect(stage);
// Simulate click on stage (not on shape)
stage.fire('click', {
target: stage,
evt: {},
});
const selectionState = get(selection);
expect(selectionState.selectedIds.size).toBe(0);
});
it('does not clear selection when clicking on shape', () => {
selection.selectMultiple([imageId, 'other-image']);
setupBackgroundDeselect(stage);
// Simulate click on shape
stage.fire('click', {
target: image,
evt: {},
});
const selectionState = get(selection);
expect(selectionState.selectedIds.size).toBe(2);
});
it('calls onDeselect callback when background clicked', () => {
const callback = vi.fn();
selection.selectOne(imageId);
setupBackgroundDeselect(stage, callback);
stage.fire('click', {
target: stage,
evt: {},
});
expect(callback).toHaveBeenCalled();
});
it('cleanup removes background deselect handler', () => {
selection.selectOne(imageId);
const cleanup = setupBackgroundDeselect(stage);
cleanup();
stage.fire('click', {
target: stage,
evt: {},
});
const selectionState = get(selection);
expect(selectionState.selectedIds.size).toBe(1);
});
});
describe('Programmatic Selection', () => {
it('selectImage selects single image', () => {
selectImage(imageId);
expect(isImageSelected(imageId)).toBe(true);
expect(getSelectedCount()).toBe(1);
});
it('selectImage with multiSelect adds to selection', () => {
selectImage('image-1');
selectImage('image-2', true);
expect(getSelectedCount()).toBe(2);
expect(getSelectedImageIds()).toEqual(['image-1', 'image-2']);
});
it('deselectImage removes from selection', () => {
selection.selectMultiple([imageId, 'other-image']);
deselectImage(imageId);
expect(isImageSelected(imageId)).toBe(false);
expect(isImageSelected('other-image')).toBe(true);
});
it('toggleImageSelection toggles state', () => {
toggleImageSelection(imageId);
expect(isImageSelected(imageId)).toBe(true);
toggleImageSelection(imageId);
expect(isImageSelected(imageId)).toBe(false);
});
it('selectAllImages selects all provided IDs', () => {
const allIds = ['img-1', 'img-2', 'img-3', 'img-4'];
selectAllImages(allIds);
expect(getSelectedCount()).toBe(4);
expect(getSelectedImageIds()).toEqual(allIds);
});
it('clearAllSelection clears everything', () => {
selection.selectMultiple(['img-1', 'img-2', 'img-3']);
clearAllSelection();
expect(getSelectedCount()).toBe(0);
expect(getSelectedImageIds()).toEqual([]);
});
});
describe('Query Functions', () => {
it('getSelectedCount returns correct count', () => {
expect(getSelectedCount()).toBe(0);
selection.selectOne(imageId);
expect(getSelectedCount()).toBe(1);
selection.addToSelection('other-image');
expect(getSelectedCount()).toBe(2);
});
it('getSelectedImageIds returns array of IDs', () => {
selection.selectMultiple(['img-1', 'img-2', 'img-3']);
const ids = getSelectedImageIds();
expect(Array.isArray(ids)).toBe(true);
expect(ids.length).toBe(3);
expect(ids).toContain('img-1');
expect(ids).toContain('img-2');
expect(ids).toContain('img-3');
});
it('isImageSelected returns correct boolean', () => {
expect(isImageSelected(imageId)).toBe(false);
selection.selectOne(imageId);
expect(isImageSelected(imageId)).toBe(true);
selection.clearSelection();
expect(isImageSelected(imageId)).toBe(false);
});
});
describe('Edge Cases', () => {
it('handles selecting non-existent image', () => {
selectImage('non-existent-id');
expect(getSelectedCount()).toBe(1);
expect(isImageSelected('non-existent-id')).toBe(true);
});
it('handles deselecting non-selected image', () => {
deselectImage('not-selected-id');
expect(getSelectedCount()).toBe(0);
});
it('handles toggling same image multiple times', () => {
toggleImageSelection(imageId);
toggleImageSelection(imageId);
toggleImageSelection(imageId);
expect(isImageSelected(imageId)).toBe(true);
});
it('handles empty array in selectAllImages', () => {
selectAllImages([]);
expect(getSelectedCount()).toBe(0);
});
it('handles large selection sets', () => {
const largeSet = Array.from({ length: 1000 }, (_, i) => `img-${i}`);
selectAllImages(largeSet);
expect(getSelectedCount()).toBe(1000);
});
});
describe('Touch Events', () => {
it('handles tap event same as click', () => {
setupImageSelection(image, imageId);
image.fire('tap', { evt: { ctrlKey: false, metaKey: false } });
expect(isImageSelected(imageId)).toBe(true);
});
it('prevents event bubbling to stage', () => {
setupImageSelection(image, imageId);
setupBackgroundDeselect(stage);
const clickEvent = new Event('click', { bubbles: true, cancelable: true });
Object.defineProperty(clickEvent, 'cancelBubble', {
writable: true,
value: false,
});
image.fire('click', {
evt: {
ctrlKey: false,
metaKey: false,
...clickEvent,
},
});
// Image should be selected
expect(isImageSelected(imageId)).toBe(true);
});
});
});

View File

@@ -0,0 +1,997 @@
/**
* Component tests for upload components
* Tests FilePicker, DropZone, ProgressBar, and ErrorDisplay Svelte components
*/
import { render, fireEvent, screen, waitFor } from '@testing-library/svelte';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import FilePicker from '$lib/components/upload/FilePicker.svelte';
import DropZone from '$lib/components/upload/DropZone.svelte';
import ProgressBar from '$lib/components/upload/ProgressBar.svelte';
import ErrorDisplay from '$lib/components/upload/ErrorDisplay.svelte';
import type { ImageUploadProgress } from '$lib/types/images';
// Mock the image store functions
vi.mock('$lib/stores/images', () => ({
uploadSingleImage: vi.fn(),
uploadZipFile: vi.fn(),
uploadProgress: {
update: vi.fn(),
},
}));
describe('FilePicker', () => {
let uploadSingleImage: ReturnType<typeof vi.fn>;
let uploadZipFile: ReturnType<typeof vi.fn>;
beforeEach(async () => {
const imageStore = await import('$lib/stores/images');
uploadSingleImage = imageStore.uploadSingleImage;
uploadZipFile = imageStore.uploadZipFile;
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders the file picker button', () => {
render(FilePicker);
const button = screen.getByRole('button', { name: /choose files/i });
expect(button).toBeInTheDocument();
expect(button).not.toBeDisabled();
});
it('renders with custom accept attribute', () => {
render(FilePicker, { props: { accept: 'image/png,.jpg' } });
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
it('renders with multiple attribute by default', () => {
const { container } = render(FilePicker);
const fileInput = container.querySelector('input[type="file"]');
expect(fileInput).toHaveAttribute('multiple');
});
it('can disable multiple file selection', () => {
const { container } = render(FilePicker, { props: { multiple: false } });
const fileInput = container.querySelector('input[type="file"]');
expect(fileInput).not.toHaveAttribute('multiple');
});
it('hides the file input element', () => {
const { container } = render(FilePicker);
const fileInput = container.querySelector('input[type="file"]') as HTMLElement;
expect(fileInput).toHaveStyle({ display: 'none' });
});
});
describe('File Selection', () => {
it('opens file picker when button is clicked', async () => {
const { container } = render(FilePicker);
const button = screen.getByRole('button', { name: /choose files/i });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.fn();
fileInput.click = clickSpy;
await fireEvent.click(button);
expect(clickSpy).toHaveBeenCalledTimes(1);
});
it('handles single image file upload', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
const { container, component } = render(FilePicker);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image content'], 'test.jpg', { type: 'image/jpeg' });
await fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(uploadSingleImage).toHaveBeenCalledWith(file);
});
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 1 });
});
it('handles multiple image file uploads', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
const { container, component } = render(FilePicker);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const files = [
new File(['image1'], 'test1.jpg', { type: 'image/jpeg' }),
new File(['image2'], 'test2.png', { type: 'image/png' }),
new File(['image3'], 'test3.gif', { type: 'image/gif' }),
];
await fireEvent.change(fileInput, { target: { files } });
await waitFor(() => {
expect(uploadSingleImage).toHaveBeenCalledTimes(3);
});
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 3 });
});
it('handles ZIP file upload', async () => {
uploadZipFile.mockResolvedValue({ success: true });
const { container, component } = render(FilePicker);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['zip content'], 'images.zip', { type: 'application/zip' });
await fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(uploadZipFile).toHaveBeenCalledWith(file);
});
expect(uploadSingleImage).not.toHaveBeenCalled();
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
});
it('handles mixed image and ZIP file uploads', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
uploadZipFile.mockResolvedValue({ success: true });
const { container, component } = render(FilePicker);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const files = [
new File(['image'], 'test.jpg', { type: 'image/jpeg' }),
new File(['zip'], 'archive.zip', { type: 'application/zip' }),
new File(['image'], 'test.png', { type: 'image/png' }),
];
await fireEvent.change(fileInput, { target: { files } });
await waitFor(() => {
expect(uploadSingleImage).toHaveBeenCalledTimes(2);
expect(uploadZipFile).toHaveBeenCalledTimes(1);
});
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 3 });
});
it('resets file input after upload', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
const { container } = render(FilePicker);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
await fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(uploadSingleImage).toHaveBeenCalled();
});
expect(fileInput.value).toBe('');
});
});
describe('Loading State', () => {
it('shows loading state during upload', async () => {
uploadSingleImage.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
);
const { container } = render(FilePicker);
const button = screen.getByRole('button');
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
await fireEvent.change(fileInput, { target: { files: [file] } });
// During upload
expect(button).toBeDisabled();
expect(screen.getByText(/uploading/i)).toBeInTheDocument();
// Wait for upload to complete
await waitFor(() => {
expect(button).not.toBeDisabled();
});
expect(screen.queryByText(/uploading/i)).not.toBeInTheDocument();
});
it('disables button during upload', async () => {
uploadSingleImage.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
);
const { container } = render(FilePicker);
const button = screen.getByRole('button');
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
expect(button).not.toBeDisabled();
await fireEvent.change(fileInput, { target: { files: [file] } });
expect(button).toBeDisabled();
await waitFor(() => {
expect(button).not.toBeDisabled();
});
});
it('shows spinner during upload', async () => {
uploadSingleImage.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
);
const { container } = render(FilePicker);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
await fireEvent.change(fileInput, { target: { files: [file] } });
const spinner = container.querySelector('.spinner');
expect(spinner).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('.spinner')).not.toBeInTheDocument();
});
});
});
describe('Error Handling', () => {
it('dispatches upload-error event on upload failure', async () => {
uploadSingleImage.mockRejectedValue(new Error('Upload failed'));
const { container, component } = render(FilePicker);
const uploadErrorHandler = vi.fn();
component.$on('upload-error', uploadErrorHandler);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
await fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(uploadErrorHandler).toHaveBeenCalledTimes(1);
});
expect(uploadErrorHandler.mock.calls[0][0].detail).toEqual({ error: 'Upload failed' });
});
it('re-enables button after error', async () => {
uploadSingleImage.mockRejectedValue(new Error('Upload failed'));
const { container } = render(FilePicker);
const button = screen.getByRole('button');
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
await fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(button).not.toBeDisabled();
});
});
it('handles no files selected gracefully', async () => {
const { container } = render(FilePicker);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
await fireEvent.change(fileInput, { target: { files: null } });
expect(uploadSingleImage).not.toHaveBeenCalled();
expect(uploadZipFile).not.toHaveBeenCalled();
});
});
});
describe('DropZone', () => {
let uploadSingleImage: ReturnType<typeof vi.fn>;
let uploadZipFile: ReturnType<typeof vi.fn>;
beforeEach(async () => {
const imageStore = await import('$lib/stores/images');
uploadSingleImage = imageStore.uploadSingleImage;
uploadZipFile = imageStore.uploadZipFile;
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders the drop zone', () => {
render(DropZone);
expect(screen.getByText(/drag and drop images here/i)).toBeInTheDocument();
expect(screen.getByText(/or use the file picker above/i)).toBeInTheDocument();
});
it('shows default state initially', () => {
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone');
expect(dropZone).not.toHaveClass('dragging');
expect(dropZone).not.toHaveClass('uploading');
});
});
describe('Drag and Drop', () => {
it('shows dragging state on drag enter', async () => {
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
await fireEvent.dragEnter(dropZone);
expect(dropZone).toHaveClass('dragging');
expect(screen.getByText(/drop files here/i)).toBeInTheDocument();
});
it('removes dragging state on drag leave', async () => {
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
await fireEvent.dragEnter(dropZone);
expect(dropZone).toHaveClass('dragging');
await fireEvent.dragLeave(dropZone);
expect(dropZone).not.toHaveClass('dragging');
});
it('handles drag over event', async () => {
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true });
const preventDefaultSpy = vi.spyOn(dragOverEvent, 'preventDefault');
dropZone.dispatchEvent(dragOverEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
});
it('handles single image file drop', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
const { container, component } = render(DropZone);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
const dropEvent = new DragEvent('drop', {
bubbles: true,
cancelable: true,
dataTransfer: new DataTransfer(),
});
Object.defineProperty(dropEvent, 'dataTransfer', {
value: {
files: [file],
},
});
await fireEvent(dropZone, dropEvent);
await waitFor(() => {
expect(uploadSingleImage).toHaveBeenCalledWith(file);
});
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 1 });
});
it('handles multiple image files drop', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
const { container, component } = render(DropZone);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const files = [
new File(['image1'], 'test1.jpg', { type: 'image/jpeg' }),
new File(['image2'], 'test2.png', { type: 'image/png' }),
];
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files },
});
await fireEvent(dropZone, dropEvent);
await waitFor(() => {
expect(uploadSingleImage).toHaveBeenCalledTimes(2);
});
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
});
it('handles ZIP file drop', async () => {
uploadZipFile.mockResolvedValue({ success: true });
const { container, component } = render(DropZone);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const file = new File(['zip'], 'images.zip', { type: 'application/zip' });
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
});
await fireEvent(dropZone, dropEvent);
await waitFor(() => {
expect(uploadZipFile).toHaveBeenCalledWith(file);
});
expect(uploadSingleImage).not.toHaveBeenCalled();
});
it('filters out invalid file types', async () => {
const { container, component } = render(DropZone, { props: { accept: 'image/*,.zip' } });
const uploadErrorHandler = vi.fn();
component.$on('upload-error', uploadErrorHandler);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const files = [new File(['text'], 'document.txt', { type: 'text/plain' })];
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files },
});
await fireEvent(dropZone, dropEvent);
await waitFor(() => {
expect(uploadErrorHandler).toHaveBeenCalledTimes(1);
});
expect(uploadErrorHandler.mock.calls[0][0].detail).toEqual({
error: 'No valid image files found',
});
expect(uploadSingleImage).not.toHaveBeenCalled();
expect(uploadZipFile).not.toHaveBeenCalled();
});
it('removes dragging state after drop', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
await fireEvent.dragEnter(dropZone);
expect(dropZone).toHaveClass('dragging');
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
});
await fireEvent(dropZone, dropEvent);
expect(dropZone).not.toHaveClass('dragging');
});
});
describe('Loading State', () => {
it('shows uploading state during upload', async () => {
uploadSingleImage.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
);
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
});
await fireEvent(dropZone, dropEvent);
expect(dropZone).toHaveClass('uploading');
expect(screen.getByText(/uploading/i)).toBeInTheDocument();
await waitFor(() => {
expect(dropZone).not.toHaveClass('uploading');
});
});
it('shows spinner during upload', async () => {
uploadSingleImage.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
);
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
});
await fireEvent(dropZone, dropEvent);
const spinner = container.querySelector('.spinner-large');
expect(spinner).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('.spinner-large')).not.toBeInTheDocument();
});
});
});
describe('Error Handling', () => {
it('dispatches upload-error event on upload failure', async () => {
uploadSingleImage.mockRejectedValue(new Error('Network error'));
const { container, component } = render(DropZone);
const uploadErrorHandler = vi.fn();
component.$on('upload-error', uploadErrorHandler);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
});
await fireEvent(dropZone, dropEvent);
await waitFor(() => {
expect(uploadErrorHandler).toHaveBeenCalledTimes(1);
});
expect(uploadErrorHandler.mock.calls[0][0].detail).toEqual({ error: 'Network error' });
});
it('returns to normal state after error', async () => {
uploadSingleImage.mockRejectedValue(new Error('Upload failed'));
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
});
await fireEvent(dropZone, dropEvent);
await waitFor(() => {
expect(dropZone).not.toHaveClass('uploading');
});
});
it('handles drop event with no files', async () => {
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: null },
});
await fireEvent(dropZone, dropEvent);
expect(uploadSingleImage).not.toHaveBeenCalled();
expect(uploadZipFile).not.toHaveBeenCalled();
});
});
});
describe('ProgressBar', () => {
describe('Rendering', () => {
it('renders progress item with filename', () => {
const item: ImageUploadProgress = {
filename: 'test-image.jpg',
status: 'uploading',
progress: 50,
};
render(ProgressBar, { props: { item } });
expect(screen.getByText('test-image.jpg')).toBeInTheDocument();
});
it('shows progress bar for uploading status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'uploading',
progress: 75,
};
const { container } = render(ProgressBar, { props: { item } });
expect(screen.getByText('75%')).toBeInTheDocument();
const progressBar = container.querySelector('.progress-bar-fill') as HTMLElement;
expect(progressBar).toHaveStyle({ width: '75%' });
});
it('shows progress bar for processing status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'processing',
progress: 90,
};
render(ProgressBar, { props: { item } });
expect(screen.getByText('90%')).toBeInTheDocument();
});
it('shows success message for complete status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'complete',
progress: 100,
};
render(ProgressBar, { props: { item } });
expect(screen.getByText(/upload complete/i)).toBeInTheDocument();
expect(screen.queryByText(/\d+%/)).not.toBeInTheDocument();
});
it('shows error message for error status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'error',
progress: 0,
error: 'File too large',
};
render(ProgressBar, { props: { item } });
expect(screen.getByText('File too large')).toBeInTheDocument();
});
it('shows close button for complete status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'complete',
progress: 100,
};
render(ProgressBar, { props: { item } });
const closeButton = screen.getByRole('button', { name: /remove/i });
expect(closeButton).toBeInTheDocument();
});
it('shows close button for error status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'error',
progress: 0,
error: 'Failed',
};
render(ProgressBar, { props: { item } });
const closeButton = screen.getByRole('button', { name: /remove/i });
expect(closeButton).toBeInTheDocument();
});
it('hides close button for uploading status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'uploading',
progress: 50,
};
render(ProgressBar, { props: { item } });
const closeButton = screen.queryByRole('button', { name: /remove/i });
expect(closeButton).not.toBeInTheDocument();
});
});
describe('Status Icons', () => {
it('shows correct icon for uploading status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'uploading',
progress: 50,
};
const { container } = render(ProgressBar, { props: { item } });
const statusIcon = container.querySelector('.status-icon');
expect(statusIcon).toHaveTextContent('⟳');
});
it('shows correct icon for processing status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'processing',
progress: 90,
};
const { container } = render(ProgressBar, { props: { item } });
const statusIcon = container.querySelector('.status-icon');
expect(statusIcon).toHaveTextContent('⟳');
});
it('shows correct icon for complete status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'complete',
progress: 100,
};
const { container } = render(ProgressBar, { props: { item } });
const statusIcon = container.querySelector('.status-icon');
expect(statusIcon).toHaveTextContent('✓');
});
it('shows correct icon for error status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'error',
progress: 0,
error: 'Failed',
};
const { container } = render(ProgressBar, { props: { item } });
const statusIcon = container.querySelector('.status-icon');
expect(statusIcon).toHaveTextContent('✗');
});
});
describe('Remove Functionality', () => {
it('removes item from store when close button is clicked', async () => {
const imageStore = await import('$lib/stores/images');
const updateFn = vi.fn((callback) => callback([]));
imageStore.uploadProgress.update = updateFn;
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'complete',
progress: 100,
};
render(ProgressBar, { props: { item } });
const closeButton = screen.getByRole('button', { name: /remove/i });
await fireEvent.click(closeButton);
expect(updateFn).toHaveBeenCalled();
});
});
describe('Progress Display', () => {
it('shows progress percentage correctly', () => {
const testCases = [0, 25, 50, 75, 100];
testCases.forEach((progress) => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'uploading',
progress,
};
const { unmount, container } = render(ProgressBar, { props: { item } });
expect(screen.getByText(`${progress}%`)).toBeInTheDocument();
const progressBar = container.querySelector('.progress-bar-fill') as HTMLElement;
expect(progressBar).toHaveStyle({ width: `${progress}%` });
unmount();
});
});
it('truncates long filenames', () => {
const item: ImageUploadProgress = {
filename: 'very-long-filename-that-should-be-truncated-with-ellipsis.jpg',
status: 'uploading',
progress: 50,
};
const { container } = render(ProgressBar, { props: { item } });
const filenameElement = container.querySelector('.filename') as HTMLElement;
expect(filenameElement).toHaveStyle({
overflow: 'hidden',
'text-overflow': 'ellipsis',
'white-space': 'nowrap',
});
});
});
});
describe('ErrorDisplay', () => {
describe('Rendering', () => {
it('renders error message', () => {
render(ErrorDisplay, { props: { error: 'Upload failed' } });
expect(screen.getByText('Upload failed')).toBeInTheDocument();
});
it('renders with error icon', () => {
const { container } = render(ErrorDisplay, { props: { error: 'Test error' } });
const icon = container.querySelector('.error-icon svg');
expect(icon).toBeInTheDocument();
});
it('has proper ARIA role', () => {
render(ErrorDisplay, { props: { error: 'Test error' } });
const errorDisplay = screen.getByRole('alert');
expect(errorDisplay).toBeInTheDocument();
});
it('shows dismiss button by default', () => {
render(ErrorDisplay, { props: { error: 'Test error' } });
const dismissButton = screen.getByRole('button', { name: /dismiss error/i });
expect(dismissButton).toBeInTheDocument();
});
it('hides dismiss button when dismissible is false', () => {
render(ErrorDisplay, { props: { error: 'Test error', dismissible: false } });
const dismissButton = screen.queryByRole('button', { name: /dismiss error/i });
expect(dismissButton).not.toBeInTheDocument();
});
});
describe('Dismiss Functionality', () => {
it('dispatches dismiss event when button is clicked', async () => {
const { component } = render(ErrorDisplay, { props: { error: 'Test error' } });
const dismissHandler = vi.fn();
component.$on('dismiss', dismissHandler);
const dismissButton = screen.getByRole('button', { name: /dismiss error/i });
await fireEvent.click(dismissButton);
expect(dismissHandler).toHaveBeenCalledTimes(1);
});
it('does not dispatch dismiss event when dismissible is false', () => {
const { component } = render(ErrorDisplay, {
props: { error: 'Test error', dismissible: false },
});
const dismissHandler = vi.fn();
component.$on('dismiss', dismissHandler);
// No dismiss button should exist
const dismissButton = screen.queryByRole('button', { name: /dismiss error/i });
expect(dismissButton).not.toBeInTheDocument();
});
});
describe('Error Messages', () => {
it('handles short error messages', () => {
render(ErrorDisplay, { props: { error: 'Error' } });
expect(screen.getByText('Error')).toBeInTheDocument();
});
it('handles long error messages', () => {
const longError =
'This is a very long error message that contains detailed information about what went wrong during the upload process. It should be displayed correctly with proper line wrapping.';
render(ErrorDisplay, { props: { error: longError } });
expect(screen.getByText(longError)).toBeInTheDocument();
});
it('handles error messages with special characters', () => {
const errorWithSpecialChars = "File 'test.jpg' couldn't be uploaded: size > 50MB";
render(ErrorDisplay, { props: { error: errorWithSpecialChars } });
expect(screen.getByText(errorWithSpecialChars)).toBeInTheDocument();
});
it('handles empty error messages', () => {
render(ErrorDisplay, { props: { error: '' } });
const errorMessage = screen.getByRole('alert');
expect(errorMessage).toBeInTheDocument();
});
});
describe('Styling', () => {
it('applies error styling classes', () => {
const { container } = render(ErrorDisplay, { props: { error: 'Test error' } });
const errorDisplay = container.querySelector('.error-display');
expect(errorDisplay).toBeInTheDocument();
expect(errorDisplay).toHaveClass('error-display');
});
it('has proper visual hierarchy', () => {
const { container } = render(ErrorDisplay, { props: { error: 'Test error' } });
const errorIcon = container.querySelector('.error-icon');
const errorContent = container.querySelector('.error-content');
const dismissButton = container.querySelector('.dismiss-button');
expect(errorIcon).toBeInTheDocument();
expect(errorContent).toBeInTheDocument();
expect(dismissButton).toBeInTheDocument();
});
});
});

View File

@@ -216,7 +216,7 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
- [X] T094 [US3] Implement ZIP upload handler in frontend/src/lib/utils/zip-upload.ts - [X] T094 [US3] Implement ZIP upload handler in frontend/src/lib/utils/zip-upload.ts
- [X] T095 [P] [US3] Create upload progress component in frontend/src/lib/components/upload/ProgressBar.svelte - [X] T095 [P] [US3] Create upload progress component in frontend/src/lib/components/upload/ProgressBar.svelte
- [X] T096 [P] [US3] Create upload error display in frontend/src/lib/components/upload/ErrorDisplay.svelte - [X] T096 [P] [US3] Create upload error display in frontend/src/lib/components/upload/ErrorDisplay.svelte
- [ ] T097 [P] [US3] Write upload component tests in frontend/tests/components/upload.test.ts - [X] T097 [P] [US3] Write upload component tests in frontend/tests/components/upload.test.ts
**Infrastructure:** **Infrastructure:**
@@ -232,33 +232,33 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
--- ---
## Phase 6: Canvas Navigation & Viewport (FR12 - Critical) (Week 5) ## Phase 6: Canvas Navigation & Viewport (FR12 - Critical) (Week 5) ✅ COMPLETE
**User Story:** Users must be able to navigate the infinite canvas efficiently **User Story:** Users must be able to navigate the infinite canvas efficiently
**Independent Test Criteria:** **Independent Test Criteria:**
- [ ] Users can pan canvas (drag or spacebar+drag) - [X] Users can pan canvas (drag or spacebar+drag)
- [ ] Users can zoom in/out (mouse wheel, pinch) - [X] Users can zoom in/out (mouse wheel, pinch)
- [ ] Users can rotate canvas view - [X] Users can rotate canvas view
- [ ] Users can reset camera and fit to screen - [X] Users can reset camera and fit to screen
- [ ] Viewport state persists - [X] Viewport state persists
**Frontend Tasks:** **Frontend Tasks:**
- [ ] T100 [US4] Initialize Konva.js Stage in frontend/src/lib/canvas/Stage.svelte - [X] T100 [US4] Initialize Konva.js Stage in frontend/src/lib/canvas/Stage.svelte
- [ ] T101 [US4] Implement pan functionality in frontend/src/lib/canvas/controls/pan.ts - [X] T101 [US4] Implement pan functionality in frontend/src/lib/canvas/controls/pan.ts
- [ ] T102 [P] [US4] Implement zoom functionality in frontend/src/lib/canvas/controls/zoom.ts - [X] T102 [P] [US4] Implement zoom functionality in frontend/src/lib/canvas/controls/zoom.ts
- [ ] T103 [P] [US4] Implement canvas rotation in frontend/src/lib/canvas/controls/rotate.ts - [X] T103 [P] [US4] Implement canvas rotation in frontend/src/lib/canvas/controls/rotate.ts
- [ ] T104 [US4] Create viewport store in frontend/src/lib/stores/viewport.ts - [X] T104 [US4] Create viewport store in frontend/src/lib/stores/viewport.ts
- [ ] T105 [US4] Implement reset camera function in frontend/src/lib/canvas/controls/reset.ts - [X] T105 [US4] Implement reset camera function in frontend/src/lib/canvas/controls/reset.ts
- [ ] T106 [US4] Implement fit-to-screen function in frontend/src/lib/canvas/controls/fit.ts - [X] T106 [US4] Implement fit-to-screen function in frontend/src/lib/canvas/controls/fit.ts
- [ ] T107 [US4] Add touch gesture support in frontend/src/lib/canvas/gestures.ts (pinch, two-finger pan) - [X] T107 [US4] Add touch gesture support in frontend/src/lib/canvas/gestures.ts (pinch, two-finger pan)
- [ ] T108 [US4] Persist viewport state to backend when changed - [X] T108 [US4] Persist viewport state to backend when changed
- [ ] T109 [P] [US4] Write canvas control tests in frontend/tests/canvas/controls.test.ts - [X] T109 [P] [US4] Write canvas control tests in frontend/tests/canvas/controls.test.ts
**Backend Tasks:** **Backend Tasks:**
- [ ] T110 [US4] Add viewport persistence endpoint PATCH /boards/{id}/viewport in backend/app/api/boards.py - [X] T110 [US4] Add viewport persistence endpoint PATCH /boards/{id}/viewport in backend/app/api/boards.py
**Deliverables:** **Deliverables:**
- Infinite canvas working - Infinite canvas working
@@ -268,33 +268,33 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
--- ---
## Phase 7: Image Positioning & Selection (FR5 - Critical) (Week 5-6) ## Phase 7: Image Positioning & Selection (FR5 - Critical) (Week 5-6) ✅ COMPLETE
**User Story:** Users must be able to freely position and organize images on canvas **User Story:** Users must be able to freely position and organize images on canvas
**Independent Test Criteria:** **Independent Test Criteria:**
- [ ] Users can drag images to any position - [X] Users can drag images to any position
- [ ] Images can overlap (Z-order controlled) - [X] Images can overlap (Z-order controlled)
- [ ] Users can select single/multiple images - [X] Users can select single/multiple images
- [ ] Selection shows visual indicators - [X] Selection shows visual indicators
- [ ] Positions persist in database - [X] Positions persist in database
**Frontend Tasks:** **Frontend Tasks:**
- [ ] T111 [US5] Create Konva Image wrapper in frontend/src/lib/canvas/Image.svelte - [X] T111 [US5] Create Konva Image wrapper in frontend/src/lib/canvas/Image.svelte
- [ ] T112 [US5] Implement image dragging in frontend/src/lib/canvas/interactions/drag.ts - [X] T112 [US5] Implement image dragging in frontend/src/lib/canvas/interactions/drag.ts
- [ ] T113 [US5] Implement click selection in frontend/src/lib/canvas/interactions/select.ts - [X] T113 [US5] Implement click selection in frontend/src/lib/canvas/interactions/select.ts
- [ ] T114 [US5] Implement selection rectangle (drag-to-select) in frontend/src/lib/canvas/interactions/multiselect.ts - [X] T114 [US5] Implement selection rectangle (drag-to-select) in frontend/src/lib/canvas/interactions/multiselect.ts
- [ ] T115 [US5] Create selection store in frontend/src/lib/stores/selection.ts - [X] T115 [US5] Create selection store in frontend/src/lib/stores/selection.ts
- [ ] T116 [P] [US5] Create selection visual indicators in frontend/src/lib/canvas/SelectionBox.svelte - [X] T116 [P] [US5] Create selection visual indicators in frontend/src/lib/canvas/SelectionBox.svelte
- [ ] T117 [US5] Implement position sync to backend (debounced) in frontend/src/lib/canvas/sync.ts - [X] T117 [US5] Implement position sync to backend (debounced) in frontend/src/lib/canvas/sync.ts
- [ ] T118 [P] [US5] Write dragging tests in frontend/tests/canvas/drag.test.ts - [X] T118 [P] [US5] Write dragging tests in frontend/tests/canvas/drag.test.ts
- [ ] T119 [P] [US5] Write selection tests in frontend/tests/canvas/select.test.ts - [X] T119 [P] [US5] Write selection tests in frontend/tests/canvas/select.test.ts
**Backend Tasks:** **Backend Tasks:**
- [ ] T120 [US5] Implement image position update endpoint PATCH /boards/{id}/images/{image_id} in backend/app/api/images.py - [X] T120 [US5] Implement image position update endpoint PATCH /boards/{id}/images/{image_id} in backend/app/api/images.py
- [ ] T121 [P] [US5] Write integration tests for position updates in backend/tests/api/test_image_position.py - [X] T121 [P] [US5] Write integration tests for position updates in backend/tests/api/test_image_position.py
**Deliverables:** **Deliverables:**
- Images draggable - Images draggable