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 });
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { 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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%)',
|
||||||
|
|||||||
@@ -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