feat: add Upload Manager with progress tracking and status panel

- UploadManager store: tracks jobs through uploading → processing → done/failed
- UploadPanel component: floating bottom-left widget showing upload progress,
  file names, sizes, status with auto-dismiss for completed items
- axios onUploadProgress wired for real-time upload percentage
- Video jobs transition to "processing" after upload, then "done" when
  media:job:update socket event arrives (reuses existing pipeline)
- Failed uploads show error message from server response
- Clear button to dismiss finished/failed items
This commit is contained in:
Hiren Kangad
2026-03-10 18:56:25 +05:30
parent 26527006a6
commit 38fb3edf14
6 changed files with 320 additions and 11 deletions
+4 -1
View File
@@ -107,11 +107,14 @@ export function saveCanvas(boardId: string, canvasState: string, thumbnail?: str
return api.post(`/api/boards/${boardId}/save`, { canvas_state: canvasState, thumbnail }); 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(); const formData = new FormData();
formData.append('image', file); formData.append('image', file);
return api.post(`/api/upload/boards/${boardId}/images`, formData, { return api.post(`/api/upload/boards/${boardId}/images`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: onProgress
? (e) => { if (e.total) onProgress(e.loaded / e.total); }
: undefined,
}); });
} }
+21 -5
View File
@@ -2,6 +2,7 @@ import { Graphics } from 'pixi.js';
import type { Viewport } from 'pixi-viewport'; import type { Viewport } from 'pixi-viewport';
import type { SceneManager } from './SceneManager'; import type { SceneManager } from './SceneManager';
import type { SelectionManager } from './SelectionManager'; import type { SelectionManager } from './SelectionManager';
import type { UploadManager } from '../stores/uploadManager';
import { uploadImage, uploadImageFromUrl } from '../api'; import { uploadImage, uploadImageFromUrl } from '../api';
type OnChange = () => void; type OnChange = () => void;
@@ -83,6 +84,7 @@ export function setupDragDrop(
boardId: string, boardId: string,
onChange: OnChange, onChange: OnChange,
selection?: SelectionManager | null, selection?: SelectionManager | null,
uploads?: UploadManager | null,
): () => void { ): () => void {
function onDragOver(e: DragEvent) { function onDragOver(e: DragEvent) {
e.preventDefault(); e.preventDefault();
@@ -118,7 +120,7 @@ export function setupDragDrop(
removePlaceholder(viewport, placeholder); removePlaceholder(viewport, placeholder);
const { id } = handleUploadResult(res, viewport, sceneManager, world.x, world.y, onChange); const { id } = handleUploadResult(res, viewport, sceneManager, world.x, world.y, onChange);
selection?.selectOnly(id); selection?.selectOnly(id);
} catch (err) { } catch (err: any) {
console.error('URL image upload failed:', err); console.error('URL image upload failed:', err);
removePlaceholder(viewport, placeholder); removePlaceholder(viewport, placeholder);
} }
@@ -152,17 +154,24 @@ export function setupDragDrop(
for (let c = 0; c < col; c++) cursorX += (colWidths[c] || 220) + GAP; for (let c = 0; c < col; c++) cursorX += (colWidths[c] || 220) + GAP;
const placeholder = createPlaceholder(viewport, cursorX, cursorY); const placeholder = createPlaceholder(viewport, cursorX, cursorY);
const jobId = uploads?.addJob(file, boardId);
try { try {
const res = await uploadImage(boardId, file); const res = await uploadImage(boardId, file, (p) => {
if (jobId) uploads?.setProgress(jobId, p);
});
removePlaceholder(viewport, placeholder); removePlaceholder(viewport, placeholder);
const { w: placedW, h: placedH, id } = handleUploadResult(res, viewport, sceneManager, cursorX, cursorY, onChange); const { w: placedW, h: placedH, id } = handleUploadResult(res, viewport, sceneManager, cursorX, cursorY, onChange);
newItemIds.push(id); newItemIds.push(id);
if (placedW > (colWidths[col] || 0)) colWidths[col] = placedW; if (placedW > (colWidths[col] || 0)) colWidths[col] = placedW;
if (placedH > rowMaxH) rowMaxH = placedH; 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); console.error('Image upload failed:', err);
removePlaceholder(viewport, placeholder); 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 (220 > (colWidths[col] || 0)) colWidths[col] = 220;
if (150 > rowMaxH) rowMaxH = 150; if (150 > rowMaxH) rowMaxH = 150;
} }
@@ -216,6 +225,7 @@ export function setupPaste(
boardId: string, boardId: string,
onChange: OnChange, onChange: OnChange,
selection?: SelectionManager | null, selection?: SelectionManager | null,
uploads?: UploadManager | null,
): () => void { ): () => void {
async function onPaste(e: ClipboardEvent) { async function onPaste(e: ClipboardEvent) {
const items = e.clipboardData?.items; const items = e.clipboardData?.items;
@@ -237,15 +247,21 @@ export function setupPaste(
const cy = center.y; const cy = center.y;
const placeholder = createPlaceholder(viewport, cx - 100, cy - 75); const placeholder = createPlaceholder(viewport, cx - 100, cy - 75);
const jobId = uploads?.addJob(file, boardId);
try { try {
const res = await uploadImage(boardId, file); const res = await uploadImage(boardId, file, (p) => {
if (jobId) uploads?.setProgress(jobId, p);
});
removePlaceholder(viewport, placeholder); removePlaceholder(viewport, placeholder);
const { id } = handleUploadResult(res, viewport, sceneManager, cx - 100, cy - 75, onChange); const { id } = handleUploadResult(res, viewport, sceneManager, cx - 100, cy - 75, onChange);
newItemIds.push(id); 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); console.error('Image paste upload failed:', err);
removePlaceholder(viewport, placeholder); removePlaceholder(viewport, placeholder);
if (jobId) uploads?.setFailed(jobId, err.response?.data?.error || err.message || 'Upload failed');
} }
} }
+132
View File
@@ -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<string, string> = {
uploading: 'Uploading',
processing: 'Processing',
done: 'Done',
failed: 'Failed',
};
const STATUS_COLORS: Record<string, string> = {
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 (
<div style={{
padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: '3px',
borderBottom: '1px solid #222',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{
width: '6px', height: '6px', borderRadius: '50%', background: color, flexShrink: 0,
animation: job.status === 'processing' ? 'pulse 1.5s infinite' : undefined,
}} />
<span style={{
flex: 1, fontSize: '11px', color: '#ccc', overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{job.fileName}
</span>
<span style={{ fontSize: '10px', color: '#555', flexShrink: 0 }}>
{formatSize(job.fileSize)}
</span>
{(job.status === 'done' || job.status === 'failed') && (
<button onClick={onDismiss} style={{
background: 'none', border: 'none', color: '#444', cursor: 'pointer',
fontSize: '10px', padding: '0 2px', flexShrink: 0,
}}>x</button>
)}
</div>
{/* Progress bar for uploading */}
{job.status === 'uploading' && (
<div style={{ height: '3px', background: '#222', borderRadius: '2px', overflow: 'hidden' }}>
<div style={{
height: '100%', width: `${pct}%`, background: color,
borderRadius: '2px', transition: 'width 0.2s ease',
}} />
</div>
)}
{/* Status label */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '10px', color: color }}>
{job.status === 'uploading' ? `${STATUS_LABELS[job.status]} ${pct}%` : STATUS_LABELS[job.status]}
</span>
{job.error && (
<span style={{ fontSize: '10px', color: '#f87171' }}> {job.error}</span>
)}
</div>
</div>
);
}
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 (
<div style={{
position: 'absolute', bottom: '40px', left: '12px', zIndex: 400,
width: '240px', background: 'rgba(17,17,17,0.95)', border: '1px solid #2a2a2a',
borderRadius: '8px', overflow: 'hidden', backdropFilter: 'blur(8px)',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
}}>
{/* Header */}
<div style={{
padding: '6px 8px', borderBottom: '1px solid #2a2a2a',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span style={{ fontSize: '11px', color: '#888', fontWeight: 600 }}>
Uploads {uploadManager.activeCount > 0 ? `(${uploadManager.activeCount})` : ''}
</span>
{jobs.some((j) => j.status === 'done' || j.status === 'failed') && (
<button onClick={() => uploadManager.clearFinished()} style={{
background: 'none', border: 'none', color: '#555', cursor: 'pointer', fontSize: '10px',
}}>Clear</button>
)}
</div>
{/* Job list — max 4 visible, scroll */}
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{jobs.map((job) => (
<JobRow key={job.id} job={job} onDismiss={() => uploadManager.dismiss(job.id)} />
))}
</div>
{/* Pulse animation for processing state */}
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
`}</style>
</div>
);
}
+9 -4
View File
@@ -8,6 +8,7 @@ import { UndoManager } from '../canvas/history';
import { InboxZone } from '../canvas/InboxZone'; import { InboxZone } from '../canvas/InboxZone';
import { LaserPointer } from '../canvas/LaserPointer'; import { LaserPointer } from '../canvas/LaserPointer';
import { VideoSprite } from '../canvas/sprites/VideoSprite'; import { VideoSprite } from '../canvas/sprites/VideoSprite';
import { UploadManager } from '../stores/uploadManager';
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit // PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
import { connectSocket, disconnectSocket } from '../socket'; import { connectSocket, disconnectSocket } from '../socket';
@@ -42,6 +43,7 @@ interface CanvasSetupDeps {
syncRef: React.MutableRefObject<SyncHandle | null>; syncRef: React.MutableRefObject<SyncHandle | null>;
inboxZoneRef: React.MutableRefObject<InboxZone | null>; inboxZoneRef: React.MutableRefObject<InboxZone | null>;
canvasContainerRef: React.RefObject<HTMLDivElement | null>; canvasContainerRef: React.RefObject<HTMLDivElement | null>;
uploadManager: UploadManager;
onCanvasChange: (changedIds?: string[]) => void; onCanvasChange: (changedIds?: string[]) => void;
showToast: (msg: string) => void; showToast: (msg: string) => void;
setOnlineUsers: React.Dispatch<React.SetStateAction<OnlineUser[]>>; setOnlineUsers: React.Dispatch<React.SetStateAction<OnlineUser[]>>;
@@ -56,7 +58,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
const { const {
boardData, resolvedBoardId, user, isPublicView, boardData, resolvedBoardId, user, isPublicView,
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
} = deps; } = deps;
const dropCleanupRef = useRef<(() => void) | null>(null); const dropCleanupRef = useRef<(() => void) | null>(null);
@@ -186,6 +188,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
const { imageId, posterAssetKey, nativeWidth, nativeHeight, duration } = data; const { imageId, posterAssetKey, nativeWidth, nativeHeight, duration } = data;
if (!imageId) return; 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 // Find the video item by matching its DB image ID stored in scene data
for (const item of scene.items.values()) { for (const item of scene.items.values()) {
if (item.data.type !== 'video') continue; if (item.data.type !== 'video') continue;
@@ -296,9 +301,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
dropTarget = canvasEl?.parentElement ?? null; dropTarget = canvasEl?.parentElement ?? null;
} }
if (dropTarget) { 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); }, 200);
@@ -318,5 +323,5 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
pasteCleanupRef.current?.(); pasteCleanupRef.current?.();
disconnectSocket(); 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]);
} }
+7 -1
View File
@@ -23,6 +23,8 @@ import VideoControls from '../components/VideoControls';
import ShortcutsHelp from '../components/ShortcutsHelp'; import ShortcutsHelp from '../components/ShortcutsHelp';
import MattermostImport from '../components/MattermostImport'; import MattermostImport from '../components/MattermostImport';
import Minimap from '../components/Minimap'; import Minimap from '../components/Minimap';
import UploadPanel from '../components/UploadPanel';
import { UploadManager } from '../stores/uploadManager';
import { InboxZone } from '../canvas/InboxZone'; import { InboxZone } from '../canvas/InboxZone';
import { getItemWorldBounds } from '../canvas/SceneManager'; import { getItemWorldBounds } from '../canvas/SceneManager';
import { VideoSprite } from '../canvas/sprites/VideoSprite'; import { VideoSprite } from '../canvas/sprites/VideoSprite';
@@ -60,6 +62,7 @@ export default function Editor({ isPublicView }: EditorProps) {
const inboxZoneRef = useRef<InboxZone | null>(null); const inboxZoneRef = useRef<InboxZone | null>(null);
const clipboardRef = useRef<SceneItem[]>([]); const clipboardRef = useRef<SceneItem[]>([]);
const canvasContainerRef = useRef<HTMLDivElement>(null); const canvasContainerRef = useRef<HTMLDivElement>(null);
const [uploadManager] = useState(() => new UploadManager());
// UI state // UI state
const [activeTool, setActiveTool] = useState<ToolType>(ToolType.SELECT); const [activeTool, setActiveTool] = useState<ToolType>(ToolType.SELECT);
@@ -146,7 +149,7 @@ export default function Editor({ isPublicView }: EditorProps) {
useCanvasSetup({ useCanvasSetup({
boardData, resolvedBoardId, user, isPublicView, boardData, resolvedBoardId, user, isPublicView,
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds, uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
}); });
// Tool activation // Tool activation
@@ -631,6 +634,9 @@ export default function Editor({ isPublicView }: EditorProps) {
/> />
)} )}
{/* Upload progress panel */}
<UploadPanel uploadManager={uploadManager} />
{/* Toasts */} {/* Toasts */}
<div style={{ <div style={{
position: 'absolute', bottom: '12px', left: '50%', transform: 'translateX(-50%)', position: 'absolute', bottom: '12px', left: '50%', transform: 'translateX(-50%)',
+147
View File
@@ -0,0 +1,147 @@
export type UploadStatus = 'uploading' | 'processing' | 'done' | 'failed';
export interface UploadJob {
id: string;
fileName: string;
fileSize: number;
mediaType: 'image' | 'video';
status: UploadStatus;
progress: number; // 0-1 for upload phase
error?: string;
/** DB image ID — set after upload response, used to match media:job:update */
imageId?: string;
/** For retry */
file?: File;
boardId?: string;
createdAt: number;
}
type Listener = () => void;
export class UploadManager {
jobs = new Map<string, UploadJob>();
private _listeners = new Set<Listener>();
private _dismissTimers = new Map<string, ReturnType<typeof setTimeout>>();
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;
}