1089 lines
45 KiB
TypeScript
1089 lines
45 KiB
TypeScript
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 { DrawingSummary, Collection } from '../types';
|
|
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 { isLatestRequest, mergeUniqueDrawings } from './dashboard/pagination';
|
|
|
|
const PAGE_SIZE = 24;
|
|
|
|
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 [totalCount, setTotalCount] = useState(0);
|
|
const [isFetchingMore, setIsFetchingMore] = useState(false);
|
|
|
|
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 [showSortMenu, setShowSortMenu] = 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);
|
|
const loaderRef = useRef<HTMLDivElement>(null);
|
|
|
|
type SortField = DrawingSortField;
|
|
|
|
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [sortConfig, setSortConfig] = useState<{ field: SortField; direction: SortDirection }>({
|
|
field: 'updatedAt',
|
|
direction: 'desc'
|
|
});
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const listRequestVersionRef = useRef(0);
|
|
|
|
const { uploadFiles } = useUpload();
|
|
|
|
const hasMore = drawings.length < totalCount;
|
|
|
|
const refreshData = useCallback(async () => {
|
|
const requestVersion = ++listRequestVersionRef.current;
|
|
setIsLoading(true);
|
|
try {
|
|
const [drawingsRes, collectionsData] = await Promise.all([
|
|
api.getDrawings(debouncedSearch, selectedCollectionId, {
|
|
limit: PAGE_SIZE,
|
|
offset: 0,
|
|
sortField: sortConfig.field,
|
|
sortDirection: sortConfig.direction,
|
|
}),
|
|
api.getCollections()
|
|
]);
|
|
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
|
|
setDrawings(drawingsRes.drawings);
|
|
setTotalCount(drawingsRes.totalCount);
|
|
setCollections(collectionsData);
|
|
setSelectedIds(new Set());
|
|
} catch (err) {
|
|
console.error('Failed to fetch data:', err);
|
|
} finally {
|
|
if (isLatestRequest(requestVersion, listRequestVersionRef.current)) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
}, [debouncedSearch, selectedCollectionId, sortConfig.field, sortConfig.direction]);
|
|
|
|
const fetchMore = useCallback(async () => {
|
|
if (isFetchingMore || !hasMore || isLoading) return;
|
|
const requestVersion = listRequestVersionRef.current;
|
|
setIsFetchingMore(true);
|
|
try {
|
|
const drawingsRes = await api.getDrawings(debouncedSearch, selectedCollectionId, {
|
|
limit: PAGE_SIZE,
|
|
offset: drawings.length,
|
|
sortField: sortConfig.field,
|
|
sortDirection: sortConfig.direction,
|
|
});
|
|
if (!isLatestRequest(requestVersion, listRequestVersionRef.current)) return;
|
|
setDrawings(prev => mergeUniqueDrawings(prev, drawingsRes.drawings));
|
|
setTotalCount(drawingsRes.totalCount);
|
|
} catch (err) {
|
|
console.error('Failed to fetch more data:', err);
|
|
} finally {
|
|
setIsFetchingMore(false);
|
|
}
|
|
}, [
|
|
isFetchingMore,
|
|
hasMore,
|
|
isLoading,
|
|
debouncedSearch,
|
|
selectedCollectionId,
|
|
drawings.length,
|
|
sortConfig.field,
|
|
sortConfig.direction,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
refreshData();
|
|
}, [refreshData]);
|
|
|
|
// 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<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 = 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: <FileText size={16} /> },
|
|
{ field: 'createdAt', label: 'Date Created', icon: <Calendar size={16} /> },
|
|
{ field: 'updatedAt', label: 'Date Modified', icon: <Clock size={16} /> },
|
|
];
|
|
|
|
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));
|
|
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 => {
|
|
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) => {
|
|
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 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<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;
|
|
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 (
|
|
<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-3xl sm:text-5xl mb-6 sm:mb-8 text-slate-900 dark:text-white pl-1" style={{ fontFamily: 'Excalifont' }}>
|
|
{viewTitle}
|
|
</h1>
|
|
|
|
<div className="mb-8 flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
|
|
<div className="flex flex-1 w-full lg:w-auto gap-3 items-center flex-wrap">
|
|
<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 flex-wrap">
|
|
<div className="relative">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setShowSortMenu(!showSortMenu);
|
|
}}
|
|
className={clsx(
|
|
"flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-bold transition-all border-2 border-black dark:border-neutral-700 whitespace-nowrap h-[42px] w-full sm:w-[180px]",
|
|
"bg-white dark:bg-neutral-900 text-slate-700 dark:text-neutral-300 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-0.5 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
|
|
)}
|
|
>
|
|
<span className="text-indigo-600 dark:text-indigo-400 flex-shrink-0">{currentSortOption.icon}</span>
|
|
<span className="whitespace-nowrap flex-1 text-left">{currentSortOption.label}</span>
|
|
<ChevronDown size={16} className="text-slate-400 dark:text-neutral-500 flex-shrink-0" />
|
|
</button>
|
|
|
|
{showSortMenu && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setShowSortMenu(false)} />
|
|
<div className="absolute top-full left-0 mt-2 z-50 bg-white dark:bg-neutral-800 rounded-lg 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)] py-1 min-w-[180px]">
|
|
{sortOptions.map((option) => (
|
|
<button
|
|
key={option.field}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleSortFieldChange(option.field);
|
|
}}
|
|
className={clsx(
|
|
"w-full px-3 py-2 text-sm text-left flex items-center gap-2 transition-colors",
|
|
sortConfig.field === option.field
|
|
? "bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 font-bold"
|
|
: "text-slate-600 dark:text-neutral-300 hover:bg-slate-50 dark:hover:bg-neutral-700 hover:text-indigo-600 dark:hover:text-indigo-400"
|
|
)}
|
|
>
|
|
<span className="text-indigo-600 dark:text-indigo-400">{option.icon}</span>
|
|
<span>{option.label}</span>
|
|
{sortConfig.field === option.field && (
|
|
<span className="ml-auto text-xs">✓</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleSortDirectionToggle}
|
|
className={clsx(
|
|
"flex items-center justify-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 h-[42px] min-w-[42px]",
|
|
"bg-white dark:bg-neutral-900 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-0.5 hover:bg-indigo-50 dark:hover:bg-indigo-900/30"
|
|
)}
|
|
title={sortConfig.direction === 'asc' ? 'Sort Ascending' : 'Sort Descending'}
|
|
>
|
|
{sortConfig.direction === 'asc' ? (
|
|
<ArrowUp size={18} />
|
|
) : (
|
|
<ArrowDown size={18} />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 w-full lg:w-auto justify-start lg:justify-end flex-wrap">
|
|
<div className="flex items-center gap-2 mr-2">
|
|
<button
|
|
onClick={handleSelectAll}
|
|
disabled={sortedDrawings.length === 0}
|
|
className={clsx(
|
|
"h-[42px] w-[42px] flex items-center justify-center rounded-xl border-2 transition-all",
|
|
sortedDrawings.length > 0
|
|
? "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={allSelected ? "Deselect All" : "Select All"}
|
|
>
|
|
{allSelected ? <CheckSquare size={20} /> : <Square size={20} />}
|
|
</button>
|
|
|
|
<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.id !== '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-6 sm:p-8 rounded-full mb-5 sm:mb-6 shadow-sm">
|
|
<Inbox size={56} className="text-indigo-600 hidden sm:block" />
|
|
<Inbox size={44} className="text-indigo-600 sm:hidden" />
|
|
</div>
|
|
<h3 className="text-2xl sm:text-3xl font-bold text-slate-800 mb-2 text-center px-4">Drop files to import</h3>
|
|
<p className="text-slate-500 text-base sm:text-lg max-w-sm sm:max-w-md text-center px-4">
|
|
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 gap-3 sm:gap-4 pb-16 sm:pb-24 transition-all duration-300", isDraggingFile && "opacity-20 blur-sm")}
|
|
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))' }}
|
|
>
|
|
{sortedDrawings.length === 0 ? (
|
|
<div className="col-span-full flex flex-col items-center justify-center py-16 sm: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>
|
|
)}
|
|
|
|
{/* Infinite Scroll Trigger */}
|
|
<div ref={loaderRef} className="py-8 flex justify-center items-center h-20">
|
|
{isFetchingMore && (
|
|
<div className="flex items-center gap-2 text-indigo-600 font-bold animate-in fade-in slide-in-from-bottom-2">
|
|
<Loader2 size={24} className="animate-spin" />
|
|
<span>Loading more...</span>
|
|
</div>
|
|
)}
|
|
</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>
|
|
);
|
|
};
|