From d338c4cb8aed5c3c70024b8bcce9ce3fe84d3216 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 02:30:59 +0200 Subject: [PATCH] feat(workspace): add drag and drop support for folders --- .../src/features/workspace/WorkspaceView.tsx | 146 +++++++++++++++++- 1 file changed, 139 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 2e0e0b2..aa370c3 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -113,8 +113,9 @@ export const WorkspaceView: React.FC = ({ const [newFolderParent, setNewFolderParent] = useState(null); const [newFolderName, setNewFolderName] = useState(''); - // Sidebar Document Drag and Drop state + // Sidebar Document & Folder Drag and Drop state const [draggingDocId, setDraggingDocId] = useState(null); + const [draggingFolderPath, setDraggingFolderPath] = useState(null); const [dragOverFolder, setDragOverFolder] = useState(null); // Active focused block ref @@ -583,6 +584,7 @@ export const WorkspaceView: React.FC = ({ useEffect(() => { const handleGlobalDragEnd = () => { setDraggingDocId(null); + setDraggingFolderPath(null); setDragOverFolder(null); setDraggingBlockId(null); }; @@ -594,6 +596,100 @@ export const WorkspaceView: React.FC = ({ }; }, []); + const handleMoveFolder = async (sourcePath: string, targetParentPath: string) => { + const cleanSource = sourcePath.trim(); + const cleanTarget = targetParentPath.trim(); + + // Cannot move to self or into own subfolder + if (!cleanSource || cleanSource === cleanTarget || cleanTarget.startsWith(cleanSource + '/')) { + return; + } + + // Name of the folder being moved + const folderName = cleanSource.split('/').pop() || cleanSource; + const newFolderPath = cleanTarget ? `${cleanTarget}/${folderName}` : folderName; + + if (newFolderPath === cleanSource) return; + + triggerHaptic(15); + + // 1. Update all documents that are in cleanSource or any subfolder of cleanSource + const docsToUpdate = docs.filter((d) => { + const f = getDocFolder(d); + return f === cleanSource || f.startsWith(cleanSource + '/'); + }); + + const updatedDocsList = docs.map((d) => { + const f = getDocFolder(d); + if (f === cleanSource) { + const nextFolder = newFolderPath; + const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:')); + if (nextFolder) updatedTags.push(`folder:${nextFolder}`); + return { ...d, folderPath: nextFolder, tags: updatedTags }; + } else if (f.startsWith(cleanSource + '/')) { + const suffix = f.slice(cleanSource.length); + const nextFolder = `${newFolderPath}${suffix}`; + const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:')); + if (nextFolder) updatedTags.push(`folder:${nextFolder}`); + return { ...d, folderPath: nextFolder, tags: updatedTags }; + } + return d; + }); + + setDocs(updatedDocsList); + if (selectedDoc) { + const found = updatedDocsList.find((d) => d.id === selectedDoc.id); + if (found) setSelectedDoc(found); + } + + // Persist all affected docs to backend + for (const d of docsToUpdate) { + const f = getDocFolder(d); + let nextFolder = newFolderPath; + if (f.startsWith(cleanSource + '/')) { + const suffix = f.slice(cleanSource.length); + nextFolder = `${newFolderPath}${suffix}`; + } + const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:')); + if (nextFolder) updatedTags.push(`folder:${nextFolder}`); + + api + .updateDocument(d.id, { + folderPath: nextFolder, + tags: updatedTags, + }) + .catch((err) => console.error('Failed to update doc folder', err)); + } + + // 2. Update customFolders in state & localStorage + const updatedCustomFolders = customFolders.map((p) => { + if (p === cleanSource) return newFolderPath; + if (p.startsWith(cleanSource + '/')) { + return `${newFolderPath}${p.slice(cleanSource.length)}`; + } + return p; + }); + + if (!updatedCustomFolders.includes(newFolderPath)) { + updatedCustomFolders.push(newFolderPath); + } + const sortedCustom = Array.from(new Set(updatedCustomFolders)).sort((a, b) => + a.localeCompare(b) + ); + setCustomFolders(sortedCustom); + try { + localStorage.setItem('mindsift_workspace_folders', JSON.stringify(sortedCustom)); + } catch {} + + // Expand the new folder path and its parent + setExpandedFolders((prev) => { + const next = new Set(prev); + next.add(newFolderPath); + if (cleanTarget) next.add(cleanTarget); + return next; + }); + }; + const handleDropDocToFolder = async (folderPath: string, e: React.DragEvent) => { const docId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId; setDraggingDocId(null); @@ -1239,6 +1335,7 @@ export const WorkspaceView: React.FC = ({ const isExpanded = expandedFolders.has(node.path); const isCreatingSubfolder = isCreatingFolder && newFolderParent === node.path; const isDragOver = dragOverFolder === node.path; + const isDraggingThisFolder = draggingFolderPath === node.path; const totalItems = node.docs.length + node.subfolders.length; return ( @@ -1246,7 +1343,12 @@ export const WorkspaceView: React.FC = ({ key={node.path} className="select-none" onDragOver={(e) => { - if (draggingDocId) { + if ( + draggingDocId || + (draggingFolderPath && + draggingFolderPath !== node.path && + !node.path.startsWith(draggingFolderPath + '/')) + ) { e.preventDefault(); e.stopPropagation(); setDragOverFolder(node.path); @@ -1261,19 +1363,38 @@ export const WorkspaceView: React.FC = ({ onDrop={(e) => { e.preventDefault(); e.stopPropagation(); + const droppedFolder = + e.dataTransfer.getData('application/mindsift-folder-path') || draggingFolderPath; setDragOverFolder(null); setDraggingDocId(null); - handleDropDocToFolder(node.path, e); + setDraggingFolderPath(null); + + if (droppedFolder) { + handleMoveFolder(droppedFolder, node.path); + } else { + handleDropDocToFolder(node.path, e); + } }} >
{ + e.stopPropagation(); + e.dataTransfer.setData('application/mindsift-folder-path', node.path); + e.dataTransfer.setData('text/plain', node.path); + setDraggingFolderPath(node.path); + }} + onDragEnd={() => setDraggingFolderPath(null)} onClick={() => toggleFolder(node.path)} style={{ paddingLeft: `${node.depth * 14 + 8}px` }} className={`group flex items-center justify-between py-1.5 pr-2 cursor-pointer transition-colors text-xs ${ + isDraggingThisFolder ? 'opacity-40 scale-[0.98]' : '' + } ${ isDragOver ? 'bg-accent/20 border-l-2 border-accent text-main font-medium' : 'hover:bg-panel-elevated text-muted' }`} + title={`${node.path} (Klick: Öffnen/Schließen · Ziehen: In anderen Ordner verschieben)`} >
@@ -1535,8 +1656,8 @@ export const WorkspaceView: React.FC = ({
) : (
- {/* Drag target for Root when dragging a note */} - {draggingDocId && ( + {/* Drag target for Root when dragging a note or a folder */} + {(draggingDocId || draggingFolderPath) && (
{ e.preventDefault(); @@ -1550,9 +1671,18 @@ export const WorkspaceView: React.FC = ({ onDrop={(e) => { e.preventDefault(); e.stopPropagation(); + const droppedFolder = + e.dataTransfer.getData('application/mindsift-folder-path') || + draggingFolderPath; setDragOverFolder(null); setDraggingDocId(null); - handleDropDocToFolder('', e); + setDraggingFolderPath(null); + + if (droppedFolder) { + handleMoveFolder(droppedFolder, ''); + } else { + handleDropDocToFolder('', e); + } }} className={`mx-2 my-1.5 p-2 border-2 border-dashed rounded-btn text-center text-xs transition-colors ${ dragOverFolder === '__root__' @@ -1560,7 +1690,9 @@ export const WorkspaceView: React.FC = ({ : 'border-border text-muted bg-canvas' }`} > - 📁 In Hauptverzeichnis verschieben (ohne Ordner) + {draggingFolderPath + ? '📁 Ordner in Hauptverzeichnis verschieben' + : '📁 In Hauptverzeichnis verschieben (ohne Ordner)'}
)}