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:
@@ -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;
|
||||
});
|
||||
|
||||
Generated
-89
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user