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
8 changed files with 637 additions and 161 deletions
Showing only changes of commit 376ac1dec9 - Show all commits

View File

@@ -3,10 +3,9 @@
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session
from app.auth.jwt import get_current_user from app.core.deps import get_current_user, get_db
from app.core.deps import get_db
from app.database.models.board import Board from app.database.models.board import Board
from app.database.models.user import User from app.database.models.user import User
from app.images.processing import generate_thumbnails from app.images.processing import generate_thumbnails

View File

@@ -36,7 +36,7 @@ class Image(Base):
mime_type: Mapped[str] = mapped_column(String(100), nullable=False) mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
width: Mapped[int] = mapped_column(Integer, nullable=False) width: Mapped[int] = mapped_column(Integer, nullable=False)
height: Mapped[int] = mapped_column(Integer, nullable=False) height: Mapped[int] = mapped_column(Integer, nullable=False)
metadata: Mapped[dict] = mapped_column(JSONB, nullable=False) image_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)

View File

@@ -3,8 +3,7 @@
from collections.abc import Sequence from collections.abc import Sequence
from uuid import UUID from uuid import UUID
from sqlalchemy import select from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
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
@@ -13,11 +12,11 @@ from app.database.models.image import Image
class ImageRepository: class ImageRepository:
"""Repository for image database operations.""" """Repository for image database operations."""
def __init__(self, db: AsyncSession): def __init__(self, db: Session):
"""Initialize repository with database session.""" """Initialize repository with database session."""
self.db = db self.db = db
async def create_image( def create_image(
self, self,
user_id: UUID, user_id: UUID,
filename: str, filename: str,
@@ -28,22 +27,7 @@ class ImageRepository:
height: int, height: int,
metadata: dict, metadata: dict,
) -> Image: ) -> Image:
""" """Create new image record."""
Create new image record.
Args:
user_id: Owner user ID
filename: Original filename
storage_path: Path in MinIO
file_size: File size in bytes
mime_type: MIME type
width: Image width in pixels
height: Image height in pixels
metadata: Additional metadata (format, checksum, thumbnails, etc)
Returns:
Created Image instance
"""
image = Image( image = Image(
user_id=user_id, user_id=user_id,
filename=filename, filename=filename,
@@ -52,98 +36,59 @@ class ImageRepository:
mime_type=mime_type, mime_type=mime_type,
width=width, width=width,
height=height, height=height,
metadata=metadata, image_metadata=metadata,
) )
self.db.add(image) self.db.add(image)
await self.db.commit() self.db.commit()
await self.db.refresh(image) self.db.refresh(image)
return image return image
async def get_image_by_id(self, image_id: UUID) -> Image | None: def get_image_by_id(self, image_id: UUID) -> Image | None:
""" """Get image by ID."""
Get image by ID. return self.db.query(Image).filter(Image.id == image_id).first()
Args: def get_user_images(
image_id: Image ID self, user_id: UUID, limit: int = 50, offset: int = 0
) -> tuple[Sequence[Image], int]:
Returns: """Get all images for a user with pagination."""
Image instance or None total = self.db.query(Image).filter(Image.user_id == user_id).count()
""" images = (
result = await self.db.execute(select(Image).where(Image.id == image_id)) self.db.query(Image)
return result.scalar_one_or_none() .filter(Image.user_id == user_id)
.order_by(Image.created_at.desc())
async def get_user_images(self, user_id: UUID, limit: int = 50, offset: int = 0) -> tuple[Sequence[Image], int]: .limit(limit)
""" .offset(offset)
Get all images for a user with pagination. .all()
Args:
user_id: User ID
limit: Maximum number of images to return
offset: Number of images to skip
Returns:
Tuple of (images, total_count)
"""
# Get total count
count_result = await self.db.execute(select(Image).where(Image.user_id == user_id))
total = len(count_result.scalars().all())
# Get paginated results
result = await self.db.execute(
select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset)
) )
images = result.scalars().all()
return images, total return images, total
async def delete_image(self, image_id: UUID) -> bool: def delete_image(self, image_id: UUID) -> bool:
""" """Delete image record."""
Delete image record. image = self.get_image_by_id(image_id)
Args:
image_id: Image ID
Returns:
True if deleted, False if not found
"""
image = await self.get_image_by_id(image_id)
if not image: if not image:
return False return False
await self.db.delete(image) self.db.delete(image)
await self.db.commit() self.db.commit()
return True return True
async def increment_reference_count(self, image_id: UUID) -> None: def increment_reference_count(self, image_id: UUID) -> None:
""" """Increment reference count for image."""
Increment reference count for image. image = self.get_image_by_id(image_id)
Args:
image_id: Image ID
"""
image = await self.get_image_by_id(image_id)
if image: if image:
image.reference_count += 1 image.reference_count += 1
await self.db.commit() self.db.commit()
async def decrement_reference_count(self, image_id: UUID) -> int: def decrement_reference_count(self, image_id: UUID) -> int:
""" """Decrement reference count for image."""
Decrement reference count for image. image = self.get_image_by_id(image_id)
Args:
image_id: Image ID
Returns:
New reference count
"""
image = await self.get_image_by_id(image_id)
if image and image.reference_count > 0: if image and image.reference_count > 0:
image.reference_count -= 1 image.reference_count -= 1
await self.db.commit() self.db.commit()
return image.reference_count return image.reference_count
return 0 return 0
async def add_image_to_board( def add_image_to_board(
self, self,
board_id: UUID, board_id: UUID,
image_id: UUID, image_id: UUID,
@@ -151,19 +96,7 @@ class ImageRepository:
transformations: dict, transformations: dict,
z_order: int = 0, z_order: int = 0,
) -> BoardImage: ) -> BoardImage:
""" """Add image to board."""
Add image to board.
Args:
board_id: Board ID
image_id: Image ID
position: Canvas position {x, y}
transformations: Image transformations
z_order: Layer order
Returns:
Created BoardImage instance
"""
board_image = BoardImage( board_image = BoardImage(
board_id=board_id, board_id=board_id,
image_id=image_id, image_id=image_id,
@@ -174,50 +107,36 @@ class ImageRepository:
self.db.add(board_image) self.db.add(board_image)
# Increment reference count # Increment reference count
await self.increment_reference_count(image_id) self.increment_reference_count(image_id)
await self.db.commit() self.db.commit()
await self.db.refresh(board_image) self.db.refresh(board_image)
return board_image return board_image
async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]: def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]:
""" """Get all images for a board, ordered by z-order."""
Get all images for a board, ordered by z-order. return (
self.db.query(BoardImage)
Args: .filter(BoardImage.board_id == board_id)
board_id: Board ID .order_by(BoardImage.z_order.asc())
.all()
Returns:
List of BoardImage instances
"""
result = await self.db.execute(
select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc())
) )
return result.scalars().all()
async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool: def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool:
""" """Remove image from board."""
Remove image from board. board_image = (
self.db.query(BoardImage)
Args: .filter(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
board_id: Board ID .first()
image_id: Image ID
Returns:
True if removed, False if not found
"""
result = await self.db.execute(
select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
) )
board_image = result.scalar_one_or_none()
if not board_image: if not board_image:
return False return False
await self.db.delete(board_image) self.db.delete(board_image)
# Decrement reference count # Decrement reference count
await self.decrement_reference_count(image_id) self.decrement_reference_count(image_id)
await self.db.commit() self.db.commit()
return True return True

