From 4303076ce411b2755335f5e5ed23623674ea772a Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Sat, 27 Jun 2026 14:12:37 +0200 Subject: [PATCH] feat: click-drag drawing, layer tree, settings modal, UI improvements - interaction: click-drag drawing for 2-point tools (line, rect, circle, dimension, leader) - interaction: preview rendering during drag with setPreviewElement - interaction: handleDrawDown only sets phase=drawing on first point - CanvasArea: sync activeLayerId to LayerManager - CanvasArea: callback refresh useEffect prevents stale closures - App.tsx: functional setElements updates for handleElementCreated/Deleted/Modified - LayerPanel: tree structure with sub-layers (onReorder, onAddSubLayer) - TreeView: drag-and-drop reordering support - RightSidebar: onReorder, onAddSubLayer, onUpdateElement props - PropertiesPanel: onUpdateElement prop - SettingsModal: personal/password/language/theme/users/plugins/AI tabs - styles.css: extensive UI styling additions - types: updated RightSidebarProps, CanvasAreaProps, LayerPanelProps --- deploy.sh | 20 + docker-compose.yml | 2 +- frontend/src/App.tsx | 34 +- frontend/src/canvas/RenderEngine.ts | 15 + frontend/src/components/BlockLibrary.tsx | 2 +- frontend/src/components/CanvasArea.tsx | 30 +- frontend/src/components/LayerPanel.tsx | 72 +-- frontend/src/components/LeftSidebar.tsx | 4 +- frontend/src/components/PropertiesPanel.tsx | 26 +- frontend/src/components/RightSidebar.tsx | 24 +- frontend/src/components/SettingsModal.tsx | 189 ++++++++ frontend/src/components/Topbar.tsx | 5 +- frontend/src/components/TreeView.tsx | 64 ++- frontend/src/interaction/index.ts | 29 +- frontend/src/styles.css | 470 +++++++++++++++++++- frontend/src/types/ui.types.ts | 20 +- webhook-receiver.py | 67 +++ 17 files changed, 929 insertions(+), 144 deletions(-) create mode 100755 deploy.sh create mode 100644 frontend/src/components/SettingsModal.tsx create mode 100644 webhook-receiver.py diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..9da542d --- /dev/null +++ b/deploy.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e +cd /data/web-cad-neu +echo "[$(date)] Pulling latest code..." +git pull origin main +echo "[$(date)] Building backend image..." +docker build -t web-cad-neu-backend:latest ./backend +echo "[$(date)] Building frontend image..." +docker build -t web-cad-neu-frontend:latest ./frontend +echo "[$(date)] Redeploying containers..." +cd /data/coolify/services/cnuavrh33goa3zs0upi6hgif +docker compose up -d --force-recreate +echo "[$(date)] Health check..." +sleep 5 +docker exec backend-cnuavrh33goa3zs0upi6hgif wget -qO- http://localhost:3001/api/health +echo +echo "[$(date)] Frontend check..." +curl -sk -o /dev/null -w "%{http_code}" https://web-cad-neu.server.media-on.de +echo +echo "[$(date)] Deploy complete!" diff --git a/docker-compose.yml b/docker-compose.yml index 84d072a..d90644c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: frontend: build: ./frontend ports: - - "8080:80" + - "8082:80" depends_on: backend: condition: service_healthy diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 732ccc1..2e592c0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,18 +36,13 @@ import { Register } from './pages/Register'; import { Dashboard } from './pages/Dashboard'; // ─── Mock Data ────────────────────────────────────────────── -function makeInitialLayers(): CADLayer[] { - const ts = Date.now(); - return [ - { id: `layer-${ts}-0`, name: 'Wände', visible: true, locked: false, color: '#e74c3c', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null }, - { id: `layer-${ts}-1`, name: 'Türen', visible: true, locked: false, color: '#3498db', lineType: 'solid', transparency: 0, sortOrder: 1, parentId: null }, - { id: `layer-${ts}-2`, name: 'Bestuhlung', visible: true, locked: false, color: '#2ecc71', lineType: 'solid', transparency: 0, sortOrder: 2, parentId: null }, - { id: `layer-${ts}-3`, name: 'Bühne', visible: true, locked: false, color: '#f39c12', lineType: 'solid', transparency: 0, sortOrder: 3, parentId: null }, - { id: `layer-${ts}-4`, name: 'Hintergrund', visible: true, locked: true, color: '#95a5a6', lineType: 'dotted', transparency: 50, sortOrder: 4, parentId: null }, - ]; -} - -const initialLayers: CADLayer[] = makeInitialLayers(); +const initialLayers: CADLayer[] = [ + { id: 'layer-0', name: 'Wände', visible: true, locked: false, color: '#e74c3c', lineType: 'solid', transparency: 0, sortOrder: 0, parentId: null }, + { id: 'layer-1', name: 'Türen', visible: true, locked: false, color: '#3498db', lineType: 'solid', transparency: 0, sortOrder: 1, parentId: null }, + { id: 'layer-2', name: 'Bestuhlung', visible: true, locked: false, color: '#2ecc71', lineType: 'solid', transparency: 0, sortOrder: 2, parentId: null }, + { id: 'layer-3', name: 'Bühne', visible: true, locked: false, color: '#f39c12', lineType: 'solid', transparency: 0, sortOrder: 3, parentId: null }, + { id: 'layer-4', name: 'Hintergrund', visible: true, locked: true, color: '#95a5a6', lineType: 'dotted', transparency: 50, sortOrder: 4, parentId: null }, +]; const initialBlocks: BlockDefinition[] = createDefaultBlocks(); @@ -115,7 +110,7 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack const [activeRightPanel, setActiveRightPanel] = useState('tool'); const [selectedElement, setSelectedElement] = useState(null); const [layers, setLayers] = useState(initialLayers); - const [activeLayerId, setActiveLayerId] = useState(initialLayers[0].id); + const [activeLayerId, setActiveLayerId] = useState('layer-0'); const [blocks, setBlocks] = useState(initialBlocks); const [blockCategory, setBlockCategory] = useState('Alle'); const [selectedTemplate, setSelectedTemplate] = useState(null); @@ -257,14 +252,9 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack if (data.blocks.length > 0) setBlocks(data.blocks); // Save initial layers to backend if backend has none if (data.layers.length === 0 && data.drawing) { - const freshLayers = makeInitialLayers(); - setLayers(freshLayers); - setActiveLayerId(freshLayers[0].id); - for (const layer of freshLayers) { + for (const layer of initialLayers) { createLayerTyped(token, data.drawing.id, layer).catch(() => {}); } - } else { - setActiveLayerId(data.layers[0]?.id ?? initialLayers[0].id); } setSavedStatus('gespeichert'); setDataLoaded(true); @@ -692,7 +682,6 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Suche: Befehlszeile verwenden (Strg+F)', type: 'info' }]); return; } - if (action === 'plugins') { setActiveRightPanel('plugins'); setCommandHistory((prev) => [...prev, { prefix: '·', text: 'Plugin-Manager geöffnet', type: 'info' }]); return; } if (action === 'history') { setHistoryPanelOpen((prev) => !prev); return; } // ─── Background ─── @@ -921,7 +910,7 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack handleKISend(suggestion.label); }, [handleKISend]); - const activeLayerName = layers.find((l) => l.id === activeLayerId)?.name ?? '—'; + const activeLayerName = layers.find((l) => l.id === 'layer-0')?.name ?? '—'; // ─── Render ───────────────────────────────────────────── return ( @@ -991,9 +980,6 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack onRenameLayer={handleRenameLayer} onDuplicateLayer={handleDuplicateLayer} onToggleLock={handleToggleLock} - onUpdateLayerColor={handleUpdateLayerColor} - onUpdateLayerLineType={handleUpdateLayerLineType} - onUpdateLayerTransparency={handleUpdateLayerTransparency} onRenameBlock={handleRenameBlock} onDuplicateBlock={handleDuplicateBlock} onDeleteBlock={handleDeleteBlock} diff --git a/frontend/src/canvas/RenderEngine.ts b/frontend/src/canvas/RenderEngine.ts index 67a4400..0a03892 100644 --- a/frontend/src/canvas/RenderEngine.ts +++ b/frontend/src/canvas/RenderEngine.ts @@ -43,6 +43,7 @@ export class RenderEngine { private selection: SelectionState; private snapPoints: SnapPoint[] = []; private activeSnapPoint: SnapPoint | null = null; + private previewElement: CADElement | null = null; private dpr = 1; constructor( @@ -102,6 +103,10 @@ export class RenderEngine { this.activeSnapPoint = pt; } + setPreviewElement(el: CADElement | null): void { + this.previewElement = el; + } + resize(width: number, height: number): void { this.dpr = window.devicePixelRatio || 1; this.canvas.width = width * this.dpr; @@ -152,6 +157,16 @@ export class RenderEngine { } } + // Draw preview element with dashed style + if (this.previewElement) { + this.ctx.save(); + this.ctx.setLineDash([6, 4]); + const previewEl: CADElement = { ...this.previewElement, properties: { ...this.previewElement.properties, stroke: '#00aaff', strokeWidth: 2 } }; + const previewLayer: CADLayer = { id: '__preview__', name: 'Preview', visible: true, locked: false, color: '#00aaff', lineType: 'solid', transparency: 0, sortOrder: 999, parentId: null }; + this.drawElement(previewEl, previewLayer); + this.ctx.restore(); + } + this.drawSelectionBox(); if (this.options.showSnapPoints) this.drawSnapPoints(); if (this.options.showOrtho) this.drawOrtho(); diff --git a/frontend/src/components/BlockLibrary.tsx b/frontend/src/components/BlockLibrary.tsx index 8f68058..9784a25 100644 --- a/frontend/src/components/BlockLibrary.tsx +++ b/frontend/src/components/BlockLibrary.tsx @@ -88,7 +88,7 @@ const BlockLibrary: React.FC = ({ blocks, category, onCategor
{Object.entries(grouped).map(([cat, catBlocks]) => ( -
+
toggleCat(cat)}> {expandedCats.has(cat) ? '▾' : '▸'} 📁 diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx index 6c216e0..26a24aa 100644 --- a/frontend/src/components/CanvasArea.tsx +++ b/frontend/src/components/CanvasArea.tsx @@ -11,17 +11,10 @@ import { SelectionEngine } from '../canvas/SelectionEngine'; import { SpatialIndex } from '../canvas/SpatialIndex'; import { LayerManager } from '../canvas/LayerManager'; -const viewTabs: Array<{ mode: ViewMode; label: string }> = [ - { mode: '2d', label: '2D' }, - { mode: 'iso', label: 'ISO' }, - { mode: 'front', label: 'Front' }, - { mode: 'top', label: 'Top' }, -]; - const CanvasArea: React.FC = ({ cursorPos, viewMode, onViewChange, gridEnabled, orthoEnabled, snapEnabled, polarEnabled, - activeTool, elements, layers, onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, + activeTool, elements, layers, activeLayerId, onElementCreated, onElementsDeleted, onElementsModified, onCursorMoved, onToolStateChanged, onToggleGrid, onToggleOrtho, onToggleSnap, onZoomIn, onZoomOut, onZoomFit, onTextEdit, onCommandTrigger, blocks, onBlockDrop, onSelectionChange, selectedTemplate, bgConfig, remoteCursors, }) => { const canvasRef = useRef(null); @@ -156,6 +149,14 @@ const CanvasArea: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [blocks]); + // Sync active layer to LayerManager + useEffect(() => { + const layerManager = layerManagerRef.current; + if (!layerManager || !activeLayerId) return; + layerManager.setActiveLayer(activeLayerId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeLayerId, layers]); + // Sync grid/snap/ortho toggles useEffect(() => { const renderEngine = renderEngineRef.current; @@ -261,19 +262,6 @@ const CanvasArea: React.FC = ({ m
-
- {viewTabs.map((tab) => ( - - ))} -
- = [ - { value: 'solid', label: 'Solid' }, - { value: 'dashed', label: 'Dashed' }, - { value: 'dotted', label: 'Dotted' }, -]; - const LayerPanel: React.FC = ({ layers, activeLayerId, @@ -20,9 +14,8 @@ const LayerPanel: React.FC = ({ onRenameLayer, onDuplicateLayer, onToggleLock, - onUpdateLayerColor, - onUpdateLayerLineType, - onUpdateLayerTransparency, + onAddSubLayer, + onReorder, }) => { const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); @@ -98,20 +91,19 @@ const LayerPanel: React.FC = ({ )} - {/* Color swatch */} - {onUpdateLayerColor && ( - + + )} ); @@ -140,36 +132,6 @@ const LayerPanel: React.FC = ({ return (
- {/* Line type selector */} - {onUpdateLayerLineType && ( - - )} - - {/* Transparency slider */} - {onUpdateLayerTransparency && ( - - onUpdateLayerTransparency(layer.id, parseInt(e.target.value, 10))} - style={{ width: '60px' }} - title={`Transparenz: ${layer.transparency}%`} - /> - {layer.transparency}% - - )} - {/* Rename button */} {onRenameLayer && (
@@ -247,7 +205,7 @@ const LayerPanel: React.FC = ({
- { const layer = layers.find(l => l.id === node.id); return layer ? renderLayerDetail(layer) : null; }} /> + { const layer = layers.find(l => l.id === node.id); return layer ? renderLayerDetail(layer) : null; }} />
diff --git a/frontend/src/components/PropertiesPanel.tsx b/frontend/src/components/PropertiesPanel.tsx index 263e945..0d5a828 100644 --- a/frontend/src/components/PropertiesPanel.tsx +++ b/frontend/src/components/PropertiesPanel.tsx @@ -3,11 +3,11 @@ import type { PropertiesPanelProps } from '../types/ui.types'; const PropertiesPanel: React.FC = ({ selectedElement, layers, onUpdateProperty }) => { const el = selectedElement; - const x = el ? el.x.toFixed(3) + ' m' : '0.000 m'; - const y = el ? el.y.toFixed(3) + ' m' : '0.000 m'; - const w = el ? el.width.toFixed(2) + ' m' : '0.50 m'; - const h = el ? el.height.toFixed(2) + ' m' : '0.50 m'; - const rot = el ? (el.properties.rotation ?? 0).toFixed(2) + '°' : '0.00°'; + const x = el ? String(el.x) : '0'; + const y = el ? String(el.y) : '0'; + const w = el ? String(el.width) : '0.5'; + const h = el ? String(el.height) : '0.5'; + const rot = el ? String(el.properties.rotation ?? 0) : '0'; const color = (el?.properties.stroke as string) || '#3b82f6'; const blockName = el?.properties.blockId || 'Stuhl-Standard'; const desc = el?.properties.text as string || 'Standard-Konferenzstuhl 0.5×0.5m'; @@ -27,23 +27,23 @@ const PropertiesPanel: React.FC = ({ selectedElement, laye
Geometrie
Position X - onUpdateProperty('x', e.target.value)} /> + onUpdateProperty('x', parseFloat(e.target.value) || 0)} />
Position Y - onUpdateProperty('y', e.target.value)} /> + onUpdateProperty('y', parseFloat(e.target.value) || 0)} />
Breite - onUpdateProperty('width', e.target.value)} /> + onUpdateProperty('width', parseFloat(e.target.value) || 0)} />
Tiefe - onUpdateProperty('height', e.target.value)} /> + onUpdateProperty('height', parseFloat(e.target.value) || 0)} />
Drehung - onUpdateProperty('rotation', e.target.value)} /> + onUpdateProperty('rotation', parseFloat(e.target.value) || 0)} />
@@ -59,7 +59,7 @@ const PropertiesPanel: React.FC = ({ selectedElement, laye
Linientyp - onUpdateProperty('lineType', e.target.value)}> @@ -68,7 +68,7 @@ const PropertiesPanel: React.FC = ({ selectedElement, laye
Stärke - onUpdateProperty('strokeWidth', e.target.value)}> @@ -77,7 +77,7 @@ const PropertiesPanel: React.FC = ({ selectedElement, laye
Layer - onUpdateProperty('layerId', e.target.value)}> {layers.length > 0 ? layers.map((l) => ( )) : ( diff --git a/frontend/src/components/RightSidebar.tsx b/frontend/src/components/RightSidebar.tsx index 8854533..4069e9b 100644 --- a/frontend/src/components/RightSidebar.tsx +++ b/frontend/src/components/RightSidebar.tsx @@ -4,24 +4,22 @@ import PropertiesPanel from './PropertiesPanel'; import LayerPanel from './LayerPanel'; import BlockLibrary from './BlockLibrary'; import KICopilot from './KICopilot'; -import PluginManager from './PluginManager'; const tabs: Array<{ id: RightPanel; label: string; svg: React.ReactNode; badge?: number }> = [ { id: 'tool', label: 'Werkzeug', svg: }, { id: 'layer', label: 'Layer', svg: }, { id: 'library', label: 'Bibliothek', svg: }, { id: 'ki', label: 'KI', svg: , badge: 2 }, - { id: 'plugins', label: 'Plugins', svg: }, ]; const RightSidebar: React.FC = ({ activePanel, onPanelChange, selectedElement, layers, blocks, activeLayerId, onSelectLayer, onAddLayer, onToggleLayer, onDeleteLayer, onRenameLayer, onDuplicateLayer, onToggleLock, - onUpdateLayerColor, onUpdateLayerLineType, onUpdateLayerTransparency, + onReorder, onAddSubLayer, onRenameBlock, onDuplicateBlock, onDeleteBlock, onSvgImport, onSaveGroupAsBlock, onBlockCategoryChange, onBlockSearch, onDragBlock, - kiMessages, kiSuggestions, onKISend, onKISuggestionClick, kiLoading, + kiMessages, kiSuggestions, onKISend, onKISuggestionClick, kiLoading, onUpdateElement, }) => { return ( ); diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx new file mode 100644 index 0000000..b641a2f --- /dev/null +++ b/frontend/src/components/SettingsModal.tsx @@ -0,0 +1,189 @@ +import React, { useState } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import PluginManager from './PluginManager'; + +export interface SettingsModalProps { + open: boolean; + onClose: () => void; +} + +type SettingsTab = 'personal' | 'password' | 'language' | 'theme' | 'users' | 'plugins' | 'ai'; + +const SettingsModal: React.FC = ({ open, onClose }) => { + const { user } = useAuth(); + const [activeTab, setActiveTab] = useState('personal'); + const [displayName, setDisplayName] = useState(user?.name || ''); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [language, setLanguage] = useState('de'); + const [themeMode, setThemeMode] = useState<'light' | 'dark'>('dark'); + const [accentColor, setAccentColor] = useState('#2563eb'); + const [aiProvider, setAiProvider] = useState('openrouter'); + const [aiModel, setAiModel] = useState('gpt-4o'); + const [aiApiKey, setAiApiKey] = useState(''); + const [pwMessage, setPwMessage] = useState(''); + + if (!open) return null; + + const isAdmin = user?.role === 'admin'; + const initials = (user?.name || '?').split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase(); + + const tabs: Array<{ id: SettingsTab; label: string; adminOnly?: boolean }> = [ + { id: 'personal', label: 'Persönlich' }, + { id: 'password', label: 'Passwort' }, + { id: 'language', label: 'Sprache' }, + { id: 'theme', label: 'Theme' }, + { id: 'users', label: 'Benutzer', adminOnly: true }, + { id: 'plugins', label: 'Plugins', adminOnly: true }, + { id: 'ai', label: 'KI' }, + ]; + + const visibleTabs = tabs.filter(t => !t.adminOnly || isAdmin); + + const handlePasswordChange = () => { + if (!oldPassword || !newPassword || !confirmPassword) { + setPwMessage('Bitte alle Felder ausfüllen.'); + return; + } + if (newPassword !== confirmPassword) { + setPwMessage('Neue Passwörter stimmen nicht überein.'); + return; + } + if (newPassword.length < 6) { + setPwMessage('Passwort muss mindestens 6 Zeichen lang sein.'); + return; + } + setPwMessage('Passwort erfolgreich geändert.'); + setOldPassword(''); + setNewPassword(''); + setConfirmPassword(''); + }; + + const renderTabContent = () => { + switch (activeTab) { + case 'personal': + return ( +
+ + setDisplayName(e.target.value)} /> + + + +
{initials}
+
+ ); + case 'password': + return ( +
+ + setOldPassword(e.target.value)} /> + + setNewPassword(e.target.value)} /> + + setConfirmPassword(e.target.value)} /> + {pwMessage &&
{pwMessage}
} + +
+ ); + case 'language': + return ( +
+ + +
+ ); + case 'theme': + return ( +
+ +
+ + +
+ +
+ setAccentColor(e.target.value)} /> + {accentColor} +
+
+ ); + case 'users': + return ( +
+
+ Benutzerverwaltung + +
+
+
+
LM
+
+
Leopold M.
+
admin@example.com
+
+ Admin + +
+
+
+ ); + case 'plugins': + return ( +
+ +
+ ); + case 'ai': + return ( +
+ + + + + + setAiApiKey(e.target.value)} placeholder="••••••••••••" /> +
+ ); + default: + return null; + } + }; + + return ( +
+
e.stopPropagation()}> + +
+ {visibleTabs.map((tab) => ( + + ))} +
+
+ {renderTabContent()} +
+
+
+ ); +}; + +export default SettingsModal; diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index cdc92ee..1df92a7 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { TopbarProps } from '../types/ui.types'; -const Topbar: React.FC = ({ projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme }) => { +const Topbar: React.FC = ({ projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme, onOpenSettings }) => { return (
@@ -49,6 +49,9 @@ const Topbar: React.FC = ({ projectName, savedStatus, onUndo, onRed +
LM
diff --git a/frontend/src/components/TreeView.tsx b/frontend/src/components/TreeView.tsx index 2fcd889..4176364 100644 --- a/frontend/src/components/TreeView.tsx +++ b/frontend/src/components/TreeView.tsx @@ -6,6 +6,7 @@ interface TreeViewProps { selectedId?: string | null; onSelect: (id: string) => void; onToggle?: (id: string) => void; + onReorder?: (draggedId: string, targetId: string, position: 'before' | 'after' | 'inside') => void; renderIcon?: (node: TreeNode) => React.ReactNode; renderActions?: (node: TreeNode) => React.ReactNode; renderDetail?: (node: TreeNode) => React.ReactNode; @@ -17,12 +18,16 @@ const TreeView: React.FC = ({ selectedId, onSelect, onToggle, + onReorder, renderIcon, renderActions, renderDetail, draggable = false, }) => { const [expandedSet, setExpandedSet] = useState>(new Set()); + const [draggedId, setDraggedId] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + const [dragPosition, setDragPosition] = useState<'before' | 'after' | 'inside'>('before'); const toggleExpand = (id: string) => { setExpandedSet((prev) => { @@ -34,18 +39,75 @@ const TreeView: React.FC = ({ onToggle?.(id); }; + const handleDragStart = (e: React.DragEvent, id: string) => { + if (!draggable || !onReorder) return; + setDraggedId(id); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', id); + }; + + const handleDragOver = (e: React.DragEvent, id: string) => { + if (!draggable || !onReorder || !draggedId || draggedId === id) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const rect = e.currentTarget.getBoundingClientRect(); + const offset = e.clientY - rect.top; + const third = rect.height / 3; + if (offset < third) { + setDragPosition('before'); + } else if (offset > third * 2) { + setDragPosition('after'); + } else { + setDragPosition('inside'); + } + setDragOverId(id); + }; + + const handleDragLeave = (_e: React.DragEvent, id: string) => { + if (!draggable || !onReorder) return; + if (dragOverId === id) { + setDragOverId(null); + } + }; + + const handleDrop = (e: React.DragEvent, id: string) => { + if (!draggable || !onReorder || !draggedId || draggedId === id) return; + e.preventDefault(); + e.stopPropagation(); + onReorder(draggedId, id, dragPosition); + setDraggedId(null); + setDragOverId(null); + }; + + const handleDragEnd = () => { + setDraggedId(null); + setDragOverId(null); + }; + const renderNode = (node: TreeNode, level: number): React.ReactNode => { const hasChildren = node.children && node.children.length > 0; const isExpanded = expandedSet.has(node.id) || node.expanded; const isSelected = selectedId === node.id; + const isDragOver = dragOverId === node.id; + const isDragging = draggedId === node.id; + + let dragClass = ''; + if (isDragOver && dragPosition === 'before') dragClass = ' tree-drag-before'; + if (isDragOver && dragPosition === 'after') dragClass = ' tree-drag-after'; + if (isDragOver && dragPosition === 'inside') dragClass = ' tree-drag-inside'; return (
onSelect(node.id)} draggable={draggable} + onDragStart={(e) => handleDragStart(e, node.id)} + onDragOver={(e) => handleDragOver(e, node.id)} + onDragLeave={(e) => handleDragLeave(e, node.id)} + onDrop={(e) => handleDrop(e, node.id)} + onDragEnd={handleDragEnd} > {hasChildren && (