fix part 1
This commit is contained in:
@@ -3,10 +3,9 @@
|
||||
from uuid import UUID
|
||||
|
||||
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_db
|
||||
from app.core.deps import get_current_user, get_db
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.user import User
|
||||
from app.images.processing import generate_thumbnails
|
||||
|
||||
@@ -36,7 +36,7 @@ class Image(Base):
|
||||
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
width: 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)
|
||||
reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
from collections.abc import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
@@ -13,11 +12,11 @@ from app.database.models.image import Image
|
||||
class ImageRepository:
|
||||
"""Repository for image database operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
def __init__(self, db: Session):
|
||||
"""Initialize repository with database session."""
|
||||
self.db = db
|
||||
|
||||
async def create_image(
|
||||
def create_image(
|
||||
self,
|
||||
user_id: UUID,
|
||||
filename: str,
|
||||
@@ -28,22 +27,7 @@ class ImageRepository:
|
||||
height: int,
|
||||
metadata: dict,
|
||||
) -> Image:
|
||||
"""
|
||||
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
|
||||
"""
|
||||
"""Create new image record."""
|
||||
image = Image(
|
||||
user_id=user_id,
|
||||
filename=filename,
|
||||
@@ -52,98 +36,59 @@ class ImageRepository:
|
||||
mime_type=mime_type,
|
||||
width=width,
|
||||
height=height,
|
||||
metadata=metadata,
|
||||
image_metadata=metadata,
|
||||
)
|
||||
self.db.add(image)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(image)
|
||||
self.db.commit()
|
||||
self.db.refresh(image)
|
||||
return image
|
||||
|
||||
async def get_image_by_id(self, image_id: UUID) -> Image | None:
|
||||
"""
|
||||
Get image by ID.
|
||||
def get_image_by_id(self, image_id: UUID) -> Image | None:
|
||||
"""Get image by ID."""
|
||||
return self.db.query(Image).filter(Image.id == image_id).first()
|
||||
|
||||
Args:
|
||||
image_id: Image ID
|
||||
|
||||
Returns:
|
||||
Image instance or None
|
||||
"""
|
||||
result = await self.db.execute(select(Image).where(Image.id == image_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_user_images(self, user_id: UUID, limit: int = 50, offset: int = 0) -> tuple[Sequence[Image], int]:
|
||||
"""
|
||||
Get all images for a user with pagination.
|
||||
|
||||
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)
|
||||
def get_user_images(
|
||||
self, user_id: UUID, limit: int = 50, offset: int = 0
|
||||
) -> tuple[Sequence[Image], int]:
|
||||
"""Get all images for a user with pagination."""
|
||||
total = self.db.query(Image).filter(Image.user_id == user_id).count()
|
||||
images = (
|
||||
self.db.query(Image)
|
||||
.filter(Image.user_id == user_id)
|
||||
.order_by(Image.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
)
|
||||
images = result.scalars().all()
|
||||
|
||||
return images, total
|
||||
|
||||
async def delete_image(self, image_id: UUID) -> bool:
|
||||
"""
|
||||
Delete image record.
|
||||
|
||||
Args:
|
||||
image_id: Image ID
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
image = await self.get_image_by_id(image_id)
|
||||
def delete_image(self, image_id: UUID) -> bool:
|
||||
"""Delete image record."""
|
||||
image = self.get_image_by_id(image_id)
|
||||
if not image:
|
||||
return False
|
||||
|
||||
await self.db.delete(image)
|
||||
await self.db.commit()
|
||||
self.db.delete(image)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
async def increment_reference_count(self, image_id: UUID) -> None:
|
||||
"""
|
||||
Increment reference count for image.
|
||||
|
||||
Args:
|
||||
image_id: Image ID
|
||||
"""
|
||||
image = await self.get_image_by_id(image_id)
|
||||
def increment_reference_count(self, image_id: UUID) -> None:
|
||||
"""Increment reference count for image."""
|
||||
image = self.get_image_by_id(image_id)
|
||||
if image:
|
||||
image.reference_count += 1
|
||||
await self.db.commit()
|
||||
self.db.commit()
|
||||
|
||||
async def decrement_reference_count(self, image_id: UUID) -> int:
|
||||
"""
|
||||
Decrement reference count for image.
|
||||
|
||||
Args:
|
||||
image_id: Image ID
|
||||
|
||||
Returns:
|
||||
New reference count
|
||||
"""
|
||||
image = await self.get_image_by_id(image_id)
|
||||
def decrement_reference_count(self, image_id: UUID) -> int:
|
||||
"""Decrement reference count for image."""
|
||||
image = self.get_image_by_id(image_id)
|
||||
if image and image.reference_count > 0:
|
||||
image.reference_count -= 1
|
||||
await self.db.commit()
|
||||
self.db.commit()
|
||||
return image.reference_count
|
||||
return 0
|
||||
|
||||
async def add_image_to_board(
|
||||
def add_image_to_board(
|
||||
self,
|
||||
board_id: UUID,
|
||||
image_id: UUID,
|
||||
@@ -151,19 +96,7 @@ class ImageRepository:
|
||||
transformations: dict,
|
||||
z_order: int = 0,
|
||||
) -> BoardImage:
|
||||
"""
|
||||
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
|
||||
"""
|
||||
"""Add image to board."""
|
||||
board_image = BoardImage(
|
||||
board_id=board_id,
|
||||
image_id=image_id,
|
||||
@@ -174,50 +107,36 @@ class ImageRepository:
|
||||
self.db.add(board_image)
|
||||
|
||||
# Increment reference count
|
||||
await self.increment_reference_count(image_id)
|
||||
self.increment_reference_count(image_id)
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(board_image)
|
||||
self.db.commit()
|
||||
self.db.refresh(board_image)
|
||||
return board_image
|
||||
|
||||
async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]:
|
||||
"""
|
||||
Get all images for a board, ordered by z-order.
|
||||
|
||||
Args:
|
||||
board_id: Board ID
|
||||
|
||||
Returns:
|
||||
List of BoardImage instances
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc())
|
||||
def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]:
|
||||
"""Get all images for a board, ordered by z-order."""
|
||||
return (
|
||||
self.db.query(BoardImage)
|
||||
.filter(BoardImage.board_id == board_id)
|
||||
.order_by(BoardImage.z_order.asc())
|
||||
.all()
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool:
|
||||
"""
|
||||
Remove image from board.
|
||||
|
||||
Args:
|
||||
board_id: Board ID
|
||||
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)
|
||||
def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool:
|
||||
"""Remove image from board."""
|
||||
board_image = (
|
||||
self.db.query(BoardImage)
|
||||
.filter(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
|
||||
.first()
|
||||
)
|
||||
board_image = result.scalar_one_or_none()
|
||||
|
||||
if not board_image:
|
||||
return False
|
||||
|
||||
await self.db.delete(board_image)
|
||||
self.db.delete(board_image)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -9,32 +9,14 @@ import type { Image, BoardImage, ImageListResponse } from '$lib/types/images';
|
||||
* Upload a single image
|
||||
*/
|
||||
export async function uploadImage(file: File): Promise<Image> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await apiClient.post<Image>('/images/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
return await apiClient.uploadFile<Image>('/images/upload', file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple images from a ZIP file
|
||||
*/
|
||||
export async function uploadZip(file: File): Promise<Image[]> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await apiClient.post<Image[]>('/images/upload-zip', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
return await apiClient.uploadFile<Image[]>('/images/upload-zip', file);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
57
frontend/src/routes/+page.svelte
Normal file
57
frontend/src/routes/+page.svelte
Normal 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>
|
||||
2
frontend/src/routes/+page.ts
Normal file
2
frontend/src/routes/+page.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Disable server-side rendering for the root page
|
||||
export const ssr = false;
|
||||
506
frontend/src/routes/boards/[id]/+page.svelte
Normal file
506
frontend/src/routes/boards/[id]/+page.svelte
Normal 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
11
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user