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:
@@ -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
@@ -4,7 +4,7 @@ services:
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "8082:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
+10
-24
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user