This commit is contained in:
Zimeng Xiong
2025-11-21 19:18:07 -08:00
parent 35734936b3
commit 4e8beae0ee
66 changed files with 21548 additions and 315 deletions
+86
View File
@@ -0,0 +1,86 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { AlertTriangle, X } from 'lucide-react';
interface ConfirmModalProps {
isOpen: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
isDangerous?: boolean; // Makes confirm button red
showCancel?: boolean;
}
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
isOpen,
title,
message,
confirmText = "Delete",
cancelText = "Cancel",
onConfirm,
onCancel,
isDangerous = true,
showCancel = true
}) => {
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-neutral-900/20 backdrop-blur-sm"
onClick={onCancel}
/>
{/* Modal */}
<div className="relative w-full max-w-md bg-white rounded-2xl border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] p-6 animate-in fade-in zoom-in-95 duration-200">
<button
onClick={onCancel}
className="absolute right-4 top-4 text-neutral-400 hover:text-neutral-900 transition-colors"
>
<X size={20} />
</button>
<div className="flex flex-col items-center text-center gap-4">
<div className="w-12 h-12 rounded-full bg-rose-100 flex items-center justify-center text-rose-600 border-2 border-rose-200">
<AlertTriangle size={24} strokeWidth={2.5} />
</div>
<div className="space-y-2">
<h3 className="text-xl font-bold text-neutral-900 tracking-tight">{title}</h3>
<p className="text-sm font-medium text-neutral-500 leading-relaxed">
{message}
</p>
</div>
<div className="flex gap-3 w-full mt-2">
{/* Green for Cancel/No */}
{showCancel && (
<button
onClick={onCancel}
className="flex-1 px-4 py-2.5 bg-emerald-50 text-emerald-700 font-bold rounded-xl border-2 border-emerald-200 hover:bg-emerald-100 hover:border-emerald-300 hover:-translate-y-0.5 transition-all duration-200"
>
{cancelText}
</button>
)}
{/* Red for Confirm/Action */}
<button
onClick={onConfirm}
className={`flex-1 px-4 py-2.5 font-bold rounded-xl border-2 border-black shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-0.5 active:translate-y-0 active:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] transition-all duration-200 ${isDangerous
? 'bg-rose-600 text-white'
: 'bg-indigo-600 text-white'
}`}
>
{confirmText}
</button>
</div>
</div>
</div>
</div>,
document.body
);
};
+346
View File
@@ -0,0 +1,346 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { PenTool, Trash2, FolderInput, ArrowRight, Check, Clock, Copy } from 'lucide-react';
import type { Drawing, Collection } from '../types';
import { formatDistanceToNow } from 'date-fns';
import clsx from 'clsx';
import { exportToSvg } from "@excalidraw/excalidraw";
import * as api from '../api';
interface DrawingCardProps {
drawing: Drawing;
collections: Collection[];
isSelected: boolean;
isTrash?: boolean;
onToggleSelection: (e: React.MouseEvent) => void;
onRename: (id: string, name: string) => void;
onDelete: (id: string) => void;
onMoveToCollection: (id: string, collectionId: string | null) => void;
onDuplicate: (id: string) => void;
onClick: (id: string, e: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent, id: string) => void;
onMouseDown?: (e: React.MouseEvent, id: string) => void;
onPreviewGenerated?: (id: string, preview: string) => void;
}
const ContextMenuPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return createPortal(children, document.body);
};
export const DrawingCard: React.FC<DrawingCardProps> = ({
drawing,
collections,
isSelected,
isTrash = false,
onToggleSelection,
onRename,
onDelete,
onMoveToCollection,
onDuplicate,
onClick,
onDragStart,
onMouseDown,
onPreviewGenerated,
}) => {
const [isRenaming, setIsRenaming] = useState(false);
const [showMoveSubmenu, setShowMoveSubmenu] = useState(false);
const [showCollectionDropdown, setShowCollectionDropdown] = useState(false);
const [newName, setNewName] = useState(drawing.name);
const [previewSvg, setPreviewSvg] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (drawing.preview) {
setPreviewSvg(drawing.preview);
return;
}
const generatePreview = async () => {
// Ensure elements and appState exist before trying to generate
if (!drawing.elements || !drawing.appState) return;
try {
const svg = await exportToSvg({
elements: drawing.elements,
appState: {
...drawing.appState,
exportBackground: true,
viewBackgroundColor: drawing.appState.viewBackgroundColor || "#ffffff"
},
files: null,
exportPadding: 10
});
const previewHtml = svg.outerHTML;
setPreviewSvg(previewHtml);
// Save to backend and notify parent
api.updateDrawing(drawing.id, { preview: previewHtml }).catch(console.error);
onPreviewGenerated?.(drawing.id, previewHtml);
} catch (e) {
console.error("Failed to generate preview", e);
}
};
generatePreview();
}, [drawing, onPreviewGenerated]);
// Close context menu on click outside
useEffect(() => {
const handleClick = () => setContextMenu(null);
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []);
const handleRenameSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (newName.trim()) {
onRename(drawing.id, newName);
setIsRenaming(false);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY });
setShowMoveSubmenu(false);
};
return (
<>
<div
id={`drawing-card-${drawing.id}`}
onContextMenu={handleContextMenu}
draggable={!isRenaming}
onDragStart={(e) => {
if (isRenaming) {
e.preventDefault();
return;
}
e.dataTransfer.setData('drawingId', drawing.id);
onDragStart?.(e, drawing.id);
}}
onMouseDown={(e) => onMouseDown?.(e, drawing.id)}
className={clsx(
"drawing-card group relative flex flex-col bg-white dark:bg-neutral-900 rounded-2xl border-2 transition-all duration-200 ease-out",
!isTrash && "hover:-translate-y-1 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)]",
isTrash && "shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] opacity-80 grayscale-[0.5]",
// "always show the border for trash" -> It already has a border. Maybe "always show shadow"?
// I added default shadow for trash and reduced opacity to indicate trash state.
isSelected ? "border-neutral-500 dark:border-neutral-500 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)]" : "border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
)}
>
{/* Selection Toggle */}
<div className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-200" style={{ opacity: isSelected ? 1 : undefined }}>
<button
onClick={(e) => { e.stopPropagation(); onToggleSelection(e); }}
className={clsx(
"w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all duration-200 shadow-sm",
isSelected ? "bg-neutral-600 dark:bg-neutral-500 border-neutral-600 dark:border-neutral-500 text-white" : "bg-white dark:bg-neutral-800 border-slate-300 dark:border-neutral-600 hover:border-neutral-500 dark:hover:border-neutral-400"
)}
>
{isSelected && <Check size={14} strokeWidth={3} />}
</button>
</div>
{/* Preview Area */}
<div
onClick={(e) => !isTrash && onClick(drawing.id, e)}
className={clsx(
"aspect-[16/10] bg-slate-50 dark:bg-neutral-800/50 relative overflow-hidden flex items-center justify-center border-b-2 border-black dark:border-neutral-700 rounded-t-xl transition-colors",
!isTrash && "cursor-pointer group-hover:bg-neutral-100/30 dark:group-hover:bg-neutral-800",
isTrash && "cursor-default"
)}
>
{/* Placeholder Grid Pattern */}
<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>
{previewSvg ? (
<div
className="w-full h-full p-6 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:object-contain [&>svg]:drop-shadow-sm dark:[&>svg]:invert dark:[&>svg_rect[fill='white']]:opacity-0 dark:[&>svg_rect[fill='#ffffff']]:opacity-0 transition-transform duration-500 group-hover:scale-105"
dangerouslySetInnerHTML={{ __html: previewSvg }}
/>
) : (
<div className="w-24 h-24 bg-white dark:bg-neutral-900 rounded-2xl shadow-sm flex items-center justify-center text-neutral-300 dark:text-neutral-400 border border-slate-100 dark:border-neutral-700 transform group-hover:scale-110 group-hover:rotate-3 transition-all duration-500">
<PenTool size={40} strokeWidth={1.5} />
</div>
)}
</div>
{/* Footer */}
<div className="p-5 bg-white dark:bg-neutral-900 rounded-b-2xl relative z-10">
{isRenaming ? (
<form
onSubmit={handleRenameSubmit}
onClick={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
onMouseDown={e => e.stopPropagation()}
>
<input
autoFocus
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onBlur={() => setIsRenaming(false)}
onDragStart={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="w-full px-2 py-1 -ml-2 text-base font-bold text-slate-900 dark:text-white border-2 border-black dark:border-neutral-600 rounded-lg focus:outline-none shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] bg-white dark:bg-neutral-800"
/>
</form>
) : (
<h3
className="text-base font-bold text-slate-800 dark:text-neutral-100 truncate cursor-text select-none group-hover:text-neutral-900 dark:group-hover:text-white transition-colors"
title={drawing.name}
onDoubleClick={(e) => {
e.stopPropagation();
setIsRenaming(true);
}}
>
{drawing.name}
</h3>
)}
<div className="flex items-center justify-between mt-3 relative">
<p className="text-[11px] font-medium text-slate-400 dark:text-neutral-500 flex items-center gap-1.5">
<Clock size={11} />
{formatDistanceToNow(drawing.updatedAt)} ago
</p>
<div className="relative" onClick={e => e.stopPropagation()}>
<button
onClick={() => setShowCollectionDropdown(!showCollectionDropdown)}
className="px-2 py-1 rounded-md bg-slate-50 dark:bg-neutral-800 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-200 text-slate-500 dark:text-neutral-400 text-[10px] font-bold uppercase tracking-wide max-w-[120px] truncate transition-all cursor-pointer border border-slate-100 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600"
>
{drawing.collectionId ? (collections.find(c => c.id === drawing.collectionId)?.name || 'Collection') : 'Unorganized'}
</button>
{showCollectionDropdown && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowCollectionDropdown(false)} />
<div className="absolute right-0 bottom-8 w-48 bg-white dark:bg-neutral-900 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)] z-20 py-1 max-h-56 overflow-y-auto custom-scrollbar animate-in fade-in zoom-in-95 duration-100">
<button
onClick={() => { onMoveToCollection(drawing.id, null); setShowCollectionDropdown(false); }}
className={clsx(
"w-full px-3 py-2 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors",
drawing.collectionId === null ? "text-neutral-900 dark:text-white font-bold bg-neutral-100 dark:bg-neutral-800" : "text-slate-600 dark:text-neutral-400"
)}
>
Unorganized
{drawing.collectionId === null && <Check size={12} />}
</button>
{collections.map(c => (
<button
key={c.id}
onClick={() => { onMoveToCollection(drawing.id, c.id); setShowCollectionDropdown(false); }}
className={clsx(
"w-full px-3 py-2 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors truncate",
drawing.collectionId === c.id ? "text-neutral-900 dark:text-white font-bold bg-neutral-100 dark:bg-neutral-800" : "text-slate-600 dark:text-neutral-400"
)}
>
<span className="truncate">{c.name}</span>
{drawing.collectionId === c.id && <Check size={12} />}
</button>
))}
</div>
</>
)}
</div>
</div>
</div>
</div>
{/* Context Menu Portal */}
{contextMenu && (
<ContextMenuPortal>
<div
className="fixed inset-0 z-50"
onClick={() => setContextMenu(null)}
onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
>
<div
className="absolute bg-white dark:bg-neutral-900 rounded-lg 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)] py-1 min-w-[160px] animate-in fade-in zoom-in-95 duration-100"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => {
setIsRenaming(true);
setContextMenu(null);
}}
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2"
>
<PenTool size={14} /> Rename
</button>
<div
className="relative group/move"
onMouseEnter={() => setShowMoveSubmenu(true)}
onMouseLeave={() => setShowMoveSubmenu(false)}
>
<button
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center justify-between"
>
<span className="flex items-center gap-2"><FolderInput size={14} /> Move to...</span>
<ArrowRight size={12} />
</button>
{showMoveSubmenu && (
<div className="absolute left-full top-0 ml-1 w-40 bg-white dark:bg-neutral-900 rounded-lg 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)] py-1 max-h-64 overflow-y-auto">
<button
onClick={() => { onMoveToCollection(drawing.id, null); setContextMenu(null); }}
className={clsx(
"w-full px-3 py-1.5 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800",
drawing.collectionId === null ? "text-neutral-900 dark:text-white font-medium" : "text-slate-600 dark:text-neutral-400"
)}
>
Unorganized
{drawing.collectionId === null && <Check size={10} />}
</button>
{collections.map(c => (
<button
key={c.id}
onClick={() => { onMoveToCollection(drawing.id, c.id); setContextMenu(null); }}
className={clsx(
"w-full px-3 py-1.5 text-xs text-left flex items-center justify-between hover:bg-neutral-100 dark:hover:bg-neutral-800 truncate",
drawing.collectionId === c.id ? "text-neutral-900 dark:text-white font-medium" : "text-slate-600 dark:text-neutral-400"
)}
>
<span className="truncate">{c.name}</span>
{drawing.collectionId === c.id && <Check size={10} />}
</button>
))}
</div>
)}
</div>
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
<button
onClick={() => {
onDuplicate(drawing.id);
setContextMenu(null);
}}
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white flex items-center gap-2"
>
<Copy size={14} /> Duplicate
</button>
<div className="border-t border-slate-50 dark:border-slate-700 my-1"></div>
<button
onClick={() => {
onDelete(drawing.id);
setContextMenu(null);
}}
className="w-full px-3 py-2 text-sm text-left text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 flex items-center gap-2"
>
<Trash2 size={14} /> Delete
</button>
</div>
</div>
</ContextMenuPortal>
)}
</>
);
};
+48
View File
@@ -0,0 +1,48 @@
import React from 'react';
import { Sidebar } from './Sidebar';
import type { Collection } from '../types';
interface LayoutProps {
children: React.ReactNode;
collections: Collection[];
selectedCollectionId: string | null | undefined;
onSelectCollection: (id: string | null | undefined) => void;
onCreateCollection: (name: string) => void;
onEditCollection: (id: string, name: string) => void;
onDeleteCollection: (id: string) => void;
onDrop?: (e: React.DragEvent, collectionId: string | null) => void;
}
export const Layout: React.FC<LayoutProps> = ({
children,
collections,
selectedCollectionId,
onSelectCollection,
onCreateCollection,
onEditCollection,
onDeleteCollection,
onDrop
}) => {
return (
<div className="h-screen w-full bg-[#F3F4F6] dark:bg-neutral-950 p-4 transition-colors duration-200 overflow-hidden">
<div className="flex gap-4 items-start h-full">
<aside className="flex-shrink-0 w-[260px] h-full bg-white dark:bg-neutral-900 rounded-2xl 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)] overflow-hidden z-20 transition-colors duration-200">
<Sidebar
collections={collections}
selectedCollectionId={selectedCollectionId}
onSelectCollection={onSelectCollection}
onCreateCollection={onCreateCollection}
onEditCollection={onEditCollection}
onDeleteCollection={onDeleteCollection}
onDrop={onDrop}
/>
</aside>
<main className="flex-1 bg-white/40 dark:bg-neutral-900/40 backdrop-blur-sm rounded-2xl border border-white/50 dark:border-neutral-800/50 shadow-sm h-full transition-colors duration-200 overflow-y-auto">
<div className="max-w-[1600px] mx-auto p-6 lg:p-8 min-h-full">
{children}
</div>
</main>
</div>
</div>
);
};
+407
View File
@@ -0,0 +1,407 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon } from 'lucide-react';
import type { Collection } from '../types';
import clsx from 'clsx';
import { ConfirmModal } from './ConfirmModal';
interface SidebarProps {
collections: Collection[];
selectedCollectionId: string | null | undefined;
onSelectCollection: (id: string | null | undefined) => void;
onCreateCollection: (name: string) => void;
onEditCollection: (id: string, name: string) => void;
onDeleteCollection: (id: string) => void;
onDrop?: (e: React.DragEvent, collectionId: string | null) => void;
}
interface SidebarItemProps {
id: string | null; // null for Unorganized
icon: React.ReactNode;
label: string;
isActive: boolean;
onClick: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
extraAction?: React.ReactNode;
isEditing?: boolean;
editValue?: string;
onEditChange?: (val: string) => void;
onEditSubmit?: (e: React.FormEvent) => void;
onEditBlur?: () => void;
onDrop?: (e: React.DragEvent, collectionId: string | null) => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({
id,
icon,
label,
isActive,
onClick,
onDoubleClick,
onContextMenu,
extraAction,
isEditing,
editValue,
onEditChange,
onEditSubmit,
onEditBlur,
onDrop
}) => {
const [isDragOver, setIsDragOver] = useState(false);
return (
<div className="relative group/item px-3">
{isEditing ? (
<form onSubmit={onEditSubmit} className="py-1">
<input
autoFocus
type="text"
value={editValue}
onChange={(e) => onEditChange?.(e.target.value)}
className="w-full px-3 py-2 text-sm bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] outline-none font-bold text-slate-900 dark:text-white"
onBlur={onEditBlur}
/>
</form>
) : (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragOver(false);
onDrop?.(e, id);
}}
className={clsx(
"w-full flex items-center gap-3 px-3 py-2.5 text-sm font-bold rounded-lg transition-all duration-200 border-2 group cursor-pointer outline-none focus:ring-2 focus:ring-indigo-500",
isActive || isDragOver
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 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)] -translate-y-0.5"
: "text-slate-600 dark:text-neutral-400 border-transparent hover:bg-slate-50 dark:hover:bg-neutral-800 hover:border-black dark:hover:border-neutral-700 hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
)}
>
<span className={clsx("transition-colors duration-200", isActive || isDragOver ? "text-indigo-900 dark:text-neutral-200" : "text-slate-400 dark:text-neutral-500 group-hover:text-slate-900 dark:group-hover:text-neutral-200")}>
{icon}
</span>
<span className="truncate flex-1 text-left font-bold">{label}</span>
{extraAction && (
<div className="opacity-0 group-hover/item:opacity-100 transition-all duration-200 flex items-center gap-1 translate-x-2 group-hover/item:translate-x-0">
{extraAction}
</div>
)}
</div>
)}
</div>
);
};
export const Sidebar: React.FC<SidebarProps> = ({
collections,
selectedCollectionId,
onSelectCollection,
onCreateCollection,
onEditCollection,
onDeleteCollection,
onDrop
}) => {
const [isCreating, setIsCreating] = useState(false);
const [newCollectionName, setNewCollectionName] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; type: 'item' | 'background'; id?: string } | null>(null);
const [collectionToDelete, setCollectionToDelete] = useState<string | null>(null);
const [isTrashDragOver, setIsTrashDragOver] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const handleClickOutside = () => setContextMenu(null);
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, []);
const handleCreateSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (newCollectionName.trim()) {
onCreateCollection(newCollectionName);
setNewCollectionName('');
setIsCreating(false);
}
};
const handleEditSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editingId && editName.trim()) {
onEditCollection(editingId, editName);
setEditingId(null);
}
};
const handleItemContextMenu = (e: React.MouseEvent, id: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, type: 'item', id });
};
const handleBackgroundContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, type: 'background' });
};
return (
<>
<div className="w-[260px] flex flex-col h-full bg-transparent">
<div className="p-5 pb-2">
<h1 className="text-2xl text-slate-900 dark:text-white flex items-center gap-3 tracking-tight" style={{ fontFamily: 'Excalifont' }}>
<div className="w-8 h-8 bg-indigo-600 dark:bg-neutral-800 rounded-lg flex items-center justify-center text-white 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)]">
<LayoutGrid size={18} strokeWidth={2.5} />
</div>
<span className="mt-1">ExcaliDash</span>
</h1>
</div>
<nav
className="flex-1 overflow-y-auto py-4 space-y-8 custom-scrollbar"
onContextMenu={handleBackgroundContextMenu}
>
<div className="space-y-1">
<div className="px-6 pb-2 text-[11px] font-bold text-slate-400 dark:text-neutral-500 uppercase tracking-wider">
Library
</div>
<div className="px-3">
<button
onClick={() => onSelectCollection(undefined)}
className={clsx(
"w-full flex items-center gap-3 px-3 py-2.5 text-sm font-bold rounded-lg transition-all duration-200 border-2",
selectedCollectionId === undefined
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 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)] -translate-y-0.5"
: "text-slate-600 dark:text-neutral-400 border-transparent hover:bg-slate-50 dark:hover:bg-neutral-800 hover:border-black dark:hover:border-neutral-700 hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
)}
>
<LayoutGrid size={18} className={clsx(selectedCollectionId === undefined ? "text-indigo-900 dark:text-neutral-200" : "text-slate-400 dark:text-neutral-500")} />
All Drawings
</button>
</div>
<SidebarItem
id={null}
icon={<Archive size={18} />}
label="Unorganized"
isActive={selectedCollectionId === null}
onClick={() => onSelectCollection(null)}
onDrop={onDrop}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between px-6 pb-2 group/header">
<span className="text-[11px] font-bold text-slate-400 dark:text-neutral-500 uppercase tracking-wider">Collections</span>
<button
onClick={(e) => { e.stopPropagation(); setIsCreating(true); }}
className="p-1 text-slate-400 dark:text-neutral-500 hover:text-indigo-600 dark:hover:text-neutral-200 hover:bg-indigo-50 dark:hover:bg-neutral-800 rounded-md transition-all opacity-0 group-hover/header:opacity-100"
title="New Collection"
>
<Plus size={14} strokeWidth={2.5} />
</button>
</div>
{isCreating && (
<form onSubmit={handleCreateSubmit} className="mb-2 px-4" onClick={e => e.stopPropagation()}>
<input
autoFocus
type="text"
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
placeholder="New Collection..."
className="w-full px-3 py-2 text-sm bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-lg shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] outline-none placeholder:text-slate-400 dark:placeholder:text-neutral-500 font-bold text-slate-900 dark:text-white"
onBlur={() => !newCollectionName && setIsCreating(false)}
/>
</form>
)}
{collections.filter(c => c.name !== 'Trash').map((collection) => (
<SidebarItem
key={collection.id}
id={collection.id}
icon={selectedCollectionId === collection.id ? <FolderOpen size={18} /> : <Folder size={18} />}
label={collection.name}
isActive={selectedCollectionId === collection.id}
onClick={() => onSelectCollection(collection.id)}
onDoubleClick={() => {
setEditingId(collection.id);
setEditName(collection.name);
}}
onContextMenu={(e) => handleItemContextMenu(e, collection.id)}
isEditing={editingId === collection.id}
editValue={editName}
onEditChange={setEditName}
onEditSubmit={handleEditSubmit}
onEditBlur={() => setEditingId(null)}
onDrop={onDrop}
extraAction={
<>
<button
onClick={(e) => {
e.stopPropagation();
setEditingId(collection.id);
setEditName(collection.name);
}}
className="p-1.5 text-slate-400 dark:text-neutral-500 hover:text-indigo-600 dark:hover:text-neutral-200 hover:bg-indigo-50 dark:hover:bg-neutral-800 rounded-md transition-colors"
>
<Edit2 size={12} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setCollectionToDelete(collection.id);
}}
className="p-1.5 text-slate-400 dark:text-neutral-500 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 rounded-md transition-colors"
>
<Trash2 size={12} />
</button>
</>
}
/>
))}
</div>
</nav>
<div className="p-4 border-t border-slate-200/50 dark:border-slate-700/50 space-y-2">
<button
onDragOver={(e) => {
e.preventDefault();
setIsTrashDragOver(true);
}}
onDragLeave={() => setIsTrashDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsTrashDragOver(false);
const trashId = collections.find(c => c.name === 'Trash')?.id;
if (trashId) {
onDrop?.(e, trashId);
} else {
onDrop?.(e, 'TRASH');
}
}}
onClick={() => {
const trashCollection = collections.find(c => c.name === 'Trash');
if (trashCollection) {
navigate(`/collections?id=${trashCollection.id}`);
} else {
onCreateCollection('Trash');
}
}}
className={clsx(
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 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)] mb-4",
collections.find(c => c.name === 'Trash')?.id === selectedCollectionId || isTrashDragOver
? "bg-rose-50 dark:bg-rose-900/30 text-rose-900 dark:text-rose-300 -translate-y-0.5"
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-rose-50 dark:hover:bg-rose-900/30 hover:text-rose-900 dark:hover:text-rose-300 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"
)}
>
<Trash2 size={18} />
Trash
</button>
<button
onClick={() => navigate('/settings')}
className={clsx(
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 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)]",
selectedCollectionId === 'SETTINGS'
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 -translate-y-0.5"
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-slate-50 dark:hover:bg-neutral-800 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"
)}
>
<SettingsIcon size={18} />
Settings
</button>
</div>
</div>
{/* Context Menu */}
{contextMenu && (
<div
className="fixed inset-0 z-50"
onClick={() => setContextMenu(null)}
onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
>
<div
className="absolute bg-white dark:bg-neutral-800 rounded-lg 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)] py-1 min-w-[160px] animate-in fade-in zoom-in-95 duration-100"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{contextMenu.type === 'item' && contextMenu.id ? (
<>
<button
onClick={() => {
const collection = collections.find(c => c.id === contextMenu.id);
if (collection) {
setEditingId(collection.id);
setEditName(collection.name);
}
setContextMenu(null);
}}
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 hover:text-indigo-600 dark:hover:text-indigo-400 flex items-center gap-2"
>
<Edit2 size={14} /> Rename Collection
</button>
<button
onClick={() => {
setCollectionToDelete(contextMenu.id!);
setContextMenu(null);
}}
className="w-full px-3 py-2 text-sm text-left text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 flex items-center gap-2"
>
<Trash2 size={14} /> Delete Collection
</button>
</>
) : (
<button
onClick={() => {
setIsCreating(true);
setContextMenu(null);
}}
className="w-full px-3 py-2 text-sm text-left text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 hover:text-indigo-600 dark:hover:text-indigo-400 flex items-center gap-2"
>
<Plus size={14} /> New Collection
</button>
)}
</div>
</div>
)}
<ConfirmModal
isOpen={!!collectionToDelete}
title="Delete Collection"
message="Are you sure you want to delete this collection? All drawings inside will be moved to Unorganized."
confirmText="Delete Collection"
onConfirm={() => {
if (collectionToDelete) {
onDeleteCollection(collectionToDelete);
setCollectionToDelete(null);
}
}}
onCancel={() => setCollectionToDelete(null)}
/>
</>
);
};