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:
+4
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<SyncHandle | null>;
|
||||
inboxZoneRef: React.MutableRefObject<InboxZone | null>;
|
||||
canvasContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
uploadManager: UploadManager;
|
||||
onCanvasChange: (changedIds?: string[]) => void;
|
||||
showToast: (msg: string) => void;
|
||||
setOnlineUsers: React.Dispatch<React.SetStateAction<OnlineUser[]>>;
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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<InboxZone | null>(null);
|
||||
const clipboardRef = useRef<SceneItem[]>([]);
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [uploadManager] = useState(() => new UploadManager());
|
||||
|
||||
// UI state
|
||||
const [activeTool, setActiveTool] = useState<ToolType>(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 */}
|
||||
<UploadPanel uploadManager={uploadManager} />
|
||||
|
||||
{/* Toasts */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '12px', left: '50%', transform: 'translateX(-50%)',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user