fix part 1
This commit is contained in:
@@ -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