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
|
frontend/node_modules
|
||||||
.DS_Store
|
backend/node_modules
|
||||||
|
|
||||||
|
# Database
|
||||||
backend/prisma/*.db
|
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 archiver from "archiver";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { PrismaClient } from "./generated/client";
|
import { PrismaClient, Prisma } from "./generated/client";
|
||||||
import {
|
import {
|
||||||
sanitizeDrawingData,
|
sanitizeDrawingData,
|
||||||
validateImportedDrawing,
|
validateImportedDrawing,
|
||||||
@@ -112,6 +112,81 @@ const io = new Server(httpServer, {
|
|||||||
maxHttpBufferSize: 1e8, // 100 MB
|
maxHttpBufferSize: 1e8, // 100 MB
|
||||||
});
|
});
|
||||||
const prisma = new PrismaClient();
|
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;
|
const PORT = process.env.PORT || 8000;
|
||||||
|
|
||||||
// Multer setup for file uploads with streaming support
|
// Multer setup for file uploads with streaming support
|
||||||
@@ -189,7 +264,24 @@ app.use((req, res, next) => {
|
|||||||
// Rate limiting middleware (basic implementation)
|
// Rate limiting middleware (basic implementation)
|
||||||
const requestCounts = new Map<string, { count: number; resetTime: number }>();
|
const requestCounts = new Map<string, { count: number; resetTime: number }>();
|
||||||
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
|
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) => {
|
app.use((req, res, next) => {
|
||||||
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||||
@@ -486,36 +578,84 @@ app.get("/health", (req, res) => {
|
|||||||
// GET /drawings
|
// GET /drawings
|
||||||
app.get("/drawings", async (req, res) => {
|
app.get("/drawings", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { search, collectionId } = req.query;
|
const { search, collectionId, includeData } = req.query;
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
|
const searchTerm =
|
||||||
|
typeof search === "string" && search.trim().length > 0
|
||||||
|
? search.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (search) {
|
if (searchTerm) {
|
||||||
where.name = { contains: String(search) };
|
where.name = { contains: searchTerm };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let collectionFilterKey = "default";
|
||||||
if (collectionId === "null") {
|
if (collectionId === "null") {
|
||||||
where.collectionId = null;
|
where.collectionId = null;
|
||||||
|
collectionFilterKey = "null";
|
||||||
} else if (collectionId) {
|
} else if (collectionId) {
|
||||||
where.collectionId = String(collectionId);
|
const normalizedCollectionId = String(collectionId);
|
||||||
|
where.collectionId = normalizedCollectionId;
|
||||||
|
collectionFilterKey = `id:${normalizedCollectionId}`;
|
||||||
} else {
|
} else {
|
||||||
// Default: Exclude trash, but include unorganized (null)
|
// Default: Exclude trash, but include unorganized (null)
|
||||||
where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }];
|
where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawings = await prisma.drawing.findMany({
|
const shouldIncludeData =
|
||||||
where,
|
typeof includeData === "string"
|
||||||
orderBy: { updatedAt: "desc" },
|
? includeData.toLowerCase() === "true" || includeData === "1"
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const cacheKey = buildDrawingsCacheKey({
|
||||||
|
searchTerm: searchTerm ?? "",
|
||||||
|
collectionFilter: collectionFilterKey,
|
||||||
|
includeData: shouldIncludeData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse JSON strings for response
|
const cachedBody = getCachedDrawingsBody(cacheKey);
|
||||||
const parsedDrawings = drawings.map((d: any) => ({
|
if (cachedBody) {
|
||||||
...d,
|
res.setHeader("X-Cache", "HIT");
|
||||||
elements: JSON.parse(d.elements),
|
res.setHeader("Content-Type", "application/json");
|
||||||
appState: JSON.parse(d.appState),
|
return res.send(cachedBody);
|
||||||
files: JSON.parse(d.files || "{}"),
|
}
|
||||||
}));
|
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.status(500).json({ error: "Failed to fetch drawings" });
|
res.status(500).json({ error: "Failed to fetch drawings" });
|
||||||
@@ -591,6 +731,7 @@ app.post("/drawings", async (req, res) => {
|
|||||||
files: JSON.stringify(payload.files ?? {}),
|
files: JSON.stringify(payload.files ?? {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
invalidateDrawingsCache();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
...newDrawing,
|
...newDrawing,
|
||||||
@@ -668,6 +809,7 @@ app.put("/drawings/:id", async (req, res) => {
|
|||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
invalidateDrawingsCache();
|
||||||
|
|
||||||
console.log("[API] Update complete", {
|
console.log("[API] Update complete", {
|
||||||
id,
|
id,
|
||||||
@@ -698,6 +840,7 @@ app.delete("/drawings/:id", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
await prisma.drawing.delete({ where: { id } });
|
await prisma.drawing.delete({ where: { id } });
|
||||||
|
invalidateDrawingsCache();
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: "Failed to delete drawing" });
|
res.status(500).json({ error: "Failed to delete drawing" });
|
||||||
@@ -724,6 +867,7 @@ app.post("/drawings/:id/duplicate", async (req, res) => {
|
|||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
invalidateDrawingsCache();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
...newDrawing,
|
...newDrawing,
|
||||||
@@ -794,6 +938,7 @@ app.delete("/collections/:id", async (req, res) => {
|
|||||||
where: { id },
|
where: { id },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
invalidateDrawingsCache();
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1059,6 +1204,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
|
|||||||
|
|
||||||
// Reinitialize Prisma client
|
// Reinitialize Prisma client
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
invalidateDrawingsCache();
|
||||||
|
|
||||||
res.json({ success: true, message: "Database imported successfully" });
|
res.json({ success: true, message: "Database imported successfully" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+35
-10
@@ -1,5 +1,5 @@
|
|||||||
import axios from "axios";
|
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";
|
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;
|
return Number.isNaN(parsed) ? Date.now() : parsed;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deserializeDrawing = (drawing: any): Drawing => ({
|
const deserializeTimestamps = <T extends { createdAt: any; updatedAt: any }>(
|
||||||
...drawing,
|
data: T
|
||||||
createdAt: coerceTimestamp(drawing.createdAt),
|
): T & { createdAt: number; updatedAt: number } => ({
|
||||||
updatedAt: coerceTimestamp(drawing.updatedAt),
|
...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,
|
search?: string,
|
||||||
collectionId?: string | null
|
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 = {};
|
const params: any = {};
|
||||||
if (search) params.search = search;
|
if (search) params.search = search;
|
||||||
if (collectionId !== undefined)
|
if (collectionId !== undefined)
|
||||||
params.collectionId = collectionId === null ? "null" : collectionId;
|
params.collectionId = collectionId === null ? "null" : collectionId;
|
||||||
const response = await api.get<Drawing[]>("/drawings", { params });
|
if (options?.includeData) {
|
||||||
return response.data.map(deserializeDrawing);
|
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) => {
|
export const getDrawing = async (id: string) => {
|
||||||
const response = await api.get<Drawing>(`/drawings/${id}`);
|
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 { createPortal } from 'react-dom';
|
||||||
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download } from 'lucide-react';
|
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy, Download, Loader2 } from 'lucide-react';
|
||||||
import type { Drawing, Collection } from '../types';
|
import type { DrawingSummary, Collection, Drawing } from '../types';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { exportToSvg } from "@excalidraw/excalidraw";
|
import { exportToSvg } from "@excalidraw/excalidraw";
|
||||||
@@ -10,8 +10,14 @@ import { exportDrawingToFile } from '../utils/exportUtils';
|
|||||||
|
|
||||||
import * as api from '../api';
|
import * as api from '../api';
|
||||||
|
|
||||||
|
type HydratedDrawingData = {
|
||||||
|
elements: any[];
|
||||||
|
appState: any;
|
||||||
|
files: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
interface DrawingCardProps {
|
interface DrawingCardProps {
|
||||||
drawing: Drawing;
|
drawing: DrawingSummary;
|
||||||
collections: Collection[];
|
collections: Collection[];
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isTrash?: boolean;
|
isTrash?: boolean;
|
||||||
@@ -49,30 +55,74 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
|
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
|
||||||
const [showCollectionDropdown, setShowCollectionDropdown] = useState(false);
|
const [showCollectionDropdown, setShowCollectionDropdown] = useState(false);
|
||||||
const [newName, setNewName] = useState(drawing.name);
|
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 [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(() => {
|
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) {
|
if (drawing.preview) {
|
||||||
setPreviewSvg(drawing.preview);
|
setPreviewSvg(drawing.preview);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const generatePreview = async () => {
|
const generatePreview = async () => {
|
||||||
// Ensure elements and appState exist before trying to generate
|
|
||||||
if (!drawing.elements || !drawing.appState) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const data = await ensureFullData();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!data?.elements || !data?.appState) return;
|
||||||
|
|
||||||
const svg = await exportToSvg({
|
const svg = await exportToSvg({
|
||||||
elements: drawing.elements,
|
elements: data.elements,
|
||||||
appState: {
|
appState: {
|
||||||
...drawing.appState,
|
...data.appState,
|
||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
viewBackgroundColor: drawing.appState.viewBackgroundColor || "#ffffff"
|
viewBackgroundColor: data.appState.viewBackgroundColor || "#ffffff"
|
||||||
},
|
},
|
||||||
files: drawing.files || {},
|
files: data.files || {},
|
||||||
exportPadding: 10
|
exportPadding: 10
|
||||||
});
|
});
|
||||||
|
if (cancelled) return;
|
||||||
const previewHtml = svg.outerHTML;
|
const previewHtml = svg.outerHTML;
|
||||||
setPreviewSvg(previewHtml);
|
setPreviewSvg(previewHtml);
|
||||||
|
|
||||||
@@ -80,11 +130,42 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
api.updateDrawing(drawing.id, { preview: previewHtml }).catch(console.error);
|
api.updateDrawing(drawing.id, { preview: previewHtml }).catch(console.error);
|
||||||
onPreviewGenerated?.(drawing.id, previewHtml);
|
onPreviewGenerated?.(drawing.id, previewHtml);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to generate preview", e);
|
if (!cancelled) {
|
||||||
|
console.error("Failed to generate preview", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
generatePreview();
|
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
|
// Close context menu on click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -327,14 +408,22 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={async (e) => {
|
||||||
exportDrawingToFile(drawing);
|
e.stopPropagation();
|
||||||
|
await handleExport();
|
||||||
setContextMenu(null);
|
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>
|
</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>
|
<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 { Plus, Search, Loader2, Inbox, Trash2, Folder, ArrowRight, Copy, Upload } from 'lucide-react';
|
||||||
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||||
import * as api from '../api';
|
import * as api from '../api';
|
||||||
import type { Drawing, Collection } from '../types';
|
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';
|
||||||
@@ -45,7 +45,7 @@ export const Dashboard: React.FC = () => {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [drawings, setDrawings] = useState<Drawing[]>([]);
|
const [drawings, setDrawings] = useState<DrawingSummary[]>([]);
|
||||||
const [collections, setCollections] = useState<Collection[]>([]);
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
|
|
||||||
// Derived state from URL
|
// Derived state from URL
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
export interface Drawing {
|
export interface DrawingSummary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
elements: any[];
|
|
||||||
appState: any;
|
|
||||||
files: Record<string, any> | null;
|
|
||||||
collectionId: string | null;
|
collectionId: string | null;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
createdAt: 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 {
|
export interface Collection {
|
||||||
|
|||||||
Reference in New Issue
Block a user