mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 13:52:56 +00:00
Compare commits
2 Commits
19e7ed6ea1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 54c8088404 | |||
| cd22ee1ee8 |
+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}`);
|
||||
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; }
|
||||
}
|
||||
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
|
||||
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);
|
||||
}
|
||||
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);
|
||||
} catch (err: any) {
|
||||
console.error('Library import from postMessage failed:', err);
|
||||
excalidrawAPI.updateScene({
|
||||
appState: { errorMessage: err?.message || 'Library import failed' },
|
||||
});
|
||||
}, [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,13 +736,18 @@ export const FileBrowser: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
visibleDrawings.map((drawing) => (
|
||||
visibleDrawings.map((drawing) => {
|
||||
const isSelected = selectedIds.has(drawing.id);
|
||||
return (
|
||||
<Card
|
||||
key={drawing.id}
|
||||
className={styles.drawingCard}
|
||||
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 === ' ') {
|
||||
@@ -628,6 +757,17 @@ export const FileBrowser: React.FC = () => {
|
||||
}}
|
||||
aria-label={`Open drawing ${drawing.title}`}
|
||||
>
|
||||
<button
|
||||
className={styles.selectBox}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelect(drawing.id);
|
||||
}}
|
||||
aria-label={isSelected ? 'Deselect' : 'Select'}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{isSelected ? <SquareCheck size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
<div className={styles.thumbnail}>
|
||||
{drawing.thumbnail_url ? (
|
||||
<img src={drawing.thumbnail_url} alt="" loading="lazy" />
|
||||
@@ -687,7 +827,8 @@ export const FileBrowser: React.FC = () => {
|
||||
)}
|
||||
</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,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;
|
||||
@@ -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"))
|
||||
|
||||
+140
-10
@@ -40,9 +40,30 @@ type CreateDrawingRequest struct {
|
||||
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 {
|
||||
FolderID *string `json:"folder_id"`
|
||||
ProjectID *string `json:"project_id"`
|
||||
FolderID NullString `json:"folder_id"`
|
||||
ProjectID NullString `json:"project_id"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Visibility *string `json:"visibility"`
|
||||
@@ -61,6 +82,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"`
|
||||
@@ -616,11 +642,11 @@ func (s *Store) UpdateDrawing(ctx context.Context, userID, drawingID string, req
|
||||
}
|
||||
current.Visibility = *req.Visibility
|
||||
}
|
||||
if req.FolderID != nil {
|
||||
current.FolderID = req.FolderID
|
||||
if req.FolderID.Valid {
|
||||
current.FolderID = req.FolderID.Value
|
||||
}
|
||||
if req.ProjectID != nil {
|
||||
current.ProjectID = req.ProjectID
|
||||
if req.ProjectID.Valid {
|
||||
current.ProjectID = req.ProjectID.Value
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, 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
|
||||
}
|
||||
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 +998,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 +1011,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