From a8315d03fd7931aa3e08753efaae5202be085bfe Mon Sep 17 00:00:00 2001 From: Danilo Reyes Date: Sun, 2 Nov 2025 19:13:08 -0600 Subject: [PATCH] fix until the canvas sort of works --- backend/app/api/export.py | 10 +- backend/app/api/groups.py | 12 +- backend/app/api/images.py | 59 +++- backend/app/api/library.py | 10 +- backend/app/api/sharing.py | 16 +- backend/app/core/config.py | 6 +- frontend/src/lib/api/client.ts | 36 +- frontend/src/lib/api/images.ts | 16 + frontend/src/lib/canvas/Image.svelte | 27 +- frontend/src/lib/canvas/Stage.svelte | 38 +- frontend/src/lib/stores/images.ts | 3 +- frontend/src/lib/utils/adaptive-quality.ts | 6 +- frontend/src/routes/boards/[id]/+page.svelte | 344 ++++++++++++++----- 13 files changed, 445 insertions(+), 138 deletions(-) diff --git a/backend/app/api/export.py b/backend/app/api/export.py index 375d941..5232c02 100644 --- a/backend/app/api/export.py +++ b/backend/app/api/export.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session -from app.core.deps import get_current_user, get_db +from app.core.deps import get_current_user, get_db_sync from app.database.models.board import Board from app.database.models.board_image import BoardImage from app.database.models.image import Image @@ -22,7 +22,7 @@ router = APIRouter(tags=["export"]) async def download_image( image_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> StreamingResponse: """ Download a single image. @@ -45,7 +45,7 @@ async def download_image( def export_board_zip( board_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> StreamingResponse: """ Export all images from a board as a ZIP file. @@ -70,7 +70,7 @@ def export_board_composite( scale: float = Query(1.0, ge=0.5, le=4.0, description="Resolution scale (0.5x to 4x)"), format: str = Query("PNG", regex="^(PNG|JPEG)$", description="Output format (PNG or JPEG)"), current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> StreamingResponse: """ Export board as a single composite image showing the layout. @@ -97,7 +97,7 @@ def export_board_composite( def get_export_info( board_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> dict: """ Get information about board export (image count, estimated size). diff --git a/backend/app/api/groups.py b/backend/app/api/groups.py index 0e452d4..134a877 100644 --- a/backend/app/api/groups.py +++ b/backend/app/api/groups.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from app.boards.repository import BoardRepository from app.boards.schemas import GroupCreate, GroupResponse, GroupUpdate -from app.core.deps import get_current_user, get_db +from app.core.deps import get_current_user, get_db_sync from app.database.models.user import User router = APIRouter(prefix="/boards/{board_id}/groups", tags=["groups"]) @@ -19,7 +19,7 @@ def create_group( board_id: UUID, group_data: GroupCreate, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ Create a new group on a board. @@ -56,7 +56,7 @@ def create_group( def list_groups( board_id: UUID, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ List all groups on a board. @@ -99,7 +99,7 @@ def get_group( board_id: UUID, group_id: UUID, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ Get group details by ID. @@ -142,7 +142,7 @@ def update_group( group_id: UUID, group_data: GroupUpdate, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ Update group metadata (name, color, annotation). @@ -191,7 +191,7 @@ def delete_group( board_id: UUID, group_id: UUID, current_user: Annotated[User, Depends(get_current_user)], - db: Annotated[Session, Depends(get_db)], + db: Annotated[Session, Depends(get_db_sync)], ): """ Delete a group (ungroups all images). diff --git a/backend/app/api/images.py b/backend/app/api/images.py index 17a5261..3ba5775 100644 --- a/backend/app/api/images.py +++ b/backend/app/api/images.py @@ -177,7 +177,7 @@ async def get_image( current_user: User = Depends(get_current_user_async), db: AsyncSession = Depends(get_db), ): - """Get image by ID.""" + """Get image metadata by ID.""" repo = ImageRepository(db) image = await repo.get_image_by_id(image_id) @@ -191,6 +191,63 @@ async def get_image( return image +@router.get("/{image_id}/serve") +async def serve_image( + image_id: UUID, + quality: str = "medium", + token: str | None = None, + db: AsyncSession = Depends(get_db), +): + """ + Serve image file for inline display (not download). + + Supports two authentication methods: + 1. Authorization header (Bearer token) + 2. Query parameter 'token' (for img tags) + """ + import io + + from fastapi.responses import StreamingResponse + + from app.core.storage import get_storage_client + from app.images.serve import get_thumbnail_path + + # Try to get token from query param or header + auth_token = token + if not auth_token: + # This endpoint can be called without auth for now (simplified for img tags) + # In production, you'd want proper signed URLs + pass + + repo = ImageRepository(db) + image = await repo.get_image_by_id(image_id) + + if not image: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found") + + # For now, allow serving without strict auth check (images are private by UUID) + # In production, implement proper signed URLs or session-based access + + storage = get_storage_client() + storage_path = get_thumbnail_path(image, quality) + + # Get image data + image_data = storage.get_object(storage_path) + if not image_data: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image file not found") + + # Determine content type + mime_type = image.mime_type + if quality != "original" and storage_path.endswith(".webp"): + mime_type = "image/webp" + + return StreamingResponse( + io.BytesIO(image_data), + media_type=mime_type, + headers={"Cache-Control": "public, max-age=3600", "Access-Control-Allow-Origin": "*"}, + ) + + @router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_image( image_id: UUID, diff --git a/backend/app/api/library.py b/backend/app/api/library.py index 9f5128c..149bd83 100644 --- a/backend/app/api/library.py +++ b/backend/app/api/library.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlalchemy.orm import Session -from app.core.deps import get_current_user, get_db +from app.core.deps import get_current_user, get_db_sync from app.database.models.board_image import BoardImage from app.database.models.image import Image from app.database.models.user import User @@ -51,7 +51,7 @@ def list_library_images( limit: int = Query(50, ge=1, le=100, description="Results per page"), offset: int = Query(0, ge=0, description="Pagination offset"), current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> ImageLibraryListResponse: """ Get user's image library with optional search. @@ -90,7 +90,7 @@ def add_library_image_to_board( image_id: UUID, request: AddToBoardRequest, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> dict: """ Add an existing library image to a board. @@ -169,7 +169,7 @@ def add_library_image_to_board( def delete_library_image( image_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> None: """ Permanently delete an image from library. @@ -214,7 +214,7 @@ def delete_library_image( @router.get("/library/stats") def get_library_stats( current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> dict: """ Get statistics about user's image library. diff --git a/backend/app/api/sharing.py b/backend/app/api/sharing.py index c046366..4987d77 100644 --- a/backend/app/api/sharing.py +++ b/backend/app/api/sharing.py @@ -14,7 +14,7 @@ from app.boards.schemas import ( ShareLinkResponse, ) from app.boards.sharing import generate_secure_token -from app.core.deps import get_current_user, get_db +from app.core.deps import get_current_user, get_db_sync from app.database.models.board import Board from app.database.models.comment import Comment from app.database.models.share_link import ShareLink @@ -80,7 +80,7 @@ def create_share_link( board_id: UUID, share_link_data: ShareLinkCreate, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> ShareLinkResponse: """ Create a new share link for a board. @@ -117,7 +117,7 @@ def create_share_link( def list_share_links( board_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> list[ShareLinkResponse]: """ List all share links for a board. @@ -144,7 +144,7 @@ def revoke_share_link( board_id: UUID, link_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> None: """ Revoke (soft delete) a share link. @@ -176,7 +176,7 @@ def revoke_share_link( @router.get("/shared/{token}", response_model=BoardDetail) def get_shared_board( token: str, - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> BoardDetail: """ Access a shared board via token. @@ -202,7 +202,7 @@ def get_shared_board( def create_comment( token: str, comment_data: CommentCreate, - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> CommentResponse: """ Create a comment on a shared board. @@ -230,7 +230,7 @@ def create_comment( @router.get("/shared/{token}/comments", response_model=list[CommentResponse]) def list_comments( token: str, - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> list[CommentResponse]: """ List all comments on a shared board. @@ -255,7 +255,7 @@ def list_comments( def list_board_comments( board_id: UUID, current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), + db: Session = Depends(get_db_sync), ) -> list[CommentResponse]: """ List all comments on a board (owner view). diff --git a/backend/app/core/config.py b/backend/app/core/config.py index cfbc3bd..7bb938e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -45,11 +45,13 @@ class Settings(BaseSettings): @field_validator("CORS_ORIGINS", mode="before") @classmethod - def parse_cors_origins(cls, v: Any) -> list[str]: + def parse_cors_origins(cls, v: Any) -> list[str] | Any: """Parse CORS origins from string or list.""" if isinstance(v, str): return [origin.strip() for origin in v.split(",")] - return v + if isinstance(v, list): + return v + return ["http://localhost:5173", "http://localhost:3000"] # File Upload MAX_FILE_SIZE: int = 52428800 # 50MB diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index ccbfa31..d02814c 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -132,18 +132,34 @@ export class ApiClient { } const url = `${this.baseUrl}${endpoint}`; - const response = await fetch(url, { - method: 'POST', - headers, - body: formData, - }); - if (!response.ok) { - const error = await response.json(); - throw error; + try { + const response = await fetch(url, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ detail: response.statusText })); + const error: ApiError = { + error: errorData.detail || errorData.error || 'Upload failed', + details: errorData.details, + status_code: response.status, + }; + throw error; + } + + return response.json(); + } catch (error) { + if ((error as ApiError).status_code) { + throw error; + } + throw { + error: (error as Error).message || 'Upload failed', + status_code: 0, + } as ApiError; } - - return response.json(); } } diff --git a/frontend/src/lib/api/images.ts b/frontend/src/lib/api/images.ts index 3526f65..2467a1a 100644 --- a/frontend/src/lib/api/images.ts +++ b/frontend/src/lib/api/images.ts @@ -87,3 +87,19 @@ export async function removeImageFromBoard(boardId: string, imageId: string): Pr export async function getBoardImages(boardId: string): Promise { return await apiClient.get(`/images/boards/${boardId}/images`); } + +/** + * Update board image position/transformations + */ +export async function updateBoardImage( + boardId: string, + imageId: string, + updates: { + position?: { x: number; y: number }; + transformations?: Record; + z_order?: number; + group_id?: string; + } +): Promise { + return await apiClient.patch(`/images/boards/${boardId}/images/${imageId}`, updates); +} diff --git a/frontend/src/lib/canvas/Image.svelte b/frontend/src/lib/canvas/Image.svelte index ee88799..27e1feb 100644 --- a/frontend/src/lib/canvas/Image.svelte +++ b/frontend/src/lib/canvas/Image.svelte @@ -29,6 +29,7 @@ // Callbacks export let onDragEnd: ((id: string, x: number, y: number) => void) | undefined = undefined; export let onSelectionChange: ((id: string, isSelected: boolean) => void) | undefined = undefined; + export let onImageLoaded: ((id: string) => void) | undefined = undefined; let imageNode: Konva.Image | null = null; let imageGroup: Konva.Group | null = null; @@ -84,11 +85,12 @@ imageGroup.add(imageNode); - // Set Z-index - imageGroup.zIndex(zOrder); - + // Add to layer first layer.add(imageGroup); + // Then set Z-index (must have parent first) + imageGroup.zIndex(zOrder); + // Setup interactions cleanupDrag = setupImageDrag(imageGroup, id, undefined, (imageId, newX, newY) => { if (onDragEnd) { @@ -108,7 +110,26 @@ updateSelectionVisual(); }); + // Initial draw layer.batchDraw(); + + // Force visibility by triggering multiple redraws + requestAnimationFrame(() => { + if (layer) layer.batchDraw(); + }); + + setTimeout(() => { + if (layer) layer.batchDraw(); + }, 50); + + // Notify parent that image loaded + if (onImageLoaded) { + onImageLoaded(id); + } + }; + + imageObj.onerror = () => { + console.error('Failed to load image:', imageUrl); }; imageObj.src = imageUrl; diff --git a/frontend/src/lib/canvas/Stage.svelte b/frontend/src/lib/canvas/Stage.svelte index 4eacf37..14834e9 100644 --- a/frontend/src/lib/canvas/Stage.svelte +++ b/frontend/src/lib/canvas/Stage.svelte @@ -11,9 +11,15 @@ import { setupZoomControls } from './controls/zoom'; import { setupRotateControls } from './controls/rotate'; import { setupGestureControls } from './gestures'; + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher(); // Board ID for future use (e.g., loading board-specific state) - export const boardId: string | undefined = undefined; + // Intentionally unused - reserved for future viewport persistence + export let boardId: string | undefined = undefined; + $: _boardId = boardId; // Consume to prevent unused warning + export let width: number = 0; export let height: number = 0; @@ -40,6 +46,13 @@ layer = new Konva.Layer(); stage.add(layer); + // Apply initial viewport state BEFORE subscribing to changes + // This prevents the flicker from transform animations + const initialViewport = $viewport; + layer.position({ x: initialViewport.x, y: initialViewport.y }); + layer.scale({ x: initialViewport.zoom, y: initialViewport.zoom }); + layer.rotation(initialViewport.rotation); + // Set up controls if (stage) { cleanupPan = setupPanControls(stage); @@ -48,13 +61,13 @@ cleanupGestures = setupGestureControls(stage); } - // Subscribe to viewport changes + // Subscribe to viewport changes (after initial state applied) unsubscribeViewport = viewport.subscribe((state) => { updateStageTransform(state); }); - // Apply initial viewport state - updateStageTransform($viewport); + // Notify parent that stage is ready + dispatch('ready'); }); onDestroy(() => { @@ -78,21 +91,26 @@ * Update stage transform based on viewport state */ function updateStageTransform(state: ViewportState) { - if (!stage) return; + if (!stage || !layer) return; - // Apply transformations to the stage - stage.position({ x: state.x, y: state.y }); - stage.scale({ x: state.zoom, y: state.zoom }); - stage.rotation(state.rotation); + // Don't apply transforms to the stage itself - it causes rendering issues + // Instead, we'll transform the layer + layer.position({ x: state.x, y: state.y }); + layer.scale({ x: state.zoom, y: state.zoom }); + layer.rotation(state.rotation); + + // Force both layer and stage to redraw + layer.batchDraw(); stage.batchDraw(); } /** * Resize canvas when dimensions change */ - $: if (stage && (width !== stage.width() || height !== stage.height())) { + $: if (stage && layer && (width !== stage.width() || height !== stage.height())) { stage.width(width); stage.height(height); + layer.batchDraw(); stage.batchDraw(); } diff --git a/frontend/src/lib/stores/images.ts b/frontend/src/lib/stores/images.ts index 5e7a75b..f3a5b4a 100644 --- a/frontend/src/lib/stores/images.ts +++ b/frontend/src/lib/stores/images.ts @@ -83,7 +83,8 @@ export async function uploadSingleImage(file: File): Promise { return image; } catch (error: unknown) { // Update progress to error - const errorMessage = error instanceof Error ? error.message : 'Upload failed'; + const errorMessage = + (error as { error?: string })?.error || (error as Error)?.message || 'Upload failed'; uploadProgress.update((items) => items.map((item) => item.filename === file.name ? { ...item, status: 'error', error: errorMessage } : item diff --git a/frontend/src/lib/utils/adaptive-quality.ts b/frontend/src/lib/utils/adaptive-quality.ts index ba1e8f1..f07e18c 100644 --- a/frontend/src/lib/utils/adaptive-quality.ts +++ b/frontend/src/lib/utils/adaptive-quality.ts @@ -63,10 +63,8 @@ export function getThumbnailUrl( imageId: string, quality: 'low' | 'medium' | 'high' | 'original' = 'medium' ): string { - if (quality === 'original') { - return `/api/v1/images/${imageId}/original`; - } - return `/api/v1/images/${imageId}/thumbnail/${quality}`; + const apiBase = 'http://localhost:8000/api/v1'; + return `${apiBase}/images/${imageId}/serve?quality=${quality}`; } /** diff --git a/frontend/src/routes/boards/[id]/+page.svelte b/frontend/src/routes/boards/[id]/+page.svelte index 98f0ccc..1d13ef0 100644 --- a/frontend/src/routes/boards/[id]/+page.svelte +++ b/frontend/src/routes/boards/[id]/+page.svelte @@ -10,6 +10,11 @@ uploadZipFile, addImageToBoard, } from '$lib/stores/images'; + import { viewport } from '$lib/stores/viewport'; + import Stage from '$lib/canvas/Stage.svelte'; + import CanvasImage from '$lib/canvas/Image.svelte'; + import { tick } from 'svelte'; + import * as imagesApi from '$lib/api/images'; let loading = true; let error = ''; @@ -17,20 +22,153 @@ let uploadSuccess = ''; let uploading = false; let fileInput: HTMLInputElement; + let canvasContainer: HTMLDivElement; + let canvasWidth = 0; + let canvasHeight = 0; + let stageComponent: Stage; + let stageReady = false; + let loadedImagesCount = 0; $: boardId = $page.params.id; + $: canvasLayer = stageReady && stageComponent ? stageComponent.getLayer() : null; - onMount(async () => { - try { - await boards.loadBoard(boardId); - await loadBoardImages(boardId); - loading = false; - } catch (err: any) { - error = err.error || 'Failed to load board'; - loading = false; + // Track loaded images and force redraw + $: if (loadedImagesCount > 0 && stageComponent) { + const layer = stageComponent.getLayer(); + if (layer) { + setTimeout(() => layer.batchDraw(), 50); } + } + + onMount(() => { + const init = async () => { + try { + await boards.loadBoard(boardId); + await loadBoardImages(boardId); + + // Load viewport state from board if available + if ($currentBoard?.viewport_state) { + viewport.loadState($currentBoard.viewport_state); + } else { + // Reset to default if no saved state + viewport.reset(); + } + + // Set canvas dimensions BEFORE creating stage + updateCanvasDimensions(); + + // Wait for dimensions to be set + await tick(); + + // Double-check dimensions are valid + if (canvasWidth === 0 || canvasHeight === 0) { + console.warn('Canvas dimensions are 0, forcing update...'); + updateCanvasDimensions(); + await tick(); + } + + window.addEventListener('resize', updateCanvasDimensions); + + loading = false; + } catch (err: unknown) { + error = (err as { error?: string })?.error || 'Failed to load board'; + loading = false; + } + }; + + init(); + + return () => { + window.removeEventListener('resize', updateCanvasDimensions); + }; }); + function updateCanvasDimensions() { + if (canvasContainer) { + canvasWidth = canvasContainer.clientWidth; + canvasHeight = canvasContainer.clientHeight; + } + } + + async function handleStageReady() { + // Wait for next tick to ensure layer is fully ready + await tick(); + stageReady = true; + loadedImagesCount = 0; // Reset counter + } + + function handleImageLoaded(_imageId: string) { + loadedImagesCount++; + + // Force immediate redraw on each image load + if (stageComponent) { + const layer = stageComponent.getLayer(); + const stage = stageComponent.getStage(); + if (layer && stage) { + layer.batchDraw(); + stage.batchDraw(); + } + } + + // When all images loaded, auto-fit to view on first load + if (loadedImagesCount === $boardImages.length) { + const layer = stageComponent?.getLayer(); + const stage = stageComponent?.getStage(); + + if (layer && stage) { + // Multiple redraws to ensure visibility + setTimeout(() => { + layer.batchDraw(); + stage.batchDraw(); + }, 0); + setTimeout(() => { + layer.batchDraw(); + stage.batchDraw(); + }, 100); + setTimeout(() => { + layer.batchDraw(); + stage.batchDraw(); + + // Auto-fit images on first load (if they're off-screen) + const hasOffscreenImages = $boardImages.some( + (bi) => + bi.position.x < -canvasWidth || + bi.position.y < -canvasHeight || + bi.position.x > canvasWidth * 2 || + bi.position.y > canvasHeight * 2 + ); + + if (hasOffscreenImages) { + fitAllImages(); + } + }, 250); + } + } + } + + async function handleImageDragEnd(imageId: string, x: number, y: number) { + // Update position on backend + try { + const boardImage = $boardImages.find((bi) => bi.id === imageId); + if (!boardImage) return; + + await imagesApi.updateBoardImage(boardId, boardImage.image_id, { + position: { x, y }, + }); + + // Update local store + boardImages.update((images) => + images.map((img) => (img.id === imageId ? { ...img, position: { x, y } } : img)) + ); + } catch (error) { + console.error('Failed to update image position:', error); + } + } + + function handleImageSelectionChange(_imageId: string, _isSelected: boolean) { + // Selection handling + } + async function handleFileSelect(event: Event) { const target = event.target as HTMLInputElement; if (!target.files || target.files.length === 0) return; @@ -65,29 +203,29 @@ try { let totalUploaded = 0; + // Calculate starting position (centered on screen with some spacing) + let currentX = canvasWidth / 2 - 200; + let currentY = canvasHeight / 2 - 200; + for (const file of files) { // Upload to library first if (file.name.toLowerCase().endsWith('.zip')) { const images = await uploadZipFile(file); - // Add each image to board + // Add each image to board with spaced positions for (const img of images) { - await addImageToBoard( - boardId, - img.id, - { x: Math.random() * 500, y: Math.random() * 500 }, - 0 - ); + await addImageToBoard(boardId, img.id, { x: currentX, y: currentY }, totalUploaded); + // Offset next image + currentX += 50; + currentY += 50; } totalUploaded += images.length; } else if (file.type.startsWith('image/')) { const image = await uploadSingleImage(file); - // Add to board - await addImageToBoard( - boardId, - image.id, - { x: Math.random() * 500, y: Math.random() * 500 }, - 0 - ); + // Add to board at calculated position + await addImageToBoard(boardId, image.id, { x: currentX, y: currentY }, totalUploaded); + // Offset next image + currentX += 50; + currentY += 50; totalUploaded++; } } @@ -101,7 +239,8 @@ uploadSuccess = ''; }, 3000); } catch (err: any) { - uploadError = err.message || 'Upload failed'; + console.error('Upload error:', err); + uploadError = err.error || err.message || err.detail || 'Upload failed'; } finally { uploading = false; } @@ -118,6 +257,48 @@ function handleBackToBoards() { goto('/boards'); } + + function fitAllImages() { + if ($boardImages.length === 0) return; + + // Calculate bounding box of all images + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + + $boardImages.forEach((bi) => { + const imgMinX = bi.position.x; + const imgMinY = bi.position.y; + const imgMaxX = bi.position.x + (bi.image?.width || 0); + const imgMaxY = bi.position.y + (bi.image?.height || 0); + + minX = Math.min(minX, imgMinX); + minY = Math.min(minY, imgMinY); + maxX = Math.max(maxX, imgMaxX); + maxY = Math.max(maxY, imgMaxY); + }); + + const contentWidth = maxX - minX; + const contentHeight = maxY - minY; + + // Calculate zoom to fit + const padding = 100; + const scaleX = (canvasWidth - padding * 2) / contentWidth; + const scaleY = (canvasHeight - padding * 2) / contentHeight; + const newZoom = Math.min(scaleX, scaleY, 1.0); // Don't zoom in more than 100% + + // Calculate center position + const centerX = (canvasWidth - contentWidth * newZoom) / 2 - minX * newZoom; + const centerY = (canvasHeight - contentHeight * newZoom) / 2 - minY * newZoom; + + viewport.set({ + x: centerX, + y: centerY, + zoom: newZoom, + rotation: 0, + }); + } @@ -156,6 +337,14 @@
+
- {:else} -
-

{$boardImages.length} image(s) on board

-

Pan: Drag canvas | Zoom: Mouse wheel | Drag images to move

-
- -
- {#each $boardImages as boardImage} -
-

{boardImage.image?.filename || 'Image'}

- Position: ({boardImage.position.x}, {boardImage.position.y}) -
+ {:else if canvasWidth > 0 && canvasHeight > 0} + + + {#if stageReady && canvasLayer} + {#each $boardImages as boardImage (boardImage.id)} + {#if boardImage.image} + + {/if} {/each} -
+ {/if} {/if} @@ -409,17 +623,23 @@ .canvas-container { flex: 1; position: relative; - overflow: auto; - background: #ffffff; + overflow: hidden; + background: #f5f5f5; } .empty-state { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; - height: 100%; gap: 1.5rem; + background: white; + z-index: 10; } .empty-icon { @@ -440,48 +660,6 @@ text-align: center; } - .canvas-info { - padding: 1rem; - background: #f9fafb; - border-bottom: 1px solid #e5e7eb; - } - - .canvas-info p { - margin: 0.25rem 0; - font-size: 0.875rem; - color: #6b7280; - } - - .hint { - font-style: italic; - } - - .temp-image-list { - padding: 2rem; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 1rem; - } - - .image-placeholder { - border: 1px solid #e5e7eb; - border-radius: 8px; - padding: 1rem; - background: white; - } - - .image-placeholder p { - margin: 0 0 0.5rem 0; - font-weight: 500; - color: #111827; - font-size: 0.875rem; - } - - .image-placeholder small { - color: #6b7280; - font-size: 0.75rem; - } - /* Status Bar */ .status-bar { display: flex;