From 3af9640acedbaa9acc49f0579587bdd9cef71a77 Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Sat, 4 Jul 2026 18:11:11 +0200 Subject: [PATCH] feat: dashboard UI improvements - 8 changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/routes/projectFolders.ts | 13 +- frontend/package-lock.json | 89 ---------- frontend/src/pages/Dashboard.tsx | 233 +++++++++++++++++++++++---- frontend/src/services/api.ts | 27 ++++ frontend/src/styles.css | 164 +++++++++++++++++++ 5 files changed, 402 insertions(+), 124 deletions(-) diff --git a/backend/src/routes/projectFolders.ts b/backend/src/routes/projectFolders.ts index 19d9b56..4069939 100644 --- a/backend/src/routes/projectFolders.ts +++ b/backend/src/routes/projectFolders.ts @@ -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; }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f2274ac..ebb082c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index fecf33f..6b303f3 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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( + + ); + } else if (shapeType === 1) { + arr.push( + + ); + } else { + arr.push( + + ); + } + } + return arr; + }, [seed]); + + return ( +
+ + + {shapes} + +
+ ); +} + export function Dashboard({ onOpenProject }: DashboardProps) { const { user, logout, token } = useAuth(); const [projects, setProjects] = useState([]); @@ -34,6 +76,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) { const [shares, setShares] = useState([]); const [sharesLoading, setSharesLoading] = useState(false); const [draggedProjectId, setDraggedProjectId] = useState(null); + const [draggedFolderId, setDraggedFolderId] = useState(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; folderId: string | null } | null>(null); const [renamingFolderId, setRenamingFolderId] = useState(null); const [renameValue, setRenameValue] = useState(''); @@ -47,6 +90,9 @@ export function Dashboard({ onOpenProject }: DashboardProps) { const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [mobileTreeOpen, setMobileTreeOpen] = useState(false); const [mobileInfoOpen, setMobileInfoOpen] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [users, setUsers] = useState([]); + 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 ( -
+
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) { >✎ -

Web CAD

+

Web CAD

{user?.name} ({user?.role})
{token && } +
@@ -432,7 +545,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) { {/* Root: Alle Projekte */}
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); }} > -

{project.name}

- {project.description &&

{project.description}

} + +

{project.name}

+ {project.description &&

{project.description}

}
{new Date(project.created_at).toLocaleDateString('de-DE')}
@@ -547,17 +667,18 @@ export function Dashboard({ onOpenProject }: DashboardProps) { {selectedProject ? ( <> setEditingProjectName(e.target.value)} placeholder="Projektname" />