Files
ExcaliDash/frontend/src/pages/Dashboard.tsx
T
Zimeng Xiong 0476315322 0.2.1 Release (#32)
* feat(security): implement CSRF protection

* chore: clean up CSRF implementation

  - Remove unused generateCsrfToken export from security.ts
  - Remove redundant /csrf-token path check (GET already exempt)
  - Restore defineConfig wrapper in vitest.config.ts for type safety

* add K8S note in README, fix broken e2e

* feat/upload-bar (#30)

* feat/upload-bar: add a upload bar when user upload file, indicate the upload process

* feat/save-loading-status: add save status when click back button from editor

* fix: address PR review issues in upload and save features

- Replace deprecated substr() with substring() in UploadContext
- Fix broken error handling that checked stale task status
- Fix missing useEffect dependency in UploadStatus
- Fix CSS class conflict in progress bar styling
- Add error recovery for save state in Editor (reset on failure)
- Use .finally() instead of .then() to ensure refresh on upload failure
- Fix inconsistent indentation in UploadContext

* fix e2e tests

---------

Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com>

* chore: pre-release v0.2.1-dev

* Update backend/src/security.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix filename/math random UUID generation

---------

Co-authored-by: AdrianAcala <adrianacala017@gmail.com>
Co-authored-by: adamant368 <60790941+Yiheng-Liu@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-14 11:25:27 -08:00

937 lines
38 KiB
TypeScript

import React, { useEffect, useState, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Layout } from '../components/Layout';
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 { DrawingSummary, Collection } from '../types';
import { useDebounce } from '../hooks/useDebounce';
import clsx from 'clsx';
import { ConfirmModal } from '../components/ConfirmModal';
import { useUpload } from '../context/UploadContext';
type Point = { x: number; y: number };
type SelectionBounds = {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
};
const getSelectionBounds = (start: Point, current: Point): SelectionBounds => {
const left = Math.min(start.x, current.x);
const right = Math.max(start.x, current.x);
const top = Math.min(start.y, current.y);
const bottom = Math.max(start.y, current.y);
return {
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
};
};
const DragOverlayPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return createPortal(children, document.body);
};
export const Dashboard: React.FC = () => {
const [searchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const [drawings, setDrawings] = useState<DrawingSummary[]>([]);
const [collections, setCollections] = useState<Collection[]>([]);
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<Set<string>>(new Set());
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
const [showBulkMoveMenu, setShowBulkMoveMenu] = useState(false);
const [drawingToDelete, setDrawingToDelete] = useState<string | null>(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<Point | null>(null);
const [dragCurrent, setDragCurrent] = useState<Point | null>(null);
const [potentialDragId, setPotentialDragId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
type SortField = 'name' | 'createdAt' | 'updatedAt';
type SortDirection = 'asc' | 'desc';
const searchInputRef = useRef<HTMLInputElement>(null);
const [sortConfig, setSortConfig] = useState<{ field: SortField; direction: SortDirection }>({
field: 'updatedAt',
direction: 'desc'
});
const [isLoading, setIsLoading] = useState(false);
const { uploadFiles } = useUpload();
const refreshData = useCallback(async () => {
setIsLoading(true);
try {
const [drawingsData, collectionsData] = await Promise.all([
api.getDrawings(debouncedSearch, selectedCollectionId),
api.getCollections()
]);
setDrawings(drawingsData);
setCollections(collectionsData);
setSelectedIds(new Set());
} catch (err) {
console.error('Failed to fetch data:', err);
} finally {
setIsLoading(false);
}
}, [debouncedSearch, selectedCollectionId]);
useEffect(() => {
refreshData();
}, [refreshData]);
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<SelectionBounds | null>(() => {
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 = React.useMemo(() => {
return [...drawings].sort((a, b) => {
const { field, direction } = sortConfig;
const modifier = direction === 'asc' ? 1 : -1;
if (field === 'name') return a.name.localeCompare(b.name) * modifier;
if (field === 'createdAt') return (a.createdAt - b.createdAt) * modifier;
if (field === 'updatedAt') return (a.updatedAt - b.updatedAt) * modifier;
return 0;
});
}, [drawings, sortConfig]);
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 handleSort = (field: SortField) => {
setSortConfig(current => {
if (current.field === field) return { ...current, direction: current.direction === 'asc' ? 'desc' : 'asc' };
const defaultDirection = field === 'name' ? 'asc' : 'desc';
return { field, direction: defaultDirection };
});
};
const SortButton = ({ field, label }: { field: SortField; label: string }) => {
const isActive = sortConfig.field === field;
return (
<button
onClick={() => handleSort(field)}
className={`
flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-bold transition-all border-2 border-black dark:border-neutral-700
${isActive
? 'bg-indigo-100 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] -translate-y-0.5'
: 'bg-white dark:bg-neutral-900 text-slate-600 dark:text-neutral-400 hover:bg-slate-50 dark:hover:bg-neutral-800 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5'
}
`}
>
{label}
<div className="flex flex-col -space-y-1">
<svg className={`w-2.5 h-2.5 ${isActive && sortConfig.direction === 'asc' ? 'text-indigo-600 dark:text-neutral-200' : 'text-slate-400 dark:text-neutral-600'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="m18 15-6-6-6 6" /></svg>
<svg className={`w-2.5 h-2.5 ${isActive && sortConfig.direction === 'desc' ? 'text-indigo-600 dark:text-neutral-200' : 'text-slate-400 dark:text-neutral-600'}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6" /></svg>
</div>
</button>
);
};
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));
await api.updateDrawing(id, { name });
};
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 => prev.filter(d => d.id !== id));
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 => prev.filter(d => d.id !== id));
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 => prev.filter(d => !selectedIds.has(d.id)));
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 => prev.filter(d => !selectedIds.has(d.id)));
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;
return updated.filter(d => {
if (selectedCollectionId === null) return d.collectionId === null;
return d.collectionId === selectedCollectionId;
});
});
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 => {
return prev.map(d => d.id === id ? { ...d, collectionId } : d)
.filter(d => {
if (selectedCollectionId === undefined) return true;
if (selectedCollectionId === null) return d.collectionId === null;
return d.collectionId === selectedCollectionId;
});
});
try {
await api.updateDrawing(id, { collectionId });
} catch (error) {
console.error("Failed to move drawing:", error);
refreshData();
}
};
const handleCreateCollection = async (name: string) => {
await api.createCollection(name);
const newCollections = await api.getCollections();
setCollections(newCollections);
};
const handleEditCollection = async (id: string, name: string) => {
setCollections(prev => prev.map(c => c.id === id ? { ...c, name } : c));
await api.updateCollection(id, name);
};
const handleDeleteCollection = async (id: string) => {
setCollections(prev => prev.filter(c => c.id !== id));
if (selectedCollectionId === id) {
setSelectedCollectionId(undefined);
}
await api.deleteCollection(id);
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 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<string>();
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;
return updated.filter(d => {
if (selectedCollectionId === null) return d.collectionId === null;
return d.collectionId === selectedCollectionId;
});
});
// 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 (
<Layout
collections={visibleCollections}
selectedCollectionId={selectedCollectionId}
onSelectCollection={setSelectedCollectionId}
onCreateCollection={handleCreateCollection}
onEditCollection={handleEditCollection}
onDeleteCollection={handleDeleteCollection}
onDrop={handleDrop}
>
<div
id="drag-preview"
className="fixed top-[-1000px] left-[-1000px] w-[160px] aspect-[16/10] pointer-events-none"
>
{dragPreviewDrawings.length > 0 && (
<div className="relative w-full h-full">
{dragPreviewDrawings.slice(0, 3).map((d, i) => (
<div
key={d.id}
className="absolute inset-0 bg-slate-50 border-2 border-black rounded-xl shadow-sm flex items-center justify-center overflow-hidden"
style={{
transform: `translate(${i * 4}px, ${i * 4}px)`,
zIndex: 3 - i,
width: '100%',
height: '100%'
}}
>
<div className="absolute inset-0 opacity-[0.3] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] [background-size:24px_24px]"></div>
{d.preview ? (
<div
className="w-full h-full p-2 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:object-contain [&>svg]:drop-shadow-sm relative z-10"
dangerouslySetInnerHTML={{ __html: d.preview }}
/>
) : (
<div className="text-slate-300 relative z-10"><Folder size={24} /></div>
)}
</div>
))}
{dragPreviewDrawings.length > 1 && (
<div className="absolute -top-2 -right-2 bg-indigo-600 text-white text-xs font-bold w-6 h-6 rounded-full flex items-center justify-center border-2 border-white shadow-sm z-50">
{dragPreviewDrawings.length}
</div>
)}
</div>
)}
</div>
{isDragSelecting && selectionBounds && (
<DragOverlayPortal>
<div
className="fixed z-50 pointer-events-none border-2 border-black dark:border-neutral-500 bg-neutral-500/20 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
style={{
left: selectionBounds.left,
top: selectionBounds.top,
width: selectionBounds.width,
height: selectionBounds.height,
}}
/>
</DragOverlayPortal>
)}
<h1 className="text-5xl mb-8 text-slate-900 dark:text-white pl-1" style={{ fontFamily: 'Excalifont' }}>
{viewTitle}
</h1>
<div className="mb-8 flex flex-col xl:flex-row items-center justify-between gap-4">
<div className="flex flex-1 w-full gap-3 items-center">
<div className="relative flex-1 group max-w-md transition-all duration-200 focus-within:-translate-y-0.5">
<input
ref={searchInputRef}
type="text"
placeholder="Search drawings..."
value={search}
onChange={(e) => 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"
/>
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 dark:text-neutral-500 group-focus-within:text-indigo-500 dark:group-focus-within:text-neutral-300 transition-colors pointer-events-none" size={18} />
<div className="absolute right-3 top-1/2 transform -translate-y-1/2 -mt-px pointer-events-none">
<kbd className="hidden sm:inline-flex items-center h-5 px-1.5 text-[10px] font-bold text-slate-400 dark:text-neutral-600 bg-slate-100 dark:bg-neutral-800 border border-slate-300 dark:border-neutral-700 rounded shadow-[0px_2px_0px_0px_rgba(0,0,0,0.05)]">
<span className="text-xs mr-0.5"></span>K
</kbd>
</div>
</div>
<div className="flex items-center gap-2 p-1 overflow-x-auto no-scrollbar">
<SortButton field="name" label="Name" />
<SortButton field="createdAt" label="Date Created" />
<SortButton field="updatedAt" label="Date Modified" />
</div>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto justify-end">
<div className="flex items-center gap-2 mr-2">
<button
onClick={handleBulkDeleteClick}
disabled={!hasSelection}
className={clsx(
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
hasSelection
? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-rose-600 dark:text-rose-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 hover:bg-rose-50 dark:hover:bg-rose-900/30"
: "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed"
)}
title={isTrashView ? "Delete Permanently" : "Move to Trash"}
>
<Trash2 size={20} />
</button>
<button
onClick={handleBulkDuplicate}
disabled={!hasSelection || isTrashView}
className={clsx(
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
hasSelection && !isTrashView
? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-indigo-600 dark:text-indigo-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
: "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed"
)}
title="Duplicate Selected"
>
<Copy size={20} />
</button>
<div className="relative">
<button
onClick={() => hasSelection && setShowBulkMoveMenu(!showBulkMoveMenu)}
disabled={!hasSelection}
className={clsx(
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
hasSelection
? "bg-white dark:bg-neutral-800 border-black dark:border-neutral-700 text-emerald-600 dark:text-emerald-400 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 hover:bg-emerald-50 dark:hover:bg-emerald-900/30"
: "bg-slate-100 dark:bg-neutral-900 border-slate-300 dark:border-neutral-800 text-slate-300 dark:text-neutral-700 cursor-not-allowed"
)}
title="Move Selected"
>
<div className="relative">
<Folder size={20} />
<ArrowRight size={12} className="absolute -bottom-1 -right-1 bg-white dark:bg-slate-800 rounded-full border border-current" strokeWidth={3} />
</div>
</button>
{showBulkMoveMenu && hasSelection && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowBulkMoveMenu(false)} />
<div className="absolute right-0 top-full mt-2 w-56 bg-white dark:bg-neutral-800 rounded-xl border-2 border-black dark:border-neutral-700 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] z-50 py-1 max-h-64 overflow-y-auto custom-scrollbar animate-in fade-in zoom-in-95 duration-100">
<div className="px-3 py-2 text-[10px] font-bold uppercase text-slate-400 dark:text-neutral-500 tracking-wider border-b border-slate-100 dark:border-neutral-700 mb-1">
Move {selectedIds.size} items to...
</div>
<button
onClick={() => handleBulkMove(null)}
className="w-full px-3 py-2 text-sm text-left flex items-center gap-2 text-slate-600 dark:text-slate-300 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
<Inbox size={14} /> Unorganized
</button>
{collections.filter(c => c.name !== 'Trash').map(c => (
<button
key={c.id}
onClick={() => handleBulkMove(c.id)}
className="w-full px-3 py-2 text-sm text-left flex items-center gap-2 text-slate-600 dark:text-slate-300 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors truncate"
>
<Folder size={14} /> <span className="truncate">{c.name}</span>
</button>
))}
</div>
</>
)}
</div>
</div>
<input
type="file"
multiple
accept=".json,.excalidraw"
className="hidden"
id="dashboard-import"
onChange={(e) => {
handleImportDrawings(e.target.files);
e.target.value = '';
}}
/>
<button
onClick={() => document.getElementById('dashboard-import')?.click()}
disabled={isTrashView}
className={clsx(
"h-[42px] w-full sm:w-auto flex items-center justify-center gap-2 px-6 rounded-xl border-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)] transition-all font-bold text-sm whitespace-nowrap",
isTrashView
? "bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-600 border-slate-300 dark:border-slate-700 shadow-none cursor-not-allowed"
: "bg-emerald-600 dark:bg-neutral-800 text-white hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 active:translate-y-0 active:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:active:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
)}
>
<Upload size={18} strokeWidth={2.5} />
Import
</button>
<button
onClick={handleCreateDrawing}
disabled={isTrashView}
className={clsx(
"h-[42px] w-full sm:w-auto flex items-center justify-center gap-2 px-6 rounded-xl border-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)] transition-all font-bold text-sm whitespace-nowrap",
isTrashView
? "bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-600 border-slate-300 dark:border-slate-700 shadow-none cursor-not-allowed"
: "bg-indigo-600 dark:bg-neutral-800 text-white hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-1 active:translate-y-0 active:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:active:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
)}
>
<Plus size={18} strokeWidth={2.5} />
New Drawing
</button>
</div>
</div>
<div
className="min-h-full select-none relative"
onMouseDown={handleMouseDown}
ref={containerRef}
onDragOver={(e) => {
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 && (
<div className="absolute inset-0 z-50 bg-white/80 backdrop-blur-sm border-4 border-dashed border-indigo-400 rounded-3xl flex flex-col items-center justify-center animate-in fade-in duration-200">
<div className="bg-indigo-50 p-8 rounded-full mb-6 shadow-sm">
<Inbox size={64} className="text-indigo-600" />
</div>
<h3 className="text-3xl font-bold text-slate-800 mb-2">Drop files to import</h3>
<p className="text-slate-500 text-lg max-w-md text-center">
Drop .excalidraw or .json files here to add them to
<span className="font-bold text-indigo-600 mx-1">
{viewTitle}
</span>
</p>
</div>
)}
{isLoading && drawings.length === 0 ? (
<div className="flex justify-center items-center h-64 text-indigo-600">
<Loader2 size={32} className="animate-spin" />
</div>
) : (
<div className={clsx("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 pb-24 transition-all duration-300", isDraggingFile && "opacity-20 blur-sm")}>
{sortedDrawings.length === 0 ? (
<div className="col-span-full flex flex-col items-center justify-center py-32 text-slate-400 dark:text-neutral-500 border-2 border-dashed border-slate-200 dark:border-neutral-700 rounded-3xl bg-slate-50/50 dark:bg-neutral-800/50">
<div className="w-20 h-20 bg-white dark:bg-slate-800 rounded-full shadow-sm border border-slate-100 dark:border-slate-700 flex items-center justify-center mb-6">
{isTrashView ? <Trash2 size={32} className="text-slate-300 dark:text-slate-600" /> : <Inbox size={32} className="text-slate-300 dark:text-slate-600" />}
</div>
<p className="text-lg font-semibold text-slate-600 dark:text-slate-400">
{isTrashView ? "Your trash is empty" : "No drawings found"}
</p>
{!isTrashView && (
<p className="text-sm mt-2 text-slate-400 dark:text-neutral-500 max-w-xs text-center">
{search ? `No results for "${search}"` : "Create a new drawing to get started!"}
</p>
)}
{search && (
<button
onClick={() => setSearch('')}
className="mt-4 text-indigo-600 dark:text-indigo-400 font-medium hover:underline text-sm"
>
Clear search
</button>
)}
</div>
) : (
sortedDrawings.map((drawing) => (
<DrawingCard
key={drawing.id}
drawing={drawing}
collections={collections}
isSelected={selectedIds.has(drawing.id)}
onToggleSelection={(e) => 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}
/>
))
)}
</div>
)}
</div>
<ConfirmModal
isOpen={!!drawingToDelete}
title="Delete Drawing"
message="Are you sure you want to permanently delete this drawing? This action cannot be undone."
confirmText="Delete Permanently"
onConfirm={() => drawingToDelete && executePermanentDelete(drawingToDelete)}
onCancel={() => setDrawingToDelete(null)}
/>
<ConfirmModal
isOpen={showBulkDeleteConfirm}
title="Delete Selected Drawings"
message={`Are you sure you want to permanently delete ${selectedIds.size} drawings? This action cannot be undone.`}
confirmText={`Delete ${selectedIds.size} Drawings`}
onConfirm={executeBulkPermanentDelete}
onCancel={() => setShowBulkDeleteConfirm(false)}
/>
<ConfirmModal
isOpen={showImportError.isOpen}
title="Import Failed"
message={showImportError.message}
confirmText="OK"
showCancel={false}
isDangerous={false}
onConfirm={() => setShowImportError({ isOpen: false, message: '' })}
onCancel={() => setShowImportError({ isOpen: false, message: '' })}
/>
</Layout>
);
};