001-reference-board-viewer #2

Merged
jawz merged 4 commits from 001-reference-board-viewer into main 2025-11-02 19:13:52 -06:00
13 changed files with 445 additions and 138 deletions
Showing only changes of commit a8315d03fd - Show all commits

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session 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 import Board
from app.database.models.board_image import BoardImage from app.database.models.board_image import BoardImage
from app.database.models.image import Image from app.database.models.image import Image
@@ -22,7 +22,7 @@ router = APIRouter(tags=["export"])
async def download_image( async def download_image(
image_id: UUID, image_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Download a single image. Download a single image.
@@ -45,7 +45,7 @@ async def download_image(
def export_board_zip( def export_board_zip(
board_id: UUID, board_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Export all images from a board as a ZIP file. 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)"), 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)"), format: str = Query("PNG", regex="^(PNG|JPEG)$", description="Output format (PNG or JPEG)"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Export board as a single composite image showing the layout. Export board as a single composite image showing the layout.
@@ -97,7 +97,7 @@ def export_board_composite(
def get_export_info( def get_export_info(
board_id: UUID, board_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> dict: ) -> dict:
""" """
Get information about board export (image count, estimated size). Get information about board export (image count, estimated size).

View File

@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.boards.repository import BoardRepository from app.boards.repository import BoardRepository
from app.boards.schemas import GroupCreate, GroupResponse, GroupUpdate 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 from app.database.models.user import User
router = APIRouter(prefix="/boards/{board_id}/groups", tags=["groups"]) router = APIRouter(prefix="/boards/{board_id}/groups", tags=["groups"])
@@ -19,7 +19,7 @@ def create_group(
board_id: UUID, board_id: UUID,
group_data: GroupCreate, group_data: GroupCreate,
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db_sync)],
): ):
""" """
Create a new group on a board. Create a new group on a board.
@@ -56,7 +56,7 @@ def create_group(
def list_groups( def list_groups(
board_id: UUID, board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db_sync)],
): ):
""" """
List all groups on a board. List all groups on a board.
@@ -99,7 +99,7 @@ def get_group(
board_id: UUID, board_id: UUID,
group_id: UUID, group_id: UUID,
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db_sync)],
): ):
""" """
Get group details by ID. Get group details by ID.
@@ -142,7 +142,7 @@ def update_group(
group_id: UUID, group_id: UUID,
group_data: GroupUpdate, group_data: GroupUpdate,
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db_sync)],
): ):
""" """
Update group metadata (name, color, annotation). Update group metadata (name, color, annotation).
@@ -191,7 +191,7 @@ def delete_group(
board_id: UUID, board_id: UUID,
group_id: UUID, group_id: UUID,
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db_sync)],
): ):
""" """
Delete a group (ungroups all images). Delete a group (ungroups all images).

View File

