Skip to content

Commit d817201

Browse files
AmirMohammad CheraghaliAmirMohammad Cheraghali
authored andcommitted
feat: Add Multi-Select Lasso and Bulk Action Bar
1 parent ae26c84 commit d817201

1 file changed

Lines changed: 99 additions & 26 deletions

File tree

src/components/dashboard/MyStructures.tsx

Lines changed: 99 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -588,8 +588,9 @@ function StructureCard({
588588

589589
return (
590590
<div
591-
className={`group rounded-2xl transition-all duration-200 hover:shadow-xl hover:shadow-black/30 flex flex-col relative z-0 hover:z-50
592-
${selected ? 'shadow-blue-500/10 shadow-lg' : ''}`}
591+
className={`structure-card group rounded-2xl transition-all duration-200 hover:shadow-xl hover:shadow-black/30 flex flex-col relative z-0 hover:z-50
592+
${selected ? 'shadow-blue-500/10 shadow-lg ring-2 ring-blue-500/50' : ''}`}
593+
data-id={item.id}
593594
onClick={e => onSelect(e, item.id)}
594595
onDoubleClick={() => onDoubleClick?.(item.id)}
595596
onContextMenu={e => onContextMenu(e, 'structure', item)}
@@ -602,9 +603,20 @@ function StructureCard({
602603
{hovered && <HoverPreview item={item} />}
603604

604605
{/* Inner clipping wrapper */}
605-
<div className={`flex-1 flex flex-col bg-[var(--bg-header)] border rounded-2xl overflow-hidden transition-colors
606+
<div className={`flex-1 flex flex-col bg-[var(--bg-header)] border rounded-2xl overflow-hidden transition-colors relative
606607
${selected ? 'border-blue-500/60' : 'border-[var(--border-main)] group-hover:border-neutral-600'}`}>
607608

609+
{/* Global Selection Checkbox */}
610+
<div className={`absolute top-2 left-2 z-20
611+
${selected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}
612+
transition-opacity duration-200`}>
613+
<button onClick={(e) => { e.stopPropagation(); onSelect(e, item.id); }}
614+
className={`p-1.5 rounded-md backdrop-blur-md border shadow-sm transition-all
615+
${selected ? 'bg-blue-500/90 border-blue-400 text-white' : 'bg-black/40 border-white/20 text-[var(--text-secondary)] hover:bg-black/60 hover:text-[var(--text-primary)] hover:border-white/40'}`}>
616+
<CheckSquare className="w-4 h-4" />
617+
</button>
618+
</div>
619+
608620
{/* Gradient strip or RCSB Thumbnail */}
609621
{hasThumbnail ? (
610622
<div className="relative h-36 overflow-hidden bg-[var(--input-bg)]">
@@ -617,26 +629,11 @@ function StructureCard({
617629
el.parentElement!.style.display = 'none';
618630
}}
619631
/>
620-
<div className="absolute inset-0 bg-gradient-to-t from-neutral-900/70 via-transparent to-transparent" />
632+
<div className="absolute inset-0 bg-gradient-to-t from-neutral-900/70 via-transparent to-transparent pointer-events-none" />
621633
<span className={`absolute top-2 right-2 text-[10px] font-bold px-2 py-0.5 rounded-md border backdrop-blur-sm ${badge}`}>{item.file_type}</span>
622634
</div>
623635
) : (
624-
<div className={`h-1 w-full bg-gradient-to-r ${strip}`} >
625-
<div className={`absolute top-2 left-2 z-10
626-
${selected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}
627-
transition-opacity duration-200`}>
628-
<button onClick={(e) => {
629-
e.stopPropagation();
630-
onSelect(e, item.id);
631-
}}
632-
className={`p-1.5 rounded-md backdrop-blur-md border shadow-sm transition-all
633-
${selected
634-
? 'bg-blue-500/90 border-blue-400 text-white'
635-
: 'bg-black/40 border-white/20 text-[var(--text-secondary)] hover:bg-black/60 hover:text-[var(--text-primary)] hover:border-white/40'}`}>
636-
<CheckSquare className="w-4 h-4" />
637-
</button>
638-
</div>
639-
</div>
636+
<div className={`h-1 w-full bg-gradient-to-r ${strip}`} />
640637
)}
641638

642639
<div className={`p-5 flex flex-col flex-1`}>
@@ -966,9 +963,61 @@ export const MyStructures = () => {
966963
const [isWindowDragOver, setIsWindowDragOver] = useState(false);
967964
const [dragOverBreadcrumb, setDragOverBreadcrumb] = useState<string | null | undefined>(undefined);
968965

969-
// Selection
966+
// Selection & Lasso
970967
const [selected, setSelected] = useState<Set<string>>(new Set());
971968
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
969+
const [lasso, setLasso] = useState<{ startX: number, startY: number, currX: number, currY: number } | null>(null);
970+
const [lassoSelected, setLassoSelected] = useState<Set<string>>(new Set());
971+
const gridRef = useRef<HTMLDivElement>(null);
972+
973+
// Lasso Engine Intersections
974+
useEffect(() => {
975+
if (!lasso || !gridRef.current) return;
976+
const rect = {
977+
left: Math.min(lasso.startX, lasso.currX),
978+
right: Math.max(lasso.startX, lasso.currX),
979+
top: Math.min(lasso.startY, lasso.currY),
980+
bottom: Math.max(lasso.startY, lasso.currY)
981+
};
982+
983+
const cards = gridRef.current.querySelectorAll('.structure-card');
984+
const newLasso = new Set<string>();
985+
986+
cards.forEach(card => {
987+
const cardRect = card.getBoundingClientRect();
988+
// A simple overlap test
989+
if (
990+
rect.left < cardRect.right &&
991+
rect.right > cardRect.left &&
992+
rect.top < cardRect.bottom &&
993+
rect.bottom > cardRect.top
994+
) {
995+
const id = card.getAttribute('data-id');
996+
if (id) newLasso.add(id);
997+
}
998+
});
999+
1000+
// Only update if changed to prevent thrashing
1001+
setLassoSelected(prev => {
1002+
if (prev.size !== newLasso.size) return newLasso;
1003+
for (let id of newLasso) if (!prev.has(id)) return newLasso;
1004+
return prev;
1005+
});
1006+
}, [lasso?.currX, lasso?.currY, lasso?.startX, lasso?.startY]);
1007+
1008+
useEffect(() => {
1009+
const handlePointerUp = () => {
1010+
if (lasso) {
1011+
if (lassoSelected.size > 0) {
1012+
setSelected(prev => new Set([...prev, ...lassoSelected]));
1013+
}
1014+
setLasso(null);
1015+
setLassoSelected(new Set());
1016+
}
1017+
};
1018+
window.addEventListener('pointerup', handlePointerUp);
1019+
return () => window.removeEventListener('pointerup', handlePointerUp);
1020+
}, [lasso, lassoSelected]);
9721021

9731022
// Inspector Sidebar
9741023
const [showInspector, setShowInspector] = useState(() => localStorage.getItem('quercus_show_inspector') === 'true');
@@ -1790,7 +1839,18 @@ export const MyStructures = () => {
17901839

17911840
{/* Grid */}
17921841
{!loading && viewMode === 'grid' && (
1793-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 lg:gap-4">
1842+
<div
1843+
ref={gridRef}
1844+
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 lg:gap-4 select-none touch-none"
1845+
onPointerDown={e => {
1846+
// Only activate lasso if clicking explicitly on the blank gaps
1847+
if ((e.target as HTMLElement).closest('button, a, input, .structure-card')) return;
1848+
setLasso({ startX: e.clientX, startY: e.clientY, currX: e.clientX, currY: e.clientY });
1849+
}}
1850+
onPointerMove={e => {
1851+
if (lasso) setLasso(prev => ({ ...prev!, currX: e.clientX, currY: e.clientY }));
1852+
}}
1853+
>
17941854
{/* Render Subfolders */}
17951855
{activeSubfolders.map((sub: Collection) => (
17961856
<FolderCard key={sub.id} collection={sub} count={collectionCounts[sub.id] || 0} onOpen={() => setActiveCollection(sub.id)} onDropStructure={handleDropMove} onContextMenu={handleContextMenu} previews={structures.filter(s => s.collection_id === sub.id).slice(0, 3)} />
@@ -1799,7 +1859,7 @@ export const MyStructures = () => {
17991859
{/* Render Structures */}
18001860
{filtered.map(item => (
18011861
<StructureCard key={item.id} item={item}
1802-
selected={selected.has(item.id)} onSelect={toggleSelect}
1862+
selected={selected.has(item.id) || lassoSelected.has(item.id)} onSelect={toggleSelect}
18031863
onToggleStar={handleToggleStar} onDelete={handleDelete}
18041864
onRename={handleRename} onNotesChange={handleNotesChange}
18051865
onTagsChange={handleTagsChange} onDuplicate={handleDuplicate}
@@ -1941,11 +2001,24 @@ export const MyStructures = () => {
19412001
/>
19422002
)}
19432003

2004+
{/* Lasso Drag Box (Overlay) */}
2005+
{lasso && (
2006+
<div
2007+
className="fixed z-[100] bg-blue-500/20 border border-blue-500/50 pointer-events-none"
2008+
style={{
2009+
left: Math.min(lasso.startX, lasso.currX),
2010+
top: Math.min(lasso.startY, lasso.currY),
2011+
width: Math.abs(lasso.currX - lasso.startX),
2012+
height: Math.abs(lasso.currY - lasso.startY)
2013+
}}
2014+
/>
2015+
)}
2016+
19442017
{/* Bulk action floating bar */}
19452018
{selected.size > 0 && (
1946-
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-[var(--input-bg)] border border-neutral-600 rounded-2xl px-5 py-3 shadow-2xl shadow-black/50">
1947-
<span className="text-sm font-medium text-[var(--text-primary)]">{selected.size} selected</span>
1948-
<div className="w-px h-4 bg-neutral-600" />
2019+
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-[var(--input-bg)]/90 backdrop-blur-xl border border-white/10 rounded-full px-5 py-3 shadow-2xl shadow-black/50 animate-in slide-in-from-bottom-8 fade-in duration-300 ease-out">
2020+
<span className="text-sm font-semibold text-[var(--text-primary)]">{selected.size} selected</span>
2021+
<div className="w-px h-5 bg-white/10 mx-1" />
19492022
{activeCollection === '__trash__' ? (
19502023
<>
19512024
<button onClick={handleBulkRestore}

0 commit comments

Comments
 (0)