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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user