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

896 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
import SettingsModal from '../components/SettingsModal';
import {
getProjects, deleteProject, updateProject,
getProjectFolders, createProjectFolder, renameProjectFolder, deleteProjectFolder,
moveProjectToFolder, moveProjectFolder, getProjectShares, getUsers, createProjectShare,
getDrawings, getElements,
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 rawEls = await getElements(token, drawings[0].id);
// API may return DBElement with properties_json (string) instead of properties (object)
const transformed: CADElement[] = rawEls.map((el: any) => ({
id: el.id,
type: el.type,
layerId: el.layer_id ?? el.layerId ?? '',
x: el.x ?? 0,
y: el.y ?? 0,
width: el.width ?? 0,
height: el.height ?? 0,
properties: typeof el.properties_json === 'string' ? JSON.parse(el.properties_json || '{}') : (el.properties ?? {}),
}));
if (!cancelled) setElements(transformed);
} catch {
if (!cancelled) setElements([]);
} finally {
if (!cancelled) setLoading(false);
}
}
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]);
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>
)}
</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);
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);
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]);
// 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();
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;
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);
onOpenProject(projectId);
};
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);
setDraggedFolderId(null);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', `project:${projectId}`);
};
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) => {
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;
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;
const isDragging = draggedFolderId === folder.id;
return (
<div key={folder.id} className={isDragging ? 'tree-dragging' : ''}>
<div
className={`folder-tree-item${isSelected ? ' selected' : ''}${isDragOver ? ' drag-over' : ''}`}
draggable
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"
title="Löschen"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(folder.id);
}}
></button>
</span>
)}
</div>
{isExpanded && children.map(child => renderFolderItem(child, depth + 1))}
</div>
);
};
// 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>
<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} />}
<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
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!} />
<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
className="dashboard-icon-btn"
title="Öffnen"
aria-label="Öffnen"
onClick={(e) => { e.stopPropagation(); onOpenProject(project.id); }}
>
<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
className="dashboard-icon-btn"
title="Teilen"
aria-label="Teilen"
onClick={(e) => { e.stopPropagation(); setShareProjectId(project.id); }}
>
<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
className="dashboard-icon-btn dashboard-icon-btn-danger"
title="Löschen"
aria-label="Löschen"
onClick={(e) => handleDelete(project.id, e)}
>
<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
className="info-panel-title-input info-panel-title-small"
type="text"
value={editingProjectName}
onChange={(e) => setEditingProjectName(e.target.value)}
placeholder="Projektname"
/>
<textarea
className="info-panel-desc-textarea info-panel-desc-tall"
value={editingProjectDesc}
onChange={(e) => setEditingProjectDesc(e.target.value)}
placeholder="Beschreibung (optional)"
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>
<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)}
>
Projekt öffnen
</button>
</>
) : (
<div className="info-panel-placeholder">
<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)}
>Löschen</button>
</>
)}
</div>
</>
)}
{token && (
<ShareDialog
token={token}
projectId={shareProjectId ?? ''}
open={!!shareProjectId}
onClose={() => setShareProjectId(null)}
/>
)}
<SettingsModal
open={showSettings}
onClose={() => setShowSettings(false)}
/>
</div>
);
}