diff --git a/.gitignore b/.gitignore index fcda649..7ba1516 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,76 @@ +# Dependencies frontend/node_modules +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 -backend/prisma/*.db \ No newline at end of file +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary files +*.tmp +*.temp \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index b7b3bf4..0f5dc0c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 = ( + 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(); + +/** + * 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(); 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) { diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 0bd9856..4c203a2 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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 = ( + 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; + +export function getDrawings( + search: string | undefined, + collectionId: string | null | undefined, + options: { includeData: true } +): Promise; + +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("/drawings", { params }); - return response.data.map(deserializeDrawing); -}; + if (options?.includeData) { + params.includeData = "true"; + const response = await api.get("/drawings", { params }); + return response.data.map(deserializeDrawing); + } + const response = await api.get("/drawings", { params }); + return response.data.map(deserializeDrawingSummary); +} export const getDrawing = async (id: string) => { const response = await api.get(`/drawings/${id}`); diff --git a/frontend/src/components/DrawingCard.tsx b/frontend/src/components/DrawingCard.tsx index b383d2d..f30eb1f 100644 --- a/frontend/src/components/DrawingCard.tsx +++ b/frontend/src/components/DrawingCard.tsx @@ -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; +}; + interface DrawingCardProps { - drawing: Drawing; + drawing: DrawingSummary; collections: Collection[]; isSelected: boolean; isTrash?: boolean; @@ -49,30 +55,74 @@ export const DrawingCard: React.FC = ({ const [showMoveSubmenu, setShowMoveSubmenu] = useState(false); const [showCollectionDropdown, setShowCollectionDropdown] = useState(false); const [newName, setNewName] = useState(drawing.name); - const [previewSvg, setPreviewSvg] = useState(null); + const [previewSvg, setPreviewSvg] = useState(drawing.preview ?? null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const [isExporting, setIsExporting] = useState(false); + const [exportError, setExportError] = useState(null); + const [fullData, setFullData] = useState(null); + + const fullDataRef = React.useRef(fullData); + fullDataRef.current = fullData; + const fullDataPromiseRef = React.useRef | 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 => { + 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 = ({ 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 = ({ + {exportError && ( +
+ {exportError} +
+ )}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 2fd1be5..be5eb2d 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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([]); + const [drawings, setDrawings] = useState([]); const [collections, setCollections] = useState([]); // 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 }} /> - +