import React, { useState, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; 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"; import { exportDrawingToFile } from '../utils/exportUtils'; import * as api from '../api'; type HydratedDrawingData = { elements: any[]; appState: any; files: Record; }; interface DrawingCardProps { drawing: DrawingSummary; collections: Collection[]; isSelected: boolean; isTrash?: boolean; onToggleSelection: (e: React.MouseEvent) => void; onRename: (id: string, name: string) => void; onDelete: (id: string) => void; onMoveToCollection: (id: string, collectionId: string | null) => void; onDuplicate: (id: string) => void; onClick: (id: string, e: React.MouseEvent) => void; onDragStart?: (e: React.DragEvent, id: string) => void; onMouseDown?: (e: React.MouseEvent, id: string) => void; onPreviewGenerated?: (id: string, preview: string) => void; } const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => { return createPortal(children, document.body); }; export const DrawingCard: React.FC = ({ drawing, collections, isSelected, isTrash = false, onToggleSelection, onRename, onDelete, onMoveToCollection, onDuplicate, onClick, onDragStart, onMouseDown, onPreviewGenerated, }) => { const [isRenaming, setIsRenaming] = useState(false); const [showMoveSubmenu, setShowMoveSubmenu] = useState(false); const [showCollectionDropdown, setShowCollectionDropdown] = useState(false); const [newName, setNewName] = useState(drawing.name); 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 () => { try { const data = await ensureFullData(); if (cancelled) return; if (!data?.elements || !data?.appState) return; const svg = await exportToSvg({ elements: data.elements, appState: { ...data.appState, exportBackground: true, viewBackgroundColor: data.appState.viewBackgroundColor || "#ffffff" }, files: data.files || {}, exportPadding: 10 }); if (cancelled) return; const previewHtml = svg.outerHTML; setPreviewSvg(previewHtml); // Save to backend and notify parent api.updateDrawing(drawing.id, { preview: previewHtml }).catch(console.error); onPreviewGenerated?.(drawing.id, previewHtml); } catch (e) { if (!cancelled) { console.error("Failed to generate preview", e); } } }; generatePreview(); 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(() => { const handleClick = () => setContextMenu(null); document.addEventListener('click', handleClick); return () => document.removeEventListener('click', handleClick); }, []); const handleRenameSubmit = (e: React.FormEvent) => { e.preventDefault(); if (newName.trim()) { onRename(drawing.id, newName); setIsRenaming(false); } }; const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY }); setShowMoveSubmenu(false); }; return ( <>
{ if (isRenaming) { e.preventDefault(); return; } e.dataTransfer.setData('drawingId', drawing.id); onDragStart?.(e, drawing.id); }} onMouseDown={(e) => onMouseDown?.(e, drawing.id)} className={clsx( "drawing-card group relative flex flex-col bg-white dark:bg-neutral-900 rounded-2xl border-2 transition-all duration-200 ease-out", !isTrash && "hover:-translate-y-1 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)]", isTrash && "shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] opacity-80 grayscale-[0.5]", // "always show the border for trash" -> It already has a border. Maybe "always show shadow"? // I added default shadow for trash and reduced opacity to indicate trash state. isSelected ? "border-neutral-500 dark:border-neutral-500 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.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)]" )} > {/* Selection Toggle */}
{/* Preview Area */}
!isTrash && onClick(drawing.id, e)} className={clsx( "aspect-[16/10] bg-slate-50 dark:bg-neutral-800/50 relative overflow-hidden flex items-center justify-center border-b-2 border-black dark:border-neutral-700 rounded-t-xl transition-colors", !isTrash && "cursor-pointer group-hover:bg-neutral-100/30 dark:group-hover:bg-neutral-800", isTrash && "cursor-default" )} > {/* Placeholder Grid Pattern */}
{previewSvg ? (
) : (
)}
{/* Footer */}
{isRenaming ? (
e.stopPropagation()} onPointerDown={e => e.stopPropagation()} onMouseDown={e => e.stopPropagation()} > setNewName(e.target.value)} onBlur={() => setIsRenaming(false)} onDragStart={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} className="w-full px-2 py-1 -ml-2 text-base font-bold text-slate-900 dark:text-white border-2 border-black dark:border-neutral-600 rounded-lg focus:outline-none shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] bg-white dark:bg-neutral-800" />
) : (

{ e.stopPropagation(); setIsRenaming(true); }} > {drawing.name}

)}

{formatDistanceToNow(drawing.updatedAt)} ago

e.stopPropagation()}> {showCollectionDropdown && ( <>
setShowCollectionDropdown(false)} />
{collections.map(c => ( ))}
)}
{/* Context Menu Portal */} {contextMenu && (
setContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }} >
e.stopPropagation()} >
setShowMoveSubmenu(true)} onMouseLeave={() => setShowMoveSubmenu(false)} > {showMoveSubmenu && (
{collections.map(c => ( ))}
)}
{exportError && (
{exportError}
)}
)} ); };