Merge pull request #15 from AdrianAcala/perf/drawings-optim
perf: optimize drawings endpoint with caching and lazy loading
This commit is contained in:
+74
-1
@@ -1,3 +1,76 @@
|
||||
# Dependencies
|
||||
frontend/node_modules
|
||||
.DS_Store
|
||||
backend/node_modules
|
||||
|
||||
# Database
|
||||
backend/prisma/*.db
|
||||
backend/prisma/dev.db
|
||||
|
||||
# Generated files
|
||||
backend/src/generated/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.staging
|
||||
|
||||
# Build outputs
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
backend/dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env.test
|
||||
|
||||
# IDE/Editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
+163
-17
@@ -11,7 +11,7 @@ import multer from "multer";
|
||||
import archiver from "archiver";
|
||||
import { z } from "zod";
|
||||
// @ts-ignore
|
||||
import { PrismaClient } from "./generated/client";
|
||||
import { PrismaClient, Prisma } from "./generated/client";
|
||||
import {
|
||||
sanitizeDrawingData,
|
||||
validateImportedDrawing,
|
||||
@@ -112,6 +112,81 @@ const io = new Server(httpServer, {
|
||||
maxHttpBufferSize: 1e8, // 100 MB
|
||||
});
|
||||
const prisma = new PrismaClient();
|
||||
const parseJsonField = <T>(
|
||||
rawValue: string | null | undefined,
|
||||
fallback: T
|
||||
): T => {
|
||||
if (!rawValue) return fallback;
|
||||
try {
|
||||
return JSON.parse(rawValue) as T;
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse JSON field", {
|
||||
error,
|
||||
valuePreview: rawValue.slice(0, 50),
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const DRAWINGS_CACHE_TTL_MS = (() => {
|
||||
const parsed = Number(process.env.DRAWINGS_CACHE_TTL_MS);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 5_000;
|
||||
}
|
||||
return parsed;
|
||||
})();
|
||||
type DrawingsCacheEntry = { body: Buffer; expiresAt: number };
|
||||
const drawingsCache = new Map<string, DrawingsCacheEntry>();
|
||||
|
||||
/**
|
||||
* Builds a cache key for the drawings list endpoint.
|
||||
* NOTE: This key does NOT include sort order. If sorting options are added
|
||||
* to the endpoint in the future, they must be included in this key.
|
||||
*/
|
||||
const buildDrawingsCacheKey = (keyParts: {
|
||||
searchTerm: string;
|
||||
collectionFilter: string;
|
||||
includeData: boolean;
|
||||
}) =>
|
||||
JSON.stringify([
|
||||
keyParts.searchTerm,
|
||||
keyParts.collectionFilter,
|
||||
keyParts.includeData ? "full" : "summary",
|
||||
]);
|
||||
|
||||
const getCachedDrawingsBody = (key: string): Buffer | null => {
|
||||
const entry = drawingsCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
drawingsCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.body;
|
||||
};
|
||||
|
||||
const cacheDrawingsResponse = (key: string, payload: any): Buffer => {
|
||||
const body = Buffer.from(JSON.stringify(payload));
|
||||
drawingsCache.set(key, {
|
||||
body,
|
||||
expiresAt: Date.now() + DRAWINGS_CACHE_TTL_MS,
|
||||
});
|
||||
return body;
|
||||
};
|
||||
|
||||
const invalidateDrawingsCache = () => {
|
||||
drawingsCache.clear();
|
||||
};
|
||||
|
||||
// Cleanup cache every 60 seconds
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of drawingsCache.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
drawingsCache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60_000).unref(); // unref so it doesn't keep the process alive if everything else stops
|
||||
|
||||
const PORT = process.env.PORT || 8000;
|
||||
|
||||
// Multer setup for file uploads with streaming support
|
||||
@@ -189,7 +264,24 @@ app.use((req, res, next) => {
|
||||
// Rate limiting middleware (basic implementation)
|
||||
const requestCounts = new Map<string, { count: number; resetTime: number }>();
|
||||
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
|
||||
const RATE_LIMIT_MAX_REQUESTS = 1000; // Max requests per window
|
||||
|
||||
// Cleanup rate limit map every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, data] of requestCounts.entries()) {
|
||||
if (now > data.resetTime) {
|
||||
requestCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000).unref();
|
||||
|
||||
const RATE_LIMIT_MAX_REQUESTS = (() => {
|
||||
const parsed = Number(process.env.RATE_LIMIT_MAX_REQUESTS);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 1000;
|
||||
}
|
||||
return parsed;
|
||||
})(); // Max requests per window
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||
@@ -486,36 +578,84 @@ app.get("/health", (req, res) => {
|
||||
// GET /drawings
|
||||
app.get("/drawings", async (req, res) => {
|
||||
try {
|
||||
const { search, collectionId } = req.query;
|
||||
const { search, collectionId, includeData } = req.query;
|
||||
const where: any = {};
|
||||
const searchTerm =
|
||||
typeof search === "string" && search.trim().length > 0
|
||||
? search.trim()
|
||||
: undefined;
|
||||
|
||||
if (search) {
|
||||
where.name = { contains: String(search) };
|
||||
if (searchTerm) {
|
||||
where.name = { contains: searchTerm };
|
||||
}
|
||||
|
||||
let collectionFilterKey = "default";
|
||||
if (collectionId === "null") {
|
||||
where.collectionId = null;
|
||||
collectionFilterKey = "null";
|
||||
} else if (collectionId) {
|
||||
where.collectionId = String(collectionId);
|
||||
const normalizedCollectionId = String(collectionId);
|
||||
where.collectionId = normalizedCollectionId;
|
||||
collectionFilterKey = `id:${normalizedCollectionId}`;
|
||||
} else {
|
||||
// Default: Exclude trash, but include unorganized (null)
|
||||
where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }];
|
||||
}
|
||||
|
||||
const drawings = await prisma.drawing.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
const shouldIncludeData =
|
||||
typeof includeData === "string"
|
||||
? includeData.toLowerCase() === "true" || includeData === "1"
|
||||
: false;
|
||||
|
||||
const cacheKey = buildDrawingsCacheKey({
|
||||
searchTerm: searchTerm ?? "",
|
||||
collectionFilter: collectionFilterKey,
|
||||
includeData: shouldIncludeData,
|
||||
});
|
||||
|
||||
// Parse JSON strings for response
|
||||
const parsedDrawings = drawings.map((d: any) => ({
|
||||
...d,
|
||||
elements: JSON.parse(d.elements),
|
||||
appState: JSON.parse(d.appState),
|
||||
files: JSON.parse(d.files || "{}"),
|
||||
}));
|
||||
const cachedBody = getCachedDrawingsBody(cacheKey);
|
||||
if (cachedBody) {
|
||||
res.setHeader("X-Cache", "HIT");
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
return res.send(cachedBody);
|
||||
}
|
||||
|
||||
res.json(parsedDrawings);
|
||||
const summarySelect: Prisma.DrawingSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
collectionId: true,
|
||||
preview: true,
|
||||
version: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
};
|
||||
|
||||
const queryOptions: Prisma.DrawingFindManyArgs = {
|
||||
where,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
};
|
||||
|
||||
if (!shouldIncludeData) {
|
||||
queryOptions.select = summarySelect;
|
||||
}
|
||||
|
||||
const drawings = await prisma.drawing.findMany(queryOptions);
|
||||
|
||||
let responsePayload: any = drawings;
|
||||
|
||||
if (shouldIncludeData) {
|
||||
responsePayload = drawings.map((d: any) => ({
|
||||
...d,
|
||||
elements: parseJsonField(d.elements, []),
|
||||
appState: parseJsonField(d.appState, {}),
|
||||
files: parseJsonField(d.files, {}),
|
||||
}));
|
||||
}
|
||||
|
||||
const body = cacheDrawingsResponse(cacheKey, responsePayload);
|
||||
res.setHeader("X-Cache", "MISS");
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
return res.send(body);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: "Failed to fetch drawings" });
|
||||
@@ -591,6 +731,7 @@ app.post("/drawings", async (req, res) => {
|
||||
files: JSON.stringify(payload.files ?? {}),
|
||||
},
|
||||
});
|
||||
invalidateDrawingsCache();
|
||||
|
||||
res.json({
|
||||
...newDrawing,
|
||||
@@ -668,6 +809,7 @@ app.put("/drawings/:id", async (req, res) => {
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
invalidateDrawingsCache();
|
||||
|
||||
console.log("[API] Update complete", {
|
||||
id,
|
||||
@@ -698,6 +840,7 @@ app.delete("/drawings/:id", async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await prisma.drawing.delete({ where: { id } });
|
||||
invalidateDrawingsCache();
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: "Failed to delete drawing" });
|
||||
@@ -724,6 +867,7 @@ app.post("/drawings/:id/duplicate", async (req, res) => {
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
invalidateDrawingsCache();
|
||||
|
||||
res.json({
|
||||
...newDrawing,
|
||||
@@ -794,6 +938,7 @@ app.delete("/collections/:id", async (req, res) => {
|
||||
where: { id },
|
||||
}),
|
||||
]);
|
||||
invalidateDrawingsCache();
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -1059,6 +1204,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
|
||||
|
||||
// Reinitialize Prisma client
|
||||
await prisma.$disconnect();
|
||||
invalidateDrawingsCache();
|
||||
|
||||
res.json({ success: true, message: "Database imported successfully" });
|
||||
} catch (error) {
|
||||
|
||||
+35
-10
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user