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
This commit is contained in:
2026-06-27 14:12:37 +02:00
parent 39422a4348
commit 4303076ce4
17 changed files with 929 additions and 144 deletions
Executable
+20
View File
@@ -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!"
+1 -1
View File
@@ -4,7 +4,7 @@ services:
frontend:
build: ./frontend
ports:
- "8080:80"
- "8082:80"
depends_on:
backend:
condition: service_healthy
+10 -24
View File
@@ -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<CADEditorProps> = ({ projectId, token, onNavigateBack
const [activeRightPanel, setActiveRightPanel] = useState<RightPanel>('tool');
const [selectedElement, setSelectedElement] = useState<CADElement | null>(null);
const [layers, setLayers] = useState<CADLayer[]>(initialLayers);
const [activeLayerId, setActiveLayerId] = useState<string>(initialLayers[0].id);
const [activeLayerId, setActiveLayerId] = useState<string>('layer-0');
const [blocks, setBlocks] = useState<BlockDefinition[]>(initialBlocks);
const [blockCategory, setBlockCategory] = useState('Alle');
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
@@ -257,14 +252,9 @@ const CADEditor: React.FC<CADEditorProps> = ({ 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<CADEditorProps> = ({ 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<CADEditorProps> = ({ 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<CADEditorProps> = ({ projectId, token, onNavigateBack
onRenameLayer={handleRenameLayer}
onDuplicateLayer={handleDuplicateLayer}
onToggleLock={handleToggleLock}
onUpdateLayerColor={handleUpdateLayerColor}
onUpdateLayerLineType={handleUpdateLayerLineType}
onUpdateLayerTransparency={handleUpdateLayerTransparency}
onRenameBlock={handleRenameBlock}
onDuplicateBlock={handleDuplicateBlock}
onDeleteBlock={handleDeleteBlock}
+15
View File
@@ -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();
+1 -1
View File
@@ -88,7 +88,7 @@ const BlockLibrary: React.FC<BlockLibraryProps> = ({ blocks, category, onCategor
</div>
<div className="tree tree-library" role="tree" aria-label="Bibliothek-Struktur">
{Object.entries(grouped).map(([cat, catBlocks]) => (
<div key={cat} className="tree-node">
<div key={cat} className="lib-tree-node">
<div className="tree-item tree-item-folder" onClick={() => toggleCat(cat)}>
<span className="tree-toggle">{expandedCats.has(cat) ? '▾' : '▸'}</span>
<span className="tree-icon">📁</span>
+9 -21
View File
@@ -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<CanvasAreaProps> = ({
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<HTMLCanvasElement>(null);
@@ -156,6 +149,14 @@ const CanvasArea: React.FC<CanvasAreaProps> = ({
// 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<CanvasAreaProps> = ({
<span style={{ color: 'var(--color-text-faint)' }}>m</span>
</div>
<div className="canvas-view-tabs" role="tablist" aria-label="Ansicht umschalten">
{viewTabs.map((tab) => (
<button
key={tab.mode}
className={`canvas-view-tab${viewMode === tab.mode ? ' active' : ''}`}
data-view={tab.mode}
onClick={() => onViewChange(tab.mode)}
>
{tab.label}
</button>
))}
</div>
<canvas
ref={canvasRef}
className="canvas-svg"
+15 -57
View File
@@ -4,12 +4,6 @@ import type { CADLayer } from '../types/cad.types';
import type { TreeNode } from '../types/ui.types';
import TreeView from './TreeView';
const lineTypeOptions: Array<{ value: 'solid' | 'dashed' | 'dotted'; label: string }> = [
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'dotted', label: 'Dotted' },
];
const LayerPanel: React.FC<LayerPanelProps> = ({
layers,
activeLayerId,
@@ -20,9 +14,8 @@ const LayerPanel: React.FC<LayerPanelProps> = ({
onRenameLayer,
onDuplicateLayer,
onToggleLock,
onUpdateLayerColor,
onUpdateLayerLineType,
onUpdateLayerTransparency,
onAddSubLayer,
onReorder,
}) => {
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
@@ -98,20 +91,19 @@ const LayerPanel: React.FC<LayerPanelProps> = ({
</button>
)}
{/* Color swatch */}
{onUpdateLayerColor && (
<label
className="layer-color-swatch"
title="Ebenenfarbe"
style={{ backgroundColor: layer.color, display: 'inline-block', width: '14px', height: '14px', borderRadius: '3px', cursor: 'pointer', border: '1px solid var(--color-border)' }}
{/* Add sub-layer button */}
{onAddSubLayer && (
<button
className="layer-action-btn"
title="Unter-Ebene hinzufügen"
aria-label="Unter-Ebene hinzufügen"
onClick={(e) => {
e.stopPropagation();
onAddSubLayer(layer.id);
}}
>
<input
type="color"
value={layer.color}
onChange={(e) => onUpdateLayerColor(layer.id, e.target.value)}
style={{ opacity: 0, width: 0, height: 0 }}
/>
</label>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
)}
</React.Fragment>
);
@@ -140,36 +132,6 @@ const LayerPanel: React.FC<LayerPanelProps> = ({
return (
<div className="layer-detail-row" style={{ display: 'flex', gap: '6px', padding: '2px 8px', alignItems: 'center', fontSize: '11px', color: 'var(--color-text-muted)' }}>
{/* Line type selector */}
{onUpdateLayerLineType && (
<select
value={layer.lineType}
onChange={(e) => onUpdateLayerLineType(layer.id, e.target.value as 'solid' | 'dashed' | 'dotted')}
style={{ fontSize: '10px', padding: '1px 4px' }}
title="Linientyp"
>
{lineTypeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
)}
{/* Transparency slider */}
{onUpdateLayerTransparency && (
<React.Fragment>
<input
type="range"
min="0"
max="100"
value={layer.transparency}
onChange={(e) => onUpdateLayerTransparency(layer.id, parseInt(e.target.value, 10))}
style={{ width: '60px' }}
title={`Transparenz: ${layer.transparency}%`}
/>
<span style={{ minWidth: '28px' }}>{layer.transparency}%</span>
</React.Fragment>
)}
{/* Rename button */}
{onRenameLayer && (
<button
@@ -226,10 +188,6 @@ const LayerPanel: React.FC<LayerPanelProps> = ({
style={{ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 8px', cursor: 'pointer' }}
onClick={() => onSelectLayer(layer.id)}
>
<span
className="layer-color-dot"
style={{ width: '10px', height: '10px', borderRadius: '2px', backgroundColor: layer.color, flexShrink: 0 }}
/>
<span className="layer-name" style={{ flex: 1, fontSize: '12px' }}>{layer.name}</span>
{renderLayerActions({ id: layer.id, name: layer.name })}
</div>
@@ -247,7 +205,7 @@ const LayerPanel: React.FC<LayerPanelProps> = ({
</button>
</div>
<div className="tree tree-layer" role="tree" aria-label="Layer-Struktur">
<TreeView nodes={nodes} selectedId={activeLayerId} onSelect={onSelectLayer} onToggle={onToggleLayer} renderActions={renderLayerActions} renderDetail={(node) => { const layer = layers.find(l => l.id === node.id); return layer ? renderLayerDetail(layer) : null; }} />
<TreeView nodes={nodes} selectedId={activeLayerId} onSelect={onSelectLayer} onToggle={onToggleLayer} onReorder={onReorder} draggable={true} renderActions={renderLayerActions} renderDetail={(node) => { const layer = layers.find(l => l.id === node.id); return layer ? renderLayerDetail(layer) : null; }} />
</div>
<button className="add-layer-btn" onClick={onAddLayer}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
+2 -2
View File
@@ -56,12 +56,12 @@ const sections: Array<{ label: string; tools: ToolDef[] }> = [
{ label: 'Bestuhlung', tools: sectionBestuhlung },
];
const LeftSidebar: React.FC<LeftSidebarProps> = ({ activeTool, onToolChange, selectedTemplate, onTemplateSelect }) => {
const LeftSidebar: React.FC<LeftSidebarProps> = ({ activeTool, onToolChange, selectedTemplate, onTemplateSelect, onCollapse }) => {
return (
<aside className="leftbar" aria-label="Werkzeugpalette">
<div className="leftbar-header">
<span className="leftbar-title">Werkzeuge</span>
<button className="leftbar-toggle" aria-label="Linke Leiste ausblenden">
<button className="leftbar-toggle" aria-label="Linke Leiste ausblenden" onClick={onCollapse}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
</div>
+13 -13
View File
@@ -3,11 +3,11 @@ import type { PropertiesPanelProps } from '../types/ui.types';
const PropertiesPanel: React.FC<PropertiesPanelProps> = ({ 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<PropertiesPanelProps> = ({ selectedElement, laye
<div className="panel-section-title"><span>Geometrie</span></div>
<div className="prop-row">
<span className="prop-label">Position X</span>
<input className="prop-input" type="text" value={x} aria-label="Position X" onChange={(e) => onUpdateProperty('x', e.target.value)} />
<input className="prop-input" type="text" value={x} aria-label="Position X" onChange={(e) => onUpdateProperty('x', parseFloat(e.target.value) || 0)} />
</div>
<div className="prop-row">
<span className="prop-label">Position Y</span>
<input className="prop-input" type="text" value={y} aria-label="Position Y" onChange={(e) => onUpdateProperty('y', e.target.value)} />
<input className="prop-input" type="text" value={y} aria-label="Position Y" onChange={(e) => onUpdateProperty('y', parseFloat(e.target.value) || 0)} />
</div>
<div className="prop-row">
<span className="prop-label">Breite</span>
<input className="prop-input" type="text" value={w} aria-label="Breite" onChange={(e) => onUpdateProperty('width', e.target.value)} />
<input className="prop-input" type="text" value={w} aria-label="Breite" onChange={(e) => onUpdateProperty('width', parseFloat(e.target.value) || 0)} />
</div>
<div className="prop-row">
<span className="prop-label">Tiefe</span>
<input className="prop-input" type="text" value={h} aria-label="Tiefe" onChange={(e) => onUpdateProperty('height', e.target.value)} />
<input className="prop-input" type="text" value={h} aria-label="Tiefe" onChange={(e) => onUpdateProperty('height', parseFloat(e.target.value) || 0)} />
</div>
<div className="prop-row">
<span className="prop-label">Drehung</span>
<input className="prop-input" type="text" value={rot} aria-label="Drehung" onChange={(e) => onUpdateProperty('rotation', e.target.value)} />
<input className="prop-input" type="text" value={rot} aria-label="Drehung" onChange={(e) => onUpdateProperty('rotation', parseFloat(e.target.value) || 0)} />
</div>
</div>
@@ -59,7 +59,7 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({ selectedElement, laye
</div>
<div className="prop-row">
<span className="prop-label">Linientyp</span>
<select className="prop-select" aria-label="Linientyp" onChange={(e) => onUpdateProperty('lineType', e.target.value)}>
<select className="prop-select" aria-label="Linientyp" value={el?.properties.lineType || 'solid'} onChange={(e) => onUpdateProperty('lineType', e.target.value)}>
<option>Solid (durchgehend)</option>
<option>Dashed (gestrichelt)</option>
<option>Dotted (gepunktet)</option>
@@ -68,7 +68,7 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({ selectedElement, laye
</div>
<div className="prop-row">
<span className="prop-label">Stärke</span>
<select className="prop-select" aria-label="Linienstärke" onChange={(e) => onUpdateProperty('strokeWidth', e.target.value)}>
<select className="prop-select" aria-label="Linienstärke" value={String(el?.properties.strokeWidth ?? '0.18')} onChange={(e) => onUpdateProperty('strokeWidth', e.target.value)}>
<option>0.18 mm · Normal</option>
<option>0.25 mm · Dick</option>
<option>0.35 mm · Extra</option>
@@ -77,7 +77,7 @@ const PropertiesPanel: React.FC<PropertiesPanelProps> = ({ selectedElement, laye
</div>
<div className="prop-row">
<span className="prop-label">Layer</span>
<select className="prop-select" aria-label="Layer zuweisen" onChange={(e) => onUpdateProperty('layerId', e.target.value)}>
<select className="prop-select" aria-label="Layer zuweisen" value={el?.layerId || ''} onChange={(e) => onUpdateProperty('layerId', e.target.value)}>
{layers.length > 0 ? layers.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
)) : (
+15 -9
View File
@@ -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: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg> },
{ id: 'layer', label: 'Layer', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg> },
{ id: 'library', label: 'Bibliothek', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><line x1="3" y1="12" x2="21" y2="12"/></svg> },
{ id: 'ki', label: 'KI', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>, badge: 2 },
{ id: 'plugins', label: 'Plugins', svg: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> },
];
const RightSidebar: React.FC<RightSidebarProps> = ({
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 (
<aside className="rightbar" aria-label="Eigenschaften-Panels">
@@ -44,10 +42,21 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
<div className="rightbar-content">
<div className={`rightbar-panel${activePanel === 'tool' ? ' active' : ''}`} data-panel-content="tool">
{activePanel === 'tool' && <PropertiesPanel selectedElement={selectedElement} layers={layers} onUpdateProperty={() => {}} />}
{activePanel === 'tool' && <PropertiesPanel selectedElement={selectedElement} layers={layers} onUpdateProperty={(key, value) => {
if (!selectedElement || !onUpdateElement) return;
const updated = { ...selectedElement };
if (key === 'x' || key === 'y' || key === 'width' || key === 'height') {
(updated as any)[key] = value as number;
} else if (key === 'layerId') {
updated.layerId = value as string;
} else {
updated.properties = { ...updated.properties, [key]: value };
}
onUpdateElement(updated);
}} />}
</div>
<div className={`rightbar-panel${activePanel === 'layer' ? ' active' : ''}`} data-panel-content="layer">
{activePanel === 'layer' && <LayerPanel layers={layers} activeLayerId={activeLayerId} onSelectLayer={onSelectLayer ?? (() => {})} onAddLayer={onAddLayer ?? (() => {})} onToggleLayer={onToggleLayer ?? (() => {})} onDeleteLayer={onDeleteLayer} onRenameLayer={onRenameLayer} onDuplicateLayer={onDuplicateLayer} onToggleLock={onToggleLock} onUpdateLayerColor={onUpdateLayerColor} onUpdateLayerLineType={onUpdateLayerLineType} onUpdateLayerTransparency={onUpdateLayerTransparency} />}
{activePanel === 'layer' && <LayerPanel layers={layers} activeLayerId={activeLayerId} onSelectLayer={onSelectLayer ?? (() => {})} onAddLayer={onAddLayer ?? (() => {})} onToggleLayer={onToggleLayer ?? (() => {})} onDeleteLayer={onDeleteLayer} onRenameLayer={onRenameLayer} onDuplicateLayer={onDuplicateLayer} onToggleLock={onToggleLock} onReorder={onReorder} onAddSubLayer={onAddSubLayer} />}
</div>
<div className={`rightbar-panel${activePanel === 'library' ? ' active' : ''}`} data-panel-content="library">
{activePanel === 'library' && <BlockLibrary blocks={blocks} category="Alle" onCategoryChange={onBlockCategoryChange ?? (() => {})} onSearch={onBlockSearch ?? (() => {})} onDragBlock={onDragBlock ?? (() => {})} onRenameBlock={onRenameBlock} onDuplicateBlock={onDuplicateBlock} onDeleteBlock={onDeleteBlock} onSvgImport={onSvgImport} onSaveGroupAsBlock={onSaveGroupAsBlock} />}
@@ -55,9 +64,6 @@ const RightSidebar: React.FC<RightSidebarProps> = ({
<div className={`rightbar-panel${activePanel === 'ki' ? ' active' : ''}`} data-panel-content="ki">
{activePanel === 'ki' && <KICopilot messages={kiMessages ?? []} suggestions={kiSuggestions ?? []} onSend={onKISend ?? (() => {})} onSuggestionClick={onKISuggestionClick ?? (() => {})} loading={kiLoading} />}
</div>
<div className={`rightbar-panel${activePanel === 'plugins' ? ' active' : ''}`} data-panel-content="plugins">
{activePanel === 'plugins' && <PluginManager />}
</div>
</div>
</aside>
);
+189
View File
@@ -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<SettingsModalProps> = ({ open, onClose }) => {
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<SettingsTab>('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 (
<div className="settings-tab-content">
<label className="settings-label">Anzeigename</label>
<input className="settings-input" type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
<label className="settings-label">E-Mail</label>
<input className="settings-input" type="email" value={user?.email || ''} readOnly style={{ opacity: 0.6 }} />
<label className="settings-label">Avatar</label>
<div className="settings-avatar-preview">{initials}</div>
</div>
);
case 'password':
return (
<div className="settings-tab-content">
<label className="settings-label">Altes Passwort</label>
<input className="settings-input" type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
<label className="settings-label">Neues Passwort</label>
<input className="settings-input" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
<label className="settings-label">Passwort bestätigen</label>
<input className="settings-input" type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
{pwMessage && <div className="settings-message">{pwMessage}</div>}
<button className="settings-btn" onClick={handlePasswordChange}>Passwort ändern</button>
</div>
);
case 'language':
return (
<div className="settings-tab-content">
<label className="settings-label">Sprache</label>
<select className="settings-input" value={language} onChange={(e) => setLanguage(e.target.value)}>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
);
case 'theme':
return (
<div className="settings-tab-content">
<label className="settings-label">Erscheinungsbild</label>
<div className="settings-toggle-group">
<button className={`settings-toggle-btn${themeMode === 'light' ? ' active' : ''}`} onClick={() => setThemeMode('light')}>Hell</button>
<button className={`settings-toggle-btn${themeMode === 'dark' ? ' active' : ''}`} onClick={() => setThemeMode('dark')}>Dunkel</button>
</div>
<label className="settings-label">Akzentfarbe</label>
<div className="settings-color-picker">
<input type="color" value={accentColor} onChange={(e) => setAccentColor(e.target.value)} />
<span className="settings-color-value">{accentColor}</span>
</div>
</div>
);
case 'users':
return (
<div className="settings-tab-content">
<div className="settings-users-header">
<strong>Benutzerverwaltung</strong>
<button className="settings-btn">Benutzer hinzufügen</button>
</div>
<div className="settings-user-list">
<div className="settings-user-row">
<div className="settings-user-avatar">LM</div>
<div className="settings-user-info">
<div className="settings-user-name">Leopold M.</div>
<div className="settings-user-email">admin@example.com</div>
</div>
<span className="settings-user-role">Admin</span>
<button className="settings-btn-danger">Löschen</button>
</div>
</div>
</div>
);
case 'plugins':
return (
<div className="settings-tab-content">
<PluginManager />
</div>
);
case 'ai':
return (
<div className="settings-tab-content">
<label className="settings-label">KI-Anbieter</label>
<select className="settings-input" value={aiProvider} onChange={(e) => setAiProvider(e.target.value)}>
<option value="openrouter">OpenRouter</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="local">Lokal (Ollama)</option>
</select>
<label className="settings-label">Modell</label>
<select className="settings-input" value={aiModel} onChange={(e) => setAiModel(e.target.value)}>
<option value="gpt-4o">GPT-4o</option>
<option value="gpt-4o-mini">GPT-4o mini</option>
<option value="claude-3.5-sonnet">Claude 3.5 Sonnet</option>
<option value="llama-3.3-70b">Llama 3.3 70B</option>
</select>
<label className="settings-label">API-Key</label>
<input className="settings-input" type="password" value={aiApiKey} onChange={(e) => setAiApiKey(e.target.value)} placeholder="••••••••••••" />
</div>
);
default:
return null;
}
};
return (
<div className="settings-modal-overlay" onClick={onClose}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
<button className="settings-modal-close" onClick={onClose} aria-label="Schließen"></button>
<div className="settings-modal-tabs">
{visibleTabs.map((tab) => (
<button
key={tab.id}
className={`settings-modal-tab${activeTab === tab.id ? ' active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="settings-modal-content">
{renderTabContent()}
</div>
</div>
</div>
);
};
export default SettingsModal;
+4 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import type { TopbarProps } from '../types/ui.types';
const Topbar: React.FC<TopbarProps> = ({ projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme }) => {
const Topbar: React.FC<TopbarProps> = ({ projectName, savedStatus, onUndo, onRedo, onThemeToggle, theme, onOpenSettings }) => {
return (
<header className="topbar" role="banner">
<div className="topbar-left">
@@ -49,6 +49,9 @@ const Topbar: React.FC<TopbarProps> = ({ projectName, savedStatus, onUndo, onRed
<button className="icon-btn-top" aria-label="Benachrichtigungen" title="Benachrichtigungen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
</button>
<button className="icon-btn-top" aria-label="Einstellungen" title="Einstellungen" onClick={onOpenSettings}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="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>
<div className="avatar" aria-label="Benutzer Leopold M." title="Leopold M.">LM</div>
</div>
</header>
+63 -1
View File
@@ -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<TreeViewProps> = ({
selectedId,
onSelect,
onToggle,
onReorder,
renderIcon,
renderActions,
renderDetail,
draggable = false,
}) => {
const [expandedSet, setExpandedSet] = useState<Set<string>>(new Set());
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const [dragPosition, setDragPosition] = useState<'before' | 'after' | 'inside'>('before');
const toggleExpand = (id: string) => {
setExpandedSet((prev) => {
@@ -34,18 +39,75 @@ const TreeView: React.FC<TreeViewProps> = ({
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 (
<React.Fragment key={node.id}>
<div
className={`tree-node${isSelected ? ' active' : ''}`}
className={`tree-node${isSelected ? ' active' : ''}${dragClass}${isDragging ? ' tree-dragging' : ''}`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => 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 && (
<button
+22 -7
View File
@@ -349,6 +349,21 @@ export class InteractionEngine {
this.selectionEngine.finishBoxSelect(this.allElements);
this.emitSelectionChange();
this.requestRender();
return;
}
// Click-drag: confirm draw on mouseup for 2-point tools
if (this.state.phase === 'drawing' && this.state.points.length >= 1) {
const raw = this.getWorldCoords(e);
const snapped = this.applySnap(raw.x, raw.y);
const final = this.applyOrtho(snapped.x, snapped.y);
const tool = this.state.activeTool;
// For 2-point tools: use mouseup position as second point
if (tool === 'line' || tool === 'rect' || tool === 'circle' || tool === 'dimension' || tool === 'leader') {
this.state.points.push(final);
this.confirmDraw();
return;
}
}
}
@@ -466,7 +481,10 @@ export class InteractionEngine {
}
private handleDrawDown(pt: { x: number; y: number }): void {
this.state.phase = 'drawing';
// Clear any previous points when starting a new draw operation
if (this.state.points.length === 0) {
this.state.phase = 'drawing';
}
this.state.points.push(pt);
// Arc: auto-confirm after 3 points (center, start, end)
@@ -481,12 +499,8 @@ export class InteractionEngine {
return;
}
// Line, rect, circle, dimension, leader: auto-confirm after 2 points
if ((this.state.activeTool === 'line' || this.state.activeTool === 'rect' || this.state.activeTool === 'circle' || this.state.activeTool === 'dimension' || this.state.activeTool === 'leader') && this.state.points.length >= 2) {
this.updatePreview(pt);
this.confirmDraw();
return;
}
// Line, rect, circle, dimension, leader: wait for mouseup (click-drag behavior)
// Points are collected on mousedown, confirmed on mouseup
this.updatePreview(pt);
this.notifyToolStateChanged();
@@ -1000,6 +1014,7 @@ export class InteractionEngine {
if (this.animationFrame !== null) return;
this.animationFrame = requestAnimationFrame(() => {
this.animationFrame = null;
this.renderEngine.setPreviewElement(this.state.previewElement);
this.renderEngine.render();
});
}
+467 -3
View File
@@ -372,8 +372,7 @@ a:hover { text-decoration: underline; }
align-items: center;
padding: 0 var(--spacing-md);
gap: var(--spacing-lg);
overflow-x: auto;
scrollbar-width: thin;
overflow: hidden;
}
.ribbon-group {
display: flex;
@@ -435,7 +434,13 @@ a:hover { text-decoration: underline; }
overflow: hidden;
position: relative;
}
.app-body.left-collapsed { grid-template-columns: 0 1fr var(--rightbar-w); }
.app-body.left-collapsed { grid-template-columns: 28px 1fr var(--rightbar-w); }
.app-body.left-collapsed .leftbar { padding: 4px; }
.app-body.left-collapsed .leftbar-header { padding: 4px; border: none; justify-content: center; }
.app-body.left-collapsed .leftbar-title { display: none; }
.app-body.left-collapsed .leftbar .tool-section { display: none; }
.app-body.left-collapsed .leftbar-toggle svg { transform: rotate(180deg); }
.app-body.left-collapsed .leftbar-toggle { display: grid !important; }
.app-body.right-collapsed { grid-template-columns: var(--leftbar-w) 1fr 0; }
.app-body.both-collapsed { grid-template-columns: 0 1fr 0; }
@@ -1430,6 +1435,153 @@ body.drawer-open { overflow: hidden; }
.tree-row:hover { background: var(--color-surface-2, rgba(0,0,0,0.04)); }
.tree-row.active { background: rgba(37, 99, 235, 0.10); color: var(--color-primary, #2563eb); font-weight: 500; }
/* TreeView component actual classes */
.tree-view { padding: 2px 0; }
.tree-node {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
min-height: 28px;
color: var(--color-text);
transition: background-color .12s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-node:hover { background: var(--color-surface-2); }
.tree-node.active { background: var(--color-primary-50); color: var(--color-primary); font-weight: 500; }
[data-theme="dark"] .tree-node.active { background: var(--color-surface-3); }
.tree-toggle {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
padding: 0;
border-radius: 3px;
transition: background-color .12s;
}
.tree-toggle:hover { background: var(--color-surface-3); color: var(--color-text); }
.tree-toggle svg { width: 12px; height: 12px; transition: transform .15s; }
.tree-toggle-placeholder { width: 16px; flex-shrink: 0; }
.tree-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--color-text);
}
.tree-node.active .tree-label { color: var(--color-primary); }
.tree-count {
font-size: 10px;
color: var(--color-text-muted);
background: var(--color-surface-2);
padding: 1px 6px;
border-radius: 8px;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.tree-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.tree-detail {
padding: 4px 8px 4px 24px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-2);
}
.tree-children {
border-left: 1px dashed var(--color-border);
margin-left: 11px;
}
/* BlockLibrary tree (distinct from TreeView) */
.lib-tree-node { margin-bottom: 2px; }
.lib-tree-node .tree-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
min-height: 28px;
color: var(--color-text);
transition: background-color .12s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lib-tree-node .tree-item:hover { background: var(--color-surface-2); }
.lib-tree-node .tree-item .tree-toggle {
width: 16px;
flex-shrink: 0;
color: var(--color-text-muted);
font-size: 12px;
}
.lib-tree-node .tree-item .tree-icon { font-size: 14px; flex-shrink: 0; }
.lib-tree-node .tree-item .tree-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--color-text);
}
.lib-tree-node .tree-item .tree-count {
font-size: 10px;
color: var(--color-text-muted);
background: var(--color-surface-2);
padding: 1px 6px;
border-radius: 8px;
flex-shrink: 0;
}
.lib-tree-node .tree-children {
padding-left: 20px;
border-left: 1px dashed var(--color-border);
margin-left: 11px;
}
.lib-tree-node .tree-item-leaf {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
cursor: grab;
min-height: 28px;
color: var(--color-text);
transition: background-color .12s;
}
.lib-tree-node .tree-item-leaf:hover { background: var(--color-surface-2); }
.lib-tree-node .tree-item-leaf .tree-icon { font-size: 14px; flex-shrink: 0; }
.lib-tree-node .tree-item-leaf .tree-label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
color: var(--color-text);
}
.lib-tree-node .tree-item-leaf .tree-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity .15s;
}
.lib-tree-node .tree-item-leaf:hover .tree-actions { opacity: 1; }
.tree-toggle {
width: 16px;
height: 16px;
@@ -1546,3 +1698,315 @@ body.drawer-open { overflow: hidden; }
background: white; transition: transform 0.2s;
}
.plugin-toggle.on .plugin-toggle-knob { transform: translateX(16px); }
/* Export Menu */
.export-menu-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.export-menu {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 16px;
min-width: 280px;
}
.export-menu-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.export-menu-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
border-radius: var(--radius-sm);
color: var(--color-text);
transition: background var(--t-fast);
text-align: left;
}
.export-menu-item:hover { background: var(--color-surface-2); }
.export-menu-format {
font-weight: 700;
font-size: 14px;
color: var(--color-primary);
min-width: 40px;
}
.export-menu-desc {
font-size: 12px;
color: var(--color-text-muted);
}
/* ===================== DRAG & DROP INDICATORS ===================== */
.tree-node.tree-drag-before {
border-top: 2px solid var(--color-primary, #2563eb);
margin-top: -1px;
}
.tree-node.tree-drag-after {
border-bottom: 2px solid var(--color-primary, #2563eb);
margin-bottom: -1px;
}
.tree-node.tree-drag-inside {
border: 2px solid var(--color-primary, #2563eb);
background: rgba(37, 99, 235, 0.08);
border-radius: 4px;
}
.tree-node.tree-dragging {
opacity: 0.4;
}
/* ===================== SETTINGS MODAL ===================== */
.settings-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
}
.settings-modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
max-width: 700px;
max-height: 80vh;
width: 90%;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.settings-modal-close {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: var(--color-text-muted);
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, color 0.2s;
z-index: 1;
}
.settings-modal-close:hover {
background: var(--color-surface-2);
color: var(--color-text);
}
.settings-modal-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border);
padding: 0 12px;
flex-shrink: 0;
overflow-x: auto;
}
.settings-modal-tab {
padding: 12px 16px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-text-muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: color 0.2s, border-color 0.2s;
}
.settings-modal-tab:hover {
color: var(--color-text);
}
.settings-modal-tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.settings-modal-content {
padding: 20px 24px;
overflow-y: auto;
flex: 1;
}
.settings-tab-content {
display: flex;
flex-direction: column;
gap: 6px;
}
.settings-label {
font-size: 12px;
font-weight: 600;
color: var(--color-text-muted);
margin-top: 10px;
margin-bottom: 2px;
}
.settings-label:first-child {
margin-top: 0;
}
.settings-input {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
color: var(--color-text);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.settings-input:focus {
border-color: var(--color-primary);
}
.settings-avatar-preview {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
}
.settings-message {
font-size: 12px;
color: var(--color-primary);
padding: 8px 0;
}
.settings-btn {
padding: 8px 16px;
border: none;
border-radius: var(--radius-sm);
background: var(--color-primary);
color: white;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
margin-top: 12px;
align-self: flex-start;
}
.settings-btn:hover {
opacity: 0.85;
}
.settings-btn-danger {
padding: 6px 12px;
border: none;
border-radius: var(--radius-sm);
background: #e74c3c;
color: white;
font-size: 12px;
cursor: pointer;
transition: opacity 0.2s;
}
.settings-btn-danger:hover {
opacity: 0.85;
}
.settings-toggle-group {
display: flex;
gap: 8px;
}
.settings-toggle-btn {
padding: 8px 20px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
color: var(--color-text-muted);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.settings-toggle-btn.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.settings-color-picker {
display: flex;
align-items: center;
gap: 10px;
}
.settings-color-picker input[type="color"] {
width: 40px;
height: 32px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
background: none;
}
.settings-color-value {
font-size: 13px;
color: var(--color-text-muted);
font-family: monospace;
}
.settings-users-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.settings-user-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.settings-user-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
}
.settings-user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
.settings-user-info {
flex: 1;
min-width: 0;
}
.settings-user-name {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
}
.settings-user-email {
font-size: 11px;
color: var(--color-text-muted);
}
.settings-user-role {
font-size: 11px;
font-weight: 600;
color: var(--color-primary);
background: rgba(37, 99, 235, 0.1);
padding: 3px 8px;
border-radius: 4px;
}
+16 -4
View File
@@ -9,9 +9,9 @@ import type { UserCursor } from '../crdt';
export type ViewMode = '2d' | 'iso' | 'front' | 'top';
export type RibbonTab = 'start' | 'insert' | 'format' | 'view' | 'tools' | 'ki';
export type RightPanel = 'tool' | 'layer' | 'library' | 'ki' | 'plugins';
export type RightPanel = 'tool' | 'layer' | 'library' | 'ki';
export type Theme = 'light' | 'dark';
export type DrawerTab = 'tool' | 'layer' | 'library' | 'ki' | 'plugins';
export type DrawerTab = 'tool' | 'layer' | 'library' | 'ki';
export interface TreeNode {
id: string;
@@ -64,6 +64,12 @@ export interface TopbarProps {
onRedo: () => void;
onThemeToggle: () => void;
theme: Theme;
onOpenSettings?: () => void;
}
export interface SettingsModalProps {
open: boolean;
onClose: () => void;
}
export interface RibbonBarProps {
@@ -77,6 +83,7 @@ export interface LeftSidebarProps {
onToolChange: (tool: string) => void;
selectedTemplate?: string | null;
onTemplateSelect?: (templateName: string | null) => void;
onCollapse?: () => void;
}
export interface CanvasAreaProps {
@@ -90,6 +97,7 @@ export interface CanvasAreaProps {
activeTool: string;
elements: CADElement[];
layers: CADLayer[];
activeLayerId?: string;
onElementCreated: (el: CADElement) => void;
onElementsDeleted: (ids: string[]) => void;
onElementsModified: (els: CADElement[]) => void;
@@ -125,9 +133,10 @@ export interface RightSidebarProps {
onRenameLayer?: (id: string, name: string) => void;
onDuplicateLayer?: (id: string) => void;
onToggleLock?: (id: string) => void;
onUpdateLayerColor?: (id: string, color: string) => void;
onReorder?: (draggedId: string, targetId: string, position: 'before' | 'after' | 'inside') => void;
onUpdateLayerLineType?: (id: string, lineType: 'solid' | 'dashed' | 'dotted') => void;
onUpdateLayerTransparency?: (id: string, transparency: number) => void;
onAddSubLayer?: (parentId: string) => void;
onRenameBlock?: (id: string, name: string) => void;
onDuplicateBlock?: (id: string) => void;
onDeleteBlock?: (id: string) => void;
@@ -142,6 +151,7 @@ export interface RightSidebarProps {
onKISend?: (text: string) => void;
onKISuggestionClick?: (suggestion: KISuggestion) => void;
kiLoading?: boolean;
onUpdateElement?: (el: CADElement) => void;
}
export interface PropertiesPanelProps {
@@ -160,9 +170,10 @@ export interface LayerPanelProps {
onRenameLayer?: (id: string, name: string) => void;
onDuplicateLayer?: (id: string) => void;
onToggleLock?: (id: string) => void;
onUpdateLayerColor?: (id: string, color: string) => void;
onReorder?: (draggedId: string, targetId: string, position: 'before' | 'after' | 'inside') => void;
onUpdateLayerLineType?: (id: string, lineType: 'solid' | 'dashed' | 'dotted') => void;
onUpdateLayerTransparency?: (id: string, transparency: number) => void;
onAddSubLayer?: (parentId: string) => void;
}
export interface BlockLibraryProps {
@@ -212,6 +223,7 @@ export interface TreeViewProps {
nodes: TreeNode[];
onSelect: (id: string) => void;
onToggle: (id: string) => void;
onReorder?: (draggedId: string, targetId: string, position: 'before' | 'after' | 'inside') => void;
draggable?: boolean;
renderIcon?: (node: TreeNode) => ReactNode;
}
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Simple Forgejo push webhook receiver that triggers deploy.sh"""
from http.server import HTTPServer, BaseHTTPRequestHandler
import hmac, hashlib, subprocess, json, os, datetime
SECRET = os.environ.get("WEBHOOK_SECRET", "web-cad-neu-deploy-2026")
DEPLOY_SCRIPT = "/data/web-cad-neu/deploy.sh"
LOG_FILE = "/var/log/webhook-deploy.log"
PORT = int(os.environ.get("WEBHOOK_PORT", "9876"))
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/webhook":
self.send_response(404)
self.end_headers()
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
# Verify signature
signature = self.headers.get("X-Gitea-Signature", "")
computed = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, computed):
self.send_response(403)
self.end_headers()
self.wfile.write(b"Invalid signature")
return
# Parse event
event = self.headers.get("X-Gitea-Event", "")
if event != "push":
self.send_response(200)
self.end_headers()
self.wfile.write(b"Ignored: not a push event")
return
payload = json.loads(body)
ref = payload.get("ref", "")
if ref != "refs/heads/main":
self.send_response(200)
self.end_headers()
self.wfile.write(b"Ignored: not main branch")
return
# Trigger deploy in background
ts = datetime.datetime.now().isoformat()
self.log_msg(f"[{ts}] Push to main detected, triggering deploy...")
subprocess.Popen(
["bash", DEPLOY_SCRIPT],
stdout=open(LOG_FILE, "a"),
stderr=subprocess.STDOUT
)
self.send_response(200)
self.end_headers()
self.wfile.write(b"Deploy triggered")
def log_msg(self, msg):
with open(LOG_FILE, "a") as f:
f.write(msg + "\n")
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", PORT), WebhookHandler)
print(f"Webhook receiver listening on port {PORT}")
server.serve_forever()