diff --git a/backend/src/database/DatabaseInterface.ts b/backend/src/database/DatabaseInterface.ts index 8cd7c71..380eacb 100644 --- a/backend/src/database/DatabaseInterface.ts +++ b/backend/src/database/DatabaseInterface.ts @@ -79,6 +79,26 @@ export interface DBSession { created_at: string; } +export interface DBNotification { + id: string; + user_id: string; + type: string; + title: string; + message: string; + read: number; + created_at: string; +} + +export interface DBProjectShare { + id: string; + project_id: string; + shared_with_email: string; + shared_by: string; + permission: string; + share_token: string | null; + created_at: string; +} + export interface DatabaseInterface { init(): Promise; close(): void; @@ -132,4 +152,17 @@ export interface DatabaseInterface { getSession(token: string): DBSession | null; deleteSession(token: string): boolean; deleteExpiredSessions(): void; + + // Notifications + listNotifications(userId: string): DBNotification[]; + getNotification(id: string): DBNotification | null; + createNotification(data: Partial): DBNotification; + markNotificationRead(id: string): boolean; + deleteNotification(id: string): boolean; + + // Project Shares + listProjectShares(projectId: string): DBProjectShare[]; + getProjectShare(id: string): DBProjectShare | null; + createProjectShare(data: Partial): DBProjectShare; + deleteProjectShare(id: string): boolean; } diff --git a/backend/src/database/SqliteAdapter.ts b/backend/src/database/SqliteAdapter.ts index 79777f3..f36b99d 100644 --- a/backend/src/database/SqliteAdapter.ts +++ b/backend/src/database/SqliteAdapter.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'crypto'; import type { DatabaseInterface, DBProject, DBDrawing, DBLayer, DBElement, DBBlock, DBSetting, DBUser, DBSession, + DBNotification, DBProjectShare, } from './DatabaseInterface.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -146,7 +147,7 @@ export class SqliteAdapter implements DatabaseInterface { const id = data.id ?? `layer-${Date.now()}`; this.db.prepare( 'INSERT INTO layers (id, drawing_id, name, visible, locked, color, line_type, transparency, sort_order, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - ).run(id, data.drawing_id!, data.name ?? 'Layer', data.visible ?? 1, data.locked ?? 0, + ).run(id, data.drawing_id!, data.name ?? 'Layer', data.visible !== undefined ? (data.visible ? 1 : 0) : 1, data.locked !== undefined ? (data.locked ? 1 : 0) : 0, data.color ?? '#ffffff', data.line_type ?? 'solid', data.transparency ?? 0, data.sort_order ?? 0, data.parent_id ?? null); return this.db.prepare('SELECT * FROM layers WHERE id = ?').get(id) as DBLayer; @@ -157,7 +158,7 @@ export class SqliteAdapter implements DatabaseInterface { const vals: any[] = []; for (const [k, v] of Object.entries(data)) { if (['name','visible','locked','color','line_type','transparency','sort_order','parent_id'].includes(k)) { - sets.push(`${k} = ?`); vals.push(v); + sets.push(`${k} = ?`); vals.push(k === 'visible' || k === 'locked' ? (v ? 1 : 0) : v); } } if (sets.length > 0) { @@ -189,7 +190,7 @@ export class SqliteAdapter implements DatabaseInterface { const vals: any[] = []; for (const [k, v] of Object.entries(data)) { if (['layer_id','type','x','y','width','height','properties_json'].includes(k)) { - sets.push(`${k} = ?`); vals.push(v); + sets.push(`${k} = ?`); vals.push(k === 'visible' || k === 'locked' ? (v ? 1 : 0) : v); } } if (sets.length > 0) { @@ -221,7 +222,7 @@ export class SqliteAdapter implements DatabaseInterface { const vals: any[] = []; for (const [k, v] of Object.entries(data)) { if (['name','description','category','elements_json','thumbnail'].includes(k)) { - sets.push(`${k} = ?`); vals.push(v); + sets.push(`${k} = ?`); vals.push(k === 'visible' || k === 'locked' ? (v ? 1 : 0) : v); } } if (sets.length > 0) { @@ -265,4 +266,51 @@ export class SqliteAdapter implements DatabaseInterface { deleteExpiredSessions(): void { this.db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now()); } + + // ─── Notifications ────────────────────────────────── + listNotifications(userId: string): DBNotification[] { + return this.db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC').all(userId) as DBNotification[]; + } + + getNotification(id: string): DBNotification | null { + return this.db.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as DBNotification | null; + } + + createNotification(data: Partial): DBNotification { + const id = data.id ?? `notif-${Date.now()}`; + this.db.prepare( + 'INSERT INTO notifications (id, user_id, type, title, message, read) VALUES (?, ?, ?, ?, ?, ?)', + ).run(id, data.user_id!, data.type ?? 'info', data.title!, data.message!, data.read ?? 0); + return this.db.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as DBNotification; + } + + markNotificationRead(id: string): boolean { + return this.db.prepare('UPDATE notifications SET read = 1 WHERE id = ?').run(id).changes > 0; + } + + deleteNotification(id: string): boolean { + return this.db.prepare('DELETE FROM notifications WHERE id = ?').run(id).changes > 0; + } + + // ─── Project Shares ───────────────────────────────── + listProjectShares(projectId: string): DBProjectShare[] { + return this.db.prepare('SELECT * FROM project_shares WHERE project_id = ? ORDER BY created_at DESC').all(projectId) as DBProjectShare[]; + } + + getProjectShare(id: string): DBProjectShare | null { + return this.db.prepare('SELECT * FROM project_shares WHERE id = ?').get(id) as DBProjectShare | null; + } + + createProjectShare(data: Partial): DBProjectShare { + const id = data.id ?? `share-${Date.now()}`; + const shareToken = data.share_token ?? randomUUID(); + this.db.prepare( + 'INSERT INTO project_shares (id, project_id, shared_with_email, shared_by, permission, share_token) VALUES (?, ?, ?, ?, ?, ?)', + ).run(id, data.project_id!, data.shared_with_email!, data.shared_by!, data.permission ?? 'view', shareToken); + return this.db.prepare('SELECT * FROM project_shares WHERE id = ?').get(id) as DBProjectShare; + } + + deleteProjectShare(id: string): boolean { + return this.db.prepare('DELETE FROM project_shares WHERE id = ?').run(id).changes > 0; + } } diff --git a/backend/src/database/schema.sql b/backend/src/database/schema.sql index 0fb66e7..2ea8047 100644 --- a/backend/src/database/schema.sql +++ b/backend/src/database/schema.sql @@ -105,3 +105,31 @@ CREATE INDEX IF NOT EXISTS idx_layers_drawing ON layers(drawing_id); CREATE INDEX IF NOT EXISTS idx_elements_drawing ON elements(drawing_id); CREATE INDEX IF NOT EXISTS idx_elements_layer ON elements(layer_id); CREATE INDEX IF NOT EXISTS idx_blocks_drawing ON blocks(drawing_id); + + -- ─── Notifications ─────────────────────────────────── + CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'info', + title TEXT NOT NULL, + message TEXT NOT NULL, + read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + -- ─── Project Shares ────────────────────────────────── + CREATE TABLE IF NOT EXISTS project_shares ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + shared_with_email TEXT NOT NULL, + shared_by TEXT NOT NULL, + permission TEXT NOT NULL DEFAULT 'view' CHECK(permission IN ('view','edit','admin')), + share_token TEXT UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (shared_by) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id); + CREATE INDEX IF NOT EXISTS idx_shares_project ON project_shares(project_id); diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts new file mode 100644 index 0000000..7043139 --- /dev/null +++ b/backend/src/routes/notifications.ts @@ -0,0 +1,71 @@ +/** + * Notifications Routes – List, create, mark read, delete notifications + */ +import type { FastifyInstance } from 'fastify'; +import type { DatabaseInterface } from '../database/DatabaseInterface.js'; +import type { AuthService } from '../auth/AuthService.js'; + +function extractToken(request: any): string | null { + const auth = request.headers?.authorization; + if (auth && auth.startsWith('Bearer ')) return auth.slice(7); + return null; +} + +export function registerNotificationRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { + // List notifications for current user + fastify.get('/api/notifications', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + return db.listNotifications(user.id); + }); + + // Create notification (only for self) + fastify.post('/api/notifications', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + const body = request.body as { type?: string; title?: string; message?: string }; + if (!body.title || !body.message) return reply.code(400).send({ error: 'title and message are required' }); + const validTypes = ['info', 'share', 'warning', 'error']; + const type = validTypes.includes(body.type ?? '') ? body.type! : 'info'; + return reply.code(201).send(db.createNotification({ + user_id: user.id, + type, + title: body.title, + message: body.message, + })); + }); + + // Mark notification as read (ownership check) + fastify.patch('/api/notifications/:id/read', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + const { id } = request.params as { id: string }; + const notif = db.getNotification(id); + if (!notif) return reply.code(404).send({ error: 'Notification not found' }); + if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' }); + const ok = db.markNotificationRead(id); + if (!ok) return reply.code(404).send({ error: 'Notification not found' }); + return reply.code(204).send(); + }); + + // Delete notification (ownership check) + fastify.delete('/api/notifications/:id', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + const { id } = request.params as { id: string }; + const notif = db.getNotification(id); + if (!notif) return reply.code(404).send({ error: 'Notification not found' }); + if (notif.user_id !== user.id) return reply.code(403).send({ error: 'Forbidden' }); + const ok = db.deleteNotification(id); + if (!ok) return reply.code(404).send({ error: 'Notification not found' }); + return reply.code(204).send(); + }); +} diff --git a/backend/src/routes/shares.ts b/backend/src/routes/shares.ts new file mode 100644 index 0000000..15ae173 --- /dev/null +++ b/backend/src/routes/shares.ts @@ -0,0 +1,81 @@ +/** + * Project Shares Routes – List, create, delete project shares + */ +import type { FastifyInstance } from 'fastify'; +import type { DatabaseInterface } from '../database/DatabaseInterface.js'; +import type { AuthService } from '../auth/AuthService.js'; + +function extractToken(request: any): string | null { + const auth = request.headers?.authorization; + if (auth && auth.startsWith('Bearer ')) return auth.slice(7); + return null; +} + +const VALID_PERMISSIONS = ['view', 'edit', 'admin']; + +export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { + // List shares for a project (owner only) + fastify.get('/api/projects/:projectId/shares', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + const { projectId } = request.params as { projectId: string }; + const project = db.getProject(projectId); + if (!project) return reply.code(404).send({ error: 'Project not found' }); + if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' }); + return db.listProjectShares(projectId); + }); + + // Create a project share (owner only) + fastify.post('/api/projects/:projectId/shares', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + const { projectId } = request.params as { projectId: string }; + const project = db.getProject(projectId); + if (!project) return reply.code(404).send({ error: 'Project not found' }); + if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' }); + const body = request.body as { shared_with_email?: string; permission?: string }; + if (!body.shared_with_email) return reply.code(400).send({ error: 'shared_with_email is required' }); + const permission = VALID_PERMISSIONS.includes(body.permission ?? '') ? body.permission! : 'view'; + + const share = db.createProjectShare({ + project_id: projectId, + shared_with_email: body.shared_with_email, + shared_by: user.id, + permission, + }); + + // Create a notification for the shared user if they exist + const sharedUser = db.getUserByEmail(body.shared_with_email); + if (sharedUser) { + db.createNotification({ + user_id: sharedUser.id, + type: 'share', + title: 'Projekt geteilt', + message: `${user.name} hat ein Projekt mit Ihnen geteilt: ${project.name}`, + }); + } + + return reply.code(201).send(share); + }); + + // Delete a project share (owner only) + fastify.delete('/api/shares/:id', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + const { id } = request.params as { id: string }; + const share = db.getProjectShare(id); + if (!share) return reply.code(404).send({ error: 'Share not found' }); + const project = db.getProject(share.project_id); + if (!project) return reply.code(404).send({ error: 'Project not found' }); + if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' }); + const ok = db.deleteProjectShare(id); + if (!ok) return reply.code(404).send({ error: 'Share not found' }); + return reply.code(204).send(); + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 750dc64..0f7a0db 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -57,6 +57,8 @@ export async function createServer(opts: ServerOptions) { const { registerAuthRoutes } = await import('./routes/auth.js'); const { registerUserRoutes } = await import('./routes/users.js'); const { registerAIRoutes } = await import('./routes/ai.js'); + const { registerNotificationRoutes } = await import('./routes/notifications.js'); + const { registerShareRoutes } = await import('./routes/shares.js'); const authService = new AuthService(opts.db); @@ -69,6 +71,8 @@ export async function createServer(opts: ServerOptions) { registerSettingsRoutes(fastify, opts.db); registerUserRoutes(fastify, opts.db, authService); registerAIRoutes(fastify, authService); + registerNotificationRoutes(fastify, opts.db, authService); + registerShareRoutes(fastify, opts.db, authService); // Yjs collaboration WebSocket registerYjsWebSocket(fastify); diff --git a/deploy.sh b/deploy.sh index 9da542d..d4f73df 100755 --- a/deploy.sh +++ b/deploy.sh @@ -2,7 +2,7 @@ set -e cd /data/web-cad-neu echo "[$(date)] Pulling latest code..." -git pull origin main +git pull origin master echo "[$(date)] Building backend image..." docker build -t web-cad-neu-backend:latest ./backend echo "[$(date)] Building frontend image..." diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f0ea0ab..aef2c78 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import StatusBar from './components/StatusBar'; import MobileDrawers from './components/MobileDrawers'; import BackgroundImport from './components/BackgroundImport'; import HistoryPanel from './components/HistoryPanel'; +import SettingsModal from './components/SettingsModal'; import { BackgroundService, type BackgroundConfig } from './services/backgroundService'; import { HistoryManager, type CADStateSnapshot, type HistoryEntry } from './history'; import { getCommandRegistry } from './services/commandRegistry'; @@ -146,6 +147,10 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack // Mobile drawers const [mobileLeftOpen, setMobileLeftOpen] = useState(false); const [mobileRightOpen, setMobileRightOpen] = useState(false); + // Desktop sidebar collapse + const [leftSidebarCollapsed, setLeftSidebarCollapsed] = useState(false); + const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); const [activeDrawerTab, setActiveDrawerTab] = useState('tool'); // Background @@ -263,6 +268,8 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack collab.loadFromState({ elements: data.elements, layers: data.layers, blocks: data.blocks }); } } catch (err) { + if (cancelled) return; + if (err instanceof Error && err.message.includes('Superseded')) return; console.error('Failed to load project:', err); setSavedStatus('Fehler beim Laden'); } @@ -297,13 +304,19 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack setElements(merged); } - const sameLayers = collab.layers.length === layers.length && - collab.layers.every((l, i) => l.id === layers[i]?.id); - if (!sameLayers) setLayers(collab.layers); + // Never overwrite local layers with empty remote layers (prevents losing initialLayers) + if (collab.layers.length > 0) { + const sameLayers = collab.layers.length === layers.length && + collab.layers.every((l, i) => l.id === layers[i]?.id); + if (!sameLayers) setLayers(collab.layers); + } - const sameBlocks = collab.blocks.length === blocks.length && - collab.blocks.every((b, i) => b.id === blocks[i]?.id); - if (!sameBlocks) setBlocks(collab.blocks); + // Never overwrite local blocks with empty remote blocks (prevents losing defaultBlocks) + if (collab.blocks.length > 0) { + const sameBlocks = collab.blocks.length === blocks.length && + collab.blocks.every((b, i) => b.id === blocks[i]?.id); + if (!sameBlocks) setBlocks(collab.blocks); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [collab.elements, collab.layers, collab.blocks, collab.status]); @@ -787,6 +800,89 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Zoom: an Ansicht angepasst', type: 'info' }]); }, []); + // Layer reorder (drag & drop) + const handleReorderLayer = useCallback((draggedId: string, targetId: string, position: 'before' | 'after' | 'inside') => { + setLayers((prev) => { + const dragged = prev.find((l) => l.id === draggedId); + if (!dragged) return prev; + + if (position === 'inside') { + // Make dragged a child of target + const updated = prev.map((l) => + l.id === draggedId ? { ...l, parentId: targetId } : l + ); + return updated; + } + + // before/after: reorder at same level + const target = prev.find((l) => l.id === targetId); + if (!target) return prev; + + const parentId = target.parentId; + const siblings = prev + .filter((l) => l.parentId === parentId && l.id !== draggedId) + .sort((a, b) => a.sortOrder - b.sortOrder); + + const targetIdx = siblings.findIndex((l) => l.id === targetId); + const insertIdx = position === 'before' ? targetIdx : targetIdx + 1; + + siblings.splice(insertIdx, 0, { ...dragged, parentId }); + + // Reassign sortOrder + const reordered = siblings.map((l, i) => ({ ...l, sortOrder: i })); + + // Merge back: non-siblings keep original, siblings get reordered + const nonSiblings = prev.filter((l) => l.parentId !== parentId || l.id === draggedId); + const result = [...nonSiblings.filter((l) => l.id !== draggedId), ...reordered]; + return result; + }); + setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Ebene verschoben', type: 'info' }]); + }, []); + + // Add sub-layer + const handleAddSubLayer = useCallback((parentId: string) => { + setLayers((prev) => { + const parent = prev.find((l) => l.id === parentId); + if (!parent) return prev; + const newId = `layer-${Date.now()}`; + const siblings = prev.filter((l) => l.parentId === parentId); + const newLayer: CADLayer = { + id: newId, name: `Teilebene ${siblings.length + 1}`, visible: true, locked: false, + color: parent.color, lineType: 'solid', transparency: 0, + sortOrder: siblings.length, parentId, + }; + if (drawingId && token) { + createLayerTyped(token, drawingId, newLayer).catch((err) => { + console.error('Failed to save sub-layer:', err); + }); + } + return [...prev, newLayer]; + }); + }, [drawingId, token]); + + // Update single element (from properties panel) + const handleUpdateElement = useCallback((updated: CADElement) => { + setElements((prev) => { + const newElements = prev.map((e) => (e.id === updated.id ? updated : e)); + historyManagerRef.current.pushSnapshot({ + elements: newElements, layers, blocks, groups, bgConfig, + }, 'Element geändert'); + return newElements; + }); + syncHistory(); + collab.setElement(updated); + if (token) { + setSavedStatus('Speichert…'); + updateElement(token, updated.id, updated).then(() => { + setSavedStatus('gespeichert'); + }).catch((err) => { + console.error('Failed to update element:', err); + setSavedStatus('Fehler beim Speichern'); + }); + } + setSelectedElement(updated); + }, [layers, blocks, groups, bgConfig, syncHistory, token, collab]); + const handleBgApply = useCallback((config: BackgroundConfig, _image: HTMLImageElement | null) => { setBgConfig(config); setBgImportOpen(false); @@ -936,18 +1032,34 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack onRedo={handleRedo} onThemeToggle={handleThemeToggle} theme={theme} + onOpenSettings={() => setSettingsOpen(true)} + onOpenLeftDrawer={() => setMobileLeftOpen(true)} + onOpenRightDrawer={() => setMobileRightOpen(true)} /> -
+
setMobileLeftOpen(false)} + collapsed={leftSidebarCollapsed} + onToggleCollapse={() => setLeftSidebarCollapsed((p) => !p)} + blocks={blocks} + onDragBlock={handleDragBlock} + onBlockCategoryChange={handleBlockCategoryChange} + onBlockSearch={handleBlockSearch} + onRenameBlock={handleRenameBlock} + onDuplicateBlock={handleDuplicateBlock} + onDeleteBlock={handleDeleteBlock} + onSvgImport={handleSvgImport} + onSaveGroupAsBlock={handleSaveGroupAsBlock} /> = ({ projectId, token, onNavigateBack onKISend={handleKISend} onKISuggestionClick={handleSuggestionClick} kiLoading={kiLoading} + className={mobileRightOpen ? 'open' : ''} + onCollapse={() => setMobileRightOpen(false)} + collapsed={rightSidebarCollapsed} + onToggleCollapse={() => setRightSidebarCollapsed((p) => !p)} + onReorder={handleReorderLayer} + onAddSubLayer={handleAddSubLayer} + onUpdateElement={handleUpdateElement} />
= ({ projectId, token, onNavigateBack onCloseRight={() => setMobileRightOpen(false)} onRightTabChange={setActiveDrawerTab} /> + setSettingsOpen(false)} /> setBgImportOpen(false)} diff --git a/frontend/src/components/LayerPanel.tsx b/frontend/src/components/LayerPanel.tsx index 93c7e62..15da02b 100644 --- a/frontend/src/components/LayerPanel.tsx +++ b/frontend/src/components/LayerPanel.tsx @@ -131,6 +131,7 @@ const LayerPanel: React.FC = ({ selectedId={activeLayerId} onSelect={onSelectLayer} onReorder={onReorder} + draggable={true} renderIcon={(node) => node.icon} renderActions={(node) => { const layer = layers.find((l) => l.id === node.id); @@ -140,16 +141,16 @@ const LayerPanel: React.FC = ({
); @@ -183,16 +184,16 @@ const LayerPanel: React.FC = ({ ); diff --git a/frontend/src/components/LeftSidebar.tsx b/frontend/src/components/LeftSidebar.tsx index a0e9d9a..be0f320 100644 --- a/frontend/src/components/LeftSidebar.tsx +++ b/frontend/src/components/LeftSidebar.tsx @@ -56,9 +56,9 @@ const sections: Array<{ label: string; tools: ToolDef[] }> = [ { label: 'Bestuhlung', tools: sectionBestuhlung }, ]; -const LeftSidebar: React.FC = ({ activeTool, onToolChange, selectedTemplate, onTemplateSelect, onCollapse }) => { +const LeftSidebar: React.FC = ({ activeTool, onToolChange, selectedTemplate, onTemplateSelect, onCollapse, className, collapsed, onToggleCollapse }) => { return ( -