import React, { useEffect, useState, useCallback, useRef } from 'react'; import { Layout } from '../components/Layout'; import { DrawingCard } from '../components/DrawingCard'; import { Plus, Search, Loader2, Inbox, Trash2, Folder, ArrowRight, Copy, Upload, CheckSquare, Square, ArrowUp, ArrowDown, ChevronDown, FileText, Calendar, Clock } from 'lucide-react'; import { useNavigate, useSearchParams, useLocation } from 'react-router-dom'; import * as api from '../api'; import type { DrawingSortField, SortDirection } from '../api'; import { useDebounce } from '../hooks/useDebounce'; import clsx from 'clsx'; import { ConfirmModal } from '../components/ConfirmModal'; import { useUpload } from '../context/UploadContext'; import { DragOverlayPortal, getSelectionBounds, type Point, type SelectionBounds } from './dashboard/shared'; import { useDashboardData } from './dashboard/useDashboardData'; const PAGE_SIZE = 24; export const Dashboard: React.FC = () => { const [searchParams] = useSearchParams(); const location = useLocation(); const navigate = useNavigate(); const selectedCollectionId = React.useMemo(() => { if (location.pathname === '/') return undefined; if (location.pathname === '/collections') { const id = searchParams.get('id'); if (id === 'unorganized') return null; return id || undefined; } return undefined; }, [location.pathname, searchParams]); const setSelectedCollectionId = (id: string | null | undefined) => { if (id === undefined) { navigate('/'); } else if (id === null) { navigate('/collections?id=unorganized'); } else { navigate(`/collections?id=${id}`); } }; const [search, setSearch] = useState(''); const debouncedSearch = useDebounce(search, 300); const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastSelectedId, setLastSelectedId] = useState(null); const [showBulkMoveMenu, setShowBulkMoveMenu] = useState(false); const [showSortMenu, setShowSortMenu] = useState(false); const [drawingToDelete, setDrawingToDelete] = useState(null); const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false); const [showImportError, setShowImportError] = useState<{ isOpen: boolean; message: string }>({ isOpen: false, message: '' }); const [isDragSelecting, setIsDragSelecting] = useState(false); const [dragStart, setDragStart] = useState(null); const [dragCurrent, setDragCurrent] = useState(null); const [potentialDragId, setPotentialDragId] = useState(null); const containerRef = useRef(null); const loaderRef = useRef(null); type SortField = DrawingSortField; const searchInputRef = useRef(null); const [sortConfig, setSortConfig] = useState<{ field: SortField; direction: SortDirection }>({ field: 'updatedAt', direction: 'desc' }); const { uploadFiles } = useUpload(); const resetSelection = useCallback(() => { setSelectedIds(new Set()); }, []); const { drawings, setDrawings, collections, setCollections, setTotalCount, isFetchingMore, isLoading, hasMore, refreshData, fetchMore, } = useDashboardData({ debouncedSearch, selectedCollectionId, sortField: sortConfig.field, sortDirection: sortConfig.direction, pageSize: PAGE_SIZE, onRefreshSuccess: resetSelection, }); // Infinite scroll observer useEffect(() => { const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasMore) { fetchMore(); } }, { threshold: 0.1 } ); if (loaderRef.current) { observer.observe(loaderRef.current); } return () => observer.disconnect(); }, [fetchMore, hasMore]); const [isDraggingFile, setIsDraggingFile] = useState(false); const dragCounter = useRef(0); const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes('Files')) { dragCounter.current += 1; if (dragCounter.current === 1) { setIsDraggingFile(true); } } }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes('Files')) { dragCounter.current -= 1; if (dragCounter.current === 0) { setIsDraggingFile(false); } } }, []); const selectionBounds = React.useMemo(() => { if (!dragStart || !dragCurrent) return null; return getSelectionBounds(dragStart, dragCurrent); }, [dragStart, dragCurrent]); useEffect(() => { if (!isDragSelecting) return; const handleMouseMove = (e: MouseEvent) => { setDragCurrent({ x: e.clientX, y: e.clientY }); }; const handleMouseUp = (_: MouseEvent) => { if (!dragStart || !dragCurrent) { setIsDragSelecting(false); setDragStart(null); setDragCurrent(null); return; } const selectionRect = getSelectionBounds(dragStart, dragCurrent); if (selectionRect.width > 5 || selectionRect.height > 5) { const newSelectedIds = new Set(selectedIds); drawings.forEach(drawing => { const card = document.getElementById(`drawing-card-${drawing.id}`); if (card) { const rect = card.getBoundingClientRect(); if ( rect.left < selectionRect.right && rect.right > selectionRect.left && rect.top < selectionRect.bottom && rect.bottom > selectionRect.top ) { newSelectedIds.add(drawing.id); } } }); setSelectedIds(newSelectedIds); } setIsDragSelecting(false); setDragStart(null); setDragCurrent(null); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [isDragSelecting, dragStart, dragCurrent, drawings, selectedIds]); const handleMouseDown = (e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('button, a, input, textarea, .drawing-card')) return; if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) return; if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { setSelectedIds(new Set()); } setPotentialDragId(null); setIsDragSelecting(true); setDragStart({ x: e.clientX, y: e.clientY }); setDragCurrent({ x: e.clientX, y: e.clientY }); }; const sortedDrawings = drawings; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Cmd+A or Ctrl+A to Select All if ((e.metaKey || e.ctrlKey) && e.key === 'a') { // Don't select all if user is typing in an input if (document.activeElement instanceof HTMLInputElement || document.activeElement instanceof HTMLTextAreaElement) { return; } e.preventDefault(); const allIds = new Set(sortedDrawings.map(d => d.id)); setSelectedIds(allIds); } // Escape to Clear Selection if (e.key === 'Escape') { e.preventDefault(); setSelectedIds(new Set()); setLastSelectedId(null); } // Cmd+K to Search if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); searchInputRef.current?.focus(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [sortedDrawings]); const handleSortFieldChange = (field: SortField) => { setSortConfig(current => { // If changing field, use default direction for that field if (current.field !== field) { const defaultDirection = field === 'name' ? 'asc' : 'desc'; return { field, direction: defaultDirection }; } // If same field, keep current direction return current; }); setShowSortMenu(false); }; const handleSortDirectionToggle = () => { setSortConfig(current => ({ ...current, direction: current.direction === 'asc' ? 'desc' : 'asc' })); }; const sortOptions: { field: SortField; label: string; icon: React.ReactNode }[] = [ { field: 'name', label: 'Name', icon: }, { field: 'createdAt', label: 'Date Created', icon: }, { field: 'updatedAt', label: 'Date Modified', icon: }, ]; const currentSortOption = sortOptions.find(opt => opt.field === sortConfig.field) || sortOptions[0]; const isTrashView = selectedCollectionId === 'trash'; const handleCreateDrawing = async () => { if (isTrashView) return; try { const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId; const { id } = await api.createDrawing('Untitled Drawing', targetCollectionId); navigate(`/editor/${id}`); } catch (err) { console.error(err); } }; const handleImportDrawings = async (files: FileList | null) => { if (!files || isTrashView) return; const fileArray = Array.from(files); const targetCollectionId = selectedCollectionId === undefined ? null : selectedCollectionId; // Use the global upload context uploadFiles(fileArray, targetCollectionId).finally(() => { // Refresh after all uploads complete (success or failure) refreshData(); }); }; const handleRenameDrawing = async (id: string, name: string) => { setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d)); try { await api.updateDrawing(id, { name }); } catch (err) { console.error("Failed to rename drawing:", err); refreshData(); } }; const handleDeleteDrawing = async (id: string) => { if (isTrashView) { // Permanent Delete -> Confirm first setDrawingToDelete(id); } else { // Move to Trash -> No Confirm const trashId = 'trash'; // Optimistic Remove from current view setDrawings(prev => { const next = prev.filter(d => d.id !== id); if (next.length !== prev.length) { setTotalCount(t => t - 1); } return next; }); setSelectedIds(prev => { const s = new Set(prev); s.delete(id); return s; }); try { await api.updateDrawing(id, { collectionId: trashId }); } catch (err) { console.error("Failed to move to trash", err); refreshData(); } } }; const executePermanentDelete = async (id: string) => { setDrawings(prev => { const next = prev.filter(d => d.id !== id); if (next.length !== prev.length) { setTotalCount(t => t - 1); } return next; }); setSelectedIds(prev => { const s = new Set(prev); s.delete(id); return s; }); setDrawingToDelete(null); // Close modal immediately try { await api.deleteDrawing(id); } catch (err) { console.error("Failed to delete drawing", err); refreshData(); } }; const handleToggleSelection = (id: string, e: React.MouseEvent) => { setSelectedIds(prev => { const next = new Set(prev); // Handle Shift+Select if (e.shiftKey && lastSelectedId && sortedDrawings.some(d => d.id === lastSelectedId)) { const currentIndex = sortedDrawings.findIndex(d => d.id === id); const lastIndex = sortedDrawings.findIndex(d => d.id === lastSelectedId); if (currentIndex !== -1 && lastIndex !== -1) { const start = Math.min(currentIndex, lastIndex); const end = Math.max(currentIndex, lastIndex); for (let i = start; i <= end; i++) { next.add(sortedDrawings[i].id); } return next; } } if (next.has(id)) { next.delete(id); setLastSelectedId(null); } else { next.add(id); setLastSelectedId(id); } return next; }); }; const handleBulkDeleteClick = () => { if (selectedIds.size === 0) return; if (isTrashView) { setShowBulkDeleteConfirm(true); } else { executeBulkMoveToTrash(); } }; const executeBulkMoveToTrash = async () => { const trashId = 'trash'; const ids = Array.from(selectedIds); setDrawings(prev => { const next = prev.filter(d => !selectedIds.has(d.id)); setTotalCount(t => t - (prev.length - next.length)); return next; }); setSelectedIds(new Set()); try { await Promise.all(ids.map(id => api.updateDrawing(id, { collectionId: trashId }))); } catch (err) { console.error("Failed bulk move to trash", err); refreshData(); } }; const executeBulkPermanentDelete = async () => { const ids = Array.from(selectedIds); setDrawings(prev => { const next = prev.filter(d => !selectedIds.has(d.id)); setTotalCount(t => t - (prev.length - next.length)); return next; }); setSelectedIds(new Set()); setShowBulkDeleteConfirm(false); try { await Promise.all(ids.map(id => api.deleteDrawing(id))); } catch (err) { console.error("Failed bulk delete", err); refreshData(); } }; const handleBulkMove = async (collectionId: string | null) => { if (selectedIds.size === 0) return; const idsToMove = Array.from(selectedIds); // Optimistic update setDrawings(prev => { const updated = prev.map(d => selectedIds.has(d.id) ? { ...d, collectionId } : d); if (selectedCollectionId === undefined) return updated; const next = updated.filter(d => { if (selectedCollectionId === null) return d.collectionId === null; return d.collectionId === selectedCollectionId; }); setTotalCount(t => t - (prev.length - next.length)); return next; }); setSelectedIds(new Set()); // Clear selection after move setShowBulkMoveMenu(false); try { await Promise.all(idsToMove.map(id => api.updateDrawing(id, { collectionId }))); } catch (err) { console.error("Failed bulk move", err); refreshData(); } }; const handleDuplicateDrawing = async (id: string) => { try { await api.duplicateDrawing(id); refreshData(); } catch (err) { console.error("Failed to duplicate drawing:", err); } }; const handleBulkDuplicate = async () => { if (selectedIds.size === 0) return; try { const ids = Array.from(selectedIds); await Promise.all(ids.map(id => api.duplicateDrawing(id))); setSelectedIds(new Set()); refreshData(); } catch (err) { console.error("Failed bulk duplicate:", err); } }; const handleMoveToCollection = async (id: string, collectionId: string | null) => { setDrawings(prev => { const updated = prev.map(d => d.id === id ? { ...d, collectionId } : d); const next = updated.filter(d => { if (selectedCollectionId === undefined) return true; if (selectedCollectionId === null) return d.collectionId === null; return d.collectionId === selectedCollectionId; }); if (next.length !== prev.length) { setTotalCount(t => t - 1); } return next; }); try { await api.updateDrawing(id, { collectionId }); } catch (error) { console.error("Failed to move drawing:", error); refreshData(); } }; const handleCreateCollection = async (name: string) => { try { await api.createCollection(name); const newCollections = await api.getCollections(); setCollections(newCollections); } catch (err) { console.error("Failed to create collection:", err); refreshData(); } }; const handleEditCollection = async (id: string, name: string) => { setCollections(prev => prev.map(c => c.id === id ? { ...c, name } : c)); try { await api.updateCollection(id, name); } catch (err) { console.error("Failed to rename collection:", err); refreshData(); } }; const handleDeleteCollection = async (id: string) => { setCollections(prev => prev.filter(c => c.id !== id)); if (selectedCollectionId === id) { setSelectedCollectionId(undefined); } try { await api.deleteCollection(id); refreshData(); } catch (err) { console.error("Failed to delete collection:", err); refreshData(); } }; const viewTitle = React.useMemo(() => { if (selectedCollectionId === undefined) return "All Drawings"; if (selectedCollectionId === null) return "Unorganized"; if (selectedCollectionId === 'trash') return "Trash"; const collection = collections.find(c => c.id === selectedCollectionId); return collection ? collection.name : "Collection"; }, [selectedCollectionId, collections]); const hasSelection = selectedIds.size > 0; const allSelected = sortedDrawings.length > 0 && selectedIds.size === sortedDrawings.length; const handleSelectAll = () => { if (allSelected) { // Deselect all setSelectedIds(new Set()); setLastSelectedId(null); } else { // Select all const allIds = new Set(sortedDrawings.map(d => d.id)); setSelectedIds(allIds); } }; const handleDrop = async (e: React.DragEvent, targetCollectionId: string | null) => { e.preventDefault(); e.stopPropagation(); // Handle Files if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { const files = Array.from(e.dataTransfer.files); const libFiles = files.filter(f => f.name.endsWith('.excalidrawlib')); if (libFiles.length > 0) { setShowImportError({ isOpen: true, message: 'Library (.excalidrawlib) imports are not supported in this build. Please import drawings (.excalidraw/.json) instead.' }); } const drawingFiles = files.filter(f => !f.name.endsWith('.excalidrawlib')); if (drawingFiles.length > 0) { uploadFiles(drawingFiles, targetCollectionId).finally(() => { refreshData(); }); } return; } const draggedDrawingId = e.dataTransfer.getData('drawingId'); if (!draggedDrawingId) return; let idsToMove = new Set(); if (selectedIds.has(draggedDrawingId)) { idsToMove = new Set(selectedIds); } else { // Otherwise move just the dragged item idsToMove.add(draggedDrawingId); } // Optimistic Update setDrawings(prev => { const updated = prev.map(d => idsToMove.has(d.id) ? { ...d, collectionId: targetCollectionId } : d); if (selectedCollectionId === undefined) return updated; const next = updated.filter(d => { if (selectedCollectionId === null) return d.collectionId === null; return d.collectionId === selectedCollectionId; }); setTotalCount(t => t - (prev.length - next.length)); return next; }); // Clear selection if we moved selected items if (selectedIds.has(draggedDrawingId)) { setSelectedIds(new Set()); } try { await Promise.all(Array.from(idsToMove).map(id => api.updateDrawing(id, { collectionId: targetCollectionId }))); } catch (err) { console.error("Failed to move", err); refreshData(); } }; const dragPreviewDrawings = React.useMemo(() => { if (!potentialDragId) return []; if (selectedIds.has(potentialDragId) && selectedIds.size > 1) { return drawings.filter(d => selectedIds.has(d.id)); } const drawing = drawings.find(d => d.id === potentialDragId); return drawing ? [drawing] : []; }, [potentialDragId, selectedIds, drawings]); const handleCardMouseDown = (_e: React.MouseEvent, id: string) => { setPotentialDragId(id); }; const handleCardDragStart = (e: React.DragEvent, _id: string) => { const preview = document.getElementById('drag-preview'); if (preview) { e.dataTransfer.setDragImage(preview, 80, 50); } }; const handlePreviewGenerated = (id: string, preview: string) => { setDrawings(prev => prev.map(d => d.id === id ? { ...d, preview } : d)); }; const visibleCollections = React.useMemo(() => collections.filter(c => c.id !== 'trash'), [collections]); return (
{dragPreviewDrawings.length > 0 && (
{dragPreviewDrawings.slice(0, 3).map((d, i) => (
{d.preview ? (
) : (
)}
))} {dragPreviewDrawings.length > 1 && (
{dragPreviewDrawings.length}
)}
)}
{isDragSelecting && selectionBounds && (
)}

