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 (
+
+
+
+ );
+}
+
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) {
>✎
@@ -637,7 +803,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
handleContextAction('delete', contextMenu.folderId)}
- >Loeschen
+ >Löschen
>
)}
@@ -652,6 +818,11 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
onClose={() => setShareProjectId(null)}
/>
)}
+
+
setShowSettings(false)}
+ />
);
}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 0d864e7..911c41e 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -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 {
+ 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 {
+ 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;
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index cae022e..ca4a49f 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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;