diff --git a/.gitignore b/.gitignore index 0180280..c3e07d7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,9 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +**/lib/ +**/lib64/ +!frontend/src/lib/ parts/ sdist/ var/ diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts new file mode 100644 index 0000000..551900b --- /dev/null +++ b/frontend/src/lib/api/auth.ts @@ -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 { + return apiClient.post('/auth/register', data); + }, + + /** + * Login user and get JWT token + */ + async login(data: LoginRequest): Promise { + return apiClient.post('/auth/login', data); + }, + + /** + * Get current user information + */ + async getCurrentUser(): Promise { + return apiClient.get('/auth/me'); + }, +}; diff --git a/frontend/src/lib/api/boards.ts b/frontend/src/lib/api/boards.ts new file mode 100644 index 0000000..8792378 --- /dev/null +++ b/frontend/src/lib/api/boards.ts @@ -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 { + const response = await apiClient.post('/boards', data); + return response; +} + +/** + * List all boards for current user + */ +export async function listBoards( + limit: number = 50, + offset: number = 0 +): Promise { + const response = await apiClient.get( + `/boards?limit=${limit}&offset=${offset}` + ); + return response; +} + +/** + * Get board by ID + */ +export async function getBoard(boardId: string): Promise { + const response = await apiClient.get(`/boards/${boardId}`); + return response; +} + +/** + * Update board metadata + */ +export async function updateBoard(boardId: string, data: BoardUpdate): Promise { + const response = await apiClient.patch(`/boards/${boardId}`, data); + return response; +} + +/** + * Delete board + */ +export async function deleteBoard(boardId: string): Promise { + await apiClient.delete(`/boards/${boardId}`); +} + +/** + * Update board viewport state + */ +export async function updateViewport(boardId: string, viewport: ViewportState): Promise { + return updateBoard(boardId, { viewport_state: viewport }); +} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..60dac8c --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -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; + status_code: number; +} + +export class ApiClient { + private baseUrl: string; + + constructor(baseUrl: string = API_BASE_URL) { + this.baseUrl = baseUrl; + } + + private async request(endpoint: string, options: RequestInit = {}): Promise { + const { token } = get(authStore); + + const headers: Record = { + 'Content-Type': 'application/json', + ...((options.headers as Record) || {}), + }; + + // 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(endpoint: string, options?: RequestInit): Promise { + return this.request(endpoint, { ...options, method: 'GET' }); + } + + async post(endpoint: string, data?: unknown, options?: RequestInit): Promise { + return this.request(endpoint, { + ...options, + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async put(endpoint: string, data?: unknown, options?: RequestInit): Promise { + return this.request(endpoint, { + ...options, + method: 'PUT', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async patch(endpoint: string, data?: unknown, options?: RequestInit): Promise { + return this.request(endpoint, { + ...options, + method: 'PATCH', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async delete(endpoint: string, options?: RequestInit): Promise { + return this.request(endpoint, { ...options, method: 'DELETE' }); + } + + async uploadFile( + endpoint: string, + file: File, + additionalData?: Record + ): Promise { + 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(); diff --git a/frontend/src/lib/api/images.ts b/frontend/src/lib/api/images.ts new file mode 100644 index 0000000..d61feb6 --- /dev/null +++ b/frontend/src/lib/api/images.ts @@ -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 { + const formData = new FormData(); + formData.append('file', file); + + const response = await apiClient.post('/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 { + const formData = new FormData(); + formData.append('file', file); + + const response = await apiClient.post('/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 { + const params = new URLSearchParams({ + page: page.toString(), + page_size: pageSize.toString(), + }); + + return await apiClient.get(`/images/library?${params}`); +} + +/** + * Get image by ID + */ +export async function getImage(imageId: string): Promise { + return await apiClient.get(`/images/${imageId}`); +} + +/** + * Delete image permanently (only if not used on any boards) + */ +export async function deleteImage(imageId: string): Promise { + 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 { + 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(`/images/boards/${boardId}/images`, payload); +} + +/** + * Remove image from board + */ +export async function removeImageFromBoard(boardId: string, imageId: string): Promise { + await apiClient.delete(`/images/boards/${boardId}/images/${imageId}`); +} + +/** + * Get all images on a board + */ +export async function getBoardImages(boardId: string): Promise { + return await apiClient.get(`/images/boards/${boardId}/images`); +} diff --git a/frontend/src/lib/canvas/Stage.svelte b/frontend/src/lib/canvas/Stage.svelte new file mode 100644 index 0000000..4eacf37 --- /dev/null +++ b/frontend/src/lib/canvas/Stage.svelte @@ -0,0 +1,178 @@ + + +
+
+
+ + diff --git a/frontend/src/lib/canvas/controls/fit.ts b/frontend/src/lib/canvas/controls/fit.ts new file mode 100644 index 0000000..bff78fa --- /dev/null +++ b/frontend/src/lib/canvas/controls/fit.ts @@ -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); + } +} diff --git a/frontend/src/lib/canvas/controls/pan.ts b/frontend/src/lib/canvas/controls/pan.ts new file mode 100644 index 0000000..d5be934 --- /dev/null +++ b/frontend/src/lib/canvas/controls/pan.ts @@ -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) { + // 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) { + 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) { + 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); + } +} diff --git a/frontend/src/lib/canvas/controls/reset.ts b/frontend/src/lib/canvas/controls/reset.ts new file mode 100644 index 0000000..3c53942 --- /dev/null +++ b/frontend/src/lib/canvas/controls/reset.ts @@ -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); + } +} diff --git a/frontend/src/lib/canvas/controls/rotate.ts b/frontend/src/lib/canvas/controls/rotate.ts new file mode 100644 index 0000000..321f7e5 --- /dev/null +++ b/frontend/src/lib/canvas/controls/rotate.ts @@ -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); +} diff --git a/frontend/src/lib/canvas/controls/zoom.ts b/frontend/src/lib/canvas/controls/zoom.ts new file mode 100644 index 0000000..fe45a0f --- /dev/null +++ b/frontend/src/lib/canvas/controls/zoom.ts @@ -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) { + 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); +} diff --git a/frontend/src/lib/canvas/gestures.ts b/frontend/src/lib/canvas/gestures.ts new file mode 100644 index 0000000..d7f2819 --- /dev/null +++ b/frontend/src/lib/canvas/gestures.ts @@ -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'; +} diff --git a/frontend/src/lib/canvas/viewportSync.ts b/frontend/src/lib/canvas/viewportSync.ts new file mode 100644 index 0000000..87d3c3b --- /dev/null +++ b/frontend/src/lib/canvas/viewportSync.ts @@ -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 | 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 { + 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 { + if (syncTimeout) { + clearTimeout(syncTimeout); + syncTimeout = null; + } + + const state = await new Promise((resolve) => { + const unsubscribe = viewport.subscribe((s) => { + unsubscribe(); + resolve(s); + }); + }); + + await syncViewport(state); +} + +/** + * Load viewport state from backend + */ +export async function loadViewportState(boardId: string): Promise { + 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; +} diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte new file mode 100644 index 0000000..9705e73 --- /dev/null +++ b/frontend/src/lib/components/Toast.svelte @@ -0,0 +1,94 @@ + + +{#if visible} + +{/if} + + diff --git a/frontend/src/lib/components/auth/LoginForm.svelte b/frontend/src/lib/components/auth/LoginForm.svelte new file mode 100644 index 0000000..bdd7e85 --- /dev/null +++ b/frontend/src/lib/components/auth/LoginForm.svelte @@ -0,0 +1,174 @@ + + + + + diff --git a/frontend/src/lib/components/auth/RegisterForm.svelte b/frontend/src/lib/components/auth/RegisterForm.svelte new file mode 100644 index 0000000..255cb0f --- /dev/null +++ b/frontend/src/lib/components/auth/RegisterForm.svelte @@ -0,0 +1,225 @@ + + +
+
+ + + {#if errors.email} + {errors.email} + {/if} +
+ +
+ + + {#if errors.password} + {errors.password} + {:else} + Must be 8+ characters with uppercase, lowercase, and number + {/if} +
+ +
+ + + {#if errors.confirmPassword} + {errors.confirmPassword} + {/if} +
+ + +
+ + diff --git a/frontend/src/lib/components/boards/BoardCard.svelte b/frontend/src/lib/components/boards/BoardCard.svelte new file mode 100644 index 0000000..efa89ca --- /dev/null +++ b/frontend/src/lib/components/boards/BoardCard.svelte @@ -0,0 +1,206 @@ + + + + +
e.key === 'Enter' && openBoard()} + tabindex="0" +> +
+ {#if board.thumbnail_url} + {board.title} + {:else} +
+ 🖼️ +
+ {/if} + {#if board.image_count > 0} +
+ {board.image_count} + {board.image_count === 1 ? 'image' : 'images'} +
+ {/if} +
+ +
+

{board.title}

+ {#if board.description} +

{board.description}

+ {/if} +
+ Updated {formatDate(board.updated_at)} +
+
+ +
+ +
+
+ + diff --git a/frontend/src/lib/components/boards/CreateBoardModal.svelte b/frontend/src/lib/components/boards/CreateBoardModal.svelte new file mode 100644 index 0000000..e73be99 --- /dev/null +++ b/frontend/src/lib/components/boards/CreateBoardModal.svelte @@ -0,0 +1,266 @@ + + + +