View File

@@ -9,32 +9,14 @@ import type { Image, BoardImage, ImageListResponse } from '$lib/types/images';
* Upload a single image * Upload a single image
*/ */
export async function uploadImage(file: File): Promise<Image> { export async function uploadImage(file: File): Promise<Image> {
const formData = new FormData(); return await apiClient.uploadFile<Image>('/images/upload', file);
formData.append('file', file);
const response = await apiClient.post<Image>('/images/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
} }
/** /**
* Upload multiple images from a ZIP file * Upload multiple images from a ZIP file
*/ */
export async function uploadZip(file: File): Promise<Image[]> { export async function uploadZip(file: File): Promise<Image[]> {
const formData = new FormData(); return await apiClient.uploadFile<Image[]>('/images/upload-zip', file);
formData.append('file', file);
const response = await apiClient.post<Image[]>('/images/upload-zip', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response;
} }
/** /**

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
onMount(() => {
// Client-side redirect based on auth token
if (browser) {
const token = localStorage.getItem('auth_token');
if (token) {
goto('/boards');
} else {
goto('/login');
}
}
});
</script>
<svelte:head>
<title>Reference Board Viewer</title>
</svelte:head>
<div class="loading-container">
<div class="spinner"></div>
<p>Loading...</p>
</div>
<style>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
p {
color: #6b7280;
font-size: 1rem;
}
</style>

View File

@@ -0,0 +1,2 @@
// Disable server-side rendering for the root page
export const ssr = false;

View File

@@ -0,0 +1,506 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { boards, currentBoard } from '$lib/stores/boards';
import {
boardImages,
loadBoardImages,
uploadSingleImage,
uploadZipFile,
addImageToBoard,
} from '$lib/stores/images';
let loading = true;
let error = '';
let uploadError = '';
let uploadSuccess = '';
let uploading = false;
let fileInput: HTMLInputElement;
$: boardId = $page.params.id;
onMount(async () => {
try {
await boards.loadBoard(boardId);
await loadBoardImages(boardId);
loading = false;
} catch (err: any) {
error = err.error || 'Failed to load board';
loading = false;
}
});
async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
if (!target.files || target.files.length === 0) return;
await processFiles(Array.from(target.files));
// Reset input
if (target) target.value = '';
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
if (!event.dataTransfer?.files) return;
await processFiles(Array.from(event.dataTransfer.files));
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
}
async function processFiles(files: File[]) {
if (files.length === 0) return;
uploading = true;
uploadError = '';
uploadSuccess = '';
try {
let totalUploaded = 0;
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
for (const img of images) {
await addImageToBoard(
boardId,
img.id,
{ x: Math.random() * 500, y: Math.random() * 500 },
0
);
}
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
);
totalUploaded++;
}
}
// Reload board images to show new uploads
await loadBoardImages(boardId);
uploadSuccess = `Successfully uploaded ${totalUploaded} image(s)`;
setTimeout(() => {
uploadSuccess = '';
}, 3000);
} catch (err: any) {
uploadError = err.message || 'Upload failed';
} finally {
uploading = false;
}
}
function openFilePicker() {
fileInput.click();
}
function handleEditBoard() {
goto(`/boards/${boardId}/edit`);
}
function handleBackToBoards() {
goto('/boards');
}
</script>
<svelte:head>
<title>{$currentBoard?.title || 'Board'} - Reference Board Viewer</title>
</svelte:head>
<div
class="board-canvas-page"
on:drop={handleDrop}
on:dragover={handleDragOver}
role="presentation"
>
{#if loading}
<div class="loading">
<div class="spinner"></div>
<p>Loading board...</p>
</div>
{:else if error}
<div class="error-message">
<p>{error}</p>
<button on:click={handleBackToBoards}>Back to Boards</button>
</div>
{:else if $currentBoard}
<!-- Top Toolbar -->
<div class="toolbar-top">
<div class="toolbar-left">
<button class="btn-icon" on:click={handleBackToBoards} title="Back to boards">
← Boards
</button>
<div class="title-group">
<h1 class="board-title">{$currentBoard.title}</h1>
{#if $currentBoard.description}
<span class="board-desc">{$currentBoard.description}</span>
{/if}
</div>
</div>
<div class="toolbar-right">
<button
class="btn-upload"
on:click={openFilePicker}
disabled={uploading}
title="Upload images"
>
{#if uploading}
<span class="spinner-small"></span>
Uploading...
{:else}
📤 Upload Images
{/if}
</button>
<button class="btn-secondary" on:click={handleEditBoard} title="Edit board settings">
⚙️ Settings
</button>
</div>
</div>
<!-- Hidden file input -->
<input
bind:this={fileInput}
type="file"
accept="image/*,.zip"
multiple
on:change={handleFileSelect}
style="display: none;"
/>
{#if uploadSuccess}
<div class="success-banner">{uploadSuccess}</div>
{/if}
{#if uploadError}
<div class="error-banner">{uploadError}</div>
{/if}
<!-- Canvas Area -->
<div class="canvas-container">
{#if $boardImages.length === 0}
<div class="empty-state">
<div class="empty-icon">🖼️</div>
<h2>No images yet</h2>
<p>Click "Upload Images" or drag & drop files anywhere to get started</p>
<button class="btn-upload-large" on:click={openFilePicker}>
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>
{/each}
</div>
{/if}
</div>
<!-- Status Bar -->
<div class="status-bar">
<span>Board: {$currentBoard.title}</span>
<span>|</span>
<span>Images: {$boardImages.length}</span>
<span>|</span>
<span class="status-ready">● Ready</span>
</div>
{:else}
<div class="error-message">
<p>Board not found</p>
<button on:click={handleBackToBoards}>Back to Boards</button>
</div>
{/if}
</div>
<style>
.board-canvas-page {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f3f4f6;
}
.loading,
.error-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 1rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-small {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Toolbar */
.toolbar-top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.5rem;
background: white;
border-bottom: 1px solid #e5e7eb;
z-index: 100;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
}
.title-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.toolbar-right {
display: flex;
gap: 0.75rem;
}
.board-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.board-desc {
font-size: 0.875rem;
color: #6b7280;
}
.btn-icon,
.btn-upload,
.btn-secondary {
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
border: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-icon {
background: none;
border: 1px solid #d1d5db;
color: #374151;
}
.btn-icon:hover {
background: #f9fafb;
}
.btn-upload {
background: #10b981;
color: white;
}
.btn-upload:hover:not(:disabled) {
background: #059669;
}
.btn-upload:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-upload-large {
background: #3b82f6;
color: white;
padding: 0.75rem 2rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
border: none;
cursor: pointer;
}
.btn-upload-large:hover {
background: #2563eb;
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.success-banner,
.error-banner {
padding: 0.75rem 1.5rem;
text-align: center;
font-size: 0.875rem;
font-weight: 500;
}
.success-banner {
background: #d1fae5;
color: #065f46;
border-bottom: 1px solid #a7f3d0;
}
.error-banner {
background: #fee2e2;
color: #991b1b;
border-bottom: 1px solid #fecaca;
}
/* Canvas Container */
.canvas-container {
flex: 1;
position: relative;
overflow: auto;
background: #ffffff;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 1.5rem;
}
.empty-icon {
font-size: 4rem;
}
.empty-state h2 {
margin: 0;
font-size: 1.5rem;
color: #111827;
}
.empty-state p {
margin: 0;
font-size: 1rem;
color: #6b7280;
max-width: 400px;
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;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 1.5rem;
background: white;
border-top: 1px solid #e5e7eb;
font-size: 0.75rem;
color: #6b7280;
z-index: 100;
}
.status-ready {
color: #10b981;
font-weight: 500;
}
button {
font-family: inherit;
}
</style>

11
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 5173,
strictPort: false,
},
});