mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-07-29 07:33: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 { 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;
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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)}
|
||||||
@@ -521,7 +639,13 @@ export const FileBrowser: React.FC = () => {
|
|||||||
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">×</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,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("/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"))
|
||||||
|
|||||||
+113
-4
@@ -61,6 +61,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"`
|
||||||
@@ -922,7 +927,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 +977,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 +990,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)
|
||||||
|
|||||||
Reference in New Issue
Block a user