Compare commits

...

2 Commits

Author SHA1 Message Date
Tomas Dvorak 54c8088404 fix(db): handle explicit null values in update requests
Docker Images / Build and push (push) Failing after 16s
Introduce a `NullString` type to distinguish between JSON `null` and
absent fields in `UpdateDrawingRequest`. This ensures that when a
field is explicitly set to `null` in a request, the database can
correctly process the update.

Additionally, refactor the folder order migration to include proper
`goose` up/down instructions.
2026-05-21 14:17:58 +02:00
Tomas Dvorak cd22ee1ee8 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
2026-05-21 13:20:44 +02:00
7 changed files with 576 additions and 194 deletions
+30 -93
View File
@@ -22,6 +22,7 @@ const ExcalidrawWithLibrary = React.lazy(() =>
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'; import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types';
import type { ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from '@excalidraw/excalidraw/types/types'; import type { ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from '@excalidraw/excalidraw/types/types';
import { useHandleLibrary } from '@excalidraw/excalidraw';
type LooseElement = Record<string, unknown>; type LooseElement = Record<string, unknown>;
@@ -30,13 +31,6 @@ interface ExcalidrawProps {
[key: string]: unknown; [key: string]: unknown;
} }
interface LibraryItem {
id: string;
elements: ExcalidrawElement[];
status: string;
}
interface Slide { interface Slide {
id: string; id: string;
type: string; type: string;
@@ -168,6 +162,10 @@ export const Editor: React.FC = () => {
const isMutatingSceneRef = useRef(false); const isMutatingSceneRef = useRef(false);
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null); const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
useHandleLibrary({
excalidrawAPI,
});
const [showTemplates, setShowTemplates] = useState(false); const [showTemplates, setShowTemplates] = useState(false);
const [presentationMode, setPresentationMode] = useState(false); const [presentationMode, setPresentationMode] = useState(false);
const [showSaveTemplate, setShowSaveTemplate] = 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 }, { id: 'user-persona', label: 'User Persona', description: 'Goals, frustrations, behaviors', icon: null },
]; ];
// Library import from URL hash (#addLibrary=...) // Handle postMessage library imports from libraries.excalidraw.com
const processLibraryImport = React.useCallback(() => { useEffect(() => {
if (!excalidrawAPI) return; if (!excalidrawAPI) return;
const hash = window.location.hash; const handleMessage = async (event: MessageEvent) => {
const match = hash.match(/addLibrary=([^&]+)/); if (event.origin !== 'https://libraries.excalidraw.com') return;
if (!match) return; let data = event.data;
const libraryUrl = decodeURIComponent(match[1]); if (typeof data === 'string') {
fetch(libraryUrl) try { data = JSON.parse(data); } catch { return; }
.then((r) => { }
if (!r.ok) { if (data?.type === 'EXCALIDRAW_LIBRARY') {
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
try { try {
const api = excalidrawAPI as any; const items = data.data?.libraryItems || data.data?.items || data.data;
if (api.updateLibraryItems) { if (items && Array.isArray(items)) {
api.updateLibraryItems(libraryItems, 'merge'); await excalidrawAPI.updateLibrary({
} else if (api.updateScene) { libraryItems: items,
// Fallback: add elements directly to the canvas merge: true,
const currentElements = api.getSceneElements?.() || []; openLibraryMenu: true,
const importedElements = libraryItems.flatMap((item: LibraryItem) => item.elements || []); defaultStatus: 'published',
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[],
});
}
} }
} catch (e) { } catch (err: any) {
console.warn('Library import failed:', e); 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); window.addEventListener('message', handleMessage);
return () => window.removeEventListener('hashchange', handleHashChange); return () => window.removeEventListener('message', handleMessage);
}, [processLibraryImport]); }, [excalidrawAPI]);
// Not-ending arrow mode: auto-continue drawing arrows // Not-ending arrow mode: auto-continue drawing arrows
// Build slides: first slide is whole canvas, then each frame is a slide // Build slides: first slide is whole canvas, then each frame is a slide
@@ -445,6 +445,89 @@
margin: var(--space-1) 0; 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 { .newProjectForm {
display: flex; display: flex;
+264 -87
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { Card, Button, Modal } from '@/components';
import { useDrawingStore } from '@/stores'; import { useDrawingStore } from '@/stores';
import { api } from '@/services'; import { api } from '@/services';
@@ -45,6 +45,13 @@ export const FileBrowser: React.FC = () => {
const [draggedFolderId, setDraggedFolderId] = useState<string | null>(null); const [draggedFolderId, setDraggedFolderId] = useState<string | null>(null);
const [dragOverFolderId, setDragOverFolderId] = 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 // New drawing name modal state
const [showNameModal, setShowNameModal] = useState(false); const [showNameModal, setShowNameModal] = useState(false);
const [newDrawingName, setNewDrawingName] = useState(''); const [newDrawingName, setNewDrawingName] = useState('');
@@ -181,6 +188,11 @@ export const FileBrowser: React.FC = () => {
try { try {
await api.drawings.delete(drawing.id); await api.drawings.delete(drawing.id);
removeDrawing(drawing.id); removeDrawing(drawing.id);
setSelectedIds(prev => {
const next = new Set(prev);
next.delete(drawing.id);
return next;
});
setActiveMenu(null); setActiveMenu(null);
setModal(m => ({ ...m, open: false })); setModal(m => ({ ...m, open: false }));
} catch (err) { } 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) => { const handleDuplicateDrawing = async (drawing: Drawing) => {
try { try {
const newDrawing = await api.drawings.create({ const newDrawing = await api.drawings.create({
@@ -293,14 +354,6 @@ export const FileBrowser: React.FC = () => {
e.dataTransfer.effectAllowed = 'move'; 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 handleDragLeave = (e: React.DragEvent) => {
const related = e.relatedTarget as HTMLElement; const related = e.relatedTarget as HTMLElement;
const current = e.currentTarget as HTMLElement; const current = e.currentTarget as HTMLElement;
@@ -310,9 +363,27 @@ export const FileBrowser: React.FC = () => {
setDragOverFolderId(null); setDragOverFolderId(null);
}; };
const handleDrop = async (e: React.DragEvent, targetFolderId: string) => { const handleDrop = async (e: React.DragEvent, targetFolderId: string | null) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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) { if (!draggedFolderId || draggedFolderId === targetFolderId) {
setDraggedFolderId(null); setDraggedFolderId(null);
setDragOverFolderId(null); setDragOverFolderId(null);
@@ -323,7 +394,7 @@ export const FileBrowser: React.FC = () => {
const currentFolders = [...folders]; const currentFolders = [...folders];
const draggedIndex = currentFolders.findIndex(f => f.id === draggedFolderId); const draggedIndex = currentFolders.findIndex(f => f.id === draggedFolderId);
const targetIndex = currentFolders.findIndex(f => f.id === targetFolderId); const targetIndex = currentFolders.findIndex(f => f.id === targetFolderId);
if (draggedIndex === -1 || targetIndex === -1) { if (draggedIndex === -1 || targetIndex === -1) {
setDraggedFolderId(null); setDraggedFolderId(null);
setDragOverFolderId(null); setDragOverFolderId(null);
@@ -332,16 +403,16 @@ export const FileBrowser: React.FC = () => {
const [draggedFolder] = currentFolders.splice(draggedIndex, 1); const [draggedFolder] = currentFolders.splice(draggedIndex, 1);
currentFolders.splice(targetIndex, 0, draggedFolder); currentFolders.splice(targetIndex, 0, draggedFolder);
const newOrder = currentFolders.map(f => f.id); const newOrder = currentFolders.map(f => f.id);
try { try {
const reordered = await api.folders.reorder(newOrder); const reordered = await api.folders.reorder(newOrder);
setFolders(reordered); setFolders(reordered);
} catch (err) { } catch (err) {
console.error('Failed to reorder folders:', err); console.error('Failed to reorder folders:', err);
} }
setDraggedFolderId(null); setDraggedFolderId(null);
setDragOverFolderId(null); setDragOverFolderId(null);
}; };
@@ -349,6 +420,18 @@ export const FileBrowser: React.FC = () => {
const handleDragEnd = () => { const handleDragEnd = () => {
setDraggedFolderId(null); setDraggedFolderId(null);
setDragOverFolderId(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 // Close menu on outside click
@@ -478,6 +561,28 @@ export const FileBrowser: React.FC = () => {
</div> </div>
</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}> <div className={styles.content}>
<aside className={styles.sidebar} role="navigation" aria-label="Project tree"> <aside className={styles.sidebar} role="navigation" aria-label="Project tree">
{showNewProject && ( {showNewProject && (
@@ -505,7 +610,20 @@ export const FileBrowser: React.FC = () => {
</div> </div>
)} )}
<ul className={styles.folderTree} role="tree"> <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 <button
className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`} className={`${styles.folderItem} ${!activeFolderId ? styles.folderActive : ''}`}
onClick={() => handleFolderClick(null)} onClick={() => handleFolderClick(null)}
@@ -517,11 +635,17 @@ export const FileBrowser: React.FC = () => {
</button> </button>
</li> </li>
{folders.map((folder) => ( {folders.map((folder) => (
<li <li
key={folder.id} key={folder.id}
draggable draggable
onDragStart={(e) => handleDragStart(e, folder.id)} 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)} onDragLeave={(e) => handleDragLeave(e)}
onDrop={(e) => handleDrop(e, folder.id)} onDrop={(e) => handleDrop(e, folder.id)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
@@ -612,82 +736,99 @@ export const FileBrowser: React.FC = () => {
</p> </p>
</div> </div>
) : ( ) : (
visibleDrawings.map((drawing) => ( visibleDrawings.map((drawing) => {
<Card const isSelected = selectedIds.has(drawing.id);
key={drawing.id} return (
className={styles.drawingCard} <Card
hover key={drawing.id}
role="listitem" className={`${styles.drawingCard} ${isSelected ? styles.drawingSelected : ''}`}
tabIndex={0} hover
onClick={() => handleDrawingClick(drawing)} role="listitem"
onKeyDown={(e: React.KeyboardEvent) => { tabIndex={0}
if (e.key === 'Enter' || e.key === ' ') { draggable
e.preventDefault(); onDragStart={(e) => handleDrawingDragStart(e as unknown as React.DragEvent, drawing.id)}
handleDrawingClick(drawing); onDragEnd={handleDrawingDragEnd}
} onClick={() => handleDrawingClick(drawing)}
}} onKeyDown={(e: React.KeyboardEvent) => {
aria-label={`Open drawing ${drawing.title}`} if (e.key === 'Enter' || e.key === ' ') {
> e.preventDefault();
<div className={styles.thumbnail}> handleDrawingClick(drawing);
{drawing.thumbnail_url ? ( }
<img src={drawing.thumbnail_url} alt="" loading="lazy" /> }}
) : ( aria-label={`Open drawing ${drawing.title}`}
<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 <button
className={styles.more} className={styles.selectBox}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setActiveMenu(activeMenu === drawing.id ? null : drawing.id); toggleSelect(drawing.id);
setRenamingId(null);
}} }}
aria-label={`More options for ${drawing.title}`} aria-label={isSelected ? 'Deselect' : 'Select'}
aria-expanded={activeMenu === drawing.id} aria-pressed={isSelected}
> >
<MoreVertical size={16} /> {isSelected ? <SquareCheck size={16} /> : <Square size={16} />}
</button> </button>
{activeMenu === drawing.id && ( <div className={styles.thumbnail}>
<div className={styles.dropdown}> {drawing.thumbnail_url ? (
<button onClick={(e) => { e.stopPropagation(); handleDrawingClick(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Open</button> <img src={drawing.thumbnail_url} alt="" loading="lazy" />
<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> <img
<button onClick={(e) => { e.stopPropagation(); setMoveModalDrawing(drawing); setActiveMenu(null); }} className={styles.dropdownItem}>Move to...</button> src={`/api/drawings/${drawing.id}/thumbnail`}
<div className={styles.dropdownDivider} /> alt=""
<button onClick={(e) => { e.stopPropagation(); handleDeleteDrawing(drawing); }} className={`${styles.dropdownItem} ${styles.dropdownDanger}`}>Delete</button> loading="lazy"
</div> onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
)} />
</div> )}
</Card> </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> </main>
</div> </div>
@@ -759,6 +900,42 @@ export const FileBrowser: React.FC = () => {
</div> </div>
</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> </div>
</> </>
); );
@@ -0,0 +1,7 @@
-- +goose Up
ALTER TABLE workspace_folders ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
CREATE INDEX idx_workspace_folders_sort_order ON workspace_folders(team_id, sort_order);
-- +goose Down
DROP INDEX IF EXISTS idx_workspace_folders_sort_order;
ALTER TABLE workspace_folders DROP COLUMN IF EXISTS sort_order;
+48
View File
@@ -79,6 +79,9 @@ func (a *API) Routes() chi.Router {
r.Get("/stats", a.handleStats) r.Get("/stats", a.handleStats)
r.Get("/folders", a.handleListFolders) r.Get("/folders", a.handleListFolders)
r.Post("/folders", a.handleCreateFolder) 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.Get("/projects", a.handleListProjects)
r.Post("/projects", a.handleCreateProject) r.Post("/projects", a.handleCreateProject)
r.Get("/notifications", a.handleListNotifications) r.Get("/notifications", a.handleListNotifications)
@@ -625,6 +628,51 @@ func (a *API) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, folder) 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) { func (a *API) handleListProjects(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r) user, _ := currentUser(r)
teamID := strings.TrimSpace(r.URL.Query().Get("team_id")) teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
+143 -13
View File
@@ -40,12 +40,33 @@ type CreateDrawingRequest struct {
Snapshot json.RawMessage `json:"snapshot"` Snapshot json.RawMessage `json:"snapshot"`
} }
// NullString distinguishes JSON null (Valid=true, Value=nil) from absent field (Valid=false).
type NullString struct {
Value *string
Valid bool
}
func (n *NullString) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
n.Value = nil
n.Valid = true
return nil
}
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
n.Value = &s
n.Valid = true
return nil
}
type UpdateDrawingRequest struct { type UpdateDrawingRequest struct {
FolderID *string `json:"folder_id"` FolderID NullString `json:"folder_id"`
ProjectID *string `json:"project_id"` ProjectID NullString `json:"project_id"`
Title *string `json:"title"` Title *string `json:"title"`
Description *string `json:"description"` Description *string `json:"description"`
Visibility *string `json:"visibility"` Visibility *string `json:"visibility"`
} }
type CreateRevisionRequest struct { type CreateRevisionRequest struct {
@@ -61,6 +82,11 @@ type CreateFolderRequest struct {
Visibility string `json:"visibility"` Visibility string `json:"visibility"`
} }
type UpdateFolderRequest struct {
Name *string `json:"name"`
Visibility *string `json:"visibility"`
}
type CreateProjectRequest struct { type CreateProjectRequest struct {
TeamID string `json:"team_id"` TeamID string `json:"team_id"`
Name string `json:"name"` Name string `json:"name"`
@@ -616,11 +642,11 @@ func (s *Store) UpdateDrawing(ctx context.Context, userID, drawingID string, req
} }
current.Visibility = *req.Visibility current.Visibility = *req.Visibility
} }
if req.FolderID != nil { if req.FolderID.Valid {
current.FolderID = req.FolderID current.FolderID = req.FolderID.Value
} }
if req.ProjectID != nil { if req.ProjectID.Valid {
current.ProjectID = req.ProjectID current.ProjectID = req.ProjectID.Value
} }
now := time.Now().UTC() now := time.Now().UTC()
_, err = s.db.ExecContext(ctx, `UPDATE workspace_drawings _, err = s.db.ExecContext(ctx, `UPDATE workspace_drawings
@@ -922,7 +948,7 @@ func (s *Store) ListFolders(ctx context.Context, userID, teamID string) ([]Folde
return nil, ErrForbidden 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 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 { if err != nil {
return nil, err return nil, err
} }
@@ -972,10 +998,12 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
CreatedAt: now, CreatedAt: now,
UpdatedAt: 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 _, 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) (id, team_id, project_id, parent_folder_id, name, slug, path_cache, visibility, created_by, created_at, updated_at, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
folder.ID, folder.TeamID, folder.ProjectID, folder.ParentFolderID, folder.Name, folder.Slug, folder.PathCache, folder.Visibility, folder.CreatedBy, folder.CreatedAt, folder.UpdatedAt, 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 { if err != nil {
return nil, err return nil, err
@@ -983,6 +1011,108 @@ func (s *Store) CreateFolder(ctx context.Context, userID string, req CreateFolde
return folder, nil 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) { func (s *Store) ListProjects(ctx context.Context, userID, teamID string) ([]Project, error) {
if teamID == "" { if teamID == "" {
teamID, _ = s.defaultTeamID(ctx, userID) teamID, _ = s.defaultTeamID(ctx, userID)