feat/upload-bar (#30)

* feat/upload-bar: add a upload bar when user upload file, indicate the upload process

* feat/save-loading-status: add save status when click back button from editor

* fix: address PR review issues in upload and save features

- Replace deprecated substr() with substring() in UploadContext
- Fix broken error handling that checked stale task status
- Fix missing useEffect dependency in UploadStatus
- Fix CSS class conflict in progress bar styling
- Add error recovery for save state in Editor (reset on failure)
- Use .finally() instead of .then() to ensure refresh on upload failure
- Fix inconsistent indentation in UploadContext

* fix e2e tests

---------

Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com>
This commit is contained in:
adamant368
2026-01-15 01:25:17 +08:00
committed by GitHub
parent cae8f3cbf6
commit 8f9b9b4945
9 changed files with 320 additions and 84 deletions
+2 -5
View File
@@ -272,11 +272,8 @@ test.describe("Drag and Drop - File Import", () => {
const fileInput = page.locator("#dashboard-import"); const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles(fixturePath); await fileInput.setInputFiles(fixturePath);
// Wait for import success modal // Wait for upload to complete - the UploadStatus component shows "Done" when finished
await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
// Dismiss the modal
await page.getByRole("button", { name: "OK" }).click();
// Search for the imported drawing (it uses the filename as name) // Search for the imported drawing (it uses the filename as name)
await page.getByPlaceholder("Search drawings...").fill("small-image"); await page.getByPlaceholder("Search drawings...").fill("small-image");
+14 -23
View File
@@ -219,9 +219,8 @@ test.describe.serial("Import Functionality", () => {
buffer: Buffer.from(fixtureContent), buffer: Buffer.from(fixtureContent),
}); });
// Wait for success modal // Wait for upload to complete - the UploadStatus component shows "Done" when finished
await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: "OK" }).click();
// Reload to ensure dashboard state reflects the newly imported drawing // Reload to ensure dashboard state reflects the newly imported drawing
await page.reload({ waitUntil: "networkidle" }); await page.reload({ waitUntil: "networkidle" });
@@ -287,25 +286,16 @@ test.describe.serial("Import Functionality", () => {
buffer: Buffer.from(jsonContent), buffer: Buffer.from(jsonContent),
}); });
// Wait for import result - could be success or failure // Wait for upload to complete - the UploadStatus component shows "Done" when finished
const successModal = page.getByText("Import Successful"); await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 15000 });
const failModal = page.getByText("Import Failed");
await expect(successModal.or(failModal)).toBeVisible({ timeout: 15000 }); // Check if upload failed (shows "Failed" text in the upload status)
const failedIndicator = page.getByText("Failed");
// If we got a failure, check the error if (await failedIndicator.isVisible()) {
if (await failModal.isVisible()) { console.log("Import failed - skipping rest of test");
// Get the error message
const errorText = await page.locator(".modal, [role='dialog']").textContent();
console.log("Import failed with:", errorText);
// Still click OK to dismiss
await page.getByRole("button", { name: "OK" }).click();
// Skip the rest of the test since import failed
return; return;
} }
await page.getByRole("button", { name: "OK" }).click();
// Reload to force a fresh fetch of drawings after import // Reload to force a fresh fetch of drawings after import
await page.reload({ waitUntil: "networkidle" }); await page.reload({ waitUntil: "networkidle" });
@@ -335,9 +325,10 @@ test.describe.serial("Import Functionality", () => {
buffer: Buffer.from(invalidContent), buffer: Buffer.from(invalidContent),
}); });
// Should show error modal // Wait for upload to complete and check for failure indicator
await expect(page.getByText("Import Failed")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: "OK" }).click(); // Should show "Failed" status in the upload status component
await expect(page.getByText("Failed")).toBeVisible();
}); });
test("should import multiple drawings at once", async ({ page }) => { test("should import multiple drawings at once", async ({ page }) => {
@@ -374,8 +365,8 @@ test.describe.serial("Import Functionality", () => {
const fileInput = page.locator("#dashboard-import"); const fileInput = page.locator("#dashboard-import");
await fileInput.setInputFiles(files); await fileInput.setInputFiles(files);
await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 }); // Wait for upload to complete - the UploadStatus component shows "Done" when finished
await page.getByRole("button", { name: "OK" }).click(); await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 });
// Verify both were imported by searching for the unique prefix // Verify both were imported by searching for the unique prefix
await page.getByPlaceholder("Search drawings...").fill(searchPrefix); await page.getByPlaceholder("Search drawings...").fill(searchPrefix);
+3
View File
@@ -3,10 +3,12 @@ import { Dashboard } from './pages/Dashboard';
import { Editor } from './pages/Editor'; import { Editor } from './pages/Editor';
import { Settings } from './pages/Settings'; import { Settings } from './pages/Settings';
import { ThemeProvider } from './context/ThemeContext'; import { ThemeProvider } from './context/ThemeContext';
import { UploadProvider } from './context/UploadContext';
function App() { function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<UploadProvider>
<Router> <Router>
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
@@ -15,6 +17,7 @@ function App() {
<Route path="/editor/:id" element={<Editor />} /> <Route path="/editor/:id" element={<Editor />} />
</Routes> </Routes>
</Router> </Router>
</UploadProvider>
</ThemeProvider> </ThemeProvider>
); );
} }
+2
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { UploadStatus } from './UploadStatus';
import type { Collection } from '../types'; import type { Collection } from '../types';
interface LayoutProps { interface LayoutProps {
@@ -93,6 +94,7 @@ export const Layout: React.FC<LayoutProps> = ({
</div> </div>
</main> </main>
</div> </div>
<UploadStatus />
</div> </div>
); );
}; };
+132
View File
@@ -0,0 +1,132 @@
import React, { useState, useRef, useEffect } from 'react';
import { useUpload } from '../context/UploadContext';
import { Loader2, CheckCircle2, AlertCircle, X, ChevronUp, ChevronDown } from 'lucide-react';
import clsx from 'clsx';
export const UploadStatus: React.FC = () => {
const { tasks, clearCompleted, removeTask, isUploading } = useUpload();
const [isOpen, setIsOpen] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
// Auto-open when upload starts
useEffect(() => {
if (isUploading) {
setIsOpen(true);
}
}, [isUploading]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
if (tasks.length === 0) return null;
const activeCount = tasks.filter(t => t.status === 'uploading' || t.status === 'processing').length;
const completedCount = tasks.filter(t => t.status === 'success').length;
const errorCount = tasks.filter(t => t.status === 'error').length;
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2 isolate" ref={popoverRef}>
{/* Popover List */}
{isOpen && (
<div className="w-80 bg-white dark:bg-neutral-900 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] overflow-hidden animate-in slide-in-from-bottom-5 fade-in duration-200 mb-2">
<div className="p-3 border-b border-slate-100 dark:border-neutral-800 flex items-center justify-between bg-slate-50 dark:bg-neutral-800/50">
<h3 className="font-bold text-sm text-slate-700 dark:text-slate-200">
Uploads ({activeCount > 0 ? `${activeCount} active` : 'Done'})
</h3>
{(completedCount > 0 || errorCount > 0) && !isUploading && (
<button
onClick={clearCompleted}
className="text-xs text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200 font-medium"
>
Clear All
</button>
)}
</div>
<div className="max-h-60 overflow-y-auto custom-scrollbar p-1">
{tasks.map((task) => (
<div key={task.id} className="group flex items-center gap-3 p-2 hover:bg-slate-50 dark:hover:bg-neutral-800 rounded-lg transition-colors">
<div className="flex-shrink-0">
{task.status === 'uploading' && <Loader2 size={18} className="text-indigo-600 animate-spin" />}
{task.status === 'processing' && <Loader2 size={18} className="text-indigo-600 animate-spin" />}
{task.status === 'success' && <CheckCircle2 size={18} className="text-emerald-500" />}
{task.status === 'error' && <AlertCircle size={18} className="text-rose-500" />}
{task.status === 'pending' && <div className="w-[18px] h-[18px] rounded-full border-2 border-slate-300 dark:border-slate-600" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 truncate" title={task.fileName}>
{task.fileName}
</p>
<button
onClick={() => removeTask(task.id)}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-opacity p-0.5"
>
<X size={14} />
</button>
</div>
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-1.5 bg-slate-100 dark:bg-neutral-800 rounded-full overflow-hidden">
<div
className={clsx(
"h-full transition-all duration-300 ease-out rounded-full",
task.status === 'error' ? "bg-rose-500" : task.status === 'success' ? "bg-emerald-500" : "bg-indigo-600"
)}
style={{ width: `${task.progress}%` }}
/>
</div>
{task.status === 'error' ? (
<span className="text-[10px] text-rose-500 font-medium truncate max-w-[80px]" title={task.error}>Failed</span>
) : (
<span className="text-[10px] text-slate-400 font-medium w-8 text-right">{task.progress}%</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Floating Toggle Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={clsx(
"h-12 w-12 rounded-full border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] flex items-center justify-center transition-all hover:-translate-y-1 active:translate-y-0 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] bg-white dark:bg-neutral-800 text-slate-900 dark:text-white relative",
isOpen && "bg-slate-100 dark:bg-neutral-700 translate-y-0 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
)}
>
{isUploading ? (
<div className="relative">
<Loader2 size={24} className="animate-spin text-indigo-600" />
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-indigo-500"></span>
</span>
</div>
) : (
<div className="relative">
{isOpen ? <ChevronDown size={24} /> : <ChevronUp size={24} />}
{(completedCount > 0 || errorCount > 0) && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-emerald-500 rounded-full border-2 border-white dark:border-neutral-800" />
)}
</div>
)}
</button>
</div>
);
};
+86
View File
@@ -0,0 +1,86 @@
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { importDrawings } from '../utils/importUtils';
export type UploadStatus = 'pending' | 'uploading' | 'processing' | 'success' | 'error';
export interface UploadTask {
id: string;
fileName: string;
status: UploadStatus;
progress: number;
error?: string;
}
interface UploadContextType {
tasks: UploadTask[];
uploadFiles: (files: File[], targetCollectionId: string | null) => Promise<void>;
clearCompleted: () => void;
removeTask: (id: string) => void;
isUploading: boolean;
}
const UploadContext = createContext<UploadContextType | undefined>(undefined);
export const useUpload = () => {
const context = useContext(UploadContext);
if (!context) {
throw new Error('useUpload must be used within an UploadProvider');
}
return context;
};
export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [tasks, setTasks] = useState<UploadTask[]>([]);
const isUploading = tasks.some(t => t.status === 'uploading' || t.status === 'processing');
const updateTask = useCallback((id: string, updates: Partial<UploadTask>) => {
setTasks(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t));
}, []);
const removeTask = useCallback((id: string) => {
setTasks(prev => prev.filter(t => t.id !== id));
}, []);
const clearCompleted = useCallback(() => {
setTasks(prev => prev.filter(t => t.status !== 'success' && t.status !== 'error'));
}, []);
const uploadFiles = useCallback(async (files: File[], targetCollectionId: string | null) => {
const newTasks: UploadTask[] = files.map(f => ({
id: Math.random().toString(36).substring(2, 11),
fileName: f.name,
status: 'pending',
progress: 0
}));
setTasks(prev => [...newTasks, ...prev]);
// Map file names to task IDs for progress callbacks
const fileTaskMap = new Map<string, string>();
newTasks.forEach(t => fileTaskMap.set(t.fileName, t.id));
const handleProgress = (fileName: string, status: UploadStatus, progress: number, error?: string) => {
const taskId = fileTaskMap.get(fileName);
if (taskId) {
updateTask(taskId, { status, progress, error });
}
};
try {
await importDrawings(files, targetCollectionId, undefined, handleProgress);
} catch (e) {
console.error("Global upload error", e);
// Mark all new tasks as error if something crashed completely
newTasks.forEach(t => {
updateTask(t.id, { status: 'error', error: 'Upload failed unexpectedly' });
});
}
}, [updateTask]);
return (
<UploadContext.Provider value={{ tasks, uploadFiles, clearCompleted, removeTask, isUploading }}>
{children}
</UploadContext.Provider>
);
};
+10 -28
View File
@@ -9,7 +9,7 @@ import type { DrawingSummary, Collection } from '../types';
import { useDebounce } from '../hooks/useDebounce'; import { useDebounce } from '../hooks/useDebounce';
import clsx from 'clsx'; import clsx from 'clsx';
import { ConfirmModal } from '../components/ConfirmModal'; import { ConfirmModal } from '../components/ConfirmModal';
import { importDrawings } from '../utils/importUtils'; import { useUpload } from '../context/UploadContext';
type Point = { x: number; y: number }; type Point = { x: number; y: number };
@@ -78,7 +78,6 @@ export const Dashboard: React.FC = () => {
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
const [showImportError, setShowImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' }); const [showImportError, setShowImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' });
const [showImportSuccess, setShowImportSuccess] = useState(false);
const [isDragSelecting, setIsDragSelecting] = useState(false); const [isDragSelecting, setIsDragSelecting] = useState(false);
const [dragStart, setDragStart] = useState<Point | null>(null); const [dragStart, setDragStart] = useState<Point | null>(null);
@@ -99,6 +98,8 @@ export const Dashboard: React.FC = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { uploadFiles } = useUpload();
const refreshData = useCallback(async () => { const refreshData = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@@ -304,16 +305,11 @@ export const Dashboard: React.FC = () => {
const fileArray = Array.from(files); const fileArray = Array.from(files);
const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId; const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId;
const result = await importDrawings(fileArray, targetCollectionId, refreshData); // Use the global upload context
uploadFiles(fileArray, targetCollectionId).finally(() => {
if (result.failed > 0) { // Refresh after all uploads complete (success or failure)
setShowImportError({ refreshData();
isOpen: true,
message: `Import complete with errors.\nSuccess: ${result.success}\nFailed: ${result.failed}\nErrors:\n${result.errors.join('\n')}`
}); });
} else {
setShowImportSuccess(true);
}
}; };
const handleRenameDrawing = async (id: string, name: string) => { const handleRenameDrawing = async (id: string, name: string) => {
@@ -525,7 +521,6 @@ export const Dashboard: React.FC = () => {
// Handle Files // Handle Files
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
setIsLoading(true);
const libFiles = files.filter(f => f.name.endsWith('.excalidrawlib')); const libFiles = files.filter(f => f.name.endsWith('.excalidrawlib'));
if (libFiles.length > 0) { if (libFiles.length > 0) {
@@ -537,13 +532,11 @@ export const Dashboard: React.FC = () => {
const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib')); const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib'));
if (drawingFiles.length > 0) { if (drawingFiles.length > 0) {
const result = await importDrawings(drawingFiles, targetCollectionId, refreshData); uploadFiles(drawingFiles, targetCollectionId).finally(() => {
if (result.failed > 0) { refreshData();
alert(`Import complete with errors.\nSuccess: ${result.success}\nFailed: ${result.failed}\nErrors:\n${result.errors.join('\n')}`); });
}
} }
setIsLoading(false);
return; return;
} }
@@ -938,17 +931,6 @@ export const Dashboard: React.FC = () => {
onCancel={() => setShowImportError({ isOpen: false, message: '' })} onCancel={() => setShowImportError({ isOpen: false, message: '' })}
/> />
<ConfirmModal
isOpen={showImportSuccess}
title="Import Successful"
message="Drawings imported successfully."
confirmText="OK"
showCancel={false}
isDangerous={false}
variant="success"
onConfirm={() => setShowImportSuccess(false)}
onCancel={() => setShowImportSuccess(false)}
/>
</Layout> </Layout>
); );
}; };
+22 -4
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState, useRef } from 'react'; import React, { useCallback, useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Download } from 'lucide-react'; import { ArrowLeft, Download, Loader2 } from 'lucide-react';
import { Excalidraw, exportToSvg } from '@excalidraw/excalidraw'; import { Excalidraw, exportToSvg } from '@excalidraw/excalidraw';
import '@excalidraw/excalidraw/index.css'; import '@excalidraw/excalidraw/index.css';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
@@ -56,6 +56,7 @@ export const Editor: React.FC = () => {
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [initialData, setInitialData] = useState<any>(null); const [initialData, setInitialData] = useState<any>(null);
const [isSceneLoading, setIsSceneLoading] = useState(true); const [isSceneLoading, setIsSceneLoading] = useState(true);
const [isSavingOnLeave, setIsSavingOnLeave] = useState(false);
useEffect(() => { useEffect(() => {
document.title = `${drawingName} - ExcaliDash`; document.title = `${drawingName} - ExcaliDash`;
@@ -591,7 +592,12 @@ export const Editor: React.FC = () => {
// Disable native Excalidraw save dialogs // Disable native Excalidraw save dialogs
const handleBackClick = async () => { const handleBackClick = async () => {
if (isSavingOnLeave) return; // Prevent double clicks
setIsSavingOnLeave(true);
// Save drawing and generate preview before navigating // Save drawing and generate preview before navigating
try {
if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) { if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) {
const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
const appState = excalidrawAPI.current.getAppState(); const appState = excalidrawAPI.current.getAppState();
@@ -599,15 +605,16 @@ export const Editor: React.FC = () => {
latestElementsRef.current = elements; latestElementsRef.current = elements;
latestFilesRef.current = files; latestFilesRef.current = files;
try {
await Promise.all([ await Promise.all([
saveDataRef.current(elements, appState), saveDataRef.current(elements, appState),
savePreviewRef.current(elements, appState, files) savePreviewRef.current(elements, appState, files)
]); ]);
console.log("[Editor] Saved on back navigation", { drawingId: id }); console.log("[Editor] Saved on back navigation", { drawingId: id });
}
} catch (err) { } catch (err) {
console.error('Failed to save on back navigation', err); console.error('Failed to save on back navigation', err);
} } finally {
setIsSavingOnLeave(false);
} }
navigate('/'); navigate('/');
}; };
@@ -616,8 +623,19 @@ export const Editor: React.FC = () => {
<div className="h-screen flex flex-col bg-white dark:bg-neutral-950 overflow-hidden"> <div className="h-screen flex flex-col bg-white dark:bg-neutral-950 overflow-hidden">
<header className="h-14 bg-white dark:bg-neutral-900 border-b border-gray-200 dark:border-neutral-800 flex items-center px-4 justify-between z-10"> <header className="h-14 bg-white dark:bg-neutral-900 border-b border-gray-200 dark:border-neutral-800 flex items-center px-4 justify-between z-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button onClick={handleBackClick} className="p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded-full text-gray-600 dark:text-gray-300"> <button
onClick={handleBackClick}
disabled={isSavingOnLeave}
className={`flex items-center gap-2 p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded-full text-gray-600 dark:text-gray-300 disabled:opacity-50 disabled:cursor-wait transition-all duration-200 ${isSavingOnLeave ? 'pr-4' : ''}`}
>
{isSavingOnLeave ? (
<>
<Loader2 size={20} className="animate-spin" />
<span className="text-sm font-medium">Saving changes...</span>
</>
) : (
<ArrowLeft size={20} /> <ArrowLeft size={20} />
)}
</button> </button>
{isRenaming ? ( {isRenaming ? (
+29 -4
View File
@@ -1,10 +1,17 @@
import { exportToSvg } from "@excalidraw/excalidraw"; import { exportToSvg } from "@excalidraw/excalidraw";
import { api } from "../api"; import { api } from "../api";
import { type UploadStatus } from "../context/UploadContext";
export const importDrawings = async ( export const importDrawings = async (
files: File[], files: File[],
targetCollectionId: string | null, targetCollectionId: string | null,
onSuccess?: () => void | Promise<void> onSuccess?: () => void | Promise<void>,
onProgress?: (
fileName: string,
status: UploadStatus,
progress: number,
error?: string
) => void
) => { ) => {
const drawingFiles = files.filter( const drawingFiles = files.filter(
(f) => f.name.endsWith(".json") || f.name.endsWith(".excalidraw") (f) => f.name.endsWith(".json") || f.name.endsWith(".excalidraw")
@@ -18,9 +25,13 @@ export const importDrawings = async (
let failCount = 0; let failCount = 0;
const errors: string[] = []; const errors: string[] = [];
// We process files in parallel (Promise.all) but we could limit concurrency if needed.
// For now, full parallel is fine as browser limits connection count anyway.
await Promise.all( await Promise.all(
drawingFiles.map(async (file) => { drawingFiles.map(async (file) => {
try { try {
if (onProgress) onProgress(file.name, 'processing', 0); // Parsing phase
const text = await file.text(); const text = await file.text();
const data = JSON.parse(text); const data = JSON.parse(text);
@@ -50,22 +61,36 @@ export const importDrawings = async (
preview: svg.outerHTML, preview: svg.outerHTML,
}; };
if (onProgress) onProgress(file.name, 'uploading', 0);
await api.post("/drawings", payload, { await api.post("/drawings", payload, {
headers: { headers: {
// Backend uses this header to apply stricter validation for imported files. // Backend uses this header to apply stricter validation for imported files.
"X-Imported-File": "true", "X-Imported-File": "true",
}, },
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
onProgress(file.name, 'uploading', percentCompleted);
}
},
}); });
if (onProgress) onProgress(file.name, 'success', 100);
successCount++; successCount++;
} catch (err: any) { } catch (err: any) {
console.error(`Failed to import ${file.name}:`, err); console.error(`Failed to import ${file.name}:`, err);
failCount++; failCount++;
const apiMessage = const errorMessage =
err?.response?.data?.message || err?.response?.data?.message ||
err?.response?.data?.error || err?.response?.data?.error ||
err?.message || err?.message ||
"API Error"; "Upload failed";
errors.push(`${file.name}: ${apiMessage}`); errors.push(`${file.name}: ${errorMessage}`);
if (onProgress) onProgress(file.name, 'error', 0, errorMessage);
} }
}) })
); );