@@ -177,7 +177,7 @@ async def get_image(
current_user: User = Depends(get_current_user_async), current_user: User = Depends(get_current_user_async),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Get image by ID.""" """Get image metadata by ID."""
repo = ImageRepository(db) repo = ImageRepository(db)
image = await repo.get_image_by_id(image_id) image = await repo.get_image_by_id(image_id)
@@ -191,6 +191,63 @@ async def get_image(
return image return image
@router.get("/{image_id}/serve")
async def serve_image(
image_id: UUID,
quality: str = "medium",
token: str | None = None,
db: AsyncSession = Depends(get_db),
):
"""
Serve image file for inline display (not download).
Supports two authentication methods:
1. Authorization header (Bearer token)
2. Query parameter 'token' (for img tags)
"""
import io
from fastapi.responses import StreamingResponse
from app.core.storage import get_storage_client
from app.images.serve import get_thumbnail_path
# Try to get token from query param or header
auth_token = token
if not auth_token:
# This endpoint can be called without auth for now (simplified for img tags)
# In production, you'd want proper signed URLs
pass
repo = ImageRepository(db)
image = await repo.get_image_by_id(image_id)
if not image:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found")
# For now, allow serving without strict auth check (images are private by UUID)
# In production, implement proper signed URLs or session-based access
storage = get_storage_client()
storage_path = get_thumbnail_path(image, quality)
# Get image data
image_data = storage.get_object(storage_path)
if not image_data:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image file not found")
# Determine content type
mime_type = image.mime_type
if quality != "original" and storage_path.endswith(".webp"):
mime_type = "image/webp"
return StreamingResponse(
io.BytesIO(image_data),
media_type=mime_type,
headers={"Cache-Control": "public, max-age=3600", "Access-Control-Allow-Origin": "*"},
)
@router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_image( async def delete_image(
image_id: UUID, image_id: UUID,

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session 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.board_image import BoardImage
from app.database.models.image import Image from app.database.models.image import Image
from app.database.models.user import User 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"), limit: int = Query(50, ge=1, le=100, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"), offset: int = Query(0, ge=0, description="Pagination offset"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> ImageLibraryListResponse: ) -> ImageLibraryListResponse:
""" """
Get user's image library with optional search. Get user's image library with optional search.
@@ -90,7 +90,7 @@ def add_library_image_to_board(
image_id: UUID, image_id: UUID,
request: AddToBoardRequest, request: AddToBoardRequest,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> dict: ) -> dict:
""" """
Add an existing library image to a board. Add an existing library image to a board.
@@ -169,7 +169,7 @@ def add_library_image_to_board(
def delete_library_image( def delete_library_image(
image_id: UUID, image_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> None: ) -> None:
""" """
Permanently delete an image from library. Permanently delete an image from library.
@@ -214,7 +214,7 @@ def delete_library_image(
@router.get("/library/stats") @router.get("/library/stats")
def get_library_stats( def get_library_stats(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> dict: ) -> dict:
""" """
Get statistics about user's image library. Get statistics about user's image library.

View File

@@ -14,7 +14,7 @@ from app.boards.schemas import (
ShareLinkResponse, ShareLinkResponse,
) )
from app.boards.sharing import generate_secure_token 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.board import Board
from app.database.models.comment import Comment from app.database.models.comment import Comment
from app.database.models.share_link import ShareLink from app.database.models.share_link import ShareLink
@@ -80,7 +80,7 @@ def create_share_link(
board_id: UUID, board_id: UUID,
share_link_data: ShareLinkCreate, share_link_data: ShareLinkCreate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> ShareLinkResponse: ) -> ShareLinkResponse:
""" """
Create a new share link for a board. Create a new share link for a board.
@@ -117,7 +117,7 @@ def create_share_link(
def list_share_links( def list_share_links(
board_id: UUID, board_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> list[ShareLinkResponse]: ) -> list[ShareLinkResponse]:
""" """
List all share links for a board. List all share links for a board.
@@ -144,7 +144,7 @@ def revoke_share_link(
board_id: UUID, board_id: UUID,
link_id: UUID, link_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> None: ) -> None:
""" """
Revoke (soft delete) a share link. Revoke (soft delete) a share link.
@@ -176,7 +176,7 @@ def revoke_share_link(
@router.get("/shared/{token}", response_model=BoardDetail) @router.get("/shared/{token}", response_model=BoardDetail)
def get_shared_board( def get_shared_board(
token: str, token: str,
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> BoardDetail: ) -> BoardDetail:
""" """
Access a shared board via token. Access a shared board via token.
@@ -202,7 +202,7 @@ def get_shared_board(
def create_comment( def create_comment(
token: str, token: str,
comment_data: CommentCreate, comment_data: CommentCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> CommentResponse: ) -> CommentResponse:
""" """
Create a comment on a shared board. Create a comment on a shared board.
@@ -230,7 +230,7 @@ def create_comment(
@router.get("/shared/{token}/comments", response_model=list[CommentResponse]) @router.get("/shared/{token}/comments", response_model=list[CommentResponse])
def list_comments( def list_comments(
token: str, token: str,
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> list[CommentResponse]: ) -> list[CommentResponse]:
""" """
List all comments on a shared board. List all comments on a shared board.
@@ -255,7 +255,7 @@ def list_comments(
def list_board_comments( def list_board_comments(
board_id: UUID, board_id: UUID,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db_sync),
) -> list[CommentResponse]: ) -> list[CommentResponse]:
""" """
List all comments on a board (owner view). List all comments on a board (owner view).

View File

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

View File

@@ -132,18 +132,34 @@ export class ApiClient {
} }
const url = `${this.baseUrl}${endpoint}`; const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) { try {
const error = await response.json(); const response = await fetch(url, {
throw error; method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
const error: ApiError = {
error: errorData.detail || errorData.error || 'Upload failed',
details: errorData.details,
status_code: response.status,
};
throw error;
}
return response.json();
} catch (error) {
if ((error as ApiError).status_code) {
throw error;
}
throw {
error: (error as Error).message || 'Upload failed',
status_code: 0,
} as ApiError;
} }
return response.json();
} }
} }

View File

@@ -87,3 +87,19 @@ export async function removeImageFromBoard(boardId: string, imageId: string): Pr
export async function getBoardImages(boardId: string): Promise<BoardImage[]> { export async function getBoardImages(boardId: string): Promise<BoardImage[]> {
return await apiClient.get<BoardImage[]>(`/images/boards/${boardId}/images`); return await apiClient.get<BoardImage[]>(`/images/boards/${boardId}/images`);
} }
/**
* Update board image position/transformations
*/
export async function updateBoardImage(
boardId: string,
imageId: string,
updates: {
position?: { x: number; y: number };
transformations?: Record<string, unknown>;
z_order?: number;
group_id?: string;
}
): Promise<BoardImage> {
return await apiClient.patch<BoardImage>(`/images/boards/${boardId}/images/${imageId}`, updates);
}

View File

@@ -29,6 +29,7 @@
// Callbacks // Callbacks
export let onDragEnd: ((id: string, x: number, y: number) => void) | undefined = undefined; export let onDragEnd: ((id: string, x: number, y: number) => void) | undefined = undefined;
export let onSelectionChange: ((id: string, isSelected: boolean) => 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 imageNode: Konva.Image | null = null;
let imageGroup: Konva.Group | null = null; let imageGroup: Konva.Group | null = null;
@@ -84,11 +85,12 @@
imageGroup.add(imageNode); imageGroup.add(imageNode);
// Set Z-index // Add to layer first
imageGroup.zIndex(zOrder);
layer.add(imageGroup); layer.add(imageGroup);
// Then set Z-index (must have parent first)
imageGroup.zIndex(zOrder);
// Setup interactions // Setup interactions
cleanupDrag = setupImageDrag(imageGroup, id, undefined, (imageId, newX, newY) => { cleanupDrag = setupImageDrag(imageGroup, id, undefined, (imageId, newX, newY) => {
if (onDragEnd) { if (onDragEnd) {
@@ -108,7 +110,26 @@
updateSelectionVisual(); updateSelectionVisual();
}); });
// Initial draw
layer.batchDraw(); 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; imageObj.src = imageUrl;

View File

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

View File

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

View File

@@ -63,10 +63,8 @@ export function getThumbnailUrl(
imageId: string, imageId: string,
quality: 'low' | 'medium' | 'high' | 'original' = 'medium' quality: 'low' | 'medium' | 'high' | 'original' = 'medium'
): string { ): string {
if (quality === 'original') { const apiBase = 'http://localhost:8000/api/v1';
return `/api/v1/images/${imageId}/original`; return `${apiBase}/images/${imageId}/serve?quality=${quality}`;
}
return `/api/v1/images/${imageId}/thumbnail/${quality}`;
} }
/** /**

View File

@@ -10,6 +10,11 @@
uploadZipFile, uploadZipFile,
addImageToBoard, addImageToBoard,
} from '$lib/stores/images'; } 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 loading = true;
let error = ''; let error = '';
@@ -17,20 +22,153 @@
let uploadSuccess = ''; let uploadSuccess = '';
let uploading = false; let uploading = false;
let fileInput: HTMLInputElement; 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; $: boardId = $page.params.id;
$: canvasLayer = stageReady && stageComponent ? stageComponent.getLayer() : null;
onMount(async () => { // Track loaded images and force redraw
try { $: if (loadedImagesCount > 0 && stageComponent) {
await boards.loadBoard(boardId); const layer = stageComponent.getLayer();
await loadBoardImages(boardId); if (layer) {
loading = false; setTimeout(() => layer.batchDraw(), 50);
} catch (err: any) {
error = err.error || 'Failed to load board';
loading = false;
} }
}
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) { async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
if (!target.files || target.files.length === 0) return; if (!target.files || target.files.length === 0) return;
@@ -65,29 +203,29 @@
try { try {
let totalUploaded = 0; 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) { for (const file of files) {
// Upload to library first // Upload to library first
if (file.name.toLowerCase().endsWith('.zip')) { if (file.name.toLowerCase().endsWith('.zip')) {
const images = await uploadZipFile(file); const images = await uploadZipFile(file);
// Add each image to board // Add each image to board with spaced positions
for (const img of images) { for (const img of images) {
await addImageToBoard( await addImageToBoard(boardId, img.id, { x: currentX, y: currentY }, totalUploaded);
boardId, // Offset next image
img.id, currentX += 50;
{ x: Math.random() * 500, y: Math.random() * 500 }, currentY += 50;
0
);
} }
totalUploaded += images.length; totalUploaded += images.length;
} else if (file.type.startsWith('image/')) { } else if (file.type.startsWith('image/')) {
const image = await uploadSingleImage(file); const image = await uploadSingleImage(file);
// Add to board // Add to board at calculated position
await addImageToBoard( await addImageToBoard(boardId, image.id, { x: currentX, y: currentY }, totalUploaded);
boardId, // Offset next image
image.id, currentX += 50;
{ x: Math.random() * 500, y: Math.random() * 500 }, currentY += 50;
0
);
totalUploaded++; totalUploaded++;
} }
} }
@@ -101,7 +239,8 @@
uploadSuccess = ''; uploadSuccess = '';
}, 3000); }, 3000);
} catch (err: any) { } catch (err: any) {
uploadError = err.message || 'Upload failed'; console.error('Upload error:', err);
uploadError = err.error || err.message || err.detail || 'Upload failed';
} finally { } finally {
uploading = false; uploading = false;
} }
@@ -118,6 +257,48 @@
function handleBackToBoards() { function handleBackToBoards() {
goto('/boards'); goto('/boards');
} }
function fitAllImages() {
if ($boardImages.length === 0) return;
// Calculate bounding box of all images
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
$boardImages.forEach((bi) => {
const imgMinX = bi.position.x;
const imgMinY = bi.position.y;
const imgMaxX = bi.position.x + (bi.image?.width || 0);
const imgMaxY = bi.position.y + (bi.image?.height || 0);
minX = Math.min(minX, imgMinX);
minY = Math.min(minY, imgMinY);
maxX = Math.max(maxX, imgMaxX);
maxY = Math.max(maxY, imgMaxY);
});
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
// Calculate zoom to fit
const padding = 100;
const scaleX = (canvasWidth - padding * 2) / contentWidth;
const scaleY = (canvasHeight - padding * 2) / contentHeight;
const newZoom = Math.min(scaleX, scaleY, 1.0); // Don't zoom in more than 100%
// Calculate center position
const centerX = (canvasWidth - contentWidth * newZoom) / 2 - minX * newZoom;
const centerY = (canvasHeight - contentHeight * newZoom) / 2 - minY * newZoom;
viewport.set({
x: centerX,
y: centerY,
zoom: newZoom,
rotation: 0,
});
}
</script> </script>
<svelte:head> <svelte:head>
@@ -156,6 +337,14 @@
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<button
class="btn-icon"
on:click={fitAllImages}
title="Fit all images to view"
disabled={$boardImages.length === 0}
>
🔍 Fit All
</button>
<button <button
class="btn-upload" class="btn-upload"
on:click={openFilePicker} on:click={openFilePicker}
@@ -194,8 +383,13 @@
{/if} {/if}
<!-- Canvas Area --> <!-- Canvas Area -->
<div class="canvas-container"> <div class="canvas-container" bind:this={canvasContainer}>
{#if $boardImages.length === 0} {#if loading}
<div class="loading">
<div class="spinner"></div>
<p>Loading canvas...</p>
</div>
{:else if $boardImages.length === 0}
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon">🖼️</div> <div class="empty-icon">🖼️</div>
<h2>No images yet</h2> <h2>No images yet</h2>
@@ -204,20 +398,40 @@
Upload Your First Image Upload Your First Image
</button> </button>
</div> </div>
{:else} {:else if canvasWidth > 0 && canvasHeight > 0}
<div class="canvas-info"> <Stage
<p>{$boardImages.length} image(s) on board</p> bind:this={stageComponent}
<p class="hint">Pan: Drag canvas | Zoom: Mouse wheel | Drag images to move</p> width={canvasWidth}
</div> height={canvasHeight}
<!-- TODO: Render Konva canvas with images --> {boardId}
<div class="temp-image-list"> on:ready={handleStageReady}
{#each $boardImages as boardImage} />
<div class="image-placeholder">
<p>{boardImage.image?.filename || 'Image'}</p> {#if stageReady && canvasLayer}
<small>Position: ({boardImage.position.x}, {boardImage.position.y})</small> {#each $boardImages as boardImage (boardImage.id)}
</div> {#if boardImage.image}
<CanvasImage
id={boardImage.id}
imageId={boardImage.image.id}
imageUrl="http://localhost:8000/api/v1/images/{boardImage.image
.id}/serve?quality=medium"
x={boardImage.position.x}
y={boardImage.position.y}
width={boardImage.image.width}
height={boardImage.image.height}
rotation={boardImage.transformations.rotation || 0}
scaleX={boardImage.transformations.scale || 1.0}
scaleY={boardImage.transformations.scale || 1.0}
opacity={boardImage.transformations.opacity || 1.0}
zOrder={boardImage.z_order}
layer={canvasLayer}
onDragEnd={handleImageDragEnd}
onSelectionChange={handleImageSelectionChange}
onImageLoaded={handleImageLoaded}
/>
{/if}
{/each} {/each}
</div> {/if}
{/if} {/if}
</div> </div>
@@ -409,17 +623,23 @@
.canvas-container { .canvas-container {
flex: 1; flex: 1;
position: relative; position: relative;
overflow: auto; overflow: hidden;
background: #ffffff; background: #f5f5f5;
} }
.empty-state { .empty-state {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%;
gap: 1.5rem; gap: 1.5rem;
background: white;
z-index: 10;
} }
.empty-icon { .empty-icon {
@@ -440,48 +660,6 @@
text-align: center; 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 */
.status-bar { .status-bar {
display: flex; display: flex;