feat(ui,api): implement multi-select and folder management enhancements

Implements multi-select functionality in the file browser, allowing users to
perform batch actions such as deleting or moving multiple drawings at once.
Adds full CRUD support for folders, including updating folder properties
and reordering folders via a new `sort_order` column in the database.

- feat(ui): add multi-select, batch delete, and batch move in FileBrowser
- feat(api): add endpoints for updating, deleting, and reordering folders
- feat(db): add `sort_order` column and index to `workspace_folders`
- fix(editor): integrate `useHandleLibrary` for better library management
- chore(deps): update excalidraw subproject
This commit is contained in:
Tomas Dvorak
2026-05-21 13:20:44 +02:00
parent 19e7ed6ea1
commit cd22ee1ee8
7 changed files with 541 additions and 185 deletions
+30 -93
View File
@@ -22,6 +22,7 @@ const ExcalidrawWithLibrary = React.lazy(() =>
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types';
import type { ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from '@excalidraw/excalidraw/types/types';
import { useHandleLibrary } from '@excalidraw/excalidraw';
type LooseElement = Record<string, unknown>;
@@ -30,13 +31,6 @@ interface ExcalidrawProps {
[key: string]: unknown;
}
interface LibraryItem {
id: string;
elements: ExcalidrawElement[];
status: string;
}
interface Slide {
id: string;
type: string;
@@ -168,6 +162,10 @@ export const Editor: React.FC = () => {
const isMutatingSceneRef = useRef(false);
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
useHandleLibrary({
excalidrawAPI,
});
const [showTemplates, setShowTemplates] = useState(false);
const [presentationMode, setPresentationMode] = useState(false);
const [showSaveTemplate, setShowSaveTemplate] = useState(false);
@@ -1079,98 +1077,37 @@ export const Editor: React.FC = () => {
{ id: 'user-persona', label: 'User Persona', description: 'Goals, frustrations, behaviors', icon: null },
];
// Library import from URL hash (#addLibrary=...)
const processLibraryImport = React.useCallback(() => {
// Handle postMessage library imports from libraries.excalidraw.com
useEffect(() => {
if (!excalidrawAPI) return;
const hash = window.location.hash;
const match = hash.match(/addLibrary=([^&]+)/);
if (!match) return;
const libraryUrl = decodeURIComponent(match[1]);
fetch(libraryUrl)
.then((r) => {
if (!r.ok) {
throw new Error(`HTTP error! status: ${r.status}`);
}
return r.json();
})
.then((data) => {
// Excalidraw library items come in various formats
let libraryItems = data.libraryItems || data.library || data;
// Normalize to Excalidraw's expected library item format: { id, elements, status }
if (Array.isArray(libraryItems)) {
libraryItems = libraryItems.map((item: Record<string, unknown>): LibraryItem => {
if (item.libraryItem) {
return { id: (item.id as string) || (item.libraryItem as Record<string, unknown>).id as string || `item-${Math.random().toString(36).slice(2, 9)}`, elements: (item.libraryItem as Record<string, unknown>).elements as ExcalidrawElement[] || [], status: 'published' };
}
if (item.data) {
return { id: (item.id as string) || `item-${Math.random().toString(36).slice(2, 9)}`, elements: ((item.data as Record<string, unknown>).elements as ExcalidrawElement[]) || (item.elements as ExcalidrawElement[]) || [], status: 'published' };
}
if (item.elements) {
return { id: (item.id as string) || `item-${Math.random().toString(36).slice(2, 9)}`, elements: item.elements as ExcalidrawElement[], status: 'published' };
}
return item as unknown as LibraryItem;
}).filter((item: LibraryItem) => item.elements && Array.isArray(item.elements) && item.elements.length > 0);
}
// Validate libraryItems is a valid array before proceeding
if (!Array.isArray(libraryItems) || libraryItems.length === 0) {
console.warn('Library import failed: No valid library items found');
window.history.replaceState(null, '', window.location.pathname + window.location.search);
return;
}
// Use the Excalidraw imperative API to add library items
const handleMessage = async (event: MessageEvent) => {
if (event.origin !== 'https://libraries.excalidraw.com') return;
let data = event.data;
if (typeof data === 'string') {
try { data = JSON.parse(data); } catch { return; }
}
if (data?.type === 'EXCALIDRAW_LIBRARY') {
try {
const api = excalidrawAPI as any;
if (api.updateLibraryItems) {
api.updateLibraryItems(libraryItems, 'merge');
} else if (api.updateScene) {
// Fallback: add elements directly to the canvas
const currentElements = api.getSceneElements?.() || [];
const importedElements = libraryItems.flatMap((item: LibraryItem) => item.elements || []);
if (importedElements.length > 0) {
// Offset imported elements to current viewport center
const appState = api.getAppState?.() || {};
const offsetX = ((appState.scrollX as number) || 0) + 200;
const offsetY = ((appState.scrollY as number) || 0) + 200;
const minX = Math.min(...importedElements.map((el: ExcalidrawElement) => el.x));
const minY = Math.min(...importedElements.map((el: ExcalidrawElement) => el.y));
const shiftedElements = importedElements.map((el: ExcalidrawElement) => ({
...el,
id: `lib-${Math.random().toString(36).slice(2, 9)}-${el.id}`,
x: el.x - minX + offsetX,
y: el.y - minY + offsetY,
version: (el.version || 1) + 1,
versionNonce: Math.floor(Math.random() * 1000000),
updated: Date.now(),
}));
api.updateScene({
elements: [...currentElements, ...shiftedElements] as ExcalidrawElement[],
});
}
const items = data.data?.libraryItems || data.data?.items || data.data;
if (items && Array.isArray(items)) {
await excalidrawAPI.updateLibrary({
libraryItems: items,
merge: true,
openLibraryMenu: true,
defaultStatus: 'published',
});
}
} catch (e) {
console.warn('Library import failed:', e);
} catch (err: any) {
console.error('Library import from postMessage failed:', err);
excalidrawAPI.updateScene({
appState: { errorMessage: err?.message || 'Library import failed' },
});
}
window.history.replaceState(null, '', window.location.pathname + window.location.search);
})
.catch((err) => {
console.error('Failed to load library:', err);
window.history.replaceState(null, '', window.location.pathname + window.location.search);
});
}, [excalidrawAPI]);
useEffect(() => {
processLibraryImport();
}, [processLibraryImport]);
useEffect(() => {
const handleHashChange = () => {
if (window.location.hash.includes('addLibrary=')) {
processLibraryImport();
}
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, [processLibraryImport]);
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [excalidrawAPI]);
// Not-ending arrow mode: auto-continue drawing arrows
// Build slides: first slide is whole canvas, then each frame is a slide
@@ -445,6 +445,89 @@
margin: var(--space-1) 0;
}
.batchBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
background: var(--color-surface-primary-container);
border: 1px solid var(--color-primary);
border-radius: var(--border-radius-lg);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.batchCount {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-primary-darkest);
}
.batchActions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.batchBtn {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-md);
font-size: var(--text-sm);
color: var(--color-on-surface);
cursor: pointer;
&:hover {
background: var(--color-surface-low);
}
}
.batchDanger {
color: var(--color-danger-text);
&:hover {
background: rgba(224, 49, 49, 0.08);
}
}
.selectBox {
position: absolute;
top: var(--space-2);
left: var(--space-2);
z-index: 5;
background: var(--island-bg-color);
border: 1px solid var(--default-border-color);
border-radius: var(--border-radius-sm);
padding: var(--space-1);
cursor: pointer;
color: var(--color-muted);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-out);
.drawingCard:hover &,
&:focus {
opacity: 1;
}
}
.drawingSelected {
border: 2px solid var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
.selectBox {
opacity: 1;
color: var(--color-primary);
border-color: var(--color-primary);
}
}
.newProjectForm {
display: flex;
+264 -87
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, AlertCircle, Pencil, Trash2, GripVertical } from 'lucide-react';
import { Folder, ChevronRight, Grid, List, MoreVertical, Plus, Loader2, AlertCircle, Pencil, Trash2, GripVertical, Square, SquareCheck, Move } from 'lucide-react';
import { Card, Button, Modal } from '@/components';
import { useDrawingStore } from '@/stores';
import { api } from '@/services';
@@ -45,6 +45,13 @@ export const FileBrowser: React.FC = () => {
const [draggedFolderId, setDraggedFolderId] = useState<string | null>(null);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
// Drag-drop state for drawings
const [draggedDrawingId, setDraggedDrawingId] = useState<string | null>(null);
// Multi-select state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchMoveOpen, setBatchMoveOpen] = useState(false);
// New drawing name modal state
const [showNameModal, setShowNameModal] = useState(false);
const [newDrawingName, setNewDrawingName] = useState('');
@@ -181,6 +188,11 @@ export const FileBrowser: React.FC = () => {
try {
await api.drawings.delete(drawing.id);
removeDrawing(drawing.id);
setSelectedIds(prev => {
const next = new Set(prev);
next.delete(drawing.id);
return next;
});
setActiveMenu(null);
setModal(m => ({ ...m, open: false }));
} catch (err) {
@@ -191,6 +203,55 @@ export const FileBrowser: React.FC = () => {
});
};
const handleDeleteSelected = () => {
const count = selectedIds.size;
if (count === 0) return;
const selectedDrawings = visibleDrawings.filter(d => selectedIds.has(d.id));
showModal('confirm', 'Delete Drawings', `Delete ${count} drawing(s)? This cannot be undone.`, async () => {
try {
await Promise.all(selectedDrawings.map(d => api.drawings.delete(d.id)));
selectedDrawings.forEach(d => removeDrawing(d.id));
setSelectedIds(new Set());
setModal(m => ({ ...m, open: false }));
} catch (err) {
console.error('Failed to delete drawings:', err);
setModal(m => ({ ...m, open: false }));
setTimeout(() => showModal('alert', 'Error', 'Failed to delete drawings.'), 100);
}
});
};
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => {
setSelectedIds(new Set(visibleDrawings.map(d => d.id)));
};
const clearSelection = () => {
setSelectedIds(new Set());
};
const handleBatchMove = async (folderId: string | null) => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
try {
await Promise.all(ids.map(id => api.drawings.update(id, { folder_id: folderId })));
setDrawings(drawings.map(d => selectedIds.has(d.id) ? { ...d, folder_id: folderId } : d));
setSelectedIds(new Set());
setBatchMoveOpen(false);
} catch (err) {
console.error('Failed to move drawings:', err);
showModal('alert', 'Error', 'Failed to move drawings. Please try again.');
}
};
const handleDuplicateDrawing = async (drawing: Drawing) => {
try {
const newDrawing = await api.drawings.create({
@@ -293,14 +354,6 @@ export const FileBrowser: React.FC = () => {
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent, folderId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (draggedFolderId && draggedFolderId !== folderId) {
setDragOverFolderId(folderId);
}
};
const handleDragLeave = (e: React.DragEvent) => {
const related = e.relatedTarget as HTMLElement;
const current = e.currentTarget as HTMLElement;
@@ -310,9 +363,27 @@ export const FileBrowser: React.FC = () => {
setDragOverFolderId(null);
};
const handleDrop = async (e: React.DragEvent, targetFolderId: string) => {
const handleDrop = async (e: React.DragEvent, targetFolderId: string | null) => {
e.preventDefault();
e.stopPropagation();
// Handle drawing drop onto folder
if (draggedDrawingId) {
const drawing = drawings.find(d => d.id === draggedDrawingId);
if (drawing && drawing.folder_id !== targetFolderId) {
try {
await api.drawings.update(drawing.id, { folder_id: targetFolderId });
setDrawings(drawings.map(d => d.id === draggedDrawingId ? { ...d, folder_id: targetFolderId } : d));
} catch (err) {
console.error('Failed to move drawing to folder:', err);
}
}
setDraggedDrawingId(null);
setDragOverFolderId(null);
return;
}
// Handle folder reorder drop
if (!draggedFolderId || draggedFolderId === targetFolderId) {
setDraggedFolderId(null);
setDragOverFolderId(null);
@@ -323,7 +394,7 @@ export const FileBrowser: React.FC = () => {
const currentFolders = [...folders];
const draggedIndex = currentFolders.findIndex(f => f.id === draggedFolderId);
const targetIndex = currentFolders.findIndex(f => f.id === targetFolderId);
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedFolderId(null);
setDragOverFolderId(null);
@@ -332,16 +403,16 @@ export const FileBrowser: React.FC = () => {
const [draggedFolder] = currentFolders.splice(draggedIndex, 1);
currentFolders.splice(targetIndex, 0, draggedFolder);
const newOrder = currentFolders.map(f => f.id);
try {
const reordered = await api.folders.reorder(newOrder);
setFolders(reordered);
} catch (err) {
console.error('Failed to reorder folders:', err);
}
setDraggedFolderId(null);
setDragOverFolderId(null);
};
@@ -349,6 +420,18 @@ export const FileBrowser: React.FC = () => {
const handleDragEnd = () => {
setDraggedFolderId(null);
setDragOverFolderId(null);
setDraggedDrawingId(null);
};
// Drawing drag handlers
const handleDrawingDragStart = (e: React.DragEvent, drawingId: string) => {
setDraggedDrawingId(drawingId);
e.dataTransfer.effectAllowed = 'move';
};
const handleDrawingDragEnd = () => {
setDraggedDrawingId(null);
setDragOverFolderId(null);
};
// Close menu on outside click
@@ -478,6 +561,28 @@ export const FileBrowser: React.FC = () => {
</div>
</div>
{selectedIds.size > 0 && (
<div className={styles.batchBar}>
<span className={styles.batchCount}>{selectedIds.size} selected</span>
<div className={styles.batchActions}>
<button className={styles.batchBtn} onClick={selectAll}>
Select All
</button>
<button className={styles.batchBtn} onClick={() => setBatchMoveOpen(true)}>
<Move size={14} />
Move to...
</button>
<button className={`${styles.batchBtn} ${styles.batchDanger}`} onClick={handleDeleteSelected}>
<Trash2 size={14} />
Delete
</button>
<button className={styles.batchBtn} onClick={clearSelection}>
Clear
</button>
</div>
</div>
)}
<div className={styles.content}>
<aside className={styles.sidebar} role="navigation" aria-label="Project tree">
{showNewProject && (
@@ -505,7 +610,20 @@ export const FileBrowser: React.FC = () => {
</div>
)}
<ul className={styles.folderTree} role="tree">
<li>
<li
onDragOver={(e) => {
e.preventDefault();
if (draggedDrawingId) setDragOverFolderId('__root__');
}}
onDragLeave={(e) => {
const related = e.relatedTarget as HTMLElement;
const current = e.currentTarget as HTMLElement;
if (related && current.contains(related)) return;
setDragOverFolderId(null);
}}
onDrop={(e) => handleDrop(e, null)}
className={dragOverFolderId === '__root__' ? styles.dragOver : ''}
>
<button
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(null)}
@@ -517,11 +635,17 @@ export const FileBrowser: React.FC = () => {
</button>
</li>
{folders.map((folder) => (
<li
<li
key={folder.id}
draggable
onDragStart={(e) => handleDragStart(e, folder.id)}
onDragOver={(e) => handleDragOver(e, folder.id)}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if ((draggedFolderId && draggedFolderId !== folder.id) || (draggedDrawingId && draggedDrawingId !== folder.id)) {
setDragOverFolderId(folder.id);
}
}}
onDragLeave={(e) => handleDragLeave(e)}
onDrop={(e) => handleDrop(e, folder.id)}
onDragEnd={handleDragEnd}
@@ -612,82 +736,99 @@ export const FileBrowser: React.FC = () => {
</p>
</div>
) : (
visibleDrawings.map((drawing) => (
<Card
key={drawing.id}
className={styles.drawingCard}
hover
role="listitem"
tabIndex={0}
onClick={() => handleDrawingClick(drawing)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDrawingClick(drawing);
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<div className={styles.thumbnail}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.info}>
{renamingId === drawing.id ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameDrawing(drawing);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => handleRenameDrawing(drawing)}
/>
) : (
<>
<h4 className={styles.title}>{drawing.title}</h4>
<p className={styles.meta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</>
)}
</div>
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
visibleDrawings.map((drawing) => {
const isSelected = selectedIds.has(drawing.id);
return (
<Card
key={drawing.id}
className={`${styles.drawingCard} ${isSelected ? styles.drawingSelected : ''}`}
hover
role="listitem"
tabIndex={0}
draggable
onDragStart={(e) => handleDrawingDragStart(e as unknown as React.DragEvent, drawing.id)}
onDragEnd={handleDrawingDragEnd}
onClick={() => handleDrawingClick(drawing)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleDrawingClick(drawing);
}
}}
aria-label={`Open drawing ${drawing.title}`}
>
<button
className={styles.more}
className={styles.selectBox}
onClick={(e) => {
e.stopPropagation();
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
setRenamingId(null);
toggleSelect(drawing.id);
}}
aria-label={`More options for ${drawing.title}`}
aria-expanded={activeMenu === drawing.id}
aria-label={isSelected ? 'Deselect' : 'Select'}
aria-pressed={isSelected}
>
<MoreVertical size={16} />
{isSelected ? <SquareCheck size={16} /> : <Square size={16} />}
</button>
{activeMenu === drawing.id && (
<div className={styles.dropdown}>
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
<button onClick={(e) => { e.stopPropagation(); setMoveModalDrawing(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Move to...</button>
<div className={styles.dropdownDivider} />
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
</div>
)}
</div>
</Card>
))
<div className={styles.thumbnail}>
{drawing.thumbnail_url ? (
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
) : (
<img
src={`/api/drawings/${drawing.id}/thumbnail`}
alt=""
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
</div>
<div className={styles.info}>
{renamingId === drawing.id ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameDrawing(drawing);
if (e.key === 'Escape') setRenamingId(null);
}}
onBlur={() => handleRenameDrawing(drawing)}
/>
) : (
<>
<h4 className={styles.title}>{drawing.title}</h4>
<p className={styles.meta}>
Edited {new Date(drawing.updated_at).toLocaleDateString()}
</p>
</>
)}
</div>
<div className={styles.moreWrap} ref={activeMenu === drawing.id ? menuRef : undefined}>
<button
className={styles.more}
onClick={(e) => {
e.stopPropagation();
setActiveMenu(activeMenu === drawing.id ? null : drawing.id);
setRenamingId(null);
}}
aria-label={`More options for ${drawing.title}`}
aria-expanded={activeMenu === drawing.id}
>
<MoreVertical size={16} />
</button>
{activeMenu === drawing.id && (
<div className={styles.dropdown}>
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button>
<button onClick={(e) => { e.stopPropagation(); setRenamingId(drawing.id); setRenameValue(drawing.title); setActiveMenu(null); }} className={styles.dropdownItem}>Rename</button>
<button onClick={(e) => { e.stopPropagation(); handleDuplicateDrawing(drawing); }} className={styles.dropdownItem}>Duplicate</button>
<button onClick={(e) => { e.stopPropagation(); setMoveModalDrawing(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Move to...</button>
<div className={styles.dropdownDivider} />
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button>
</div>
)}
</div>
</Card>
);
})
)}
</main>
</div>
@@ -759,6 +900,42 @@ export const FileBrowser: React.FC = () => {
</div>
</div>
)}
{batchMoveOpen && (
<div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-labelledby="batch-move-title" onClick={(e) => { if (e.target === e.currentTarget) setBatchMoveOpen(false); }}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h3 id="batch-move-title">Move {selectedIds.size} drawing(s)</h3>
<button className={styles.modalClose} onClick={() => setBatchMoveOpen(false)} aria-label="Close">&times;</button>
</div>
<div className={styles.modalBody}>
<p className={styles.moveHint}>Select a destination:</p>
<div className={styles.moveList}>
<button
className={styles.moveItem}
onClick={() => handleBatchMove(null)}
>
<Folder size={18} />
<span>All Projects</span>
</button>
{folders.map((f) => (
<button
key={f.id}
className={styles.moveItem}
onClick={() => handleBatchMove(f.id)}
>
<Folder size={18} />
<span>{f.name}</span>
</button>
))}
</div>
</div>
<div className={styles.modalFooter}>
<button className={styles.modalBtnSecondary} onClick={() => setBatchMoveOpen(false)}>Cancel</button>
</div>
</div>
</div>
)}
</div>
</>
);