0.2.1 Release (#32)
* feat(security): implement CSRF protection * chore: clean up CSRF implementation - Remove unused generateCsrfToken export from security.ts - Remove redundant /csrf-token path check (GET already exempt) - Restore defineConfig wrapper in vitest.config.ts for type safety * add K8S note in README, fix broken e2e * 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> * chore: pre-release v0.2.1-dev * Update backend/src/security.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix filename/math random UUID generation --------- Co-authored-by: AdrianAcala <adrianacala017@gmail.com> Co-authored-by: adamant368 <60790941+Yiheng-Liu@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.1.8",
|
||||
"version": "0.2.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
// Locally, you may need to start them manually or use npm run dev
|
||||
webServer: process.env.CI ? [
|
||||
{
|
||||
command: "cd ../backend && DATABASE_URL=file:./prisma/dev.db npm run dev",
|
||||
command: "cd ../backend && DATABASE_URL=file:./dev.db npm run dev",
|
||||
url: "http://localhost:8000/health",
|
||||
reuseExistingServer: false,
|
||||
timeout: 120000,
|
||||
|
||||
+11
-8
@@ -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 (
|
||||
<ThemeProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/collections" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/editor/:id" element={<Editor />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
<UploadProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/collections" element={<Dashboard />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/editor/:id" element={<Editor />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</UploadProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,91 @@ export const api = axios.create({
|
||||
baseURL: API_URL,
|
||||
});
|
||||
|
||||
// CSRF Token Management
|
||||
let csrfToken: string | null = null;
|
||||
let csrfHeaderName: string = "x-csrf-token";
|
||||
let csrfTokenPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Fetch a fresh CSRF token from the server
|
||||
*/
|
||||
export const fetchCsrfToken = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await axios.get<{ token: string; header: string }>(
|
||||
`${API_URL}/csrf-token`
|
||||
);
|
||||
csrfToken = response.data.token;
|
||||
csrfHeaderName = response.data.header || "x-csrf-token";
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch CSRF token:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure we have a valid CSRF token, fetching one if needed
|
||||
*/
|
||||
const ensureCsrfToken = async (): Promise<void> => {
|
||||
if (csrfToken) return;
|
||||
|
||||
// Prevent multiple simultaneous token fetches
|
||||
if (!csrfTokenPromise) {
|
||||
csrfTokenPromise = fetchCsrfToken().finally(() => {
|
||||
csrfTokenPromise = null;
|
||||
});
|
||||
}
|
||||
await csrfTokenPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the cached CSRF token (useful for handling 403 errors)
|
||||
*/
|
||||
export const clearCsrfToken = (): void => {
|
||||
csrfToken = null;
|
||||
};
|
||||
|
||||
// Add request interceptor to include CSRF token
|
||||
api.interceptors.request.use(
|
||||
async (config) => {
|
||||
// Only add CSRF token for state-changing methods
|
||||
const method = config.method?.toUpperCase();
|
||||
if (method && ["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
||||
await ensureCsrfToken();
|
||||
if (csrfToken) {
|
||||
config.headers[csrfHeaderName] = csrfToken;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Add response interceptor to handle CSRF token errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
// If we get a 403 with CSRF error, clear token and retry once
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
error.response?.data?.error?.includes("CSRF")
|
||||
) {
|
||||
clearCsrfToken();
|
||||
|
||||
// Retry the request once with a fresh token
|
||||
const originalRequest = error.config;
|
||||
if (!originalRequest._csrfRetry) {
|
||||
originalRequest._csrfRetry = true;
|
||||
await fetchCsrfToken();
|
||||
if (csrfToken) {
|
||||
originalRequest.headers[csrfHeaderName] = csrfToken;
|
||||
}
|
||||
return api(originalRequest);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
const coerceTimestamp = (value: string | number | Date): number => {
|
||||
if (typeof value === "number") return value;
|
||||
if (value instanceof Date) return value.getTime();
|
||||
|
||||
@@ -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<LayoutProps> = ({
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<UploadStatus />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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: crypto.randomUUID(),
|
||||
fileName: f.name,
|
||||
status: 'pending',
|
||||
progress: 0
|
||||
}));
|
||||
|
||||
setTasks(prev => [...newTasks, ...prev]);
|
||||
|
||||
// Map file index to task ID for progress callbacks (handles duplicate filenames)
|
||||
const indexToTaskId = new Map<number, string>();
|
||||
newTasks.forEach((t, index) => indexToTaskId.set(index, t.id));
|
||||
|
||||
const handleProgress = (fileIndex: number, status: UploadStatus, progress: number, error?: string) => {
|
||||
const taskId = indexToTaskId.get(fileIndex);
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -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<Point | null>(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: '' })}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<any>(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 = () => {
|
||||
<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">
|
||||
<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">
|
||||
<ArrowLeft size={20} />
|
||||
<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} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isRenaming ? (
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { exportToSvg } from "@excalidraw/excalidraw";
|
||||
import { API_URL } from "../api";
|
||||
import { api } from "../api";
|
||||
import { type UploadStatus } from "../context/UploadContext";
|
||||
|
||||
export const importDrawings = async (
|
||||
files: File[],
|
||||
targetCollectionId: string | null,
|
||||
onSuccess?: () => void | Promise<void>
|
||||
onSuccess?: () => void | Promise<void>,
|
||||
onProgress?: (
|
||||
fileIndex: number,
|
||||
status: UploadStatus,
|
||||
progress: number,
|
||||
error?: string
|
||||
) => void
|
||||
) => {
|
||||
const drawingFiles = files.filter(
|
||||
(f) => f.name.endsWith(".json") || f.name.endsWith(".excalidraw")
|
||||
@@ -18,9 +25,21 @@ export const importDrawings = async (
|
||||
let failCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Build a map from drawingFile index to original file index for progress reporting
|
||||
const originalIndexMap = new Map<number, number>();
|
||||
drawingFiles.forEach((df, i) => {
|
||||
const originalIndex = files.indexOf(df);
|
||||
originalIndexMap.set(i, originalIndex);
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
drawingFiles.map(async (file, drawingIndex) => {
|
||||
const fileIndex = originalIndexMap.get(drawingIndex) ?? drawingIndex;
|
||||
try {
|
||||
if (onProgress) onProgress(fileIndex, 'processing', 0); // Parsing phase
|
||||
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
@@ -50,21 +69,36 @@ export const importDrawings = async (
|
||||
preview: svg.outerHTML,
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_URL}/drawings`, {
|
||||
method: "POST",
|
||||
if (onProgress) onProgress(fileIndex, 'uploading', 0);
|
||||
|
||||
await api.post("/drawings", payload, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Backend uses this header to apply stricter validation for imported files.
|
||||
"X-Imported-File": "true",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const percentCompleted = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
);
|
||||
onProgress(fileIndex, 'uploading', percentCompleted);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("API Error");
|
||||
if (onProgress) onProgress(fileIndex, 'success', 100);
|
||||
successCount++;
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to import ${file.name}:`, err);
|
||||
failCount++;
|
||||
errors.push(`${file.name}: ${err.message}`);
|
||||
const errorMessage =
|
||||
err?.response?.data?.message ||
|
||||
err?.response?.data?.error ||
|
||||
err?.message ||
|
||||
"Upload failed";
|
||||
errors.push(`${file.name}: ${errorMessage}`);
|
||||
if (onProgress) onProgress(fileIndex, 'error', 0, errorMessage);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -15,19 +15,16 @@ try {
|
||||
console.warn("Unable to read VERSION file:", error);
|
||||
}
|
||||
|
||||
if (
|
||||
!process.env.VITE_APP_VERSION ||
|
||||
process.env.VITE_APP_VERSION.trim().length === 0
|
||||
) {
|
||||
process.env.VITE_APP_VERSION = versionFromFile;
|
||||
if (!process.env.VITE_APP_BUILD_LABEL) {
|
||||
process.env.VITE_APP_BUILD_LABEL = "local development build";
|
||||
}
|
||||
}
|
||||
const appVersion = process.env.VITE_APP_VERSION?.trim() || versionFromFile;
|
||||
const buildLabel = process.env.VITE_APP_BUILD_LABEL?.trim() || "local development build";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion),
|
||||
'import.meta.env.VITE_APP_BUILD_LABEL': JSON.stringify(buildLabel),
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
|
||||
Reference in New Issue
Block a user