import { Fragment, useState, useEffect, useCallback } from 'react' import { toast } from 'sonner' import { Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame, Radio, Zap, Lightbulb, RadioTower, Share2, type LucideIcon, } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' import { clampHandles, sideDefault } from '@/utils/handleUtils' import { THEMES } from '@/utils/themes' import { applyOpacity } from '@/utils/colorUtils' import type { NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle, EdgeLineStyle, } from '@/types' import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types' import { EDGE_LINE_STYLES, EDGE_LINE_STYLE_LABELS, EDGE_TYPE_BASE_WIDTH, EDGE_TYPE_DEFAULT_LINE, clampWidthMult, dashArrayFor, } from '@/utils/edgeLineStyle' import { MarkerShapePicker } from './MarkerShapePicker' // ── Node types exposed for custom style, grouped by category (skip groupRect/group) ── const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ { label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] }, { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] }, { label: 'IoT', types: ['iot', 'camera', 'cpl'] }, { label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] }, { label: 'Z-Wave', types: ['zwave_coordinator', 'zwave_router', 'zwave_enddevice'] }, { label: 'Personal', types: ['computer', 'laptop', 'mobile'] }, { label: 'Generic', types: ['generic'] }, ] const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical'] const NODE_ICONS: Record = { isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers, vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi, camera: Camera, printer: Printer, computer: Monitor, laptop: Laptop, mobile: Smartphone, cpl: PlugZap, docker_host: Anchor, docker_container: Package, zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb, zwave_coordinator: RadioTower, zwave_router: Share2, zwave_enddevice: Lightbulb, generic: Circle, } // ── Default style for a node type (from default theme) ───────────────────── function defaultNodeStyle(nodeType: NodeType): NodeTypeStyle { const accent = THEMES.default.colors.nodeAccents[nodeType] ?? THEMES.default.colors.nodeAccents.generic return { borderColor: accent.border, borderOpacity: 1, bgColor: THEMES.default.colors.nodeCardBackground, bgOpacity: 1, iconColor: accent.icon, iconOpacity: 1, width: 0, height: 0, } } function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle { return { color: THEMES.default.colors.edgeColors[edgeType], opacity: 1, pathStyle: 'bezier', lineStyle: EDGE_TYPE_DEFAULT_LINE[edgeType], widthMult: 1, animated: 'none', arrowStart: 'none', arrowEnd: 'none', } } // ── Edge line preview (renders the actual dash pattern + width) ──────────────── interface EdgeLineSwatchProps { color: string lineStyle: EdgeLineStyle strokeWidth: number width?: number } function EdgeLineSwatch({ color, lineStyle, strokeWidth, width = 40 }: EdgeLineSwatchProps) { const h = 12 return ( ) } // ── Color + opacity row ────────────────────────────────────────────────────── interface ColorRowProps { label: string color: string opacity: number onColorChange: (v: string) => void onOpacityChange: (v: number) => void } function ColorRow({ label, color, opacity, onColorChange, onOpacityChange }: ColorRowProps) { return (
{label} onColorChange(e.target.value)} className="w-7 h-7 rounded cursor-pointer border border-[#30363d] bg-transparent p-0.5" />
onOpacityChange(parseFloat(e.target.value))} className="flex-1 h-1 accent-[#00d4ff]" /> {Math.round(opacity * 100)}%
) } // ── Node type editor ───────────────────────────────────────────────────────── interface NodeEditorProps { nodeType: NodeType style: NodeTypeStyle onChange: (s: NodeTypeStyle) => void onApplyToExisting: () => void } function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditorProps) { const set = useCallback((k: K, v: NodeTypeStyle[K]) => { onChange({ ...style, [k]: v }) }, [style, onChange]) return (
{NODE_TYPE_LABELS[nodeType]}
set('borderColor', v)} onOpacityChange={(v) => set('borderOpacity', v)} /> set('bgColor', v)} onOpacityChange={(v) => set('bgOpacity', v)} /> set('iconColor', v)} onOpacityChange={(v) => set('iconOpacity', v)} />
Default size
0 = auto (min 140 × 50 px, grows with content)
W set('width', parseInt(e.target.value, 10) || 0)} className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]" />
H set('height', parseInt(e.target.value, 10) || 0)} className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]" />
Default connection points
New {NODE_TYPE_LABELS[nodeType]} nodes start with these (0–64 per side)
{([ ['Top', 'top', 'topHandles'], ['Right', 'right', 'rightHandles'], ['Bottom', 'bottom', 'bottomHandles'], ['Left', 'left', 'leftHandles'], ] as const).map(([label, side, key]) => (
{label} set(key, clampHandles(side, parseInt(e.target.value, 10)))} aria-label={`${label} default connection points`} className="w-16 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]" />
))}
) } // ── Edge type editor ───────────────────────────────────────────────────────── interface EdgeEditorProps { edgeType: EdgeType style: EdgeTypeStyle onChange: (s: EdgeTypeStyle) => void onApplyToExisting: () => void } function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditorProps) { const set = useCallback((k: K, v: EdgeTypeStyle[K]) => { onChange({ ...style, [k]: v }) }, [style, onChange]) return (
{EDGE_TYPE_LABELS[edgeType]}
set('color', v)} onOpacityChange={(v) => set('opacity', v)} />
Line style
{EDGE_LINE_STYLES.map((ls) => ( ))}
Line width {style.widthMult}×
set('widthMult', clampWidthMult(parseInt(e.target.value, 10)))} aria-label="Line width multiplier" className="w-full h-1 accent-[#00d4ff]" />
Path style
{(['bezier', 'smooth'] as EdgePathStyle[]).map((ps) => ( ))}
Animation
Endpoints
set('arrowStart', s)} /> set('arrowEnd', s)} />
) } // ── Main modal ─────────────────────────────────────────────────────────────── type Tab = 'nodes' | 'edges' type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeType } | null interface CustomStyleModalProps { open: boolean onClose: () => void /** When opening, preselect this node type's editor (shortcut from NodeModal). */ initialNodeType?: NodeType } export function CustomStyleModal({ open, onClose, initialNodeType }: CustomStyleModalProps) { const { customStyle, setCustomStyle } = useThemeStore() const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore() const [tab, setTab] = useState('nodes') const [selection, setSelection] = useState(null) const [draft, setDraft] = useState(() => ({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges }, })) // Reset the draft to the saved customStyle whenever the modal is (re)opened. // The parent keeps this component mounted and only toggles `open`, so Radix's // onOpenChange never fires for a parent-driven open — we key off the prop edge // instead. Without this, abandoned edits (Cancel) would leak into the next open. useEffect(() => { if (open) { setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } }) if (initialNodeType) { setTab('nodes') setSelection({ kind: 'node', type: initialNodeType }) } else { setSelection(null) } } // Intentional snapshot-on-open: we don't want live customStyle changes to // clobber an in-progress edit, only a fresh open should reset. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) const handleOpen = (isOpen: boolean) => { if (!isOpen) onClose() } const getNodeStyle = (t: NodeType): NodeTypeStyle => draft.nodes[t] ?? defaultNodeStyle(t) const getEdgeStyle = (t: EdgeType): EdgeTypeStyle => draft.edges[t] ?? defaultEdgeStyle(t) const handleNodeChange = (t: NodeType, s: NodeTypeStyle) => setDraft((d) => ({ ...d, nodes: { ...d.nodes, [t]: s } })) const handleEdgeChange = (t: EdgeType, s: EdgeTypeStyle) => setDraft((d) => ({ ...d, edges: { ...d.edges, [t]: s } })) const handleApplyNodeType = (t: NodeType) => { const style = getNodeStyle(t) applyTypeNodeStyle(t, style) toast.success(`Applied style to all ${NODE_TYPE_LABELS[t]} nodes`) } const handleApplyEdgeType = (t: EdgeType) => { const style = getEdgeStyle(t) applyTypeEdgeStyle(t, style) toast.success(`Applied style to all ${EDGE_TYPE_LABELS[t]} edges`) } const handleSave = () => { setCustomStyle(draft) markUnsaved() toast.success('Custom style saved — save your canvas to persist') onClose() } const handleApplyAll = () => { setCustomStyle(draft) applyAllCustomStyles(draft) markUnsaved() toast.success('Custom style applied to all nodes and edges') onClose() } const selectedNodeStyle = selection?.kind === 'node' ? getNodeStyle(selection.type) : null const selectedEdgeStyle = selection?.kind === 'edge' ? getEdgeStyle(selection.type) : null return ( Custom Style Editor
{/* Left panel — type list */}
{/* Tabs */}
{(['nodes', 'edges'] as Tab[]).map((t) => ( ))}
{/* Type list */}
{tab === 'nodes' && NODE_TYPE_GROUPS.map((group) => (
{group.label}
{group.types.map((t) => { const Icon = NODE_ICONS[t] ?? Circle const style = draft.nodes[t] const isSelected = selection?.kind === 'node' && selection.type === t const swatchColor = style ? applyOpacity(style.borderColor, style.borderOpacity) : THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e' return ( ) })}
))} {tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => { const style = draft.edges[t] const isSelected = selection?.kind === 'edge' && selection.type === t const swatchColor = style ? applyOpacity(style.color, style.opacity) : THEMES.default.colors.edgeColors[t] const lineStyle = style?.lineStyle ?? EDGE_TYPE_DEFAULT_LINE[t] const widthMult = clampWidthMult(style?.widthMult) return ( ) })}
{/* Right panel — editor */}
{!selection && (
Select a {tab === 'nodes' ? 'node type' : 'edge type'} from the list to edit its style
)} {selection?.kind === 'node' && selectedNodeStyle && ( handleNodeChange(selection.type, s)} onApplyToExisting={() => handleApplyNodeType(selection.type)} /> )} {selection?.kind === 'edge' && selectedEdgeStyle && ( handleEdgeChange(selection.type, s)} onApplyToExisting={() => handleApplyEdgeType(selection.type)} /> )}
{/* Footer */}
) }