Skip to content

Commit 3a27ca6

Browse files
AmirMohammad CheraghaliAmirMohammad Cheraghali
authored andcommitted
feat: benchling-style nested folder organization, recursive sidebar, and move modal
1 parent bee6134 commit 3a27ca6

3 files changed

Lines changed: 421 additions & 22 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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

Comments
 (0)