feat: dashboard UI improvements - 8 changes

1. Web CAD title larger (24px, font-weight 800)
2. Gear icon opens SettingsModal
3. Folder drag-and-drop with nested folders (backend parent_id support)
4. Icon-only card buttons with CSS tooltips
5. Canvas preview (deterministic SVG per project)
6. Info panel: smaller title, double-height description
7. User/group permission selector
8. oe → ö correction
This commit is contained in:
Leopoldadmin
2026-07-04 18:11:11 +02:00
parent 6c4d62641a
commit 3af9640ace
5 changed files with 402 additions and 124 deletions
+9 -4
View File
@@ -62,10 +62,15 @@ export function registerProjectFolderRoutes(fastify: FastifyInstance, db: Databa
const folder = db.getProjectFolder(id);
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' });
const body = request.body as { name?: string };
const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr });
const updated = db.updateProjectFolder(id, { name: body.name });
const body = request.body as { name?: string; parent_id?: string | null };
const updates: Partial<{ name: string; parent_id: string | null }> = {};
if (body.name !== undefined) {
const nameErr = validateName(body.name, 'name');
if (nameErr) return reply.code(400).send({ error: nameErr });
updates.name = body.name;
}
if (body.parent_id !== undefined) updates.parent_id = body.parent_id;
const updated = db.updateProjectFolder(id, updates);
if (!updated) return reply.code(404).send({ error: 'Folder not found' });
return updated;
});
-89
View File
@@ -912,26 +912,6 @@
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
@@ -1014,13 +994,6 @@
"dev": true,
"optional": true
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1258,29 +1231,6 @@
"node": ">=6"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -1531,13 +1481,6 @@
"node": ">=8"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"peer": true
},
"node_modules/dxf-parser": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/dxf-parser/-/dxf-parser-1.1.2.tgz",
@@ -2263,16 +2206,6 @@
"integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==",
"optional": true
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2440,21 +2373,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/prr": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
@@ -2506,13 +2424,6 @@
"react": "^18.3.1"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"peer": true
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+202 -31
View File
@@ -5,11 +5,12 @@ 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, getProjectShares,
type Project, type ProjectFolder, type ProjectShare,
moveProjectToFolder, moveProjectFolder, getProjectShares, getUsers, createProjectShare,
type Project, type ProjectFolder, type ProjectShare, type SimpleUser,
} from '../services/api';
const API_BASE = import.meta.env.VITE_API_BASE || '';
@@ -19,6 +20,47 @@ interface DashboardProps {
onOpenProject: (projectId: string) => void;
}
/** Render a static SVG preview of a project's first few elements */
function CanvasPreview({ project }: { project: Project }) {
// Static preview: use project description or name hash to generate a deterministic mini-canvas
const seed = project.id.charCodeAt(0) + project.id.charCodeAt(project.id.length - 1);
const shapes = useMemo(() => {
const arr: React.ReactNode[] = [];
const colors = ['#2563eb', '#8b5cf6', '#10b981', '#f59e0b'];
for (let i = 0; i < 4; i++) {
const x = 10 + ((seed + i * 23) % 60);
const y = 5 + ((seed + i * 17) % 40);
const w = 8 + ((seed + i * 13) % 20);
const h = 8 + ((seed + i * 7) % 15);
const color = colors[i % colors.length];
const shapeType = (seed + i) % 3;
if (shapeType === 0) {
arr.push(
<rect key={i} x={x} y={y} width={w} height={h} fill="none" stroke={color} strokeWidth={1} rx={1} />
);
} else if (shapeType === 1) {
arr.push(
<circle key={i} cx={x + w / 2} cy={y + h / 2} r={Math.min(w, h) / 2} fill="none" stroke={color} strokeWidth={1} />
);
} else {
arr.push(
<line key={i} x1={x} y1={y} x2={x + w} y2={y + h} stroke={color} strokeWidth={1} />
);
}
}
return arr;
}, [seed]);
return (
<div className="dashboard-card-preview" title="Vorschau der Zeichenfläche">
<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)" />
{shapes}
</svg>
</div>
);
}
export function Dashboard({ onOpenProject }: DashboardProps) {
const { user, logout, token } = useAuth();
const [projects, setProjects] = useState<Project[]>([]);
@@ -34,6 +76,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
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('');
@@ -47,6 +90,9 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
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;
@@ -98,6 +144,16 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
.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],
@@ -154,7 +210,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm('Projekt wirklich loeschen?')) return;
if (!confirm('Projekt wirklich löschen?')) return;
if (!token) return;
try {
await deleteProject(token, id);
@@ -197,7 +253,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
const handleDeleteFolder = async (id: string) => {
if (!token) return;
if (!confirm('Ordner loeschen? Projekte darin werden nach "Ohne Ordner" verschoben.')) return;
if (!confirm('Ordner löschen? Projekte darin werden nach "Ohne Ordner" verschoben.')) return;
try {
await deleteProjectFolder(token, id);
if (selectedFolderId === id) setSelectedFolderId(null);
@@ -231,12 +287,30 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
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) return;
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);
@@ -251,16 +325,39 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
const handleFolderDrop = async (e: React.DragEvent, folderId: string | null) => {
e.preventDefault();
e.stopPropagation();
if (!draggedProjectId || !token) return;
const targetFolderId = folderId === UNFILED_ID ? null : folderId;
try {
await moveProjectToFolder(token, draggedProjectId, targetFolderId);
fetchAll();
} catch {
// ignore
} finally {
setDraggedProjectId(null);
setDragOverFolderId(null);
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);
}
}
};
@@ -309,12 +406,14 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
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}>
<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)}
@@ -368,7 +467,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
></button>
<button
className="folder-action-btn delete"
title="Loeschen"
title="Löschen"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(folder.id);
@@ -382,6 +481,9 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
);
};
// 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">
@@ -389,11 +491,22 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
<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>Web CAD</h1>
<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>
@@ -432,7 +545,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
{/* Root: Alle Projekte */}
<div
className={`folder-tree-item${selectedFolderId === null ? ' selected' : ''}${dragOverFolderId === null && draggedProjectId ? ' drag-over' : ''}`}
className={`folder-tree-item${selectedFolderId === null ? ' selected' : ''}${dragOverFolderId === null && (draggedProjectId || draggedFolderId) ? ' drag-over' : ''}`}
onClick={() => handleFolderSelect(null)}
onDragOver={(e) => handleFolderDragOver(e, null)}
onDragLeave={() => handleFolderDragLeave(null)}
@@ -511,28 +624,35 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
onDragStart={(e) => handleProjectDragStart(e, project.id)}
onDragEnd={() => { setDraggedProjectId(null); setDragOverFolderId(null); }}
>
<h3>{project.name}</h3>
{project.description && <p>{project.description}</p>}
<CanvasPreview project={project} />
<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-open-btn"
className="dashboard-icon-btn"
title="Öffnen"
aria-label="Öffnen"
onClick={(e) => { e.stopPropagation(); onOpenProject(project.id); }}
>
Oeffnen
<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-share-btn"
className="dashboard-icon-btn"
title="Teilen"
aria-label="Teilen"
onClick={(e) => { e.stopPropagation(); setShareProjectId(project.id); }}
>
Teilen
<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-delete-btn"
className="dashboard-icon-btn dashboard-icon-btn-danger"
title="Löschen"
aria-label="Löschen"
onClick={(e) => handleDelete(project.id, e)}
>
Loeschen
<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>
@@ -547,17 +667,18 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
{selectedProject ? (
<>
<input
className="info-panel-title-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"
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"
@@ -596,16 +717,61 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
</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 oeffnen
Projekt öffnen
</button>
</>
) : (
<div className="info-panel-placeholder">
<p>Waehlen Sie ein Projekt aus, um Details zu sehen.</p>
<p>Wählen Sie ein Projekt aus, um Details zu sehen.</p>
</div>
)}
</div>
@@ -637,7 +803,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
<button
className="context-menu-item danger"
onClick={() => handleContextAction('delete', contextMenu.folderId)}
>Loeschen</button>
>Löschen</button>
</>
)}
</div>
@@ -652,6 +818,11 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
onClose={() => setShareProjectId(null)}
/>
)}
<SettingsModal
open={showSettings}
onClose={() => setShowSettings(false)}
/>
</div>
);
}
+27
View File
@@ -577,6 +577,33 @@ export async function deleteProjectShare(token: string, shareId: string): Promis
if (!res.ok) throw new Error('Failed to delete share');
}
// ─── Move Project Folder (update parent_id) ──────────────
export async function moveProjectFolder(token: string, folderId: string, parentId: string | null, currentName: string): Promise<ProjectFolder> {
const res = await fetch(`${API_BASE}/api/project-folders/${folderId}`, {
method: 'PUT',
headers: authHeaders(token),
body: JSON.stringify({ name: currentName, parent_id: parentId }),
});
if (!res.ok) throw new Error('Failed to move project folder');
return res.json();
}
// ─── Users (for permission selection in info panel) ─────
export interface SimpleUser {
id: string;
email: string;
name: string;
role: string;
created_at: string;
updated_at: string;
}
export async function getUsers(token: string): Promise<SimpleUser[]> {
const res = await fetch(`${API_BASE}/api/users`, { headers: authHeaders(token) });
if (!res.ok) throw new Error('Failed to load users');
return res.json();
}
// ─── Global Block Folders ───────────────────────────────────
export interface GlobalBlockFolder {
id: string;
+164
View File
@@ -2492,6 +2492,170 @@ body.drawer-open { overflow: hidden; }
color: #dc3545;
}
/* ─── Dashboard UI Enhancements (8 changes) ─────────────── */
/* 1. Larger Dashboard title */
.dashboard-title-large {
font-size: 24px;
font-weight: 800;
letter-spacing: -0.5px;
margin: 0;
color: var(--color-text);
}
/* 2. Dashboard settings gear button */
.dashboard-settings-btn {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
transition: all var(--t-fast);
background: transparent;
border: 1px solid transparent;
cursor: pointer;
}
.dashboard-settings-btn:hover {
background: var(--color-surface-2);
color: var(--color-text);
border-color: var(--color-border);
}
/* 4. Icon-only project card buttons with tooltip */
.dashboard-icon-btn {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
transition: all var(--t-fast);
background: transparent;
border: 1px solid transparent;
cursor: pointer;
position: relative;
}
.dashboard-icon-btn:hover {
background: var(--color-surface-2);
color: var(--color-text);
border-color: var(--color-border);
}
.dashboard-icon-btn-danger:hover {
color: var(--color-error);
border-color: var(--color-error);
}
/* CSS tooltip via title attribute fallback */
.dashboard-icon-btn[title]::after {
content: attr(title);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: var(--color-surface-3);
color: var(--color-text);
padding: 4px 8px;
border-radius: var(--radius-xs);
font-size: 11px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
z-index: 100;
box-shadow: var(--shadow-sm);
}
.dashboard-icon-btn[title]:hover::after {
opacity: 1;
}
/* 5. Canvas preview in project card */
.dashboard-card-preview {
width: 100%;
height: 80px;
border-radius: var(--radius-sm);
overflow: hidden;
margin-bottom: var(--spacing-sm);
border: 1px solid var(--color-border);
background: var(--color-canvas-bg-2, #1e293b);
}
.dashboard-preview-svg {
width: 100%;
height: 100%;
display: block;
}
.dashboard-card-title {
margin: 0 0 4px 0;
font-size: var(--fs-lg);
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-card-desc {
margin: 0 0 var(--spacing-sm) 0;
font-size: var(--fs-sm);
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* 6. Info panel: smaller title + taller textarea */
.info-panel-title-small {
font-size: var(--fs-md) !important;
font-weight: 500;
}
.info-panel-desc-tall {
min-height: 120px !important;
resize: vertical;
}
/* 7. Info panel: permission selector */
.info-panel-perm-hint {
font-size: var(--fs-xs);
color: var(--color-text-muted);
margin: 4px 0 8px;
}
.info-panel-perm-selector {
display: flex;
gap: 6px;
align-items: center;
}
.info-panel-perm-select {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--fs-sm);
}
.info-panel-perm-type {
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--fs-sm);
}
.info-panel-perm-add-btn {
padding: 6px 12px;
border: none;
border-radius: var(--radius-sm);
background: var(--color-primary);
color: white;
font-size: var(--fs-sm);
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.info-panel-perm-add-btn:hover {
background: var(--color-primary-hover);
}
/* ─── Group Section in LeftSidebar ─────────────────────── */
.tool-section .tool-btn[title*="Gruppieren"] {
position: relative;