{viewTitle}

setSearch(e.target.value)} className="w-full pl-10 pr-12 py-2.5 bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-xl focus:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:focus:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] outline-none transition-all shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] placeholder:text-slate-400 dark:placeholder:text-neutral-500 text-sm text-slate-900 dark:text-white" />
K
{showSortMenu && ( <>
setShowSortMenu(false)} />
{sortOptions.map((option) => ( ))}
)}
{showBulkMoveMenu && hasSelection && ( <>
setShowBulkMoveMenu(false)} />
Move {selectedIds.size} items to...
{collections.filter(c => c.id !== 'trash').map(c => ( ))}
)}
{ handleImportDrawings(e.target.files); e.target.value = ''; }} />
{ e.preventDefault(); if (!isDraggingFile && e.dataTransfer.types.includes('Files')) { // Fallback if dragEnter didn't fire (e.g. initial drag start outside window) setIsDraggingFile(true); } }} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} onDrop={(e) => { setIsDraggingFile(false); dragCounter.current = 0; const target = selectedCollectionId === undefined ? null : selectedCollectionId; handleDrop(e, target); }} > {isDraggingFile && (

Drop files to import

Drop .excalidraw or .json files here to add them to {viewTitle}

)} {isLoading && drawings.length === 0 ? (
) : (
{sortedDrawings.length === 0 ? (
{isTrashView ? : }

{isTrashView ? "Your trash is empty" : "No drawings found"}

{!isTrashView && (

{search ? `No results for "${search}"` : "Create a new drawing to get started!"}

)} {search && ( )}
) : ( sortedDrawings.map((drawing) => ( handleToggleSelection(drawing.id, e)} onRename={handleRenameDrawing} onDelete={handleDeleteDrawing} onDuplicate={handleDuplicateDrawing} onMoveToCollection={handleMoveToCollection} onClick={(id, e) => { if (selectedIds.size > 0 || e.shiftKey || e.metaKey || e.ctrlKey) { handleToggleSelection(id, e); } else { navigate(`/editor/${id}`); } }} onMouseDown={handleCardMouseDown} onDragStart={handleCardDragStart} onPreviewGenerated={handlePreviewGenerated} /> )) )}
)} {/* Infinite Scroll Trigger */}
{isFetchingMore && (
Loading more...
)}
drawingToDelete && executePermanentDelete(drawingToDelete)} onCancel={() => setDrawingToDelete(null)} /> setShowBulkDeleteConfirm(false)} /> setShowImportError({ isOpen: false, message: '' })} onCancel={() => setShowImportError({ isOpen: false, message: '' })} /> ); };