lib was accidentally being ignored
Some checks failed
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 3s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 3s
CI/CD Pipeline / VM Test - performance (push) Successful in 2s
CI/CD Pipeline / VM Test - security (push) Successful in 2s
CI/CD Pipeline / Backend Linting (push) Successful in 2s
CI/CD Pipeline / Frontend Linting (push) Failing after 12s
CI/CD Pipeline / Nix Flake Check (push) Successful in 37s
CI/CD Pipeline / CI Summary (push) Failing after 0s
Some checks failed
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 3s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 3s
CI/CD Pipeline / VM Test - performance (push) Successful in 2s
CI/CD Pipeline / VM Test - security (push) Successful in 2s
CI/CD Pipeline / Backend Linting (push) Successful in 2s
CI/CD Pipeline / Frontend Linting (push) Failing after 12s
CI/CD Pipeline / Nix Flake Check (push) Successful in 37s
CI/CD Pipeline / CI Summary (push) Failing after 0s
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,8 +10,9 @@ dist/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
**/lib/
|
||||||
lib64/
|
**/lib64/
|
||||||
|
!frontend/src/lib/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
|
|||||||
51
frontend/src/lib/api/auth.ts
Normal file
51
frontend/src/lib/api/auth.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Authentication API client methods
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface UserResponse {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
user: UserResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
*/
|
||||||
|
async register(data: RegisterRequest): Promise<UserResponse> {
|
||||||
|
return apiClient.post<UserResponse>('/auth/register', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login user and get JWT token
|
||||||
|
*/
|
||||||
|
async login(data: LoginRequest): Promise<TokenResponse> {
|
||||||
|
return apiClient.post<TokenResponse>('/auth/login', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user information
|
||||||
|
*/
|
||||||
|
async getCurrentUser(): Promise<UserResponse> {
|
||||||
|
return apiClient.get<UserResponse>('/auth/me');
|
||||||
|
},
|
||||||
|
};
|
||||||
64
frontend/src/lib/api/boards.ts
Normal file
64
frontend/src/lib/api/boards.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Boards API client
|
||||||
|
* Handles all board-related API calls
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client';
|
||||||
|
import type {
|
||||||
|
Board,
|
||||||
|
BoardCreate,
|
||||||
|
BoardUpdate,
|
||||||
|
BoardListResponse,
|
||||||
|
ViewportState,
|
||||||
|
} from '$lib/types/boards';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new board
|
||||||
|
*/
|
||||||
|
export async function createBoard(data: BoardCreate): Promise<Board> {
|
||||||
|
const response = await apiClient.post<Board>('/boards', data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all boards for current user
|
||||||
|
*/
|
||||||
|
export async function listBoards(
|
||||||
|
limit: number = 50,
|
||||||
|
offset: number = 0
|
||||||
|
): Promise<BoardListResponse> {
|
||||||
|
const response = await apiClient.get<BoardListResponse>(
|
||||||
|
`/boards?limit=${limit}&offset=${offset}`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get board by ID
|
||||||
|
*/
|
||||||
|
export async function getBoard(boardId: string): Promise<Board> {
|
||||||
|
const response = await apiClient.get<Board>(`/boards/${boardId}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update board metadata
|
||||||
|
*/
|
||||||
|
export async function updateBoard(boardId: string, data: BoardUpdate): Promise<Board> {
|
||||||
|
const response = await apiClient.patch<Board>(`/boards/${boardId}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete board
|
||||||
|
*/
|
||||||
|
export async function deleteBoard(boardId: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/boards/${boardId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update board viewport state
|
||||||
|
*/
|
||||||
|
export async function updateViewport(boardId: string, viewport: ViewportState): Promise<Board> {
|
||||||
|
return updateBoard(boardId, { viewport_state: viewport });
|
||||||
|
}
|
||||||
146
frontend/src/lib/api/client.ts
Normal file
146
frontend/src/lib/api/client.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* API client with authentication support
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { authStore } from '$lib/stores/auth';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
error: string;
|
||||||
|
details?: Record<string, string[]>;
|
||||||
|
status_code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
|
||||||
|
constructor(baseUrl: string = API_BASE_URL) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const { token } = get(authStore);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...((options.headers as Record<string, string>) || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add authentication token if available
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.baseUrl}${endpoint}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle non-JSON responses
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return (await response.text()) as unknown as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: ApiError = {
|
||||||
|
error: data.error || 'An error occurred',
|
||||||
|
details: data.details,
|
||||||
|
status_code: response.status,
|
||||||
|
};
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as ApiError).status_code) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw {
|
||||||
|
error: 'Network error',
|
||||||
|
details: { message: [(error as Error).message] },
|
||||||
|
status_code: 0,
|
||||||
|
} as ApiError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, { ...options, method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async post<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, {
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async put<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, {
|
||||||
|
...options,
|
||||||
|
method: 'PUT',
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async patch<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, {
|
||||||
|
...options,
|
||||||
|
method: 'PATCH',
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, { ...options, method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile<T>(
|
||||||
|
endpoint: string,
|
||||||
|
file: File,
|
||||||
|
additionalData?: Record<string, string>
|
||||||
|
): Promise<T> {
|
||||||
|
const { token } = get(authStore);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
if (additionalData) {
|
||||||
|
Object.entries(additionalData).forEach(([key, value]) => {
|
||||||
|
formData.append(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.baseUrl}${endpoint}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const apiClient = new ApiClient();
|
||||||
107
frontend/src/lib/api/images.ts
Normal file
107
frontend/src/lib/api/images.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Images API client
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from './client';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's image library with pagination
|
||||||
|
*/
|
||||||
|
export async function getImageLibrary(
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 50
|
||||||
|
): Promise<ImageListResponse> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: page.toString(),
|
||||||
|
page_size: pageSize.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await apiClient.get<ImageListResponse>(`/images/library?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image by ID
|
||||||
|
*/
|
||||||
|
export async function getImage(imageId: string): Promise<Image> {
|
||||||
|
return await apiClient.get<Image>(`/images/${imageId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete image permanently (only if not used on any boards)
|
||||||
|
*/
|
||||||
|
export async function deleteImage(imageId: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/images/${imageId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add image to board
|
||||||
|
*/
|
||||||
|
export async function addImageToBoard(
|
||||||
|
boardId: string,
|
||||||
|
imageId: string,
|
||||||
|
position: { x: number; y: number } = { x: 0, y: 0 },
|
||||||
|
zOrder: number = 0
|
||||||
|
): Promise<BoardImage> {
|
||||||
|
const payload = {
|
||||||
|
image_id: imageId,
|
||||||
|
position,
|
||||||
|
transformations: {
|
||||||
|
scale: 1.0,
|
||||||
|
rotation: 0,
|
||||||
|
opacity: 1.0,
|
||||||
|
flipped_h: false,
|
||||||
|
flipped_v: false,
|
||||||
|
greyscale: false,
|
||||||
|
},
|
||||||
|
z_order: zOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await apiClient.post<BoardImage>(`/images/boards/${boardId}/images`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove image from board
|
||||||
|
*/
|
||||||
|
export async function removeImageFromBoard(boardId: string, imageId: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/images/boards/${boardId}/images/${imageId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all images on a board
|
||||||
|
*/
|
||||||
|
export async function getBoardImages(boardId: string): Promise<BoardImage[]> {
|
||||||
|
return await apiClient.get<BoardImage[]>(`/images/boards/${boardId}/images`);
|
||||||
|
}
|
||||||
178
frontend/src/lib/canvas/Stage.svelte
Normal file
178
frontend/src/lib/canvas/Stage.svelte
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Konva.js Stage component for infinite canvas
|
||||||
|
* Main canvas component that handles rendering and interactions
|
||||||
|
*/
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import Konva from 'konva';
|
||||||
|
import { viewport } from '$lib/stores/viewport';
|
||||||
|
import type { ViewportState } from '$lib/stores/viewport';
|
||||||
|
import { setupPanControls } from './controls/pan';
|
||||||
|
import { setupZoomControls } from './controls/zoom';
|
||||||
|
import { setupRotateControls } from './controls/rotate';
|
||||||
|
import { setupGestureControls } from './gestures';
|
||||||
|
|
||||||
|
// Board ID for future use (e.g., loading board-specific state)
|
||||||
|
export const boardId: string | undefined = undefined;
|
||||||
|
export let width: number = 0;
|
||||||
|
export let height: number = 0;
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let stage: Konva.Stage | null = null;
|
||||||
|
let layer: Konva.Layer | null = null;
|
||||||
|
let unsubscribeViewport: (() => void) | null = null;
|
||||||
|
let cleanupPan: (() => void) | null = null;
|
||||||
|
let cleanupZoom: (() => void) | null = null;
|
||||||
|
let cleanupRotate: (() => void) | null = null;
|
||||||
|
let cleanupGestures: (() => void) | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Initialize Konva stage
|
||||||
|
stage = new Konva.Stage({
|
||||||
|
container,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create main layer for images
|
||||||
|
layer = new Konva.Layer();
|
||||||
|
stage.add(layer);
|
||||||
|
|
||||||
|
// Set up controls
|
||||||
|
if (stage) {
|
||||||
|
cleanupPan = setupPanControls(stage);
|
||||||
|
cleanupZoom = setupZoomControls(stage);
|
||||||
|
cleanupRotate = setupRotateControls(stage);
|
||||||
|
cleanupGestures = setupGestureControls(stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to viewport changes
|
||||||
|
unsubscribeViewport = viewport.subscribe((state) => {
|
||||||
|
updateStageTransform(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply initial viewport state
|
||||||
|
updateStageTransform($viewport);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
// Clean up event listeners
|
||||||
|
if (cleanupPan) cleanupPan();
|
||||||
|
if (cleanupZoom) cleanupZoom();
|
||||||
|
if (cleanupRotate) cleanupRotate();
|
||||||
|
if (cleanupGestures) cleanupGestures();
|
||||||
|
|
||||||
|
// Unsubscribe from viewport
|
||||||
|
if (unsubscribeViewport) unsubscribeViewport();
|
||||||
|
|
||||||
|
// Destroy Konva stage
|
||||||
|
if (stage) {
|
||||||
|
stage.destroy();
|
||||||
|
stage = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update stage transform based on viewport state
|
||||||
|
*/
|
||||||
|
function updateStageTransform(state: ViewportState) {
|
||||||
|
if (!stage) 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);
|
||||||
|
stage.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize canvas when dimensions change
|
||||||
|
*/
|
||||||
|
$: if (stage && (width !== stage.width() || height !== stage.height())) {
|
||||||
|
stage.width(width);
|
||||||
|
stage.height(height);
|
||||||
|
stage.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export stage and layer for parent components to add shapes
|
||||||
|
*/
|
||||||
|
export function getStage(): Konva.Stage | null {
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLayer(): Konva.Layer | null {
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a shape to the canvas
|
||||||
|
*/
|
||||||
|
export function addShape(shape: Konva.Shape | Konva.Group) {
|
||||||
|
if (layer) {
|
||||||
|
layer.add(shape);
|
||||||
|
layer.batchDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a shape from the canvas
|
||||||
|
*/
|
||||||
|
export function removeShape(shape: Konva.Shape | Konva.Group) {
|
||||||
|
if (layer) {
|
||||||
|
shape.destroy();
|
||||||
|
layer.batchDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all shapes from the canvas
|
||||||
|
*/
|
||||||
|
export function clearCanvas() {
|
||||||
|
if (layer) {
|
||||||
|
layer.destroyChildren();
|
||||||
|
layer.batchDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get canvas as data URL for export
|
||||||
|
*/
|
||||||
|
export function toDataURL(options?: {
|
||||||
|
pixelRatio?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
quality?: number;
|
||||||
|
}): string {
|
||||||
|
if (!stage) return '';
|
||||||
|
return stage.toDataURL(options);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="canvas-container">
|
||||||
|
<div bind:this={container} class="canvas-stage" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.canvas-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--canvas-bg, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-stage {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-stage:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
131
frontend/src/lib/canvas/controls/fit.ts
Normal file
131
frontend/src/lib/canvas/controls/fit.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Fit-to-screen controls for canvas
|
||||||
|
* Automatically adjusts viewport to fit content
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Konva from 'konva';
|
||||||
|
import { viewport } from '$lib/stores/viewport';
|
||||||
|
|
||||||
|
interface ContentBounds {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate bounding box of all content on the stage
|
||||||
|
*/
|
||||||
|
export function getContentBounds(stage: Konva.Stage): ContentBounds | null {
|
||||||
|
const layer = stage.getLayers()[0];
|
||||||
|
if (!layer) return null;
|
||||||
|
|
||||||
|
const children = layer.getChildren();
|
||||||
|
if (children.length === 0) return null;
|
||||||
|
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
children.forEach((child) => {
|
||||||
|
const box = child.getClientRect();
|
||||||
|
minX = Math.min(minX, box.x);
|
||||||
|
minY = Math.min(minY, box.y);
|
||||||
|
maxX = Math.max(maxX, box.x + box.width);
|
||||||
|
maxY = Math.max(maxY, box.y + box.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isFinite(minX) || !isFinite(minY) || !isFinite(maxX) || !isFinite(maxY)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fit all content to screen with padding
|
||||||
|
*/
|
||||||
|
export function fitToScreen(
|
||||||
|
stage: Konva.Stage,
|
||||||
|
padding: number = 50,
|
||||||
|
animate: boolean = false
|
||||||
|
): boolean {
|
||||||
|
const bounds = getContentBounds(stage);
|
||||||
|
if (!bounds) return false;
|
||||||
|
|
||||||
|
const screenWidth = stage.width();
|
||||||
|
const screenHeight = stage.height();
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support using Konva.Tween
|
||||||
|
viewport.fitToScreen(bounds.width, bounds.height, screenWidth, screenHeight, padding);
|
||||||
|
} else {
|
||||||
|
viewport.fitToScreen(bounds.width, bounds.height, screenWidth, screenHeight, padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fit specific content bounds to screen
|
||||||
|
*/
|
||||||
|
export function fitBoundsToScreen(
|
||||||
|
stage: Konva.Stage,
|
||||||
|
bounds: ContentBounds,
|
||||||
|
padding: number = 50,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
const screenWidth = stage.width();
|
||||||
|
const screenHeight = stage.height();
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support
|
||||||
|
viewport.fitToScreen(bounds.width, bounds.height, screenWidth, screenHeight, padding);
|
||||||
|
} else {
|
||||||
|
viewport.fitToScreen(bounds.width, bounds.height, screenWidth, screenHeight, padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Center content on screen without changing zoom
|
||||||
|
*/
|
||||||
|
export function centerContent(stage: Konva.Stage, animate: boolean = false): boolean {
|
||||||
|
const bounds = getContentBounds(stage);
|
||||||
|
if (!bounds) return false;
|
||||||
|
|
||||||
|
const screenWidth = stage.width();
|
||||||
|
const screenHeight = stage.height();
|
||||||
|
|
||||||
|
const centerX = (screenWidth - bounds.width) / 2 - bounds.x;
|
||||||
|
const centerY = (screenHeight - bounds.height) / 2 - bounds.y;
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support
|
||||||
|
viewport.setPan(centerX, centerY);
|
||||||
|
} else {
|
||||||
|
viewport.setPan(centerX, centerY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fit to window size (100% viewport)
|
||||||
|
*/
|
||||||
|
export function fitToWindow(stage: Konva.Stage, animate: boolean = false): void {
|
||||||
|
const screenWidth = stage.width();
|
||||||
|
const screenHeight = stage.height();
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support
|
||||||
|
viewport.fitToScreen(screenWidth, screenHeight, screenWidth, screenHeight, 0);
|
||||||
|
} else {
|
||||||
|
viewport.fitToScreen(screenWidth, screenHeight, screenWidth, screenHeight, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
frontend/src/lib/canvas/controls/pan.ts
Normal file
133
frontend/src/lib/canvas/controls/pan.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Pan controls for infinite canvas
|
||||||
|
* Supports mouse drag and spacebar+drag
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Konva from 'konva';
|
||||||
|
import { viewport } from '$lib/stores/viewport';
|
||||||
|
|
||||||
|
export function setupPanControls(stage: Konva.Stage): () => void {
|
||||||
|
let isPanning = false;
|
||||||
|
let isSpacePressed = false;
|
||||||
|
let lastPointerPosition: { x: number; y: number } | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mouse down - start panning
|
||||||
|
*/
|
||||||
|
function handleMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||||
|
// Only pan with middle mouse button or left button with space
|
||||||
|
if (e.evt.button === 1 || (e.evt.button === 0 && isSpacePressed)) {
|
||||||
|
isPanning = true;
|
||||||
|
lastPointerPosition = stage.getPointerPosition();
|
||||||
|
stage.container().style.cursor = 'grabbing';
|
||||||
|
e.evt.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mouse move - perform panning
|
||||||
|
*/
|
||||||
|
function handleMouseMove(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||||
|
if (!isPanning || !lastPointerPosition) return;
|
||||||
|
|
||||||
|
const currentPos = stage.getPointerPosition();
|
||||||
|
if (!currentPos) return;
|
||||||
|
|
||||||
|
const deltaX = currentPos.x - lastPointerPosition.x;
|
||||||
|
const deltaY = currentPos.y - lastPointerPosition.y;
|
||||||
|
|
||||||
|
viewport.panBy(deltaX, deltaY);
|
||||||
|
lastPointerPosition = currentPos;
|
||||||
|
|
||||||
|
e.evt.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mouse up - stop panning
|
||||||
|
*/
|
||||||
|
function handleMouseUp(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||||
|
if (isPanning) {
|
||||||
|
isPanning = false;
|
||||||
|
lastPointerPosition = null;
|
||||||
|
stage.container().style.cursor = isSpacePressed ? 'grab' : 'default';
|
||||||
|
e.evt.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle key down - enable space bar panning
|
||||||
|
*/
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.code === 'Space' && !isSpacePressed) {
|
||||||
|
isSpacePressed = true;
|
||||||
|
stage.container().style.cursor = 'grab';
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle key up - disable space bar panning
|
||||||
|
*/
|
||||||
|
function handleKeyUp(e: KeyboardEvent) {
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
isSpacePressed = false;
|
||||||
|
stage.container().style.cursor = isPanning ? 'grabbing' : 'default';
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle context menu - prevent default on middle click
|
||||||
|
*/
|
||||||
|
function handleContextMenu(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach event listeners
|
||||||
|
stage.on('mousedown', handleMouseDown);
|
||||||
|
stage.on('mousemove', handleMouseMove);
|
||||||
|
stage.on('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
const container = stage.container();
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
|
container.addEventListener('contextmenu', handleContextMenu);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
stage.off('mousedown', handleMouseDown);
|
||||||
|
stage.off('mousemove', handleMouseMove);
|
||||||
|
stage.off('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
|
container.removeEventListener('contextmenu', handleContextMenu);
|
||||||
|
|
||||||
|
// Reset cursor
|
||||||
|
stage.container().style.cursor = 'default';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pan to specific position (programmatic)
|
||||||
|
*/
|
||||||
|
export function panTo(x: number, y: number, animate: boolean = false) {
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support using Konva.Tween
|
||||||
|
viewport.setPan(x, y);
|
||||||
|
} else {
|
||||||
|
viewport.setPan(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pan by delta amount (programmatic)
|
||||||
|
*/
|
||||||
|
export function panBy(deltaX: number, deltaY: number, animate: boolean = false) {
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support using Konva.Tween
|
||||||
|
viewport.panBy(deltaX, deltaY);
|
||||||
|
} else {
|
||||||
|
viewport.panBy(deltaX, deltaY);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
frontend/src/lib/canvas/controls/reset.ts
Normal file
54
frontend/src/lib/canvas/controls/reset.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Reset camera controls for canvas
|
||||||
|
* Resets viewport to default state
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { viewport } from '$lib/stores/viewport';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset camera to default position (0, 0), zoom 1.0, rotation 0
|
||||||
|
*/
|
||||||
|
export function resetCamera(animate: boolean = false): void {
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support using Konva.Tween
|
||||||
|
viewport.reset();
|
||||||
|
} else {
|
||||||
|
viewport.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset only pan position
|
||||||
|
*/
|
||||||
|
export function resetPan(animate: boolean = false): void {
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support
|
||||||
|
viewport.setPan(0, 0);
|
||||||
|
} else {
|
||||||
|
viewport.setPan(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset only zoom level
|
||||||
|
*/
|
||||||
|
export function resetZoom(animate: boolean = false): void {
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support
|
||||||
|
viewport.setZoom(1.0);
|
||||||
|
} else {
|
||||||
|
viewport.setZoom(1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset only rotation
|
||||||
|
*/
|
||||||
|
export function resetRotation(animate: boolean = false): void {
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support
|
||||||
|
viewport.setRotation(0);
|
||||||
|
} else {
|
||||||
|
viewport.setRotation(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
frontend/src/lib/canvas/controls/rotate.ts
Normal file
117
frontend/src/lib/canvas/controls/rotate.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Rotation controls for infinite canvas
|
||||||
|
* Supports keyboard shortcuts and programmatic rotation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Konva from 'konva';
|
||||||
|
import { viewport } from '$lib/stores/viewport';
|
||||||
|
|
||||||
|
const ROTATION_STEP = 15; // Degrees per key press
|
||||||
|
const ROTATION_FAST_STEP = 45; // Degrees with Shift modifier
|
||||||
|
|
||||||
|
export function setupRotateControls(_stage: Konva.Stage): () => void {
|
||||||
|
/**
|
||||||
|
* Handle key down for rotation shortcuts
|
||||||
|
* R = rotate clockwise
|
||||||
|
* Shift+R = rotate counter-clockwise
|
||||||
|
* Ctrl+R = reset rotation
|
||||||
|
*/
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
// Ignore if typing in input field
|
||||||
|
if (
|
||||||
|
document.activeElement?.tagName === 'INPUT' ||
|
||||||
|
document.activeElement?.tagName === 'TEXTAREA'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset rotation (Ctrl/Cmd + R)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
|
||||||
|
e.preventDefault();
|
||||||
|
viewport.setRotation(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate clockwise/counter-clockwise
|
||||||
|
if (e.key === 'r' || e.key === 'R') {
|
||||||
|
e.preventDefault();
|
||||||
|
const step = e.shiftKey ? ROTATION_FAST_STEP : ROTATION_STEP;
|
||||||
|
const direction = e.shiftKey ? -1 : 1; // Shift reverses direction
|
||||||
|
viewport.rotateBy(step * direction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach event listener
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate to specific angle (programmatic)
|
||||||
|
*/
|
||||||
|
export function rotateTo(degrees: number, animate: boolean = false) {
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support using Konva.Tween
|
||||||
|
viewport.setRotation(degrees);
|
||||||
|
} else {
|
||||||
|
viewport.setRotation(degrees);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate by delta degrees (programmatic)
|
||||||
|
*/
|
||||||
|
export function rotateBy(degrees: number, animate: boolean = false) {
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support using Konva.Tween
|
||||||
|
viewport.rotateBy(degrees);
|
||||||
|
} else {
|
||||||
|
viewport.rotateBy(degrees);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate clockwise by one step
|
||||||
|
*/
|
||||||
|
export function rotateClockwise() {
|
||||||
|
viewport.rotateBy(ROTATION_STEP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate counter-clockwise by one step
|
||||||
|
*/
|
||||||
|
export function rotateCounterClockwise() {
|
||||||
|
viewport.rotateBy(-ROTATION_STEP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset rotation to 0 degrees
|
||||||
|
*/
|
||||||
|
export function resetRotation() {
|
||||||
|
viewport.setRotation(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate to 90 degrees
|
||||||
|
*/
|
||||||
|
export function rotateTo90() {
|
||||||
|
viewport.setRotation(90);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate to 180 degrees
|
||||||
|
*/
|
||||||
|
export function rotateTo180() {
|
||||||
|
viewport.setRotation(180);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate to 270 degrees
|
||||||
|
*/
|
||||||
|
export function rotateTo270() {
|
||||||
|
viewport.setRotation(270);
|
||||||
|
}
|
||||||
104
frontend/src/lib/canvas/controls/zoom.ts
Normal file
104
frontend/src/lib/canvas/controls/zoom.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Zoom controls for infinite canvas
|
||||||
|
* Supports mouse wheel and pinch gestures
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Konva from 'konva';
|
||||||
|
import { viewport } from '$lib/stores/viewport';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
const ZOOM_SPEED = 1.1; // Zoom factor per wheel tick
|
||||||
|
const MIN_ZOOM_DELTA = 0.01; // Minimum zoom change to prevent jitter
|
||||||
|
|
||||||
|
export function setupZoomControls(stage: Konva.Stage): () => void {
|
||||||
|
/**
|
||||||
|
* Handle wheel event - zoom in/out
|
||||||
|
*/
|
||||||
|
function handleWheel(e: Konva.KonvaEventObject<WheelEvent>) {
|
||||||
|
e.evt.preventDefault();
|
||||||
|
|
||||||
|
const oldZoom = get(viewport).zoom;
|
||||||
|
const pointer = stage.getPointerPosition();
|
||||||
|
|
||||||
|
if (!pointer) return;
|
||||||
|
|
||||||
|
// Calculate new zoom level
|
||||||
|
let direction = e.evt.deltaY > 0 ? -1 : 1;
|
||||||
|
|
||||||
|
// Handle trackpad vs mouse wheel (deltaMode)
|
||||||
|
if (e.evt.deltaMode === 1) {
|
||||||
|
// Line scrolling (mouse wheel)
|
||||||
|
direction = direction * 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomFactor = direction > 0 ? ZOOM_SPEED : 1 / ZOOM_SPEED;
|
||||||
|
const newZoom = oldZoom * zoomFactor;
|
||||||
|
|
||||||
|
// Apply bounds
|
||||||
|
const bounds = viewport.getBounds();
|
||||||
|
const clampedZoom = Math.max(bounds.minZoom, Math.min(bounds.maxZoom, newZoom));
|
||||||
|
|
||||||
|
// Only update if change is significant
|
||||||
|
if (Math.abs(clampedZoom - oldZoom) > MIN_ZOOM_DELTA) {
|
||||||
|
viewport.setZoom(clampedZoom, pointer.x, pointer.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach event listener
|
||||||
|
stage.on('wheel', handleWheel);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
stage.off('wheel', handleWheel);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom to specific level (programmatic)
|
||||||
|
*/
|
||||||
|
export function zoomTo(zoom: number, centerX?: number, centerY?: number, animate: boolean = false) {
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support using Konva.Tween
|
||||||
|
viewport.setZoom(zoom, centerX, centerY);
|
||||||
|
} else {
|
||||||
|
viewport.setZoom(zoom, centerX, centerY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom by factor (programmatic)
|
||||||
|
*/
|
||||||
|
export function zoomBy(
|
||||||
|
factor: number,
|
||||||
|
centerX?: number,
|
||||||
|
centerY?: number,
|
||||||
|
animate: boolean = false
|
||||||
|
) {
|
||||||
|
if (animate) {
|
||||||
|
// TODO: Add animation support using Konva.Tween
|
||||||
|
viewport.zoomBy(factor, centerX, centerY);
|
||||||
|
} else {
|
||||||
|
viewport.zoomBy(factor, centerX, centerY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom in by one step
|
||||||
|
*/
|
||||||
|
export function zoomIn(centerX?: number, centerY?: number) {
|
||||||
|
viewport.zoomBy(ZOOM_SPEED, centerX, centerY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom out by one step
|
||||||
|
*/
|
||||||
|
export function zoomOut(centerX?: number, centerY?: number) {
|
||||||
|
viewport.zoomBy(1 / ZOOM_SPEED, centerX, centerY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset zoom to 100%
|
||||||
|
*/
|
||||||
|
export function resetZoom() {
|
||||||
|
viewport.setZoom(1.0);
|
||||||
|
}
|
||||||
143
frontend/src/lib/canvas/gestures.ts
Normal file
143
frontend/src/lib/canvas/gestures.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Touch gesture controls for canvas
|
||||||
|
* Supports pinch-to-zoom and two-finger pan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Konva from 'konva';
|
||||||
|
import { viewport } from '$lib/stores/viewport';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
interface TouchState {
|
||||||
|
distance: number;
|
||||||
|
center: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupGestureControls(stage: Konva.Stage): () => void {
|
||||||
|
let lastTouchState: TouchState | null = null;
|
||||||
|
let isTouching = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance between two touch points
|
||||||
|
*/
|
||||||
|
function getTouchDistance(touch1: Touch, touch2: Touch): number {
|
||||||
|
const dx = touch1.clientX - touch2.clientX;
|
||||||
|
const dy = touch1.clientY - touch2.clientY;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate center point between two touches
|
||||||
|
*/
|
||||||
|
function getTouchCenter(touch1: Touch, touch2: Touch): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: (touch1.clientX + touch2.clientX) / 2,
|
||||||
|
y: (touch1.clientY + touch2.clientY) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get touch state from touch event
|
||||||
|
*/
|
||||||
|
function getTouchState(touches: TouchList): TouchState | null {
|
||||||
|
if (touches.length !== 2) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
distance: getTouchDistance(touches[0], touches[1]),
|
||||||
|
center: getTouchCenter(touches[0], touches[1]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle touch start
|
||||||
|
*/
|
||||||
|
function handleTouchStart(e: TouchEvent) {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
isTouching = true;
|
||||||
|
lastTouchState = getTouchState(e.touches);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle touch move - pinch zoom and two-finger pan
|
||||||
|
*/
|
||||||
|
function handleTouchMove(e: TouchEvent) {
|
||||||
|
if (!isTouching || e.touches.length !== 2 || !lastTouchState) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const currentState = getTouchState(e.touches);
|
||||||
|
if (!currentState) return;
|
||||||
|
|
||||||
|
// Calculate zoom based on distance change (pinch)
|
||||||
|
const distanceRatio = currentState.distance / lastTouchState.distance;
|
||||||
|
const oldZoom = get(viewport).zoom;
|
||||||
|
const newZoom = oldZoom * distanceRatio;
|
||||||
|
|
||||||
|
// Apply zoom with center point
|
||||||
|
viewport.setZoom(newZoom, currentState.center.x, currentState.center.y);
|
||||||
|
|
||||||
|
// Calculate pan based on center point movement (two-finger drag)
|
||||||
|
const deltaX = currentState.center.x - lastTouchState.center.x;
|
||||||
|
const deltaY = currentState.center.y - lastTouchState.center.y;
|
||||||
|
|
||||||
|
viewport.panBy(deltaX, deltaY);
|
||||||
|
|
||||||
|
// Update last state
|
||||||
|
lastTouchState = currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle touch end
|
||||||
|
*/
|
||||||
|
function handleTouchEnd(e: TouchEvent) {
|
||||||
|
if (e.touches.length < 2) {
|
||||||
|
isTouching = false;
|
||||||
|
lastTouchState = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle touch cancel
|
||||||
|
*/
|
||||||
|
function handleTouchCancel() {
|
||||||
|
isTouching = false;
|
||||||
|
lastTouchState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach event listeners to stage container
|
||||||
|
const container = stage.container();
|
||||||
|
|
||||||
|
container.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||||
|
container.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
container.addEventListener('touchend', handleTouchEnd);
|
||||||
|
container.addEventListener('touchcancel', handleTouchCancel);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('touchstart', handleTouchStart);
|
||||||
|
container.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
container.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
container.removeEventListener('touchcancel', handleTouchCancel);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if device supports touch
|
||||||
|
*/
|
||||||
|
export function isTouchDevice(): boolean {
|
||||||
|
return (
|
||||||
|
'ontouchstart' in window ||
|
||||||
|
navigator.maxTouchPoints > 0 ||
|
||||||
|
('msMaxTouchPoints' in navigator &&
|
||||||
|
(navigator as Navigator & { msMaxTouchPoints: number }).msMaxTouchPoints > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable touch gestures
|
||||||
|
*/
|
||||||
|
export function setTouchEnabled(stage: Konva.Stage, enabled: boolean): void {
|
||||||
|
const container = stage.container();
|
||||||
|
container.style.touchAction = enabled ? 'none' : 'auto';
|
||||||
|
}
|
||||||
140
frontend/src/lib/canvas/viewportSync.ts
Normal file
140
frontend/src/lib/canvas/viewportSync.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Viewport state synchronization with backend
|
||||||
|
* Handles debounced persistence of viewport changes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { viewport } from '$lib/stores/viewport';
|
||||||
|
import type { ViewportState } from '$lib/stores/viewport';
|
||||||
|
import { apiClient } from '$lib/api/client';
|
||||||
|
|
||||||
|
// Debounce timeout for viewport persistence (ms)
|
||||||
|
const SYNC_DEBOUNCE_MS = 1000;
|
||||||
|
|
||||||
|
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let lastSyncedState: ViewportState | null = null;
|
||||||
|
let currentBoardId: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize viewport sync for a board
|
||||||
|
* Sets up automatic persistence of viewport changes
|
||||||
|
*/
|
||||||
|
export function initViewportSync(boardId: string): () => void {
|
||||||
|
currentBoardId = boardId;
|
||||||
|
|
||||||
|
// Subscribe to viewport changes
|
||||||
|
const unsubscribe = viewport.subscribe((state) => {
|
||||||
|
scheduleSyncIfChanged(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
if (syncTimeout) {
|
||||||
|
clearTimeout(syncTimeout);
|
||||||
|
syncTimeout = null;
|
||||||
|
}
|
||||||
|
currentBoardId = null;
|
||||||
|
lastSyncedState = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule viewport sync if state has changed
|
||||||
|
*/
|
||||||
|
function scheduleSyncIfChanged(state: ViewportState): void {
|
||||||
|
// Check if state has actually changed
|
||||||
|
if (lastSyncedState && statesEqual(state, lastSyncedState)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (syncTimeout) {
|
||||||
|
clearTimeout(syncTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule new sync
|
||||||
|
syncTimeout = setTimeout(() => {
|
||||||
|
syncViewport(state);
|
||||||
|
}, SYNC_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync viewport state to backend
|
||||||
|
*/
|
||||||
|
async function syncViewport(state: ViewportState): Promise<void> {
|
||||||
|
if (!currentBoardId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.patch(`/api/boards/${currentBoardId}/viewport`, state);
|
||||||
|
lastSyncedState = { ...state };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync viewport state:', error);
|
||||||
|
// Don't throw - this is a background operation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force immediate sync (useful before navigation)
|
||||||
|
*/
|
||||||
|
export async function forceViewportSync(): Promise<void> {
|
||||||
|
if (syncTimeout) {
|
||||||
|
clearTimeout(syncTimeout);
|
||||||
|
syncTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await new Promise<ViewportState>((resolve) => {
|
||||||
|
const unsubscribe = viewport.subscribe((s) => {
|
||||||
|
unsubscribe();
|
||||||
|
resolve(s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncViewport(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load viewport state from backend
|
||||||
|
*/
|
||||||
|
export async function loadViewportState(boardId: string): Promise<ViewportState | null> {
|
||||||
|
try {
|
||||||
|
const board = await apiClient.get<{ viewport_state?: ViewportState }>(`/api/boards/${boardId}`);
|
||||||
|
|
||||||
|
if (board.viewport_state) {
|
||||||
|
return {
|
||||||
|
x: board.viewport_state.x || 0,
|
||||||
|
y: board.viewport_state.y || 0,
|
||||||
|
zoom: board.viewport_state.zoom || 1.0,
|
||||||
|
rotation: board.viewport_state.rotation || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load viewport state:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two viewport states are equal
|
||||||
|
*/
|
||||||
|
function statesEqual(a: ViewportState, b: ViewportState): boolean {
|
||||||
|
return (
|
||||||
|
Math.abs(a.x - b.x) < 0.01 &&
|
||||||
|
Math.abs(a.y - b.y) < 0.01 &&
|
||||||
|
Math.abs(a.zoom - b.zoom) < 0.001 &&
|
||||||
|
Math.abs(a.rotation - b.rotation) < 0.1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset viewport sync state (useful for cleanup)
|
||||||
|
*/
|
||||||
|
export function resetViewportSync(): void {
|
||||||
|
if (syncTimeout) {
|
||||||
|
clearTimeout(syncTimeout);
|
||||||
|
syncTimeout = null;
|
||||||
|
}
|
||||||
|
lastSyncedState = null;
|
||||||
|
currentBoardId = null;
|
||||||
|
}
|
||||||
94
frontend/src/lib/components/Toast.svelte
Normal file
94
frontend/src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
export let message: string;
|
||||||
|
export let type: 'success' | 'error' | 'warning' | 'info' = 'info';
|
||||||
|
export let duration: number = 3000;
|
||||||
|
export let onClose: (() => void) | undefined = undefined;
|
||||||
|
|
||||||
|
let visible = true;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (duration > 0) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
visible = false;
|
||||||
|
if (onClose) onClose();
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
visible = false;
|
||||||
|
if (onClose) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeClasses = {
|
||||||
|
success: 'bg-green-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
warning: 'bg-yellow-500',
|
||||||
|
info: 'bg-blue-500',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div class="toast {typeClasses[type]}" role="alert" aria-live="polite">
|
||||||
|
<span class="toast-message">{message}</span>
|
||||||
|
<button class="toast-close" on:click={handleClose} aria-label="Close"> × </button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 400px;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
174
frontend/src/lib/components/auth/LoginForm.svelte
Normal file
174
frontend/src/lib/components/auth/LoginForm.svelte
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let isLoading = false;
|
||||||
|
|
||||||
|
let email = '';
|
||||||
|
let password = '';
|
||||||
|
let errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
submit: { email: string; password: string };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function validateForm(): boolean {
|
||||||
|
errors = {};
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
errors.email = 'Email is required';
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
errors.email = 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
errors.password = 'Password is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch('submit', { email, password });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form on:submit={handleSubmit} class="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
bind:value={email}
|
||||||
|
disabled={isLoading}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
class:error={errors.email}
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
{#if errors.email}
|
||||||
|
<span class="error-text">{errors.email}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
disabled={isLoading}
|
||||||
|
placeholder="••••••••"
|
||||||
|
class:error={errors.password}
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
{#if errors.password}
|
||||||
|
<span class="error-text">{errors.password}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isLoading} class="submit-button">
|
||||||
|
{#if isLoading}
|
||||||
|
<span class="spinner"></span>
|
||||||
|
Logging in...
|
||||||
|
{:else}
|
||||||
|
Login
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.error {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
padding: 0.875rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
225
frontend/src/lib/components/auth/RegisterForm.svelte
Normal file
225
frontend/src/lib/components/auth/RegisterForm.svelte
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let isLoading = false;
|
||||||
|
|
||||||
|
let email = '';
|
||||||
|
let password = '';
|
||||||
|
let confirmPassword = '';
|
||||||
|
let errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
submit: { email: string; password: string };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function validatePassword(pwd: string): { valid: boolean; message: string } {
|
||||||
|
if (pwd.length < 8) {
|
||||||
|
return { valid: false, message: 'Password must be at least 8 characters' };
|
||||||
|
}
|
||||||
|
if (!/[A-Z]/.test(pwd)) {
|
||||||
|
return { valid: false, message: 'Password must contain an uppercase letter' };
|
||||||
|
}
|
||||||
|
if (!/[a-z]/.test(pwd)) {
|
||||||
|
return { valid: false, message: 'Password must contain a lowercase letter' };
|
||||||
|
}
|
||||||
|
if (!/\d/.test(pwd)) {
|
||||||
|
return { valid: false, message: 'Password must contain a number' };
|
||||||
|
}
|
||||||
|
return { valid: true, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(): boolean {
|
||||||
|
errors = {};
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
errors.email = 'Email is required';
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
errors.email = 'Please enter a valid email address';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
errors.password = 'Password is required';
|
||||||
|
} else {
|
||||||
|
const passwordValidation = validatePassword(password);
|
||||||
|
if (!passwordValidation.valid) {
|
||||||
|
errors.password = passwordValidation.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmPassword) {
|
||||||
|
errors.confirmPassword = 'Please confirm your password';
|
||||||
|
} else if (password !== confirmPassword) {
|
||||||
|
errors.confirmPassword = 'Passwords do not match';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch('submit', { email, password });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form on:submit={handleSubmit} class="register-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
bind:value={email}
|
||||||
|
disabled={isLoading}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
class:error={errors.email}
|
||||||
|
autocomplete="email"
|
||||||
|
/>
|
||||||
|
{#if errors.email}
|
||||||
|
<span class="error-text">{errors.email}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
disabled={isLoading}
|
||||||
|
placeholder="••••••••"
|
||||||
|
class:error={errors.password}
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
{#if errors.password}
|
||||||
|
<span class="error-text">{errors.password}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="help-text"> Must be 8+ characters with uppercase, lowercase, and number </span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirmPassword">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
disabled={isLoading}
|
||||||
|
placeholder="••••••••"
|
||||||
|
class:error={errors.confirmPassword}
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
{#if errors.confirmPassword}
|
||||||
|
<span class="error-text">{errors.confirmPassword}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isLoading} class="submit-button">
|
||||||
|
{#if isLoading}
|
||||||
|
<span class="spinner"></span>
|
||||||
|
Creating account...
|
||||||
|
{:else}
|
||||||
|
Create Account
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.register-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.error {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
padding: 0.875rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
206
frontend/src/lib/components/boards/BoardCard.svelte
Normal file
206
frontend/src/lib/components/boards/BoardCard.svelte
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import type { BoardSummary } from '$lib/types/boards';
|
||||||
|
|
||||||
|
export let board: BoardSummary;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ delete: void }>();
|
||||||
|
|
||||||
|
function openBoard() {
|
||||||
|
goto(`/boards/${board.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
dispatch('delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||||
|
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||||
|
<article
|
||||||
|
class="board-card"
|
||||||
|
on:click={openBoard}
|
||||||
|
on:keydown={(e) => e.key === 'Enter' && openBoard()}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="card-thumbnail">
|
||||||
|
{#if board.thumbnail_url}
|
||||||
|
<img src={board.thumbnail_url} alt={board.title} />
|
||||||
|
{:else}
|
||||||
|
<div class="placeholder-thumbnail">
|
||||||
|
<span class="placeholder-icon">🖼️</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if board.image_count > 0}
|
||||||
|
<div class="image-count">
|
||||||
|
{board.image_count}
|
||||||
|
{board.image_count === 1 ? 'image' : 'images'}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<h3 class="board-title">{board.title}</h3>
|
||||||
|
{#if board.description}
|
||||||
|
<p class="board-description">{board.description}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="card-meta">
|
||||||
|
<span class="meta-date">Updated {formatDate(board.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<button
|
||||||
|
class="action-btn delete-btn"
|
||||||
|
on:click={handleDelete}
|
||||||
|
title="Delete board"
|
||||||
|
aria-label="Delete board"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.board-card {
|
||||||
|
position: relative;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-card:focus {
|
||||||
|
outline: 2px solid #667eea;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumbnail {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea20 0%, #764ba220 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-count {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-card:hover .card-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
266
frontend/src/lib/components/boards/CreateBoardModal.svelte
Normal file
266
frontend/src/lib/components/boards/CreateBoardModal.svelte
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let initialTitle: string = '';
|
||||||
|
export let initialDescription: string = '';
|
||||||
|
|
||||||
|
let title = initialTitle;
|
||||||
|
let description = initialDescription;
|
||||||
|
let errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
create: { title: string; description?: string };
|
||||||
|
close: void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
errors = {};
|
||||||
|
|
||||||
|
if (!title.trim()) {
|
||||||
|
errors.title = 'Title is required';
|
||||||
|
} else if (title.length > 255) {
|
||||||
|
errors.title = 'Title must be 255 characters or less';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description.length > 1000) {
|
||||||
|
errors.description = 'Description must be 1000 characters or less';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
dispatch('create', {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
dispatch('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
on:click={handleBackdropClick}
|
||||||
|
on:keydown={(e) => e.key === 'Escape' && handleClose()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="modal-content">
|
||||||
|
<header class="modal-header">
|
||||||
|
<h2>Create New Board</h2>
|
||||||
|
<button class="close-btn" on:click={handleClose} aria-label="Close">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form on:submit|preventDefault={handleSubmit} class="modal-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Board Title <span class="required">*</span></label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
placeholder="e.g., Character Design References"
|
||||||
|
class:error={errors.title}
|
||||||
|
maxlength="255"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if errors.title}
|
||||||
|
<span class="error-text">{errors.title}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description (optional)</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
bind:value={description}
|
||||||
|
placeholder="Add a description for this board..."
|
||||||
|
rows="3"
|
||||||
|
maxlength="1000"
|
||||||
|
class:error={errors.description}
|
||||||
|
/>
|
||||||
|
{#if errors.description}
|
||||||
|
<span class="error-text">{errors.description}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="help-text">{description.length}/1000 characters</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" on:click={handleClose}>Cancel</button>
|
||||||
|
<button type="submit" class="btn-primary">Create Board</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-form {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:last-of-type {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.error,
|
||||||
|
textarea.error {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
display: block;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
display: block;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
261
frontend/src/lib/components/common/DeleteConfirmModal.svelte
Normal file
261
frontend/src/lib/components/common/DeleteConfirmModal.svelte
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let title: string = 'Confirm Deletion';
|
||||||
|
export let message: string =
|
||||||
|
'Are you sure you want to delete this? This action cannot be undone.';
|
||||||
|
export let itemName: string = '';
|
||||||
|
export let confirmText: string = 'Delete';
|
||||||
|
export let cancelText: string = 'Cancel';
|
||||||
|
export let isDestructive: boolean = true;
|
||||||
|
export let isProcessing: boolean = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
confirm: void;
|
||||||
|
cancel: void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
dispatch('confirm');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
dispatch('cancel');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget && !isProcessing) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && !isProcessing) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={handleKeydown} />
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
on:click={handleBackdropClick}
|
||||||
|
on:keydown={handleKeydown}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-icon" class:destructive={isDestructive}>
|
||||||
|
{#if isDestructive}
|
||||||
|
<span class="icon-warning">⚠️</span>
|
||||||
|
{:else}
|
||||||
|
<span class="icon-info">ℹ️</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="modal-header">
|
||||||
|
<h2 id="modal-title">{title}</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="message">{message}</p>
|
||||||
|
{#if itemName}
|
||||||
|
<div class="item-name">
|
||||||
|
<strong>{itemName}</strong>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-cancel" on:click={handleCancel} disabled={isProcessing}>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-confirm"
|
||||||
|
class:destructive={isDestructive}
|
||||||
|
on:click={handleConfirm}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{#if isProcessing}
|
||||||
|
<span class="spinner"></span>
|
||||||
|
Processing...
|
||||||
|
{:else}
|
||||||
|
{confirmText}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-icon.destructive {
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-icon:not(.destructive) {
|
||||||
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
color: #4b5563;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel,
|
||||||
|
.btn-confirm {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover:not(:disabled) {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm.destructive {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
196
frontend/src/lib/components/upload/DropZone.svelte
Normal file
196
frontend/src/lib/components/upload/DropZone.svelte
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Drag-and-drop zone for image uploads
|
||||||
|
*/
|
||||||
|
import { uploadSingleImage, uploadZipFile } from '$lib/stores/images';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let accept: string = 'image/*,.zip';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
let isDragging = false;
|
||||||
|
let uploading = false;
|
||||||
|
|
||||||
|
function handleDragEnter(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
isDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
isDragging = false;
|
||||||
|
|
||||||
|
if (!event.dataTransfer?.files) return;
|
||||||
|
|
||||||
|
uploading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = Array.from(event.dataTransfer.files);
|
||||||
|
|
||||||
|
// Filter files based on accept pattern
|
||||||
|
const validFiles = files.filter((file) => {
|
||||||
|
if (accept.includes('image/*')) {
|
||||||
|
return file.type.startsWith('image/') || file.name.toLowerCase().endsWith('.zip');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
dispatch('upload-error', { error: 'No valid image files found' });
|
||||||
|
uploading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of validFiles) {
|
||||||
|
// Check if ZIP file
|
||||||
|
if (file.name.toLowerCase().endsWith('.zip')) {
|
||||||
|
await uploadZipFile(file);
|
||||||
|
} else if (file.type.startsWith('image/')) {
|
||||||
|
await uploadSingleImage(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch('upload-complete', { fileCount: validFiles.length });
|
||||||
|
} catch (error: any) {
|
||||||
|
dispatch('upload-error', { error: error.message });
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="drop-zone"
|
||||||
|
class:dragging={isDragging}
|
||||||
|
class:uploading
|
||||||
|
on:dragenter={handleDragEnter}
|
||||||
|
on:dragleave={handleDragLeave}
|
||||||
|
on:dragover={handleDragOver}
|
||||||
|
on:drop={handleDrop}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="drop-zone-content">
|
||||||
|
{#if uploading}
|
||||||
|
<div class="spinner-large"></div>
|
||||||
|
<p>Uploading...</p>
|
||||||
|
{:else if isDragging}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="drop-icon"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
<p>Drop files here</p>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="upload-icon"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
<p>Drag and drop images here</p>
|
||||||
|
<p class="subtitle">or use the file picker above</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background-color: var(--color-bg-secondary, #f9fafb);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone:hover:not(.uploading) {
|
||||||
|
border-color: var(--color-primary, #3b82f6);
|
||||||
|
background-color: var(--color-bg-hover, #eff6ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone.dragging {
|
||||||
|
border-color: var(--color-primary, #3b82f6);
|
||||||
|
background-color: var(--color-bg-active, #dbeafe);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone.uploading {
|
||||||
|
border-color: var(--color-border, #d1d5db);
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon,
|
||||||
|
.drop-icon {
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone.dragging .drop-icon {
|
||||||
|
color: var(--color-primary, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-large {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid var(--color-border, #d1d5db);
|
||||||
|
border-top-color: var(--color-primary, #3b82f6);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
91
frontend/src/lib/components/upload/ErrorDisplay.svelte
Normal file
91
frontend/src/lib/components/upload/ErrorDisplay.svelte
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Error display component for upload failures
|
||||||
|
*/
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let error: string;
|
||||||
|
export let dismissible: boolean = true;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function handleDismiss() {
|
||||||
|
dispatch('dismiss');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="error-display" role="alert">
|
||||||
|
<div class="error-icon">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="error-content">
|
||||||
|
<p class="error-message">{error}</p>
|
||||||
|
</div>
|
||||||
|
{#if dismissible}
|
||||||
|
<button class="dismiss-button" on:click={handleDismiss} aria-label="Dismiss error"> × </button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.error-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--color-error-bg, #fee2e2);
|
||||||
|
border: 1px solid var(--color-error-border, #fecaca);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-error-text, #991b1b);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-button:hover {
|
||||||
|
background-color: var(--color-error-hover, #fecaca);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
131
frontend/src/lib/components/upload/FilePicker.svelte
Normal file
131
frontend/src/lib/components/upload/FilePicker.svelte
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* File picker component for selecting images
|
||||||
|
*/
|
||||||
|
import { uploadSingleImage, uploadZipFile } from '$lib/stores/images';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let accept: string = 'image/*,.zip';
|
||||||
|
export let multiple: boolean = true;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
let uploading = false;
|
||||||
|
|
||||||
|
async function handleFileSelect(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
if (!target.files || target.files.length === 0) return;
|
||||||
|
|
||||||
|
uploading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = Array.from(target.files);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
// Check if ZIP file
|
||||||
|
if (file.name.toLowerCase().endsWith('.zip')) {
|
||||||
|
await uploadZipFile(file);
|
||||||
|
} else {
|
||||||
|
await uploadSingleImage(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch('upload-complete', { fileCount: files.length });
|
||||||
|
} catch (error: any) {
|
||||||
|
dispatch('upload-error', { error: error.message });
|
||||||
|
} finally {
|
||||||
|
uploading = false;
|
||||||
|
// Reset input to allow uploading same file again
|
||||||
|
if (target) target.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFilePicker() {
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button type="button" class="file-picker-button" on:click={openFilePicker} disabled={uploading}>
|
||||||
|
{#if uploading}
|
||||||
|
<span class="spinner"></span>
|
||||||
|
Uploading...
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
Choose Files
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
{accept}
|
||||||
|
{multiple}
|
||||||
|
on:change={handleFileSelect}
|
||||||
|
style="display: none;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.file-picker-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background-color: var(--color-primary, #3b82f6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s,
|
||||||
|
transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-picker-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-primary-hover, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-picker-button:active:not(:disabled) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-picker-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
155
frontend/src/lib/components/upload/ProgressBar.svelte
Normal file
155
frontend/src/lib/components/upload/ProgressBar.svelte
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Upload progress bar component
|
||||||
|
*/
|
||||||
|
import type { ImageUploadProgress } from '$lib/types/images';
|
||||||
|
import { uploadProgress } from '$lib/stores/images';
|
||||||
|
|
||||||
|
export let item: ImageUploadProgress;
|
||||||
|
|
||||||
|
function getStatusColor(status: ImageUploadProgress['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'complete':
|
||||||
|
return 'var(--color-success, #10b981)';
|
||||||
|
case 'error':
|
||||||
|
return 'var(--color-error, #ef4444)';
|
||||||
|
case 'uploading':
|
||||||
|
case 'processing':
|
||||||
|
return 'var(--color-primary, #3b82f6)';
|
||||||
|
default:
|
||||||
|
return 'var(--color-border, #d1d5db)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusIcon(status: ImageUploadProgress['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'complete':
|
||||||
|
return '✓';
|
||||||
|
case 'error':
|
||||||
|
return '✗';
|
||||||
|
case 'uploading':
|
||||||
|
case 'processing':
|
||||||
|
return '⟳';
|
||||||
|
default:
|
||||||
|
return '○';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove() {
|
||||||
|
uploadProgress.update((items) => items.filter((i) => i.filename !== item.filename));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="progress-item" data-status={item.status}>
|
||||||
|
<div class="progress-header">
|
||||||
|
<span class="status-icon" style="color: {getStatusColor(item.status)}">
|
||||||
|
{getStatusIcon(item.status)}
|
||||||
|
</span>
|
||||||
|
<span class="filename">{item.filename}</span>
|
||||||
|
{#if item.status === 'complete' || item.status === 'error'}
|
||||||
|
<button class="close-button" on:click={handleRemove} title="Remove">×</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if item.status === 'uploading' || item.status === 'processing'}
|
||||||
|
<div class="progress-bar-container">
|
||||||
|
<div
|
||||||
|
class="progress-bar-fill"
|
||||||
|
style="width: {item.progress}%; background-color: {getStatusColor(item.status)}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text">{item.progress}%</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if item.status === 'error' && item.error}
|
||||||
|
<div class="error-message">{item.error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if item.status === 'complete'}
|
||||||
|
<div class="success-message">Upload complete</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.progress-item {
|
||||||
|
background-color: var(--color-bg-secondary, #f9fafb);
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filename {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
background-color: var(--color-bg-hover, #f3f4f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--color-bg, #e5e7eb);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-error, #ef4444);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-success, #10b981);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
frontend/src/lib/stores/auth.ts
Normal file
98
frontend/src/lib/stores/auth.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Authentication store
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: AuthState = {
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to load auth from localStorage
|
||||||
|
const loadAuthFromStorage = (): Partial<AuthState> => {
|
||||||
|
if (typeof window === 'undefined') return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
const userStr = localStorage.getItem('auth_user');
|
||||||
|
|
||||||
|
if (token && userStr) {
|
||||||
|
const user = JSON.parse(userStr);
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load auth from storage:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAuthStore = () => {
|
||||||
|
const { subscribe, set, update } = writable<AuthState>({
|
||||||
|
...initialState,
|
||||||
|
...loadAuthFromStorage(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
login: (user: User, token: string) => {
|
||||||
|
// Save to localStorage
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('auth_token', token);
|
||||||
|
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
// Clear localStorage
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('auth_user');
|
||||||
|
}
|
||||||
|
|
||||||
|
set(initialState);
|
||||||
|
},
|
||||||
|
|
||||||
|
setLoading: (isLoading: boolean) => {
|
||||||
|
update((state) => ({ ...state, isLoading }));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUser: (user: User) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
update((state) => ({ ...state, user }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authStore = createAuthStore();
|
||||||
203
frontend/src/lib/stores/boards.ts
Normal file
203
frontend/src/lib/stores/boards.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Boards store - Svelte store for board state management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import type { Writable } from 'svelte/store';
|
||||||
|
import type { Board, BoardSummary, BoardCreate, BoardUpdate } from '$lib/types/boards';
|
||||||
|
import * as boardsApi from '$lib/api/boards';
|
||||||
|
|
||||||
|
interface BoardsState {
|
||||||
|
boards: BoardSummary[];
|
||||||
|
currentBoard: Board | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: BoardsState = {
|
||||||
|
boards: [],
|
||||||
|
currentBoard: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create writable store
|
||||||
|
const boardsStore: Writable<BoardsState> = writable(initialState);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all boards for current user
|
||||||
|
*/
|
||||||
|
export async function loadBoards(limit: number = 50, offset: number = 0): Promise<void> {
|
||||||
|
boardsStore.update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await boardsApi.listBoards(limit, offset);
|
||||||
|
|
||||||
|
boardsStore.update((state) => ({
|
||||||
|
...state,
|
||||||
|
boards: response.boards,
|
||||||
|
total: response.total,
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to load boards';
|
||||||
|
boardsStore.update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: message,
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a specific board by ID
|
||||||
|
*/
|
||||||
|
export async function loadBoard(boardId: string): Promise<void> {
|
||||||
|
boardsStore.update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const board = await boardsApi.getBoard(boardId);
|
||||||
|
|
||||||
|
boardsStore.update((state) => ({
|
||||||
|
...state,
|
||||||
|
currentBoard: board,
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to load board';
|
||||||
|
boardsStore.update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: message,
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new board
|
||||||
|
*/
|
||||||
|
export async function createBoard(data: BoardCreate): Promise<Board> {
|
||||||
|
boardsStore.update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const board = await boardsApi.createBoard(data);
|
||||||
|
|
||||||
|
// Add to boards list
|
||||||
|
boardsStore.update((state) => ({
|
||||||
|
...state,
|
||||||
|
boards: [board, ...state.boards],
|
||||||
|
total: state.total + 1,
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return board;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to create board';
|
||||||
|
boardsStore.update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: message,
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update board metadata
|
||||||
|
*/
|
||||||
|
export async function updateBoard(boardId: string, data: BoardUpdate): Promise<Board> {
|
||||||
|
boardsStore.update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const board = await boardsApi.updateBoard(boardId, data);
|
||||||
|
|
||||||
|
// Update in boards list
|
||||||
|
boardsStore.update((state) => ({
|
||||||
|
...state,
|
||||||
|
boards: state.boards.map((b) => (b.id === boardId ? board : b)),
|
||||||
|
currentBoard: state.currentBoard?.id === boardId ? board : state.currentBoard,
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return board;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to update board';
|
||||||
|
boardsStore.update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: message,
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a board
|
||||||
|
*/
|
||||||
|
export async function deleteBoard(boardId: string): Promise<void> {
|
||||||
|
boardsStore.update((state) => ({ ...state, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await boardsApi.deleteBoard(boardId);
|
||||||
|
|
||||||
|
// Remove from boards list
|
||||||
|
boardsStore.update((state) => ({
|
||||||
|
...state,
|
||||||
|
boards: state.boards.filter((b) => b.id !== boardId),
|
||||||
|
currentBoard: state.currentBoard?.id === boardId ? null : state.currentBoard,
|
||||||
|
total: state.total - 1,
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to delete board';
|
||||||
|
boardsStore.update((state) => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: message,
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear current board
|
||||||
|
*/
|
||||||
|
export function clearCurrentBoard(): void {
|
||||||
|
boardsStore.update((state) => ({
|
||||||
|
...state,
|
||||||
|
currentBoard: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear error
|
||||||
|
*/
|
||||||
|
export function clearError(): void {
|
||||||
|
boardsStore.update((state) => ({
|
||||||
|
...state,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the store
|
||||||
|
export const boards = {
|
||||||
|
subscribe: boardsStore.subscribe,
|
||||||
|
load: loadBoards,
|
||||||
|
loadBoard,
|
||||||
|
create: createBoard,
|
||||||
|
update: updateBoard,
|
||||||
|
delete: deleteBoard,
|
||||||
|
clearCurrent: clearCurrentBoard,
|
||||||
|
clearError,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derived stores for easy access
|
||||||
|
export const boardsList = derived(boardsStore, ($boards) => $boards.boards);
|
||||||
|
export const currentBoard = derived(boardsStore, ($boards) => $boards.currentBoard);
|
||||||
|
export const boardsLoading = derived(boardsStore, ($boards) => $boards.loading);
|
||||||
|
export const boardsError = derived(boardsStore, ($boards) => $boards.error);
|
||||||
|
export const boardsTotal = derived(boardsStore, ($boards) => $boards.total);
|
||||||
184
frontend/src/lib/stores/images.ts
Normal file
184
frontend/src/lib/stores/images.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Images store for state management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import type { Image, BoardImage, ImageUploadProgress } from '$lib/types/images';
|
||||||
|
import * as imagesApi from '$lib/api/images';
|
||||||
|
|
||||||
|
// Store for user's image library
|
||||||
|
export const imageLibrary = writable<Image[]>([]);
|
||||||
|
export const imageLibraryTotal = writable<number>(0);
|
||||||
|
export const imageLibraryPage = writable<number>(1);
|
||||||
|
|
||||||
|
// Store for current board's images
|
||||||
|
export const boardImages = writable<BoardImage[]>([]);
|
||||||
|
|
||||||
|
// Store for upload progress tracking
|
||||||
|
export const uploadProgress = writable<ImageUploadProgress[]>([]);
|
||||||
|
|
||||||
|
// Derived store for active uploads
|
||||||
|
export const activeUploads = derived(uploadProgress, ($progress) =>
|
||||||
|
$progress.filter((p) => p.status === 'uploading' || p.status === 'processing')
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load user's image library
|
||||||
|
*/
|
||||||
|
export async function loadImageLibrary(page: number = 1, pageSize: number = 50): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await imagesApi.getImageLibrary(page, pageSize);
|
||||||
|
imageLibrary.set(response.images);
|
||||||
|
imageLibraryTotal.set(response.total);
|
||||||
|
imageLibraryPage.set(response.page);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load image library:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load images for a specific board
|
||||||
|
*/
|
||||||
|
export async function loadBoardImages(boardId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const images = await imagesApi.getBoardImages(boardId);
|
||||||
|
boardImages.set(images);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load board images:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a single image
|
||||||
|
*/
|
||||||
|
export async function uploadSingleImage(file: File): Promise<Image> {
|
||||||
|
const progressItem: ImageUploadProgress = {
|
||||||
|
filename: file.name,
|
||||||
|
progress: 0,
|
||||||
|
status: 'uploading',
|
||||||
|
};
|
||||||
|
|
||||||
|
uploadProgress.update((items) => [...items, progressItem]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const image = await imagesApi.uploadImage(file);
|
||||||
|
|
||||||
|
// Update progress to complete
|
||||||
|
uploadProgress.update((items) =>
|
||||||
|
items.map((item) =>
|
||||||
|
item.filename === file.name ? { ...item, progress: 100, status: 'complete' } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to library
|
||||||
|
imageLibrary.update((images) => [image, ...images]);
|
||||||
|
|
||||||
|
// Remove from progress after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
uploadProgress.update((items) => items.filter((item) => item.filename !== file.name));
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return image;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Update progress to error
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
||||||
|
uploadProgress.update((items) =>
|
||||||
|
items.map((item) =>
|
||||||
|
item.filename === file.name ? { ...item, status: 'error', error: errorMessage } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload multiple images from ZIP
|
||||||
|
*/
|
||||||
|
export async function uploadZipFile(file: File): Promise<Image[]> {
|
||||||
|
const progressItem: ImageUploadProgress = {
|
||||||
|
filename: file.name,
|
||||||
|
progress: 0,
|
||||||
|
status: 'uploading',
|
||||||
|
};
|
||||||
|
|
||||||
|
uploadProgress.update((items) => [...items, progressItem]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const images = await imagesApi.uploadZip(file);
|
||||||
|
|
||||||
|
// Update progress to complete
|
||||||
|
uploadProgress.update((items) =>
|
||||||
|
items.map((item) =>
|
||||||
|
item.filename === file.name ? { ...item, progress: 100, status: 'complete' } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to library
|
||||||
|
imageLibrary.update((existing) => [...images, ...existing]);
|
||||||
|
|
||||||
|
// Remove from progress after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
uploadProgress.update((items) => items.filter((item) => item.filename !== file.name));
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return images;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Update progress to error
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'ZIP upload failed';
|
||||||
|
uploadProgress.update((items) =>
|
||||||
|
items.map((item) =>
|
||||||
|
item.filename === file.name ? { ...item, status: 'error', error: errorMessage } : item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add image to board
|
||||||
|
*/
|
||||||
|
export async function addImageToBoard(
|
||||||
|
boardId: string,
|
||||||
|
imageId: string,
|
||||||
|
position: { x: number; y: number } = { x: 0, y: 0 },
|
||||||
|
zOrder: number = 0
|
||||||
|
): Promise<BoardImage> {
|
||||||
|
try {
|
||||||
|
const boardImage = await imagesApi.addImageToBoard(boardId, imageId, position, zOrder);
|
||||||
|
boardImages.update((images) => [...images, boardImage]);
|
||||||
|
return boardImage;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add image to board:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove image from board
|
||||||
|
*/
|
||||||
|
export async function removeImageFromBoard(boardId: string, imageId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await imagesApi.removeImageFromBoard(boardId, imageId);
|
||||||
|
boardImages.update((images) => images.filter((img) => img.image_id !== imageId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove image from board:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete image permanently
|
||||||
|
*/
|
||||||
|
export async function deleteImage(imageId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await imagesApi.deleteImage(imageId);
|
||||||
|
imageLibrary.update((images) => images.filter((img) => img.id !== imageId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete image:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
240
frontend/src/lib/stores/viewport.ts
Normal file
240
frontend/src/lib/stores/viewport.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Viewport store for canvas state management
|
||||||
|
* Manages pan, zoom, and rotation state for the infinite canvas
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writable, derived, get } from 'svelte/store';
|
||||||
|
import type { Writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export interface ViewportState {
|
||||||
|
x: number; // Pan X position
|
||||||
|
y: number; // Pan Y position
|
||||||
|
zoom: number; // Zoom level (0.1 to 5.0)
|
||||||
|
rotation: number; // Rotation in degrees (0 to 360)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewportBounds {
|
||||||
|
minZoom: number;
|
||||||
|
maxZoom: number;
|
||||||
|
minRotation: number;
|
||||||
|
maxRotation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_VIEWPORT: ViewportState = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
zoom: 1.0,
|
||||||
|
rotation: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const VIEWPORT_BOUNDS: ViewportBounds = {
|
||||||
|
minZoom: 0.1,
|
||||||
|
maxZoom: 5.0,
|
||||||
|
minRotation: 0,
|
||||||
|
maxRotation: 360,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the viewport store
|
||||||
|
function createViewportStore() {
|
||||||
|
const { subscribe, set, update }: Writable<ViewportState> = writable(DEFAULT_VIEWPORT);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
set,
|
||||||
|
update,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset viewport to default state
|
||||||
|
*/
|
||||||
|
reset: () => {
|
||||||
|
set(DEFAULT_VIEWPORT);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set pan position
|
||||||
|
*/
|
||||||
|
setPan: (x: number, y: number) => {
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pan by delta (relative movement)
|
||||||
|
*/
|
||||||
|
panBy: (deltaX: number, deltaY: number) => {
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
x: state.x + deltaX,
|
||||||
|
y: state.y + deltaY,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set zoom level (clamped to bounds)
|
||||||
|
*/
|
||||||
|
setZoom: (zoom: number, centerX?: number, centerY?: number) => {
|
||||||
|
update((state) => {
|
||||||
|
const clampedZoom = Math.max(
|
||||||
|
VIEWPORT_BOUNDS.minZoom,
|
||||||
|
Math.min(VIEWPORT_BOUNDS.maxZoom, zoom)
|
||||||
|
);
|
||||||
|
|
||||||
|
// If center point provided, zoom to that point
|
||||||
|
if (centerX !== undefined && centerY !== undefined) {
|
||||||
|
const oldZoom = state.zoom;
|
||||||
|
const zoomRatio = clampedZoom / oldZoom;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
x: centerX - (centerX - state.x) * zoomRatio,
|
||||||
|
y: centerY - (centerY - state.y) * zoomRatio,
|
||||||
|
zoom: clampedZoom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
zoom: clampedZoom,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom by factor (relative zoom)
|
||||||
|
*/
|
||||||
|
zoomBy: (factor: number, centerX?: number, centerY?: number) => {
|
||||||
|
const current = get({ subscribe });
|
||||||
|
const newZoom = current.zoom * factor;
|
||||||
|
viewport.setZoom(newZoom, centerX, centerY);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set rotation (clamped to 0-360)
|
||||||
|
*/
|
||||||
|
setRotation: (rotation: number) => {
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
rotation: ((rotation % 360) + 360) % 360, // Normalize to 0-360
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate by delta degrees (relative rotation)
|
||||||
|
*/
|
||||||
|
rotateBy: (delta: number) => {
|
||||||
|
update((state) => ({
|
||||||
|
...state,
|
||||||
|
rotation: (((state.rotation + delta) % 360) + 360) % 360,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fit content to screen
|
||||||
|
* @param contentWidth - Width of content to fit
|
||||||
|
* @param contentHeight - Height of content to fit
|
||||||
|
* @param screenWidth - Width of screen
|
||||||
|
* @param screenHeight - Height of screen
|
||||||
|
* @param padding - Padding around content (default 50px)
|
||||||
|
*/
|
||||||
|
fitToScreen: (
|
||||||
|
contentWidth: number,
|
||||||
|
contentHeight: number,
|
||||||
|
screenWidth: number,
|
||||||
|
screenHeight: number,
|
||||||
|
padding: number = 50
|
||||||
|
) => {
|
||||||
|
const availableWidth = screenWidth - padding * 2;
|
||||||
|
const availableHeight = screenHeight - padding * 2;
|
||||||
|
|
||||||
|
const scaleX = availableWidth / contentWidth;
|
||||||
|
const scaleY = availableHeight / contentHeight;
|
||||||
|
const scale = Math.min(scaleX, scaleY);
|
||||||
|
|
||||||
|
const clampedZoom = Math.max(
|
||||||
|
VIEWPORT_BOUNDS.minZoom,
|
||||||
|
Math.min(VIEWPORT_BOUNDS.maxZoom, scale)
|
||||||
|
);
|
||||||
|
|
||||||
|
const x = (screenWidth - contentWidth * clampedZoom) / 2;
|
||||||
|
const y = (screenHeight - contentHeight * clampedZoom) / 2;
|
||||||
|
|
||||||
|
set({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
zoom: clampedZoom,
|
||||||
|
rotation: 0, // Reset rotation when fitting
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load viewport state from data (e.g., from backend)
|
||||||
|
*/
|
||||||
|
loadState: (state: Partial<ViewportState>) => {
|
||||||
|
update((current) => ({
|
||||||
|
...current,
|
||||||
|
...state,
|
||||||
|
zoom: Math.max(
|
||||||
|
VIEWPORT_BOUNDS.minZoom,
|
||||||
|
Math.min(VIEWPORT_BOUNDS.maxZoom, state.zoom || current.zoom)
|
||||||
|
),
|
||||||
|
rotation:
|
||||||
|
state.rotation !== undefined ? ((state.rotation % 360) + 360) % 360 : current.rotation,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current viewport bounds
|
||||||
|
*/
|
||||||
|
getBounds: () => VIEWPORT_BOUNDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport = createViewportStore();
|
||||||
|
|
||||||
|
// Derived store for checking if viewport is at default
|
||||||
|
export const isViewportDefault = derived(viewport, ($viewport) => {
|
||||||
|
return (
|
||||||
|
$viewport.x === DEFAULT_VIEWPORT.x &&
|
||||||
|
$viewport.y === DEFAULT_VIEWPORT.y &&
|
||||||
|
$viewport.zoom === DEFAULT_VIEWPORT.zoom &&
|
||||||
|
$viewport.rotation === DEFAULT_VIEWPORT.rotation
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derived store for checking zoom limits
|
||||||
|
export const isZoomMin = derived(viewport, ($viewport) => {
|
||||||
|
return $viewport.zoom <= VIEWPORT_BOUNDS.minZoom;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isZoomMax = derived(viewport, ($viewport) => {
|
||||||
|
return $viewport.zoom >= VIEWPORT_BOUNDS.maxZoom;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to serialize viewport state for backend
|
||||||
|
export function serializeViewportState(state: ViewportState): string {
|
||||||
|
return JSON.stringify(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to deserialize viewport state from backend
|
||||||
|
export function deserializeViewportState(json: string): ViewportState {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
return {
|
||||||
|
x: typeof parsed.x === 'number' ? parsed.x : DEFAULT_VIEWPORT.x,
|
||||||
|
y: typeof parsed.y === 'number' ? parsed.y : DEFAULT_VIEWPORT.y,
|
||||||
|
zoom:
|
||||||
|
typeof parsed.zoom === 'number'
|
||||||
|
? Math.max(VIEWPORT_BOUNDS.minZoom, Math.min(VIEWPORT_BOUNDS.maxZoom, parsed.zoom))
|
||||||
|
: DEFAULT_VIEWPORT.zoom,
|
||||||
|
rotation:
|
||||||
|
typeof parsed.rotation === 'number'
|
||||||
|
? ((parsed.rotation % 360) + 360) % 360
|
||||||
|
: DEFAULT_VIEWPORT.rotation,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_VIEWPORT;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
frontend/src/lib/types/boards.ts
Normal file
44
frontend/src/lib/types/boards.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Board-related TypeScript types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ViewportState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
zoom: number;
|
||||||
|
rotation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardCreate {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardUpdate {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
viewport_state?: ViewportState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
image_count: number;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Board extends BoardSummary {
|
||||||
|
user_id: string;
|
||||||
|
viewport_state: ViewportState;
|
||||||
|
is_deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardListResponse {
|
||||||
|
boards: BoardSummary[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
71
frontend/src/lib/types/images.ts
Normal file
71
frontend/src/lib/types/images.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Image types for the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ImageMetadata {
|
||||||
|
format: string;
|
||||||
|
checksum: string;
|
||||||
|
exif?: Record<string, unknown>;
|
||||||
|
thumbnails: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Image {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
filename: string;
|
||||||
|
storage_path: string;
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
metadata: ImageMetadata;
|
||||||
|
created_at: string;
|
||||||
|
reference_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardImagePosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardImageTransformations {
|
||||||
|
scale: number;
|
||||||
|
rotation: number;
|
||||||
|
opacity: number;
|
||||||
|
flipped_h: boolean;
|
||||||
|
flipped_v: boolean;
|
||||||
|
greyscale: boolean;
|
||||||
|
crop?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardImage {
|
||||||
|
id: string;
|
||||||
|
board_id: string;
|
||||||
|
image_id: string;
|
||||||
|
position: BoardImagePosition;
|
||||||
|
transformations: BoardImageTransformations;
|
||||||
|
z_order: number;
|
||||||
|
group_id?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
image: Image;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageUploadProgress {
|
||||||
|
filename: string;
|
||||||
|
progress: number; // 0-100
|
||||||
|
status: 'pending' | 'uploading' | 'processing' | 'complete' | 'error';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageListResponse {
|
||||||
|
images: Image[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
58
frontend/src/lib/utils/clipboard.ts
Normal file
58
frontend/src/lib/utils/clipboard.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Clipboard utilities for paste-to-upload functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle paste events and extract image files
|
||||||
|
*/
|
||||||
|
export async function handlePaste(event: ClipboardEvent): Promise<File[]> {
|
||||||
|
const items = event.clipboardData?.items;
|
||||||
|
if (!items) return [];
|
||||||
|
|
||||||
|
const imageFiles: File[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
|
||||||
|
// Check if item is an image
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
// Generate a filename with timestamp
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const extension = file.type.split('/')[1] || 'png';
|
||||||
|
const newFile = new File([file], `pasted-image-${timestamp}.${extension}`, {
|
||||||
|
type: file.type,
|
||||||
|
});
|
||||||
|
imageFiles.push(newFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up global paste event listener for image uploads
|
||||||
|
*/
|
||||||
|
export function setupPasteListener(
|
||||||
|
callback: (files: File[]) => void,
|
||||||
|
element: HTMLElement | Document = document
|
||||||
|
): () => void {
|
||||||
|
const handlePasteEvent = async (event: Event) => {
|
||||||
|
const clipboardEvent = event as ClipboardEvent;
|
||||||
|
const files = await handlePaste(clipboardEvent);
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
callback(files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener('paste', handlePasteEvent);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('paste', handlePasteEvent);
|
||||||
|
};
|
||||||
|
}
|
||||||
52
frontend/src/lib/utils/errors.ts
Normal file
52
frontend/src/lib/utils/errors.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Error handling utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ApiError } from '$lib/api/client';
|
||||||
|
|
||||||
|
export const getErrorMessage = (error: unknown): string => {
|
||||||
|
if (!error) return 'An unknown error occurred';
|
||||||
|
|
||||||
|
if (typeof error === 'string') return error;
|
||||||
|
|
||||||
|
if ((error as ApiError).error) {
|
||||||
|
return (error as ApiError).error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((error as Error).message) {
|
||||||
|
return (error as Error).message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'An unknown error occurred';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isAuthError = (error: unknown): boolean => {
|
||||||
|
if (!error) return false;
|
||||||
|
|
||||||
|
const apiError = error as ApiError;
|
||||||
|
return apiError.status_code === 401 || apiError.status_code === 403;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isValidationError = (error: unknown): boolean => {
|
||||||
|
if (!error) return false;
|
||||||
|
|
||||||
|
const apiError = error as ApiError;
|
||||||
|
return apiError.status_code === 422;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getValidationErrors = (error: unknown): Record<string, string[]> => {
|
||||||
|
if (!isValidationError(error)) return {};
|
||||||
|
|
||||||
|
const apiError = error as ApiError;
|
||||||
|
return apiError.details || {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatValidationErrors = (errors: Record<string, string[]>): string => {
|
||||||
|
const messages: string[] = [];
|
||||||
|
|
||||||
|
for (const [field, fieldErrors] of Object.entries(errors)) {
|
||||||
|
messages.push(`${field}: ${fieldErrors.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages.join('\n');
|
||||||
|
};
|
||||||
40
frontend/src/lib/utils/zip-upload.ts
Normal file
40
frontend/src/lib/utils/zip-upload.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* ZIP file upload utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if file is a ZIP archive
|
||||||
|
*/
|
||||||
|
export function isZipFile(file: File): boolean {
|
||||||
|
return (
|
||||||
|
file.type === 'application/zip' ||
|
||||||
|
file.type === 'application/x-zip-compressed' ||
|
||||||
|
file.name.toLowerCase().endsWith('.zip')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate ZIP file size
|
||||||
|
*/
|
||||||
|
export function validateZipSize(file: File, maxSize: number = 200 * 1024 * 1024): boolean {
|
||||||
|
return file.size <= maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract metadata from ZIP file name
|
||||||
|
*/
|
||||||
|
export function getZipMetadata(file: File): {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
sizeFormatted: string;
|
||||||
|
} {
|
||||||
|
const sizeInMB = file.size / (1024 * 1024);
|
||||||
|
const sizeFormatted =
|
||||||
|
sizeInMB > 1 ? `${sizeInMB.toFixed(2)} MB` : `${(file.size / 1024).toFixed(2)} KB`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
sizeFormatted,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { authApi } from '$lib/api/auth';
|
import { authApi } from '$lib/api/auth';
|
||||||
import type { ApiError } from '$lib/api/client';
|
import type { ApiError } from '$lib/api/client';
|
||||||
import LoginForm from '$lib/components/auth/LoginForm.svelte';
|
import LoginForm from '$lib/components/auth/LoginForm.svelte';
|
||||||
import { authStore } from '$lib/stores/auth';
|
import { authStore, type AuthState } from '$lib/stores/auth';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let error: string = '';
|
let error: string = '';
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Redirect if already authenticated
|
// Redirect if already authenticated
|
||||||
authStore.subscribe((state) => {
|
authStore.subscribe((state: AuthState) => {
|
||||||
if (state.isAuthenticated) {
|
if (state.isAuthenticated) {
|
||||||
goto('/boards');
|
goto('/boards');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { authApi } from '$lib/api/auth';
|
import { authApi } from '$lib/api/auth';
|
||||||
import type { ApiError } from '$lib/api/client';
|
import type { ApiError } from '$lib/api/client';
|
||||||
import RegisterForm from '$lib/components/auth/RegisterForm.svelte';
|
import RegisterForm from '$lib/components/auth/RegisterForm.svelte';
|
||||||
import { authStore } from '$lib/stores/auth';
|
import { authStore, type AuthState } from '$lib/stores/auth';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let error: string = '';
|
let error: string = '';
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Redirect if already authenticated
|
// Redirect if already authenticated
|
||||||
authStore.subscribe((state) => {
|
authStore.subscribe((state: AuthState) => {
|
||||||
if (state.isAuthenticated) {
|
if (state.isAuthenticated) {
|
||||||
goto('/boards');
|
goto('/boards');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user