diff --git a/e2e/tests/drag-and-drop.spec.ts b/e2e/tests/drag-and-drop.spec.ts index f1238ce..70c36be 100644 --- a/e2e/tests/drag-and-drop.spec.ts +++ b/e2e/tests/drag-and-drop.spec.ts @@ -272,11 +272,8 @@ test.describe("Drag and Drop - File Import", () => { const fileInput = page.locator("#dashboard-import"); await fileInput.setInputFiles(fixturePath); - // Wait for import success modal - await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 }); - - // Dismiss the modal - await page.getByRole("button", { name: "OK" }).click(); + // Wait for upload to complete - the UploadStatus component shows "Done" when finished + await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 }); // Search for the imported drawing (it uses the filename as name) await page.getByPlaceholder("Search drawings...").fill("small-image"); diff --git a/e2e/tests/export-import.spec.ts b/e2e/tests/export-import.spec.ts index 7fea4cf..251038d 100644 --- a/e2e/tests/export-import.spec.ts +++ b/e2e/tests/export-import.spec.ts @@ -219,9 +219,8 @@ test.describe.serial("Import Functionality", () => { buffer: Buffer.from(fixtureContent), }); - // Wait for success modal - await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 }); - await page.getByRole("button", { name: "OK" }).click(); + // Wait for upload to complete - the UploadStatus component shows "Done" when finished + await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 }); // Reload to ensure dashboard state reflects the newly imported drawing await page.reload({ waitUntil: "networkidle" }); @@ -287,25 +286,16 @@ test.describe.serial("Import Functionality", () => { buffer: Buffer.from(jsonContent), }); - // Wait for import result - could be success or failure - const successModal = page.getByText("Import Successful"); - const failModal = page.getByText("Import Failed"); + // Wait for upload to complete - the UploadStatus component shows "Done" when finished + await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 15000 }); - await expect(successModal.or(failModal)).toBeVisible({ timeout: 15000 }); - - // If we got a failure, check the error - if (await failModal.isVisible()) { - // 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 + // Check if upload failed (shows "Failed" text in the upload status) + const failedIndicator = page.getByText("Failed"); + if (await failedIndicator.isVisible()) { + console.log("Import failed - skipping rest of test"); return; } - await page.getByRole("button", { name: "OK" }).click(); - // Reload to force a fresh fetch of drawings after import await page.reload({ waitUntil: "networkidle" }); @@ -335,9 +325,10 @@ test.describe.serial("Import Functionality", () => { buffer: Buffer.from(invalidContent), }); - // Should show error modal - await expect(page.getByText("Import Failed")).toBeVisible({ timeout: 10000 }); - await page.getByRole("button", { name: "OK" }).click(); + // Wait for upload to complete and check for failure indicator + await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 }); + // Should show "Failed" status in the upload status component + await expect(page.getByText("Failed")).toBeVisible(); }); test("should import multiple drawings at once", async ({ page }) => { @@ -374,8 +365,8 @@ test.describe.serial("Import Functionality", () => { const fileInput = page.locator("#dashboard-import"); await fileInput.setInputFiles(files); - await expect(page.getByText("Import Successful")).toBeVisible({ timeout: 10000 }); - await page.getByRole("button", { name: "OK" }).click(); + // Wait for upload to complete - the UploadStatus component shows "Done" when finished + await expect(page.getByText("Uploads (Done)")).toBeVisible({ timeout: 10000 }); // Verify both were imported by searching for the unique prefix await page.getByPlaceholder("Search drawings...").fill(searchPrefix); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9543943..efc99e9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,18 +3,21 @@ import { Dashboard } from './pages/Dashboard'; import { Editor } from './pages/Editor'; import { Settings } from './pages/Settings'; import { ThemeProvider } from './context/ThemeContext'; +import { UploadProvider } from './context/UploadContext'; function App() { return ( - - - } /> - } /> - } /> - } /> - - + + + + } /> + } /> + } /> + } /> + + + ); } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f75c275..b990797 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import { Sidebar } from './Sidebar'; +import { UploadStatus } from './UploadStatus'; import type { Collection } from '../types'; interface LayoutProps { @@ -93,6 +94,7 @@ export const Layout: React.FC = ({ + ); }; diff --git a/frontend/src/components/UploadStatus.tsx b/frontend/src/components/UploadStatus.tsx new file mode 100644 index 0000000..657bb15 --- /dev/null +++ b/frontend/src/components/UploadStatus.tsx @@ -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(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 ( +
+ {/* Popover List */} + {isOpen && ( +
+
+

+ Uploads ({activeCount > 0 ? `${activeCount} active` : 'Done'}) +

+ {(completedCount > 0 || errorCount > 0) && !isUploading && ( + + )} +
+ +
+ {tasks.map((task) => ( +
+
+ {task.status === 'uploading' && } + {task.status === 'processing' && } + {task.status === 'success' && } + {task.status === 'error' && } + {task.status === 'pending' &&
} +
+ +
+
+

+ {task.fileName} +

+ +
+ +
+
+
+
+ {task.status === 'error' ? ( + Failed + ) : ( + {task.progress}% + )} +
+
+
+ ))} +
+
+ )} + + {/* Floating Toggle Button */} + +
+ ); +}; diff --git a/frontend/src/context/UploadContext.tsx b/frontend/src/context/UploadContext.tsx new file mode 100644 index 0000000..48dfb0a --- /dev/null +++ b/frontend/src/context/UploadContext.tsx @@ -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; + clearCompleted: () => void; + removeTask: (id: string) => void; + isUploading: boolean; +} + +const UploadContext = createContext(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([]); + + const isUploading = tasks.some(t => t.status === 'uploading' || t.status === 'processing'); + + const updateTask = useCallback((id: string, updates: Partial) => { + 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(); + 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 ( + + {children} + + ); +}; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index cdc4ead..20e1496 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -9,7 +9,7 @@ import type { DrawingSummary, Collection } from '../types'; import { useDebounce } from '../hooks/useDebounce'; import clsx from 'clsx'; import { ConfirmModal } from '../components/ConfirmModal'; -import { importDrawings } from '../utils/importUtils'; +import { useUpload } from '../context/UploadContext'; type Point = { x: number; y: number }; @@ -78,7 +78,6 @@ export const Dashboard: React.FC = () => { const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); const [showImportError, setShowImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' }); - const [showImportSuccess, setShowImportSuccess] = useState(false); const [isDragSelecting, setIsDragSelecting] = useState(false); const [dragStart, setDragStart] = useState(null); @@ -99,6 +98,8 @@ export const Dashboard: React.FC = () => { const [isLoading, setIsLoading] = useState(false); + const { uploadFiles } = useUpload(); + const refreshData = useCallback(async () => { setIsLoading(true); try { @@ -303,17 +304,12 @@ export const Dashboard: React.FC = () => { const fileArray = Array.from(files); const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId; - - const result = await importDrawings(fileArray, targetCollectionId, refreshData); - - if (result.failed > 0) { - setShowImportError({ - isOpen: true, - message: `Import complete with errors.\nSuccess: ${result.success}\nFailed: ${result.failed}\nErrors:\n${result.errors.join('\n')}` - }); - } else { - setShowImportSuccess(true); - } + + // Use the global upload context + uploadFiles(fileArray, targetCollectionId).finally(() => { + // Refresh after all uploads complete (success or failure) + refreshData(); + }); }; const handleRenameDrawing = async (id: string, name: string) => { @@ -525,8 +521,7 @@ export const Dashboard: React.FC = () => { // Handle Files if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { const files = Array.from(e.dataTransfer.files); - setIsLoading(true); - + const libFiles = files.filter(f => f.name.endsWith('.excalidrawlib')); if (libFiles.length > 0) { setShowImportError({ @@ -537,13 +532,11 @@ export const Dashboard: React.FC = () => { const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib')); if (drawingFiles.length > 0) { - const result = await importDrawings(drawingFiles, targetCollectionId, refreshData); - if (result.failed > 0) { - alert(`Import complete with errors.\nSuccess: ${result.success}\nFailed: ${result.failed}\nErrors:\n${result.errors.join('\n')}`); - } + uploadFiles(drawingFiles, targetCollectionId).finally(() => { + refreshData(); + }); } - setIsLoading(false); return; } @@ -938,17 +931,6 @@ export const Dashboard: React.FC = () => { onCancel={() => setShowImportError({ isOpen: false, message: '' })} /> - setShowImportSuccess(false)} - onCancel={() => setShowImportSuccess(false)} - /> ); }; diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 8d01a15..9e65f10 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState, useRef } from 'react'; 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/excalidraw/index.css'; import debounce from 'lodash/debounce'; @@ -56,6 +56,7 @@ export const Editor: React.FC = () => { const [newName, setNewName] = useState(''); const [initialData, setInitialData] = useState(null); const [isSceneLoading, setIsSceneLoading] = useState(true); + const [isSavingOnLeave, setIsSavingOnLeave] = useState(false); useEffect(() => { document.title = `${drawingName} - ExcaliDash`; @@ -591,23 +592,29 @@ export const Editor: React.FC = () => { // Disable native Excalidraw save dialogs const handleBackClick = async () => { + if (isSavingOnLeave) return; // Prevent double clicks + + setIsSavingOnLeave(true); + // Save drawing and generate preview before navigating - if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) { - const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); - const appState = excalidrawAPI.current.getAppState(); - const files = excalidrawAPI.current.getFiles() || {}; - latestElementsRef.current = elements; - latestFilesRef.current = files; - - try { + try { + if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) { + const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); + const appState = excalidrawAPI.current.getAppState(); + const files = excalidrawAPI.current.getFiles() || {}; + latestElementsRef.current = elements; + latestFilesRef.current = files; + await Promise.all([ saveDataRef.current(elements, appState), savePreviewRef.current(elements, appState, files) ]); console.log("[Editor] Saved on back navigation", { drawingId: id }); - } catch (err) { - console.error('Failed to save on back navigation', err); } + } catch (err) { + console.error('Failed to save on back navigation', err); + } finally { + setIsSavingOnLeave(false); } navigate('/'); }; @@ -616,8 +623,19 @@ export const Editor: React.FC = () => {
- {isRenaming ? ( diff --git a/frontend/src/utils/importUtils.ts b/frontend/src/utils/importUtils.ts index 38a554c..21aac2f 100644 --- a/frontend/src/utils/importUtils.ts +++ b/frontend/src/utils/importUtils.ts @@ -1,10 +1,17 @@ import { exportToSvg } from "@excalidraw/excalidraw"; import { api } from "../api"; +import { type UploadStatus } from "../context/UploadContext"; export const importDrawings = async ( files: File[], targetCollectionId: string | null, - onSuccess?: () => void | Promise + onSuccess?: () => void | Promise, + onProgress?: ( + fileName: string, + status: UploadStatus, + progress: number, + error?: string + ) => void ) => { const drawingFiles = files.filter( (f) => f.name.endsWith(".json") || f.name.endsWith(".excalidraw") @@ -18,9 +25,13 @@ export const importDrawings = async ( let failCount = 0; 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( drawingFiles.map(async (file) => { try { + if (onProgress) onProgress(file.name, 'processing', 0); // Parsing phase + const text = await file.text(); const data = JSON.parse(text); @@ -50,22 +61,36 @@ export const importDrawings = async ( preview: svg.outerHTML, }; + if (onProgress) onProgress(file.name, 'uploading', 0); + await api.post("/drawings", payload, { headers: { // Backend uses this header to apply stricter validation for imported files. "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++; + } catch (err: any) { console.error(`Failed to import ${file.name}:`, err); failCount++; - const apiMessage = + const errorMessage = err?.response?.data?.message || err?.response?.data?.error || err?.message || - "API Error"; - errors.push(`${file.name}: ${apiMessage}`); + "Upload failed"; + errors.push(`${file.name}: ${errorMessage}`); + if (onProgress) onProgress(file.name, 'error', 0, errorMessage); } }) );