|
| 1 | +import { useState, useRef, useEffect } from 'react'; |
| 2 | +import { Database, Folder, Plus, Pencil, Trash2, Check, X, Loader2, ChevronRight, ChevronDown } from 'lucide-react'; |
| 3 | +import { createCollection, renameCollection, deleteCollection, type Collection } from '../../lib/structuresService'; |
| 4 | +import { DOT } from './CollectionsSidebar'; // reuse colors |
| 5 | + |
| 6 | +interface Props { |
| 7 | + userId: string; |
| 8 | + collections: Collection[]; |
| 9 | + activeCollection: string | null; |
| 10 | + counts: Record<string, number>; |
| 11 | + uncategorizedCount: number; |
| 12 | + onSelect: (id: string | null) => void; |
| 13 | + onCreated: (c: Collection) => void; |
| 14 | + onRenamed: (id: string, name: string) => void; |
| 15 | + onDeleted: (id: string) => void; |
| 16 | +} |
| 17 | + |
| 18 | +export function FolderTreeSidebar({ |
| 19 | + userId, collections, activeCollection, counts, uncategorizedCount, |
| 20 | + onSelect, onCreated, onRenamed, onDeleted, |
| 21 | +}: Props) { |
| 22 | + // Tree state |
| 23 | + const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); |
| 24 | + |
| 25 | + // Creation state |
| 26 | + const [creatingInId, setCreatingInId] = useState<string | 'root' | null>(null); |
| 27 | + const [newName, setNewName] = useState(''); |
| 28 | + const [newColor, setNewColor] = useState('blue'); |
| 29 | + const [saving, setSaving] = useState(false); |
| 30 | + |
| 31 | + // Rename state |
| 32 | + const [renamingId, setRenamingId] = useState<string | null>(null); |
| 33 | + const [renameDraft, setRenameDraft] = useState(''); |
| 34 | + const inputRef = useRef<HTMLInputElement>(null); |
| 35 | + |
| 36 | + useEffect(() => { if (creatingInId || renamingId) inputRef.current?.focus(); }, [creatingInId, renamingId]); |
| 37 | + |
| 38 | + // Build the tree hierarchy |
| 39 | + const foldersByParent = new Map<string | null, Collection[]>(); |
| 40 | + foldersByParent.set(null, []); |
| 41 | + |
| 42 | + collections.forEach(c => { |
| 43 | + const parent = c.parent_id || null; |
| 44 | + if (!foldersByParent.has(parent)) foldersByParent.set(parent, []); |
| 45 | + foldersByParent.get(parent)!.push(c); |
| 46 | + }); |
| 47 | + |
| 48 | + const toggleExpand = (id: string, e: React.MouseEvent) => { |
| 49 | + e.stopPropagation(); |
| 50 | + const next = new Set(expandedIds); |
| 51 | + if (next.has(id)) next.delete(id); else next.add(id); |
| 52 | + setExpandedIds(next); |
| 53 | + }; |
| 54 | + |
| 55 | + const handleCreate = async () => { |
| 56 | + if (!newName.trim()) return; |
| 57 | + setSaving(true); |
| 58 | + try { |
| 59 | + const parentId = creatingInId === 'root' ? null : creatingInId; |
| 60 | + const c = await createCollection(userId, newName.trim(), newColor, parentId); |
| 61 | + onCreated(c); |
| 62 | + setCreatingInId(null); |
| 63 | + setNewName(''); |
| 64 | + setNewColor('blue'); |
| 65 | + if (parentId) { |
| 66 | + const expl = new Set(expandedIds); |
| 67 | + expl.add(parentId); |
| 68 | + setExpandedIds(expl); |
| 69 | + } |
| 70 | + } catch { /* ignore */ } finally { setSaving(false); } |
| 71 | + }; |
| 72 | + |
| 73 | + const handleRename = async (id: string) => { |
| 74 | + if (!renameDraft.trim()) { setRenamingId(null); return; } |
| 75 | + try { await renameCollection(id, renameDraft.trim()); onRenamed(id, renameDraft.trim()); } |
| 76 | + catch { /* ignore */ } finally { setRenamingId(null); } |
| 77 | + }; |
| 78 | + |
| 79 | + const handleDelete = async (id: string) => { |
| 80 | + if (!confirm('Delete this folder? Nested items will become uncategorized.')) return; |
| 81 | + try { await deleteCollection(id); onDeleted(id); if (activeCollection === id) onSelect(null); } |
| 82 | + catch { /* ignore */ } |
| 83 | + }; |
| 84 | + |
| 85 | + const renderTree = (parentId: string | null, depth: number) => { |
| 86 | + const children = foldersByParent.get(parentId) || []; |
| 87 | + if (children.length === 0 && depth > 0) return null; |
| 88 | + |
| 89 | + return children.map(c => { |
| 90 | + const hasChildren = (foldersByParent.get(c.id) || []).length > 0; |
| 91 | + const isExpanded = expandedIds.has(c.id); |
| 92 | + const isActive = activeCollection === c.id; |
| 93 | + |
| 94 | + return ( |
| 95 | + <div key={c.id}> |
| 96 | + <div className="group relative"> |
| 97 | + {renamingId === c.id ? ( |
| 98 | + <div className="flex items-center gap-1 px-2 my-0.5" style={{ marginLeft: depth * 12 }}> |
| 99 | + <input ref={inputRef} value={renameDraft} onChange={e => setRenameDraft(e.target.value)} |
| 100 | + onKeyDown={e => { if (e.key === 'Enter') handleRename(c.id); if (e.key === 'Escape') setRenamingId(null); }} |
| 101 | + className="flex-1 bg-neutral-800 border border-blue-500/50 rounded px-2 py-0.5 text-xs text-white outline-none" /> |
| 102 | + <button onClick={() => handleRename(c.id)} className="text-emerald-400 p-0.5"><Check className="w-3 h-3" /></button> |
| 103 | + <button onClick={() => setRenamingId(null)} className="text-neutral-500 p-0.5"><X className="w-3 h-3" /></button> |
| 104 | + </div> |
| 105 | + ) : ( |
| 106 | + <button onClick={() => onSelect(c.id)} |
| 107 | + className={`w-full flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-sm transition-all text-left relative focus:outline-none |
| 108 | + ${isActive ? 'bg-neutral-800 text-white' : 'text-neutral-400 hover:bg-neutral-800/60 hover:text-neutral-200'}`} |
| 109 | + style={{ paddingLeft: `${depth * 12 + 8}px` }} |
| 110 | + > |
| 111 | + {/* Expaner Icon */} |
| 112 | + <div onClick={(e) => hasChildren ? toggleExpand(c.id, e) : undefined} |
| 113 | + className={`w-4 h-4 flex items-center justify-center shrink-0 ${hasChildren ? 'hover:bg-neutral-700 rounded-sm cursor-pointer' : 'opacity-0'}`}> |
| 114 | + {isExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />} |
| 115 | + </div> |
| 116 | + |
| 117 | + <span className={`w-2 h-2 rounded-full shrink-0 ${DOT[c.color] ?? 'bg-neutral-400'}`} /> |
| 118 | + <span className="truncate flex-1 text-[13px]">{c.name}</span> |
| 119 | + <span className="text-[10px] text-neutral-500 bg-neutral-900 px-1.5 rounded">{counts[c.id] ?? 0}</span> |
| 120 | + |
| 121 | + <span className="hidden group-hover:flex items-center gap-0.5 absolute right-2 bg-neutral-800 pl-1"> |
| 122 | + <button onClick={e => { e.stopPropagation(); setCreatingInId(c.id); }} className="p-0.5 text-neutral-500 hover:text-blue-400"><Plus className="w-3 h-3" /></button> |
| 123 | + <button onClick={e => { e.stopPropagation(); setRenamingId(c.id); setRenameDraft(c.name); }} className="p-0.5 text-neutral-500 hover:text-white"><Pencil className="w-3 h-3" /></button> |
| 124 | + <button onClick={e => { e.stopPropagation(); handleDelete(c.id); }} className="p-0.5 text-neutral-500 hover:text-red-400"><Trash2 className="w-3 h-3" /></button> |
| 125 | + </span> |
| 126 | + </button> |
| 127 | + )} |
| 128 | + </div> |
| 129 | + |
| 130 | + {/* Render children if expanded */} |
| 131 | + {isExpanded && renderTree(c.id, depth + 1)} |
| 132 | + |
| 133 | + {/* Render new folder inline creator */} |
| 134 | + {creatingInId === c.id && isExpanded && ( |
| 135 | + <div className="pl-6 pr-2 py-1" style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}> |
| 136 | + {renderCreatorBox()} |
| 137 | + </div> |
| 138 | + )} |
| 139 | + </div> |
| 140 | + ); |
| 141 | + }); |
| 142 | + }; |
| 143 | + |
| 144 | + const renderCreatorBox = () => ( |
| 145 | + <div className="bg-neutral-800/50 border border-neutral-700/50 rounded-lg p-2.5 space-y-2 relative shadow-xl z-10 w-full mt-1 overflow-hidden pointer-events-auto"> |
| 146 | + <input ref={inputRef} value={newName} onChange={e => setNewName(e.target.value)} |
| 147 | + onKeyDown={e => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') setCreatingInId(null); }} |
| 148 | + placeholder="Folder name…" |
| 149 | + className="w-full bg-neutral-900 text-xs text-white placeholder-neutral-500 px-2.5 py-1.5 rounded border border-neutral-800 focus:border-blue-500/50 outline-none" /> |
| 150 | + |
| 151 | + <div className="flex flex-wrap gap-1"> |
| 152 | + {['blue', 'violet', 'emerald', 'orange', 'pink', 'amber', 'cyan', 'rose'].map(col => ( |
| 153 | + <button key={col} onClick={() => setNewColor(col)} |
| 154 | + className={`w-3.5 h-3.5 rounded-full ${DOT[col]} transition-all ${newColor === col ? 'ring-2 ring-white/40 scale-125' : ''}`} /> |
| 155 | + ))} |
| 156 | + </div> |
| 157 | + |
| 158 | + <div className="flex gap-1 pt-1"> |
| 159 | + <button onClick={handleCreate} disabled={saving || !newName.trim()} |
| 160 | + className="flex-1 flex items-center justify-center gap-1 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded text-xs"> |
| 161 | + {saving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Check className="w-3 h-3" />} Add |
| 162 | + </button> |
| 163 | + <button onClick={() => { setCreatingInId(null); setNewName(''); }} className="px-2 text-neutral-500 hover:text-white bg-neutral-800/50 rounded hover:bg-neutral-800"> |
| 164 | + <X className="w-3.5 h-3.5" /> |
| 165 | + </button> |
| 166 | + </div> |
| 167 | + </div> |
| 168 | + ); |
| 169 | + |
| 170 | + return ( |
| 171 | + <div className="w-64 shrink-0 flex flex-col h-full bg-neutral-950/30 border-r border-neutral-800/50"> |
| 172 | + <div className="p-4 flex items-center justify-between"> |
| 173 | + <p className="text-xs font-semibold text-neutral-400 uppercase tracking-widest">Projects</p> |
| 174 | + <button onClick={() => setCreatingInId('root')} className="p-1 text-neutral-500 hover:text-white rounded-md hover:bg-neutral-800 transition-colors"> |
| 175 | + <Plus className="w-4 h-4" /> |
| 176 | + </button> |
| 177 | + </div> |
| 178 | + |
| 179 | + <div className="flex-1 overflow-y-auto px-2 pb-4 space-y-0.5 custom-scrollbar"> |
| 180 | + |
| 181 | + {/* All structures */} |
| 182 | + <button onClick={() => onSelect(null)} |
| 183 | + className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm transition-all focus:outline-none mb-2 |
| 184 | + ${activeCollection === null ? 'bg-blue-500/10 text-blue-400 font-medium' : 'text-neutral-400 hover:bg-neutral-800/60 hover:text-neutral-200'}`}> |
| 185 | + <Database className="w-4 h-4 shrink-0" /> |
| 186 | + <span className="truncate flex-1 text-left text-[13px]">Library Overview</span> |
| 187 | + <span className="text-[10px] text-neutral-500 bg-neutral-900 px-1.5 rounded">{counts['__all__'] ?? 0}</span> |
| 188 | + </button> |
| 189 | + |
| 190 | + {/* Tree Root */} |
| 191 | + {renderTree(null, 0)} |
| 192 | + |
| 193 | + {/* Root Creator */} |
| 194 | + {creatingInId === 'root' && ( |
| 195 | + <div className="px-1 mt-2"> |
| 196 | + {renderCreatorBox()} |
| 197 | + </div> |
| 198 | + )} |
| 199 | + |
| 200 | + {/* Uncategorized (bottom) */} |
| 201 | + {uncategorizedCount > 0 && ( |
| 202 | + <div className="pt-4 mt-4 border-t border-neutral-800/50"> |
| 203 | + <button onClick={() => onSelect('__none__')} |
| 204 | + className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm transition-all focus:outline-none |
| 205 | + ${activeCollection === '__none__' ? 'bg-neutral-800 text-white' : 'text-neutral-500 hover:bg-neutral-800/60 hover:text-neutral-300'}`}> |
| 206 | + <Folder className="w-4 h-4 shrink-0" /> |
| 207 | + <span className="truncate flex-1 text-left text-[13px]">Uncategorized</span> |
| 208 | + <span className="text-[10px] text-neutral-600 bg-neutral-900 px-1.5 rounded">{uncategorizedCount}</span> |
| 209 | + </button> |
| 210 | + </div> |
| 211 | + )} |
| 212 | + </div> |
| 213 | + </div> |
| 214 | + ); |
| 215 | +} |
0 commit comments