mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-07-28 23:23:47 +00:00
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:
+1
-1
Submodule excalidraw updated: b2b2815954...f6d85bc80f
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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)}
|
||||
@@ -521,7 +639,13 @@ export const FileBrowser: React.FC = () => {
|
||||
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">×</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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE workspace_folders ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_folders_sort_order ON workspace_folders(team_id, sort_order);
|
||||
@@ -79,6 +79,9 @@ func (a *API) Routes() chi.Router {
|
||||
r.Get("/stats", a.handleStats)
|
||||
r.Get("/folders", a.handleListFolders)
|
||||
r.Post("/folders", a.handleCreateFolder)
|
||||
r.Patch("/folders/{folderID}", a.handleUpdateFolder)
|
||||
r.Delete("/folders/{folderID}", a.handleDeleteFolder)
|
||||
r.Post("/folders/reorder", a.handleReorderFolders)
|
||||
r.Get("/projects", a.handleListProjects)
|
||||
r.Post("/projects", a.handleCreateProject)
|
||||
r.Get("/notifications", a.handleListNotifications)
|
||||
@@ -625,6 +628,51 @@ func (a *API) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, folder)
|
||||
}
|
||||
|
||||
func (a *API) handleUpdateFolder(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
folderID := chi.URLParam(r, "folderID")
|
||||
var req UpdateFolderRequest
|
||||
if !decodeJSON(w, r, &req, 128<<10) {
|
||||
return
|
||||
}
|
||||
folder, err := a.store.UpdateFolder(r.Context(), user.ID, folderID, req)
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, folder)
|
||||
}
|
||||
|
||||
func (a *API) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
folderID := chi.URLParam(r, "folderID")
|
||||
if err := a.store.DeleteFolder(r.Context(), user.ID, folderID); err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *API) handleReorderFolders(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
var req struct {
|
||||
FolderIDs []string `json:"folder_ids"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req, 128<<10) {
|
||||
return
|
||||
}
|
||||
if err := a.store.ReorderFolders(r.Context(), user.ID, req.FolderIDs); err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
folders, err := a.store.ListFolders(r.Context(), user.ID, "")
|
||||
if err != nil {
|
||||
writeLookupError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, folders)
|
||||
}
|
||||
|
||||
func (a *API) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := currentUser(r)
|
||||
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
|
||||
|
||||
+113
-4
@@ -61,6 +61,11 @@ type CreateFolderRequest struct {
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
type UpdateFolderRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Visibility *string `json:"visibility"`
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
TeamID string `json:"team_id"`
|
||||
Name string `json:"name"`
|
||||
@@ -922,7 +927,7 @@ func (s *Store) ListFolders(ctx context.Context, userID, teamID string) ([]Folde
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at
|
||||
FROM workspace_folders WHERE team_id = ? ORDER BY path_cache ASC`, teamID)
|
||||
FROM workspace_folders WHERE team_id = ? ORDER BY sort_order ASC, created_at ASC`, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -972,10 +977,12 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
var maxOrder int
|
||||
s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order), -1) + 1 FROM workspace_folders WHERE team_id = ?`, teamID).Scan(&maxOrder)
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO workspace_folders
|
||||
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt,
|
||||
(id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt, maxOrder,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -983,6 +990,108 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
|
||||
return folder, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateFolder(ctx context.Context, userID, folderID string, req UpdateFolderRequest) (*Folder, error) {
|
||||
var teamID string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
var updates []string
|
||||
var args []any
|
||||
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name == "" || len(name) > 120 {
|
||||
return nil, fmt.Errorf("folder name must be between 1 and 120 characters")
|
||||
}
|
||||
updates = append(updates, "name = ?")
|
||||
args = append(args, name)
|
||||
updates = append(updates, "slug = ?")
|
||||
args = append(args, slugify(name))
|
||||
updates = append(updates, "path_cache = ?")
|
||||
args = append(args, slugify(name))
|
||||
}
|
||||
if req.Visibility != nil {
|
||||
updates = append(updates, "visibility = ?")
|
||||
args = append(args, *req.Visibility)
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return s.GetFolder(ctx, folderID)
|
||||
}
|
||||
|
||||
updates = append(updates, "updated_at = ?")
|
||||
args = append(args, time.Now().UTC())
|
||||
args = append(args, folderID)
|
||||
|
||||
query := "UPDATE workspace_folders SET " + strings.Join(updates, ", ") + " WHERE id = ?"
|
||||
_, err = s.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetFolder(ctx, folderID)
|
||||
}
|
||||
|
||||
func (s *Store) GetFolder(ctx context.Context, folderID string) (*Folder, error) {
|
||||
var folder Folder
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at FROM workspace_folders WHERE id = ?`, folderID).Scan(
|
||||
&folder.ID, &folder.TeamID, &folder.ProjectID, &folder.ParentFolderID, &folder.Name, &folder.Slug, &folder.PathCache, &folder.Visibility, &folder.CreatedBy, &folder.CreatedAt, &folder.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &folder, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteFolder(ctx context.Context, userID, folderID string) error {
|
||||
var teamID string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderID).Scan(&teamID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
|
||||
return ErrForbidden
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `DELETE FROM workspace_folders WHERE id = ?`, folderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ReorderFolders(ctx context.Context, userID string, folderIDs []string) error {
|
||||
if len(folderIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var teamID string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT team_id FROM workspace_folders WHERE id = ?`, folderIDs[0]).Scan(&teamID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
|
||||
return ErrForbidden
|
||||
}
|
||||
for i, id := range folderIDs {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE workspace_folders SET sort_order = ? WHERE id = ? AND team_id = ?`, i, id, teamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListProjects(ctx context.Context, userID, teamID string) ([]Project, error) {
|
||||
if teamID == "" {
|
||||
teamID, _ = s.defaultTeamID(ctx, userID)
|
||||
|
||||
Reference in New Issue
Block a user