fix until the canvas sort of works
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 12s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / VM Test - security (push) Successful in 8s
CI/CD Pipeline / Backend Linting (push) Successful in 4s
CI/CD Pipeline / Frontend Linting (push) Successful in 30s
CI/CD Pipeline / Nix Flake Check (push) Successful in 43s
CI/CD Pipeline / VM Test - backend-integration (pull_request) Successful in 4s
CI/CD Pipeline / VM Test - full-stack (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - performance (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - security (pull_request) Successful in 2s
CI/CD Pipeline / Backend Linting (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Linting (pull_request) Successful in 17s
CI/CD Pipeline / Nix Flake Check (pull_request) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 1s
CI/CD Pipeline / CI Summary (pull_request) Successful in 1s
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 12s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / VM Test - security (push) Successful in 8s
CI/CD Pipeline / Backend Linting (push) Successful in 4s
CI/CD Pipeline / Frontend Linting (push) Successful in 30s
CI/CD Pipeline / Nix Flake Check (push) Successful in 43s
CI/CD Pipeline / VM Test - backend-integration (pull_request) Successful in 4s
CI/CD Pipeline / VM Test - full-stack (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - performance (pull_request) Successful in 2s
CI/CD Pipeline / VM Test - security (pull_request) Successful in 2s
CI/CD Pipeline / Backend Linting (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Linting (pull_request) Successful in 17s
CI/CD Pipeline / Nix Flake Check (pull_request) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 1s
CI/CD Pipeline / CI Summary (pull_request) Successful in 1s
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(",")]
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
return ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
# File Upload
|
||||
MAX_FILE_SIZE: int = 52428800 # 50MB
|
||||
|
||||
@@ -132,6 +132,8 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
@@ -139,11 +141,25 @@ export class ApiClient {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
const error: ApiError = {
|
||||
error: errorData.detail || errorData.error || 'Upload failed',
|
||||
details: errorData.details,
|
||||
status_code: response.status,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,3 +87,19 @@ export async function removeImageFromBoard(boardId: string, imageId: string): Pr
|
||||
export async function getBoardImages(boardId: string): Promise<BoardImage[]> {
|
||||
return await apiClient.get<BoardImage[]>(`/images/boards/${boardId}/images`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update board image position/transformations
|
||||
*/
|
||||
export async function updateBoardImage(
|
||||
boardId: string,
|
||||
imageId: string,
|
||||
updates: {
|
||||
position?: { x: number; y: number };
|
||||
transformations?: Record<string, unknown>;
|
||||
z_order?: number;
|
||||
group_id?: string;
|
||||
}
|
||||
): Promise<BoardImage> {
|
||||
return await apiClient.patch<BoardImage>(`/images/boards/${boardId}/images/${imageId}`, updates);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,8 @@ export async function uploadSingleImage(file: File): Promise<Image> {
|
||||
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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () => {
|
||||
// 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: any) {
|
||||
error = err.error || 'Failed to load board';
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -156,6 +337,14 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
class="btn-upload"
|
||||
on:click={openFilePicker}
|
||||
@@ -194,8 +383,13 @@
|
||||
{/if}
|
||||
|
||||
<!-- Canvas Area -->
|
||||
<div class="canvas-container">
|
||||
{#if $boardImages.length === 0}
|
||||
<div class="canvas-container" bind:this={canvasContainer}>
|
||||
{#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-icon">🖼️</div>
|
||||
<h2>No images yet</h2>
|
||||
@@ -204,20 +398,40 @@
|
||||
Upload Your First Image
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="canvas-info">
|
||||
<p>{$boardImages.length} image(s) on board</p>
|
||||
<p class="hint">Pan: Drag canvas | Zoom: Mouse wheel | Drag images to move</p>
|
||||
</div>
|
||||
<!-- TODO: Render Konva canvas with images -->
|
||||
<div class="temp-image-list">
|
||||
{#each $boardImages as boardImage}
|
||||
<div class="image-placeholder">
|
||||
<p>{boardImage.image?.filename || 'Image'}</p>
|
||||
<small>Position: ({boardImage.position.x}, {boardImage.position.y})</small>
|
||||
</div>
|
||||
{:else if canvasWidth > 0 && canvasHeight > 0}
|
||||
<Stage
|
||||
bind:this={stageComponent}
|
||||
width={canvasWidth}
|
||||
height={canvasHeight}
|
||||
{boardId}
|
||||
on:ready={handleStageReady}
|
||||
/>
|
||||
|
||||
{#if stageReady && canvasLayer}
|
||||
{#each $boardImages as boardImage (boardImage.id)}
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user