Merge pull request #15 from AdrianAcala/perf/drawings-optim

perf: optimize drawings endpoint with caching and lazy loading
This commit is contained in:
Zimeng Xiong
2025-12-01 13:59:08 -08:00
committed by GitHub
6 changed files with 394 additions and 57 deletions
+35 -10
View File
@@ -1,5 +1,5 @@
import axios from "axios";
import type { Drawing, Collection } from "../types";
import type { Drawing, Collection, DrawingSummary } from "../types";
export const API_URL = import.meta.env.VITE_API_URL || "/api";
@@ -14,23 +14,48 @@ const coerceTimestamp = (value: string | number | Date): number => {
return Number.isNaN(parsed) ? Date.now() : parsed;
};
const deserializeDrawing = (drawing: any): Drawing => ({
...drawing,
createdAt: coerceTimestamp(drawing.createdAt),
updatedAt: coerceTimestamp(drawing.updatedAt),
const deserializeTimestamps = <T extends { createdAt: any; updatedAt: any }>(
data: T
): T & { createdAt: number; updatedAt: number } => ({
...data,
createdAt: coerceTimestamp(data.createdAt),
updatedAt: coerceTimestamp(data.updatedAt),
});
export const getDrawings = async (
const deserializeDrawingSummary = (drawing: any): DrawingSummary =>
deserializeTimestamps(drawing);
const deserializeDrawing = (drawing: any): Drawing =>
deserializeTimestamps(drawing);
export function getDrawings(
search?: string,
collectionId?: string | null
) => {
): Promise<DrawingSummary[]>;
export function getDrawings(
search: string | undefined,
collectionId: string | null | undefined,
options: { includeData: true }
): Promise<Drawing[]>;
export async function getDrawings(
search?: string,
collectionId?: string | null,
options?: { includeData?: boolean }
) {
const params: any = {};
if (search) params.search = search;
if (collectionId !== undefined)
params.collectionId = collectionId === null ? "null" : collectionId;
const response = await api.get<Drawing[]>("/drawings", { params });
return response.data.map(deserializeDrawing);
};
if (options?.includeData) {
params.includeData = "true";
const response = await api.get<Drawing[]>("/drawings", { params });
return response.data.map(deserializeDrawing);
}
const response = await api.get<DrawingSummary[]>("/drawings", { params });
return response.data.map(deserializeDrawingSummary);
}
export const getDrawing = async (id: string) => {
const response = await api.get<Drawing>(`/drawings/${id}`);
+107 -18
View File
@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download } from 'lucide-react';
import type { Drawing, Collection } from '../types';
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download, Loader2 } from 'lucide-react';
import type { DrawingSummary, Collection, Drawing } from '../types';
import { formatDistanceToNow } from 'date-fns';
import clsx from 'clsx';
import { exportToSvg } from "@excalidraw/excalidraw";
@@ -10,8 +10,14 @@ import { exportDrawingToFile } from '../utils/exportUtils';
import * as api from '../api';
type HydratedDrawingData = {
elements: any[];
appState: any;
files: Record<string, any>;
};
interface DrawingCardProps {
drawing: Drawing;
drawing: DrawingSummary;
collections: Collection[];
isSelected: boolean;
isTrash?: boolean;
@@ -49,30 +55,74 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
const [showCollectionDropdown, setShowCollectionDropdown] = useState(false);
const [newName, setNewName] = useState(drawing.name);
const [previewSvg, setPreviewSvg] = useState<string | null>(null);
const [previewSvg, setPreviewSvg] = useState<string | null>(drawing.preview ?? null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [fullData, setFullData] = useState<HydratedDrawingData | null>(null);
const fullDataRef = React.useRef(fullData);
fullDataRef.current = fullData;
const fullDataPromiseRef = React.useRef<Promise<HydratedDrawingData> | null>(null);
useEffect(() => {
setFullData(null);
fullDataPromiseRef.current = null;
}, [drawing.id]);
const drawingIdRef = React.useRef(drawing.id);
drawingIdRef.current = drawing.id;
const ensureFullData = useCallback(async (): Promise<HydratedDrawingData> => {
if (fullDataRef.current) {
return fullDataRef.current;
}
if (fullDataPromiseRef.current) {
return fullDataPromiseRef.current;
}
const currentDrawingId = drawingIdRef.current;
const promise = api.getDrawing(currentDrawingId).then((fullDrawing) => {
const payload: HydratedDrawingData = {
elements: fullDrawing.elements || [],
appState: fullDrawing.appState || {},
files: fullDrawing.files || {},
};
setFullData(payload);
fullDataPromiseRef.current = null;
return payload;
}).catch((error) => {
fullDataPromiseRef.current = null;
throw error;
});
fullDataPromiseRef.current = promise;
return promise;
}, []); // Stable identity - uses refs internally
useEffect(() => {
let cancelled = false;
if (drawing.preview) {
setPreviewSvg(drawing.preview);
return;
}
const generatePreview = async () => {
// Ensure elements and appState exist before trying to generate
if (!drawing.elements || !drawing.appState) return;
try {
const data = await ensureFullData();
if (cancelled) return;
if (!data?.elements || !data?.appState) return;
const svg = await exportToSvg({
elements: drawing.elements,
elements: data.elements,
appState: {
...drawing.appState,
...data.appState,
exportBackground: true,
viewBackgroundColor: drawing.appState.viewBackgroundColor || "#ffffff"
viewBackgroundColor: data.appState.viewBackgroundColor || "#ffffff"
},
files: drawing.files || {},
files: data.files || {},
exportPadding: 10
});
if (cancelled) return;
const previewHtml = svg.outerHTML;
setPreviewSvg(previewHtml);
@@ -80,11 +130,42 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
api.updateDrawing(drawing.id, { preview: previewHtml }).catch(console.error);
onPreviewGenerated?.(drawing.id, previewHtml);
} catch (e) {
console.error("Failed to generate preview", e);
if (!cancelled) {
console.error("Failed to generate preview", e);
}
}
};
generatePreview();
}, [drawing, onPreviewGenerated]);
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [drawing.id, drawing.preview, onPreviewGenerated]); // ensureFullData has stable identity via refs
const handleExport = useCallback(async () => {
try {
setIsExporting(true);
setExportError(null);
const data = await ensureFullData();
const drawingPayload: Drawing = {
...drawing,
elements: data.elements || [],
appState: data.appState || {},
files: data.files || {},
};
exportDrawingToFile(drawingPayload);
} catch (error) {
console.error("Failed to export drawing", error);
setExportError("Failed to export drawing. Please try again.");
// Clear error after 3 seconds
setTimeout(() => setExportError(null), 3000);
} finally {
setIsExporting(false);
}
}, [drawing, ensureFullData]);
// Close context menu on click outside
useEffect(() => {
@@ -327,14 +408,22 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
</button>
<button
onClick={() => {
exportDrawingToFile(drawing);
onClick={async (e) => {
e.stopPropagation();
await handleExport();
setContextMenu(null);
}}
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2"
disabled={isExporting}
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download size={14} /> Export
{isExporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
{isExporting ? 'Exporting...' : 'Export'}
</button>
{exportError && (
<div className="px-3 py-2 text-xs text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-900/20">
{exportError}
</div>
)}
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
+6 -6
View File
@@ -5,7 +5,7 @@ import { DrawingCard } from '../components/DrawingCard';
import { Plus, Search, Loader2, Inbox, Trash2, Folder, ArrowRight, Copy, Upload } from 'lucide-react';
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import * as api from '../api';
import type { Drawing, Collection } from '../types';
import type { DrawingSummary, Collection } from '../types';
import { useDebounce } from '../hooks/useDebounce';
import clsx from 'clsx';
import { ConfirmModal } from '../components/ConfirmModal';
@@ -45,7 +45,7 @@ export const Dashboard: React.FC = () => {
const [searchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const [drawings, setDrawings] = useState<Drawing[]>([]);
const [drawings, setDrawings] = useState<DrawingSummary[]>([]);
const [collections, setCollections] = useState<Collection[]>([]);
// Derived state from URL
@@ -309,12 +309,12 @@ export const Dashboard: React.FC = () => {
const handleImportDrawings = async (files: FileList | null) => {
if (!files || isTrashView) return;
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,
@@ -810,7 +810,7 @@ export const Dashboard: React.FC = () => {
e.target.value = ''; // Reset input
}}
/>
<button
onClick={() => document.getElementById('dashboard-import')?.click()}
disabled={isTrashView}
+9 -5
View File
@@ -1,13 +1,17 @@
export interface Drawing {
export interface DrawingSummary {
id: string;
name: string;
elements: any[];
appState: any;
files: Record<string, any> | null;
collectionId: string | null;
updatedAt: number;
createdAt: number;
preview?: string;
version: number;
preview?: string | null;
}
export interface Drawing extends DrawingSummary {
elements: any[];
appState: any;
files: Record<string, any> | null;
}
export interface Collection {