Files
web-cad/frontend/src/pages/Dashboard.tsx
T

884 lines
36 KiB
TypeScript
Raw Normal View History

/**
* Dashboard Page - 3-column layout with project folder tree, project cards, and info panel
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { NotificationPanel } from '../components/NotificationPanel';
import { ShareDialog } from '../components/ShareDialog';
2026-07-04 18:11:11 +02:00
import SettingsModal from '../components/SettingsModal';
import {
getProjects, deleteProject, updateProject,
getProjectFolders, createProjectFolder, renameProjectFolder, deleteProjectFolder,
2026-07-04 18:11:11 +02:00
moveProjectToFolder, moveProjectFolder, getProjectShares, getUsers, createProjectShare,
getDrawings, getElements,
2026-07-04 18:11:11 +02:00
type Project, type ProjectFolder, type ProjectShare, type SimpleUser,
} from '../services/api';
import type { CADElement } from '../types/cad.types';
const API_BASE = import.meta.env.VITE_API_BASE || '';
const UNFILED_ID = '__unfiled__';
interface DashboardProps {
onOpenProject: (projectId: string) => void;
}
/** Render a real SVG preview of a project's first drawing elements */
function CanvasPreview({ project, token }: { project: Project; token: string }) {
const [elements, setElements] = useState<CADElement[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function loadPreview() {
try {
const drawings = await getDrawings(token, project.id);
if (drawings.length === 0) { if (!cancelled) setElements([]); return; }
const els = await getElements(token, drawings[0].id);
if (!cancelled) setElements(els);
} catch {
if (!cancelled) setElements([]);
} finally {
if (!cancelled) setLoading(false);
2026-07-04 18:11:11 +02:00
}
}
loadPreview();
return () => { cancelled = true; };
}, [token, project.id]);
// Compute bounding box of all elements for viewBox scaling
const bbox = useMemo(() => {
if (elements.length === 0) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of elements) {
minX = Math.min(minX, el.x);
minY = Math.min(minY, el.y);
maxX = Math.max(maxX, el.x + el.width);
maxY = Math.max(maxY, el.y + el.height);
}
return { minX, minY, w: Math.max(maxX - minX, 10), h: Math.max(maxY - minY, 10) };
}, [elements]);
const svgElements = useMemo(() => {
if (!bbox || elements.length === 0) return [];
return elements.slice(0, 50).map((el) => {
const stroke = el.properties.stroke || '#60a5fa';
const fill = el.properties.fill || 'none';
const sw = Math.max((el.properties.strokeWidth || 1), 0.5);
const x = el.x - bbox.minX;
const y = el.y - bbox.minY;
const w = el.width;
const h = el.height;
switch (el.type) {
case 'rect':
case 'table':
case 'stage':
return <rect key={el.id} x={x} y={y} width={w} height={h} fill={fill} stroke={stroke} strokeWidth={sw} />;
case 'circle':
return <circle key={el.id} cx={x + w/2} cy={y + h/2} r={Math.min(w, h)/2} fill={fill} stroke={stroke} strokeWidth={sw} />;
case 'line':
case 'leader':
return <line key={el.id} x1={x} y1={y} x2={x + w} y2={y + h} stroke={stroke} strokeWidth={sw} />;
case 'text':
return <text key={el.id} x={x} y={y + Math.min(h, 10)} fontSize={Math.min(el.properties.fontSize || 8, 10)} fill={stroke}>{el.properties.text || ''}</text>;
case 'polygon':
case 'polyline':
if (el.properties.points && el.properties.points.length > 1) {
const pts = el.properties.points.map(p => `${p.x - bbox.minX},${p.y - bbox.minY}`).join(' ');
return <polygon key={el.id} points={pts} fill={fill} stroke={stroke} strokeWidth={sw} />;
}
return null;
case 'chair':
return <circle key={el.id} cx={x + w/2} cy={y + h/2} r={Math.min(w, h)/2} fill={fill} stroke={stroke} strokeWidth={sw} />;
case 'arc':
return <path key={el.id} d={`M ${x} ${y+h/2} A ${w/2} ${h/2} 0 0 1 ${x+w} ${y+h/2}`} fill="none" stroke={stroke} strokeWidth={sw} />;
default:
return <rect key={el.id} x={x} y={y} width={w} height={h} fill="none" stroke={stroke} strokeWidth={sw} strokeDasharray="2,2" />;
}
});
}, [elements, bbox]);
2026-07-04 18:11:11 +02:00
return (
<div className="dashboard-card-preview" title="Vorschau der Zeichenfläche">
{loading ? (
<div className="dashboard-preview-loading">...</div>
) : elements.length === 0 || !bbox ? (
<svg viewBox="0 0 100 60" preserveAspectRatio="xMidYMid meet" className="dashboard-preview-svg">
<rect x="0" y="0" width="100" height="60" fill="var(--color-canvas-bg-2, #1e293b)" />
<text x="50" y="35" textAnchor="middle" fontSize="6" fill="#64748b">Leere Zeichnung</text>
</svg>
) : (
<svg viewBox={`${bbox.minX - 5} ${bbox.minY - 5} ${bbox.w + 10} ${bbox.h + 10}`} preserveAspectRatio="xMidYMid meet" className="dashboard-preview-svg">
<rect x={bbox.minX - 5} y={bbox.minY - 5} width={bbox.w + 10} height={bbox.h + 10} fill="var(--color-canvas-bg-2, #1e293b)" />
{svgElements}
</svg>
)}
2026-07-04 18:11:11 +02:00
</div>
);
}
export function Dashboard({ onOpenProject }: DashboardProps) {
const { user, logout, token } = useAuth();
const [projects, setProjects] = useState<Project[]>([]);
const [allProjects, setAllProjects] = useState<Project[]>([]);
const [folders, setFolders] = useState<ProjectFolder[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [shareProjectId, setShareProjectId] = useState<string | null>(null);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [shares, setShares] = useState<ProjectShare[]>([]);
const [sharesLoading, setSharesLoading] = useState(false);
const [draggedProjectId, setDraggedProjectId] = useState<string | null>(null);
2026-07-04 18:11:11 +02:00
const [draggedFolderId, setDraggedFolderId] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; folderId: string | null } | null>(null);
const [renamingFolderId, setRenamingFolderId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [showCreateFolder, setShowCreateFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [newFolderParent, setNewFolderParent] = useState<string | null>(null);
const [editingProjectName, setEditingProjectName] = useState('');
const [editingProjectDesc, setEditingProjectDesc] = useState('');
const [savingProject, setSavingProject] = useState(false);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [mobileTreeOpen, setMobileTreeOpen] = useState(false);
const [mobileInfoOpen, setMobileInfoOpen] = useState(false);
2026-07-04 18:11:11 +02:00
const [showSettings, setShowSettings] = useState(false);
const [users, setUsers] = useState<SimpleUser[]>([]);
const [usersLoading, setUsersLoading] = useState(false);
const fetchAll = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const [projs, fldrs] = await Promise.all([
getProjects(token),
getProjectFolders(token),
]);
setAllProjects(projs);
setFolders(fldrs);
} catch {
// ignore
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
fetchAll();
}, [fetchAll]);
// Default: expand all folders on first load or when folders change
useEffect(() => {
if (folders.length > 0 && expandedFolders.size === 0) {
setExpandedFolders(new Set(folders.map(f => f.id)));
}
}, [folders, expandedFolders.size]);
useEffect(() => {
if (selectedFolderId === null) {
setProjects(allProjects);
} else if (selectedFolderId === UNFILED_ID) {
setProjects(allProjects.filter(p => p.folder_id === null));
} else {
setProjects(allProjects.filter(p => p.folder_id === selectedFolderId));
}
}, [allProjects, selectedFolderId]);
useEffect(() => {
if (!token || !selectedProjectId) {
setShares([]);
return;
}
setSharesLoading(true);
getProjectShares(token, selectedProjectId)
.then(s => setShares(s))
.catch(() => setShares([]))
.finally(() => setSharesLoading(false));
}, [token, selectedProjectId]);
2026-07-04 18:11:11 +02:00
// Load users for permission selector (admin only)
useEffect(() => {
if (!token || user?.role !== 'admin') return;
setUsersLoading(true);
getUsers(token)
.then(u => setUsers(u))
.catch(() => setUsers([]))
.finally(() => setUsersLoading(false));
}, [token, user?.role]);
const selectedProject = useMemo(
() => allProjects.find(p => p.id === selectedProjectId) ?? null,
[allProjects, selectedProjectId],
);
useEffect(() => {
if (selectedProject) {
setEditingProjectName(selectedProject.name);
setEditingProjectDesc(selectedProject.description ?? '');
}
}, [selectedProject]);
const folderNameById = useMemo(() => {
const map = new Map<string, string>();
map.set(UNFILED_ID, 'Ohne Ordner');
for (const f of folders) map.set(f.id, f.name);
return map;
}, [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 () => {
if (!newName.trim() || !token) return;
try {
const folderId = selectedFolderId === null || selectedFolderId === UNFILED_ID
? null : selectedFolderId;
const res = await fetch(`${API_BASE}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ name: newName, description: newDesc || null, folder_id: folderId }),
});
if (res.ok) {
setNewName('');
setNewDesc('');
setShowCreate(false);
fetchAll();
}
} catch {
// ignore
}
};
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
2026-07-04 18:11:11 +02:00
if (!confirm('Projekt wirklich löschen?')) return;
if (!token) return;
try {
await deleteProject(token, id);
if (selectedProjectId === id) setSelectedProjectId(null);
fetchAll();
} catch {
// ignore
}
};
const handleFolderSelect = (id: string | null) => {
setSelectedFolderId(id);
};
const handleCreateFolder = async () => {
if (!newFolderName.trim() || !token) return;
const parentId = newFolderParent === UNFILED_ID ? null : newFolderParent;
try {
await createProjectFolder(token, newFolderName, parentId);
setNewFolderName('');
setShowCreateFolder(false);
setNewFolderParent(null);
fetchAll();
} catch {
// ignore
}
};
const handleRenameFolder = async (id: string) => {
if (!renameValue.trim() || !token) return;
try {
await renameProjectFolder(token, id, renameValue);
setRenamingFolderId(null);
setRenameValue('');
fetchAll();
} catch {
// ignore
}
};
const handleDeleteFolder = async (id: string) => {
if (!token) return;
2026-07-04 18:11:11 +02:00
if (!confirm('Ordner löschen? Projekte darin werden nach "Ohne Ordner" verschoben.')) return;
try {
await deleteProjectFolder(token, id);
if (selectedFolderId === id) setSelectedFolderId(null);
fetchAll();
} catch {
// ignore
}
};
const handleProjectCardClick = (projectId: string) => {
setSelectedProjectId(projectId);
setMobileInfoOpen(true);
};
const handleSaveProject = async () => {
if (!selectedProject || !token) return;
if (!editingProjectName.trim()) return;
setSavingProject(true);
try {
await updateProject(token, selectedProject.id, {
name: editingProjectName.trim(),
description: editingProjectDesc.trim() || null,
});
fetchAll();
} catch {
// ignore
} finally {
setSavingProject(false);
}
};
const handleProjectDragStart = (e: React.DragEvent, projectId: string) => {
setDraggedProjectId(projectId);
2026-07-04 18:11:11 +02:00
setDraggedFolderId(null);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', `project:${projectId}`);
};
2026-07-04 18:11:11 +02:00
const handleFolderDragStart = (e: React.DragEvent, folderId: string) => {
setDraggedFolderId(folderId);
setDraggedProjectId(null);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', `folder:${folderId}`);
};
const handleFolderDragOver = (e: React.DragEvent, folderId: string | null) => {
2026-07-04 18:11:11 +02:00
if (!draggedProjectId && !draggedFolderId) return;
// Prevent dropping a folder into itself or its descendants
if (draggedFolderId) {
if (folderId === draggedFolderId) return;
let current: string | null = folderId;
while (current) {
if (current === draggedFolderId) return; // target is a descendant of dragged
const f = folders.find(fld => fld.id === current);
current = f?.parent_id ?? null;
}
}
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverFolderId(folderId);
};
const handleFolderDragLeave = (folderId: string | null) => {
if (dragOverFolderId === folderId) {
setDragOverFolderId(null);
}
};
const handleFolderDrop = async (e: React.DragEvent, folderId: string | null) => {
e.preventDefault();
e.stopPropagation();
const targetFolderId = folderId === UNFILED_ID ? null : folderId;
2026-07-04 18:11:11 +02:00
if (draggedProjectId && token) {
try {
await moveProjectToFolder(token, draggedProjectId, targetFolderId);
fetchAll();
} catch {
// ignore
} finally {
setDraggedProjectId(null);
setDragOverFolderId(null);
}
} else if (draggedFolderId && token) {
// Move folder into target folder (or to root if null)
const folder = folders.find(f => f.id === draggedFolderId);
if (folder && folder.id !== targetFolderId && folder.parent_id !== targetFolderId) {
try {
await moveProjectFolder(token, draggedFolderId, targetFolderId, folder.name);
// Auto-expand target if it has children now
if (targetFolderId) {
setExpandedFolders(prev => new Set([...prev, targetFolderId]));
}
fetchAll();
} catch {
// ignore
} finally {
setDraggedFolderId(null);
setDragOverFolderId(null);
}
} else {
setDraggedFolderId(null);
setDragOverFolderId(null);
}
}
};
const handleContextMenu = (e: React.MouseEvent, folderId: string | null) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, folderId });
};
const handleContextAction = (action: string, folderId: string | null) => {
setContextMenu(null);
if (action === 'new') {
setNewFolderParent(folderId);
setShowCreateFolder(true);
} else if (action === 'newSubfolder' && folderId) {
setNewFolderParent(folderId);
setShowCreateFolder(true);
} else if (action === 'rename' && folderId) {
const folder = folders.find(f => f.id === folderId);
if (folder) {
setRenamingFolderId(folderId);
setRenameValue(folder.name);
}
} else if (action === 'delete' && folderId) {
handleDeleteFolder(folderId);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('de-DE', {
year: 'numeric', month: 'long', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
};
const folderIcon = (
<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;
2026-07-04 18:11:11 +02:00
const isDragging = draggedFolderId === folder.id;
return (
2026-07-04 18:11:11 +02:00
<div key={folder.id} className={isDragging ? 'tree-dragging' : ''}>
<div
className={`folder-tree-item${isSelected ? ' selected' : ''}${isDragOver ? ' drag-over' : ''}`}
draggable
2026-07-04 18:11:11 +02:00
onDragStart={(e) => handleFolderDragStart(e, folder.id)}
onDragOver={(e) => handleFolderDragOver(e, folder.id)}
onDragLeave={() => handleFolderDragLeave(folder.id)}
onDrop={(e) => handleFolderDrop(e, folder.id)}
onClick={() => handleFolderSelect(folder.id)}
onContextMenu={(e) => handleContextMenu(e, folder.id)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
>
{children.length > 0 ? (
<button
className="folder-chevron"
onClick={(e) => { e.stopPropagation(); toggleFolder(folder.id); }}
aria-label={isExpanded ? 'Einklappen' : 'Ausklappen'}
>
{isExpanded ? '▼' : '▶'}
</button>
) : (
<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"
2026-07-04 18:11:11 +02:00
title="Löschen"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(folder.id);
}}
></button>
</span>
)}
</div>
{isExpanded && children.map(child => renderFolderItem(child, depth + 1))}
</div>
);
};
2026-07-04 18:11:11 +02:00
// Shared users from shares data
const sharedUserEmails = useMemo(() => shares.map(s => s.shared_with_email), [shares]);
return (
<div className="dashboard-page">
<header className="dashboard-header">
<div className="dashboard-brand">
<button className="dashboard-hamburger" aria-label="Ordner" onClick={() => setMobileTreeOpen(true)}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
2026-07-04 18:11:11 +02:00
<h1 className="dashboard-title-large">Web CAD</h1>
<span className="dashboard-user">{user?.name} ({user?.role})</span>
</div>
<div className="dashboard-header-actions">
{token && <NotificationPanel token={token} />}
2026-07-04 18:11:11 +02:00
<button
className="dashboard-settings-btn"
aria-label="Einstellungen"
title="Einstellungen"
onClick={() => setShowSettings(true)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<button className="dashboard-logout" onClick={logout}>Abmelden</button>
</div>
</header>
{(mobileTreeOpen || mobileInfoOpen) && (
<div className="dashboard-scrim" onClick={() => { setMobileTreeOpen(false); setMobileInfoOpen(false); }} />
)}
<div className="dashboard-3col">
<div className={`dashboard-treeview${mobileTreeOpen ? ' mobile-open' : ''}`} onContextMenu={(e) => handleContextMenu(e, null)}>
<button className="mobile-panel-close" onClick={() => setMobileTreeOpen(false)}>×</button>
<div className="treeview-header">
<h3>Ordner</h3>
<button
className="treeview-add-folder-btn"
title="Neuer Ordner"
onClick={() => { setNewFolderParent(null); setShowCreateFolder(true); }}
>+</button>
</div>
{showCreateFolder && (
<div className="folder-create-form">
<input
type="text"
placeholder={newFolderParent ? 'Unterordnername' : 'Ordnername'}
value={newFolderName}
onChange={e => setNewFolderName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateFolder(); if (e.key === 'Escape') setShowCreateFolder(false); }}
autoFocus
/>
<div className="folder-create-actions">
<button onClick={handleCreateFolder}>OK</button>
<button onClick={() => { setShowCreateFolder(false); setNewFolderName(''); setNewFolderParent(null); }}>×</button>
</div>
</div>
)}
{/* Root: Alle Projekte */}
<div
2026-07-04 18:11:11 +02:00
className={`folder-tree-item${selectedFolderId === null ? ' selected' : ''}${dragOverFolderId === null && (draggedProjectId || draggedFolderId) ? ' drag-over' : ''}`}
onClick={() => handleFolderSelect(null)}
onDragOver={(e) => handleFolderDragOver(e, null)}
onDragLeave={() => handleFolderDragLeave(null)}
onDrop={(e) => handleFolderDrop(e, null)}
onContextMenu={(e) => handleContextMenu(e, null)}
style={{ paddingLeft: '8px' }}
>
<span className="folder-chevron-placeholder" />
{folderIcon}
<span className="folder-tree-name">Alle Projekte</span>
<span className="folder-count">({allProjects.length})</span>
</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 className="dashboard-content">
<div className="dashboard-section-header">
<h2>{currentFolderName}</h2>
<button className="dashboard-create-btn" onClick={() => setShowCreate(!showCreate)}>
+ Neues Projekt
</button>
</div>
{showCreate && (
<div className="dashboard-create-form">
<input
type="text"
placeholder="Projektname"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }}
autoFocus
/>
<input
type="text"
placeholder="Beschreibung (optional)"
value={newDesc}
onChange={e => setNewDesc(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }}
/>
<button onClick={handleCreate}>Erstellen</button>
<button onClick={() => setShowCreate(false)}>Abbrechen</button>
</div>
)}
{loading ? (
<p className="dashboard-loading">Lade Projekte...</p>
) : projects.length === 0 ? (
<p className="dashboard-empty">Keine Projekte in dieser Ansicht. Erstellen Sie ein neues Projekt.</p>
) : (
<div className="dashboard-project-grid">
{projects.map(project => (
<div
key={project.id}
className={`dashboard-project-card${selectedProjectId === project.id ? ' selected' : ''}`}
onClick={() => handleProjectCardClick(project.id)}
draggable
onDragStart={(e) => handleProjectDragStart(e, project.id)}
onDragEnd={() => { setDraggedProjectId(null); setDragOverFolderId(null); }}
>
<CanvasPreview project={project} token={token!} />
2026-07-04 18:11:11 +02:00
<h3 className="dashboard-card-title">{project.name}</h3>
{project.description && <p className="dashboard-card-desc">{project.description}</p>}
<div className="dashboard-project-meta">
<span>{new Date(project.created_at).toLocaleDateString('de-DE')}</span>
<div className="dashboard-project-actions">
<button
2026-07-04 18:11:11 +02:00
className="dashboard-icon-btn"
title="Öffnen"
aria-label="Öffnen"
onClick={(e) => { e.stopPropagation(); onOpenProject(project.id); }}
>
2026-07-04 18:11:11 +02:00
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 3v5h5"/><path d="M21 21H3V3h11l7 7v11z"/><path d="M9 13l2 2 4-4"/></svg>
</button>
<button
2026-07-04 18:11:11 +02:00
className="dashboard-icon-btn"
title="Teilen"
aria-label="Teilen"
onClick={(e) => { e.stopPropagation(); setShareProjectId(project.id); }}
>
2026-07-04 18:11:11 +02:00
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</button>
<button
2026-07-04 18:11:11 +02:00
className="dashboard-icon-btn dashboard-icon-btn-danger"
title="Löschen"
aria-label="Löschen"
onClick={(e) => handleDelete(project.id, e)}
>
2026-07-04 18:11:11 +02:00
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className={`dashboard-info-panel${mobileInfoOpen ? ' mobile-open' : ''}`}>
<button className="mobile-panel-back" onClick={() => setMobileInfoOpen(false)}> Zurück</button>
{selectedProject ? (
<>
<input
2026-07-04 18:11:11 +02:00
className="info-panel-title-input info-panel-title-small"
type="text"
value={editingProjectName}
onChange={(e) => setEditingProjectName(e.target.value)}
placeholder="Projektname"
/>
<textarea
2026-07-04 18:11:11 +02:00
className="info-panel-desc-textarea info-panel-desc-tall"
value={editingProjectDesc}
onChange={(e) => setEditingProjectDesc(e.target.value)}
placeholder="Beschreibung (optional)"
2026-07-04 18:11:11 +02:00
rows={8}
/>
<button
className="info-panel-save-btn"
onClick={handleSaveProject}
disabled={savingProject || !editingProjectName.trim()}
>
{savingProject ? 'Speichern...' : 'Speichern'}
</button>
<div className="info-panel-section">
<h4>Metadaten</h4>
<dl className="info-panel-meta">
<dt>Erstellt</dt>
<dd>{formatDate(selectedProject.created_at)}</dd>
<dt>Aktualisiert</dt>
<dd>{formatDate(selectedProject.updated_at)}</dd>
<dt>Owner</dt>
<dd>{selectedProject.owner_id}</dd>
<dt>Ordner</dt>
<dd>{selectedProject.folder_id ? (folderNameById.get(selectedProject.folder_id) ?? 'Unbekannt') : 'Ohne Ordner'}</dd>
</dl>
</div>
<div className="info-panel-section">
<h4>Sichtbarkeit / Freigaben</h4>
{sharesLoading ? (
<p className="info-panel-loading">Lade Freigaben...</p>
) : shares.length === 0 ? (
<p className="info-panel-empty">Keine Freigaben</p>
) : (
<ul className="info-panel-shares">
{shares.map(share => (
<li key={share.id} className="info-panel-share-item">
<span className="info-panel-share-email">{share.shared_with_email}</span>
<span className="info-panel-share-perm">{share.permission}</span>
</li>
))}
</ul>
)}
</div>
2026-07-04 18:11:11 +02:00
<div className="info-panel-section">
<h4>Berechtigungen verwalten</h4>
<p className="info-panel-perm-hint">Wählen Sie, welcher User oder welche Gruppe das Projekt sehen darf:</p>
{usersLoading ? (
<p className="info-panel-loading">Lade Benutzer...</p>
) : users.length === 0 ? (
<p className="info-panel-empty">Benutzerliste nur für Administratoren sichtbar</p>
) : (
<div className="info-panel-perm-selector">
<select className="info-panel-perm-select" defaultValue="">
<option value="" disabled> User auswählen </option>
{users
.filter(u => u.id !== selectedProject.owner_id)
.filter(u => !sharedUserEmails.includes(u.email))
.map(u => (
<option key={u.id} value={u.email}>{u.name} ({u.email})</option>
))
}
</select>
<select className="info-panel-perm-type" defaultValue="view">
<option value="view">Ansehen</option>
<option value="edit">Bearbeiten</option>
</select>
<button
className="info-panel-perm-add-btn"
onClick={async () => {
const sel = document.querySelector<HTMLSelectElement>('.info-panel-perm-select');
const permSel = document.querySelector<HTMLSelectElement>('.info-panel-perm-type');
const email = sel?.value;
const perm = permSel?.value || 'view';
if (!email || !token) return;
try {
await createProjectShare(token, selectedProject.id, { shared_with_email: email, permission: perm });
if (sel) sel.value = '';
getProjectShares(token, selectedProject.id)
.then(s => setShares(s))
.catch(() => {});
} catch {
// ignore
}
}}
>Hinzufügen</button>
</div>
)}
</div>
<button
className="info-panel-open-btn"
onClick={() => onOpenProject(selectedProject.id)}
>
2026-07-04 18:11:11 +02:00
Projekt öffnen
</button>
</>
) : (
<div className="info-panel-placeholder">
2026-07-04 18:11:11 +02:00
<p>Wählen Sie ein Projekt aus, um Details zu sehen.</p>
</div>
)}
</div>
</div>
{contextMenu && (
<>
<div className="context-menu-overlay" onClick={() => setContextMenu(null)} />
<div
className="context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
className="context-menu-item"
onClick={() => handleContextAction('new', contextMenu.folderId)}
>Neuer Ordner</button>
{contextMenu.folderId && (
<button
className="context-menu-item"
onClick={() => handleContextAction('newSubfolder', contextMenu.folderId)}
>Neuer Unterordner</button>
)}
{contextMenu.folderId && (
<>
<button
className="context-menu-item"
onClick={() => handleContextAction('rename', contextMenu.folderId)}
>Umbenennen</button>
<button
className="context-menu-item danger"
onClick={() => handleContextAction('delete', contextMenu.folderId)}
2026-07-04 18:11:11 +02:00
>Löschen</button>
</>
)}
</div>
</>
)}
{token && (
<ShareDialog
token={token}
projectId={shareProjectId ?? ''}
open={!!shareProjectId}
onClose={() => setShareProjectId(null)}
/>
)}
2026-07-04 18:11:11 +02:00
<SettingsModal
open={showSettings}
onClose={() => setShowSettings(false)}
/>
</div>
);
}