fix: dashboard tree drag&drop on folder items + collapsible + subfolders + context menu

This commit is contained in:
A0 Orchestrator
2026-07-03 18:58:14 +02:00
parent 061f862960
commit c8858430ad
2 changed files with 243 additions and 115 deletions
+164 -108
View File
@@ -5,8 +5,6 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { NotificationPanel } from '../components/NotificationPanel'; import { NotificationPanel } from '../components/NotificationPanel';
import { ShareDialog } from '../components/ShareDialog'; import { ShareDialog } from '../components/ShareDialog';
import TreeView from '../components/TreeView';
import type { TreeNode } from '../types/ui.types';
import { import {
getProjects, deleteProject, updateProject, getProjects, deleteProject, updateProject,
getProjectFolders, createProjectFolder, renameProjectFolder, deleteProjectFolder, getProjectFolders, createProjectFolder, renameProjectFolder, deleteProjectFolder,
@@ -15,7 +13,7 @@ import {
} from '../services/api'; } from '../services/api';
const API_BASE = import.meta.env.VITE_API_BASE || ''; const API_BASE = import.meta.env.VITE_API_BASE || '';
const ROOT_FOLDER_ID = '__root__'; const UNFILED_ID = '__unfiled__';
interface DashboardProps { interface DashboardProps {
onOpenProject: (projectId: string) => void; onOpenProject: (projectId: string) => void;
@@ -31,7 +29,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState(''); const [newDesc, setNewDesc] = useState('');
const [shareProjectId, setShareProjectId] = useState<string | null>(null); const [shareProjectId, setShareProjectId] = useState<string | null>(null);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(ROOT_FOLDER_ID); const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null); const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [shares, setShares] = useState<ProjectShare[]>([]); const [shares, setShares] = useState<ProjectShare[]>([]);
const [sharesLoading, setSharesLoading] = useState(false); const [sharesLoading, setSharesLoading] = useState(false);
@@ -46,6 +44,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
const [editingProjectDesc, setEditingProjectDesc] = useState(''); const [editingProjectDesc, setEditingProjectDesc] = useState('');
const [savingProject, setSavingProject] = useState(false); const [savingProject, setSavingProject] = useState(false);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null); const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
if (!token) return; if (!token) return;
@@ -68,10 +67,17 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
fetchAll(); fetchAll();
}, [fetchAll]); }, [fetchAll]);
// Default: expand all folders on first load or when folders change
useEffect(() => { useEffect(() => {
if (selectedFolderId === ROOT_FOLDER_ID) { if (folders.length > 0 && expandedFolders.size === 0) {
setExpandedFolders(new Set(folders.map(f => f.id)));
}
}, [folders, expandedFolders.size]);
useEffect(() => {
if (selectedFolderId === null) {
setProjects(allProjects); setProjects(allProjects);
} else if (selectedFolderId === null) { } else if (selectedFolderId === UNFILED_ID) {
setProjects(allProjects.filter(p => p.folder_id === null)); setProjects(allProjects.filter(p => p.folder_id === null));
} else { } else {
setProjects(allProjects.filter(p => p.folder_id === selectedFolderId)); setProjects(allProjects.filter(p => p.folder_id === selectedFolderId));
@@ -102,45 +108,31 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
} }
}, [selectedProject]); }, [selectedProject]);
const treeNodes = useMemo((): TreeNode[] => {
const rootProjectsCount = allProjects.filter(p => p.folder_id === null).length;
const root: TreeNode = {
id: ROOT_FOLDER_ID,
name: 'Alle Projekte',
count: allProjects.length,
expanded: true,
children: [
{ id: '__unfiled__', name: 'Ohne Ordner', count: rootProjectsCount },
],
};
const buildFolderNode = (folder: ProjectFolder): TreeNode => {
const childFolders = folders.filter(f => f.parent_id === folder.id);
const projCount = allProjects.filter(p => p.folder_id === folder.id).length;
return {
id: folder.id,
name: folder.name,
count: projCount,
expanded: true,
children: childFolders.map(buildFolderNode),
};
};
const topFolders = folders.filter(f => f.parent_id === null);
root.children!.push(...topFolders.map(buildFolderNode));
return [root];
}, [folders, allProjects]);
const folderNameById = useMemo(() => { const folderNameById = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
map.set(ROOT_FOLDER_ID, 'Alle Projekte'); map.set(UNFILED_ID, 'Ohne Ordner');
map.set('__unfiled__', 'Ohne Ordner');
for (const f of folders) map.set(f.id, f.name); for (const f of folders) map.set(f.id, f.name);
return map; return map;
}, [folders]); }, [folders]);
const currentFolderName = useMemo(() => {
if (selectedFolderId === null) return 'Alle Projekte';
return folderNameById.get(selectedFolderId) ?? 'Projekte';
}, [selectedFolderId, folderNameById]);
const toggleFolder = (id: string) => {
setExpandedFolders(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleCreate = async () => { const handleCreate = async () => {
if (!newName.trim() || !token) return; if (!newName.trim() || !token) return;
try { try {
const folderId = selectedFolderId === ROOT_FOLDER_ID || selectedFolderId === '__unfiled__' const folderId = selectedFolderId === null || selectedFolderId === UNFILED_ID
? null : selectedFolderId; ? null : selectedFolderId;
const res = await fetch(`${API_BASE}/api/projects`, { const res = await fetch(`${API_BASE}/api/projects`, {
method: 'POST', method: 'POST',
@@ -171,20 +163,13 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
} }
}; };
const handleFolderSelect = (id: string) => { const handleFolderSelect = (id: string | null) => {
if (id === ROOT_FOLDER_ID) { setSelectedFolderId(id);
setSelectedFolderId(ROOT_FOLDER_ID);
} else if (id === '__unfiled__') {
setSelectedFolderId(null);
} else {
setSelectedFolderId(id);
}
}; };
const handleCreateFolder = async () => { const handleCreateFolder = async () => {
if (!newFolderName.trim() || !token) return; if (!newFolderName.trim() || !token) return;
const parentId = newFolderParent === ROOT_FOLDER_ID || newFolderParent === '__unfiled__' const parentId = newFolderParent === UNFILED_ID ? null : newFolderParent;
? null : newFolderParent;
try { try {
await createProjectFolder(token, newFolderName, parentId); await createProjectFolder(token, newFolderName, parentId);
setNewFolderName(''); setNewFolderName('');
@@ -213,7 +198,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
if (!confirm('Ordner loeschen? Projekte darin werden nach "Ohne Ordner" verschoben.')) return; if (!confirm('Ordner loeschen? Projekte darin werden nach "Ohne Ordner" verschoben.')) return;
try { try {
await deleteProjectFolder(token, id); await deleteProjectFolder(token, id);
if (selectedFolderId === id) setSelectedFolderId(ROOT_FOLDER_ID); if (selectedFolderId === id) setSelectedFolderId(null);
fetchAll(); fetchAll();
} catch { } catch {
// ignore // ignore
@@ -247,25 +232,24 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
e.dataTransfer.setData('text/plain', `project:${projectId}`); e.dataTransfer.setData('text/plain', `project:${projectId}`);
}; };
const handleFolderDragOver = (e: React.DragEvent, folderId: string) => { const handleFolderDragOver = (e: React.DragEvent, folderId: string | null) => {
if (!draggedProjectId) return; if (!draggedProjectId) return;
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
setDragOverFolderId(folderId); setDragOverFolderId(folderId);
}; };
const handleFolderDragLeave = (_e: React.DragEvent, folderId: string) => { const handleFolderDragLeave = (folderId: string | null) => {
if (dragOverFolderId === folderId) { if (dragOverFolderId === folderId) {
setDragOverFolderId(null); setDragOverFolderId(null);
} }
}; };
const handleFolderDrop = async (e: React.DragEvent, folderId: string) => { const handleFolderDrop = async (e: React.DragEvent, folderId: string | null) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
(e.currentTarget as HTMLElement).classList.remove('tree-drop-target');
if (!draggedProjectId || !token) return; if (!draggedProjectId || !token) return;
const targetFolderId = folderId === ROOT_FOLDER_ID || folderId === '__unfiled__' ? null : folderId; const targetFolderId = folderId === UNFILED_ID ? null : folderId;
try { try {
await moveProjectToFolder(token, draggedProjectId, targetFolderId); await moveProjectToFolder(token, draggedProjectId, targetFolderId);
fetchAll(); fetchAll();
@@ -288,6 +272,9 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
if (action === 'new') { if (action === 'new') {
setNewFolderParent(folderId); setNewFolderParent(folderId);
setShowCreateFolder(true); setShowCreateFolder(true);
} else if (action === 'newSubfolder' && folderId) {
setNewFolderParent(folderId);
setShowCreateFolder(true);
} else if (action === 'rename' && folderId) { } else if (action === 'rename' && folderId) {
const folder = folders.find(f => f.id === folderId); const folder = folders.find(f => f.id === folderId);
if (folder) { if (folder) {
@@ -306,29 +293,89 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
}); });
}; };
const renderFolderActions = (node: TreeNode) => { const folderIcon = (
if (node.id === ROOT_FOLDER_ID || node.id === '__unfiled__') return null; <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
);
const renderFolderItem = (folder: ProjectFolder, depth: number): React.ReactNode => {
const children = folders.filter(f => f.parent_id === folder.id);
const isExpanded = expandedFolders.has(folder.id);
const projectCount = allProjects.filter(p => p.folder_id === folder.id).length;
const isSelected = selectedFolderId === folder.id;
const isDragOver = dragOverFolderId === folder.id;
const isRenaming = renamingFolderId === folder.id;
return ( return (
<span className="folder-action-buttons"> <div key={folder.id}>
<button <div
className="folder-action-btn" className={`folder-tree-item${isSelected ? ' selected' : ''}${isDragOver ? ' drag-over' : ''}`}
title="Umbenennen" draggable
onClick={(e) => { onDragOver={(e) => handleFolderDragOver(e, folder.id)}
e.stopPropagation(); onDragLeave={() => handleFolderDragLeave(folder.id)}
setRenamingFolderId(node.id); onDrop={(e) => handleFolderDrop(e, folder.id)}
const folder = folders.find(f => f.id === node.id); onClick={() => handleFolderSelect(folder.id)}
if (folder) setRenameValue(folder.name); onContextMenu={(e) => handleContextMenu(e, folder.id)}
}} style={{ paddingLeft: `${depth * 16 + 8}px` }}
></button> >
<button {children.length > 0 ? (
className="folder-action-btn delete" <button
title="Loeschen" className="folder-chevron"
onClick={(e) => { onClick={(e) => { e.stopPropagation(); toggleFolder(folder.id); }}
e.stopPropagation(); aria-label={isExpanded ? 'Einklappen' : 'Ausklappen'}
handleDeleteFolder(node.id); >
}} {isExpanded ? '▼' : '▶'}
></button> </button>
</span> ) : (
<span className="folder-chevron-placeholder" />
)}
{folderIcon}
{isRenaming ? (
<input
className="folder-rename-inline"
type="text"
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleRenameFolder(folder.id);
if (e.key === 'Escape') { setRenamingFolderId(null); setRenameValue(''); }
}}
onBlur={() => {
if (renameValue.trim()) handleRenameFolder(folder.id);
else { setRenamingFolderId(null); setRenameValue(''); }
}}
onClick={e => e.stopPropagation()}
autoFocus
/>
) : (
<span className="folder-tree-name">{folder.name}</span>
)}
{projectCount > 0 && <span className="folder-count">({projectCount})</span>}
{!isRenaming && (
<span className="folder-action-buttons">
<button
className="folder-action-btn"
title="Umbenennen"
onClick={(e) => {
e.stopPropagation();
setRenamingFolderId(folder.id);
setRenameValue(folder.name);
}}
></button>
<button
className="folder-action-btn delete"
title="Loeschen"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(folder.id);
}}
></button>
</span>
)}
</div>
{isExpanded && children.map(child => renderFolderItem(child, depth + 1))}
</div>
); );
}; };
@@ -360,7 +407,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
<div className="folder-create-form"> <div className="folder-create-form">
<input <input
type="text" type="text"
placeholder="Ordnername" placeholder={newFolderParent ? 'Unterordnername' : 'Ordnername'}
value={newFolderName} value={newFolderName}
onChange={e => setNewFolderName(e.target.value)} onChange={e => setNewFolderName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateFolder(); if (e.key === 'Escape') setShowCreateFolder(false); }} onKeyDown={e => { if (e.key === 'Enter') handleCreateFolder(); if (e.key === 'Escape') setShowCreateFolder(false); }}
@@ -368,47 +415,50 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
/> />
<div className="folder-create-actions"> <div className="folder-create-actions">
<button onClick={handleCreateFolder}>OK</button> <button onClick={handleCreateFolder}>OK</button>
<button onClick={() => { setShowCreateFolder(false); setNewFolderName(''); }}>×</button> <button onClick={() => { setShowCreateFolder(false); setNewFolderName(''); setNewFolderParent(null); }}>×</button>
</div> </div>
</div> </div>
)} )}
<TreeView {/* Root: Alle Projekte */}
nodes={treeNodes} <div
selectedId={selectedFolderId ?? undefined} className={`folder-tree-item${selectedFolderId === null ? ' selected' : ''}${dragOverFolderId === null && draggedProjectId ? ' drag-over' : ''}`}
onSelect={handleFolderSelect} onClick={() => handleFolderSelect(null)}
renderActions={renderFolderActions} onDragOver={(e) => handleFolderDragOver(e, null)}
/> onDragLeave={() => handleFolderDragLeave(null)}
onDrop={(e) => handleFolderDrop(e, null)}
{renamingFolderId && ( onContextMenu={(e) => handleContextMenu(e, null)}
<div className="folder-rename-form"> style={{ paddingLeft: '8px' }}
<input >
type="text" <span className="folder-chevron-placeholder" />
value={renameValue} {folderIcon}
onChange={e => setRenameValue(e.target.value)} <span className="folder-tree-name">Alle Projekte</span>
onKeyDown={e => { if (e.key === 'Enter') handleRenameFolder(renamingFolderId); if (e.key === 'Escape') { setRenamingFolderId(null); setRenameValue(''); } }} <span className="folder-count">({allProjects.length})</span>
onBlur={() => { if (renameValue.trim()) handleRenameFolder(renamingFolderId); else { setRenamingFolderId(null); setRenameValue(''); } }}
autoFocus
/>
</div>
)}
<div className="treeview-drop-overlay">
{folders.map(folder => (
<div
key={folder.id}
className={`treeview-drop-zone${dragOverFolderId === folder.id ? ' drag-over' : ''}`}
onDragOver={(e) => handleFolderDragOver(e, folder.id)}
onDragLeave={(e) => handleFolderDragLeave(e, folder.id)}
onDrop={(e) => handleFolderDrop(e, folder.id)}
/>
))}
</div> </div>
{/* Ohne Ordner node */}
<div
className={`folder-tree-item${selectedFolderId === UNFILED_ID ? ' selected' : ''}${dragOverFolderId === UNFILED_ID ? ' drag-over' : ''}`}
onClick={() => handleFolderSelect(UNFILED_ID)}
onDragOver={(e) => handleFolderDragOver(e, UNFILED_ID)}
onDragLeave={() => handleFolderDragLeave(UNFILED_ID)}
onDrop={(e) => handleFolderDrop(e, UNFILED_ID)}
onContextMenu={(e) => handleContextMenu(e, null)}
style={{ paddingLeft: '24px' }}
>
<span className="folder-chevron-placeholder" />
{folderIcon}
<span className="folder-tree-name">Ohne Ordner</span>
<span className="folder-count">({allProjects.filter(p => p.folder_id === null).length})</span>
</div>
{/* Top-level folders rendered recursively */}
{folders.filter(f => !f.parent_id).map(folder => renderFolderItem(folder, 1))}
</div> </div>
<div className="dashboard-content"> <div className="dashboard-content">
<div className="dashboard-section-header"> <div className="dashboard-section-header">
<h2>{folderNameById.get(selectedFolderId ?? ROOT_FOLDER_ID) ?? 'Projekte'}</h2> <h2>{currentFolderName}</h2>
<button className="dashboard-create-btn" onClick={() => setShowCreate(!showCreate)}> <button className="dashboard-create-btn" onClick={() => setShowCreate(!showCreate)}>
+ Neues Projekt + Neues Projekt
</button> </button>
@@ -561,7 +611,13 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
className="context-menu-item" className="context-menu-item"
onClick={() => handleContextAction('new', contextMenu.folderId)} onClick={() => handleContextAction('new', contextMenu.folderId)}
>Neuer Ordner</button> >Neuer Ordner</button>
{contextMenu.folderId && contextMenu.folderId !== ROOT_FOLDER_ID && contextMenu.folderId !== '__unfiled__' && ( {contextMenu.folderId && (
<button
className="context-menu-item"
onClick={() => handleContextAction('newSubfolder', contextMenu.folderId)}
>Neuer Unterordner</button>
)}
{contextMenu.folderId && (
<> <>
<button <button
className="context-menu-item" className="context-menu-item"
+79 -7
View File
@@ -282,18 +282,90 @@
.folder-action-btn:hover { color: var(--color-primary); background: rgba(37, 99, 235, 0.1); } .folder-action-btn:hover { color: var(--color-primary); background: rgba(37, 99, 235, 0.1); }
.folder-action-btn.delete:hover { color: #e74c3c; background: rgba(231, 76, 60, 0.1); } .folder-action-btn.delete:hover { color: #e74c3c; background: rgba(231, 76, 60, 0.1); }
/* Drag-over visual feedback on tree nodes */ /* Folder tree items (recursive, native drag targets) */
.tree-node.drag-over { .folder-tree-item {
background: var(--color-primary) !important; display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
color: var(--color-text);
transition: background 0.15s;
user-select: none;
}
.folder-tree-item:hover {
background: var(--color-surface-2);
}
.folder-tree-item.selected {
background: var(--color-primary);
color: white;
}
.folder-tree-item.drag-over {
background: var(--color-primary);
opacity: 0.7; opacity: 0.7;
border: 2px dashed var(--color-primary); border: 2px dashed var(--color-primary);
border-radius: 4px; border-radius: 4px;
} }
.treeview-drop-zone.drag-over { .folder-chevron {
background: rgba(37, 99, 235, 0.15); background: none;
outline: 2px dashed var(--color-primary); border: none;
border-radius: 4px; cursor: pointer;
color: var(--color-text-muted);
padding: 0;
width: 16px;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.folder-chevron:hover {
color: var(--color-text);
}
.folder-tree-item.selected .folder-chevron {
color: rgba(255, 255, 255, 0.8);
}
.folder-chevron-placeholder {
width: 16px;
flex-shrink: 0;
}
.folder-tree-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-count {
font-size: 11px;
color: var(--color-text-muted);
flex-shrink: 0;
}
.folder-tree-item.selected .folder-count {
color: rgba(255, 255, 255, 0.7);
}
.folder-rename-inline {
flex: 1;
padding: 2px 4px;
border: 1px solid var(--color-primary);
border-radius: 3px;
background: var(--color-surface);
color: var(--color-text);
font-size: 13px;
outline: none;
box-sizing: border-box;
} }
.context-menu-overlay { .context-menu-overlay {