diff --git a/frontend/src/api.ts b/frontend/src/api.ts index dc28819..9fde2ce 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -107,11 +107,14 @@ export function saveCanvas(boardId: string, canvasState: string, thumbnail?: str return api.post(`/api/boards/${boardId}/save`, { canvas_state: canvasState, thumbnail }); } -export function uploadImage(boardId: string, file: File) { +export function uploadImage(boardId: string, file: File, onProgress?: (progress: number) => void) { const formData = new FormData(); formData.append('image', file); return api.post(`/api/upload/boards/${boardId}/images`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: onProgress + ? (e) => { if (e.total) onProgress(e.loaded / e.total); } + : undefined, }); } diff --git a/frontend/src/canvas/image-drop.ts b/frontend/src/canvas/image-drop.ts index 770ceca..d0f4f9e 100644 --- a/frontend/src/canvas/image-drop.ts +++ b/frontend/src/canvas/image-drop.ts @@ -2,6 +2,7 @@ import { Graphics } from 'pixi.js'; import type { Viewport } from 'pixi-viewport'; import type { SceneManager } from './SceneManager'; import type { SelectionManager } from './SelectionManager'; +import type { UploadManager } from '../stores/uploadManager'; import { uploadImage, uploadImageFromUrl } from '../api'; type OnChange = () => void; @@ -83,6 +84,7 @@ export function setupDragDrop( boardId: string, onChange: OnChange, selection?: SelectionManager | null, + uploads?: UploadManager | null, ): () => void { function onDragOver(e: DragEvent) { e.preventDefault(); @@ -118,7 +120,7 @@ export function setupDragDrop( removePlaceholder(viewport, placeholder); const { id } = handleUploadResult(res, viewport, sceneManager, world.x, world.y, onChange); selection?.selectOnly(id); - } catch (err) { + } catch (err: any) { console.error('URL image upload failed:', err); removePlaceholder(viewport, placeholder); } @@ -152,17 +154,24 @@ export function setupDragDrop( for (let c = 0; c < col; c++) cursorX += (colWidths[c] || 220) + GAP; const placeholder = createPlaceholder(viewport, cursorX, cursorY); + const jobId = uploads?.addJob(file, boardId); try { - const res = await uploadImage(boardId, file); + const res = await uploadImage(boardId, file, (p) => { + if (jobId) uploads?.setProgress(jobId, p); + }); removePlaceholder(viewport, placeholder); const { w: placedW, h: placedH, id } = handleUploadResult(res, viewport, sceneManager, cursorX, cursorY, onChange); newItemIds.push(id); if (placedW > (colWidths[col] || 0)) colWidths[col] = placedW; if (placedH > rowMaxH) rowMaxH = placedH; - } catch (err) { + // Link upload job to DB image for processing tracking + const imgData = res.data.image || res.data; + if (jobId) uploads?.uploadComplete(jobId, imgData.id); + } catch (err: any) { console.error('Image upload failed:', err); removePlaceholder(viewport, placeholder); + if (jobId) uploads?.setFailed(jobId, err.response?.data?.error || err.message || 'Upload failed'); if (220 > (colWidths[col] || 0)) colWidths[col] = 220; if (150 > rowMaxH) rowMaxH = 150; } @@ -216,6 +225,7 @@ export function setupPaste( boardId: string, onChange: OnChange, selection?: SelectionManager | null, + uploads?: UploadManager | null, ): () => void { async function onPaste(e: ClipboardEvent) { const items = e.clipboardData?.items; @@ -237,15 +247,21 @@ export function setupPaste( const cy = center.y; const placeholder = createPlaceholder(viewport, cx - 100, cy - 75); + const jobId = uploads?.addJob(file, boardId); try { - const res = await uploadImage(boardId, file); + const res = await uploadImage(boardId, file, (p) => { + if (jobId) uploads?.setProgress(jobId, p); + }); removePlaceholder(viewport, placeholder); const { id } = handleUploadResult(res, viewport, sceneManager, cx - 100, cy - 75, onChange); newItemIds.push(id); - } catch (err) { + const imgData = res.data.image || res.data; + if (jobId) uploads?.uploadComplete(jobId, imgData.id); + } catch (err: any) { console.error('Image paste upload failed:', err); removePlaceholder(viewport, placeholder); + if (jobId) uploads?.setFailed(jobId, err.response?.data?.error || err.message || 'Upload failed'); } } diff --git a/frontend/src/components/UploadPanel.tsx b/frontend/src/components/UploadPanel.tsx new file mode 100644 index 0000000..a3368cc --- /dev/null +++ b/frontend/src/components/UploadPanel.tsx @@ -0,0 +1,132 @@ +import React, { useSyncExternalStore } from 'react'; +import { UploadManager, UploadJob } from '../stores/uploadManager'; + +interface UploadPanelProps { + uploadManager: UploadManager; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +const STATUS_LABELS: Record = { + uploading: 'Uploading', + processing: 'Processing', + done: 'Done', + failed: 'Failed', +}; + +const STATUS_COLORS: Record = { + uploading: '#4a9eff', + processing: '#ffa94d', + done: '#4ade80', + failed: '#f87171', +}; + +function JobRow({ job, onDismiss }: { job: UploadJob; onDismiss: () => void }) { + const pct = Math.round(job.progress * 100); + const color = STATUS_COLORS[job.status]; + + return ( +
+
+ + + {job.fileName} + + + {formatSize(job.fileSize)} + + {(job.status === 'done' || job.status === 'failed') && ( + + )} +
+ + {/* Progress bar for uploading */} + {job.status === 'uploading' && ( +
+
+
+ )} + + {/* Status label */} +
+ + {job.status === 'uploading' ? `${STATUS_LABELS[job.status]} ${pct}%` : STATUS_LABELS[job.status]} + + {job.error && ( + — {job.error} + )} +
+
+ ); +} + +export default function UploadPanel({ uploadManager }: UploadPanelProps) { + // Re-render on any store change + const _v = useSyncExternalStore( + (cb) => uploadManager.subscribe(cb), + () => uploadManager.jobs.size + Array.from(uploadManager.jobs.values()).reduce((s, j) => s + j.progress + (j.status === 'done' ? 100 : 0), 0), + ); + + const jobs = Array.from(uploadManager.jobs.values()) + .sort((a, b) => b.createdAt - a.createdAt); + + if (jobs.length === 0) return null; + + return ( +
+ {/* Header */} +
+ + Uploads {uploadManager.activeCount > 0 ? `(${uploadManager.activeCount})` : ''} + + {jobs.some((j) => j.status === 'done' || j.status === 'failed') && ( + + )} +
+ + {/* Job list — max 4 visible, scroll */} +
+ {jobs.map((job) => ( + uploadManager.dismiss(job.id)} /> + ))} +
+ + {/* Pulse animation for processing state */} + +
+ ); +} diff --git a/frontend/src/hooks/useCanvasSetup.ts b/frontend/src/hooks/useCanvasSetup.ts index eb89b0a..6222062 100644 --- a/frontend/src/hooks/useCanvasSetup.ts +++ b/frontend/src/hooks/useCanvasSetup.ts @@ -8,6 +8,7 @@ import { UndoManager } from '../canvas/history'; import { InboxZone } from '../canvas/InboxZone'; import { LaserPointer } from '../canvas/LaserPointer'; import { VideoSprite } from '../canvas/sprites/VideoSprite'; +import { UploadManager } from '../stores/uploadManager'; // PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit import { connectSocket, disconnectSocket } from '../socket'; @@ -42,6 +43,7 @@ interface CanvasSetupDeps { syncRef: React.MutableRefObject; inboxZoneRef: React.MutableRefObject; canvasContainerRef: React.RefObject; + uploadManager: UploadManager; onCanvasChange: (changedIds?: string[]) => void; showToast: (msg: string) => void; setOnlineUsers: React.Dispatch>; @@ -56,7 +58,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { const { boardData, resolvedBoardId, user, isPublicView, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef, - onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, + uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, } = deps; const dropCleanupRef = useRef<(() => void) | null>(null); @@ -186,6 +188,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { const { imageId, posterAssetKey, nativeWidth, nativeHeight, duration } = data; if (!imageId) return; + // Update upload manager — transitions video jobs from "processing" to "done" + uploadManager.processingComplete(imageId); + // Find the video item by matching its DB image ID stored in scene data for (const item of scene.items.values()) { if (item.data.type !== 'video') continue; @@ -296,9 +301,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { dropTarget = canvasEl?.parentElement ?? null; } if (dropTarget) { - dropCleanupRef.current = setupDragDrop(dropTarget, viewport, scene, resolvedBoardId, onCanvasChange, selection); + dropCleanupRef.current = setupDragDrop(dropTarget, viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager); } - pasteCleanupRef.current = setupPaste(viewport, scene, resolvedBoardId, onCanvasChange, selection); + pasteCleanupRef.current = setupPaste(viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager); } }, 200); @@ -318,5 +323,5 @@ export function useCanvasSetup(deps: CanvasSetupDeps) { pasteCleanupRef.current?.(); disconnectSocket(); }; - }, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, setOnlineUsers, setSelectedLayerIds]); + }, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds]); } diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 8e80553..a8db4e8 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -23,6 +23,8 @@ import VideoControls from '../components/VideoControls'; import ShortcutsHelp from '../components/ShortcutsHelp'; import MattermostImport from '../components/MattermostImport'; import Minimap from '../components/Minimap'; +import UploadPanel from '../components/UploadPanel'; +import { UploadManager } from '../stores/uploadManager'; import { InboxZone } from '../canvas/InboxZone'; import { getItemWorldBounds } from '../canvas/SceneManager'; import { VideoSprite } from '../canvas/sprites/VideoSprite'; @@ -60,6 +62,7 @@ export default function Editor({ isPublicView }: EditorProps) { const inboxZoneRef = useRef(null); const clipboardRef = useRef([]); const canvasContainerRef = useRef(null); + const [uploadManager] = useState(() => new UploadManager()); // UI state const [activeTool, setActiveTool] = useState(ToolType.SELECT); @@ -146,7 +149,7 @@ export default function Editor({ isPublicView }: EditorProps) { useCanvasSetup({ boardData, resolvedBoardId, user, isPublicView, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef, - onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, + uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, }); // Tool activation @@ -631,6 +634,9 @@ export default function Editor({ isPublicView }: EditorProps) { /> )} + {/* Upload progress panel */} + + {/* Toasts */}
void; + +export class UploadManager { + jobs = new Map(); + private _listeners = new Set(); + private _dismissTimers = new Map>(); + + subscribe(fn: Listener): () => void { + this._listeners.add(fn); + return () => this._listeners.delete(fn); + } + + private _notify() { + for (const fn of this._listeners) fn(); + } + + /** Create a new upload job. Returns the job ID. */ + addJob(file: File, boardId: string): string { + const id = crypto.randomUUID(); + const isVideo = file.type.startsWith('video/'); + this.jobs.set(id, { + id, + fileName: file.name || (isVideo ? 'video' : 'image'), + fileSize: file.size, + mediaType: isVideo ? 'video' : 'image', + status: 'uploading', + progress: 0, + file, + boardId, + createdAt: Date.now(), + }); + this._notify(); + return id; + } + + /** Update upload progress (0-1). */ + setProgress(jobId: string, progress: number) { + const job = this.jobs.get(jobId); + if (!job) return; + job.progress = progress; + this._notify(); + } + + /** Upload finished — image is done, video moves to processing. */ + uploadComplete(jobId: string, imageId: string) { + const job = this.jobs.get(jobId); + if (!job) return; + job.imageId = imageId; + job.progress = 1; + // Clean up retained file reference + delete job.file; + if (job.mediaType === 'video') { + job.status = 'processing'; + } else { + job.status = 'done'; + this._autoDismiss(jobId); + } + this._notify(); + } + + /** Video processing finished (from media:job:update socket event). */ + processingComplete(imageId: string) { + for (const job of this.jobs.values()) { + if (job.imageId === imageId && job.status === 'processing') { + job.status = 'done'; + this._autoDismiss(jobId(job)); + this._notify(); + return; + } + } + } + + /** Mark a job as failed. */ + setFailed(jobId: string, error: string) { + const job = this.jobs.get(jobId); + if (!job) return; + job.status = 'failed'; + job.error = error; + this._notify(); + } + + /** Remove a job from the list. */ + dismiss(jobId: string) { + this._clearDismissTimer(jobId); + this.jobs.delete(jobId); + this._notify(); + } + + /** Remove all completed/failed jobs. */ + clearFinished() { + for (const [id, job] of this.jobs) { + if (job.status === 'done' || job.status === 'failed') { + this._clearDismissTimer(id); + this.jobs.delete(id); + } + } + this._notify(); + } + + /** Get active (non-done) job count. */ + get activeCount(): number { + let count = 0; + for (const job of this.jobs.values()) { + if (job.status !== 'done') count++; + } + return count; + } + + private _autoDismiss(jobId: string) { + this._clearDismissTimer(jobId); + this._dismissTimers.set(jobId, setTimeout(() => { + this.jobs.delete(jobId); + this._dismissTimers.delete(jobId); + this._notify(); + }, 4000)); + } + + private _clearDismissTimer(jobId: string) { + const timer = this._dismissTimers.get(jobId); + if (timer) { + clearTimeout(timer); + this._dismissTimers.delete(jobId); + } + } +} + +function jobId(job: UploadJob): string { + return job.id; +}