Files
homelable/frontend/src/components/modals/NodeModal.tsx
T
Pouzor e8530ad3db feat: shortcut from node Appearance to canvas-wide type style
Adds a link under the colour swatches in the add/edit node modal that opens the
Custom Style editor with the node's type preselected, so a user can edit the
style for every node of that type without navigating Style -> Customize -> pick
type manually.

- CustomStyleModal: new optional `initialNodeType` prop; when set, the modal
  opens straight into that node type's editor instead of the empty placeholder.
- NodeModal: new optional `onEditTypeStyle(type)` prop renders the shortcut link
  (hidden for group/groupRect and when no handler is wired).
- App: `styleEditorType` state; both Add and Edit NodeModals forward the handler,
  and a standalone CustomStyleModal instance opens preselected (stacked over the
  node modal so in-progress node edits are preserved).

ha-relevant: yes
2026-07-04 15:14:33 +02:00

722 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, createElement, useState } from 'react'
import modalStyles from './modal-interactive.module.css'
import { RotateCcw, ChevronDown, Palette } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod, type NodeTypeStyle } from '@/types'
import { useThemeStore } from '@/stores/themeStore'
import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { BrandIconPicker } from './BrandIconPicker'
import { MAX_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
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: 'Electrical', types: ['grid', 'ups', 'battery', 'generator', 'solar_panel', 'inverter', 'circuit_breaker', 'contactor', 'electrical_switch', 'socket', 'light', 'meter', 'transformer', 'load'] },
{ label: 'Generic', types: ['generic', 'groupRect'] },
]
// Maps a side to its per-type default field on NodeTypeStyle.
const SIDE_STYLE_KEY: Record<Side, keyof NodeTypeStyle> = {
top: 'topHandles',
bottom: 'bottomHandles',
left: 'leftHandles',
right: 'rightHandles',
}
/**
* Compact per-side connection-point control: [− N +] with a typable value.
* Placed spatially around a node preview (see the Connection Points section).
*/
function CPStepper({ label, side, value, onChange }: {
label: string
side: Side
value: number
onChange: (v: number) => void
}) {
const min = sideDefault(side)
const labelEl = <span className="text-[10px] text-muted-foreground/80 leading-none">{label}</span>
const belowLabel = side === 'bottom'
const btn = 'w-6 h-full flex items-center justify-center text-sm text-muted-foreground hover:text-foreground hover:bg-[#21262d] disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-default'
return (
<div className="flex flex-col items-center gap-1">
{!belowLabel && labelEl}
<div className="flex items-center h-7 rounded-md border border-[#30363d] bg-[#0d1117] overflow-hidden">
<button
type="button"
aria-label={`Decrease ${label} connection points`}
onClick={() => onChange(clampHandles(side, value - 1))}
disabled={value <= min}
className={btn}
>
−
</button>
<input
type="number"
min={min}
max={MAX_HANDLES}
value={value}
aria-label={`${label} connection points`}
onChange={(e) => onChange(clampHandles(side, Number(e.target.value)))}
className="w-9 h-full bg-transparent text-center text-xs font-mono text-foreground outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<button
type="button"
aria-label={`Increase ${label} connection points`}
onClick={() => onChange(clampHandles(side, value + 1))}
disabled={value >= MAX_HANDLES}
className={btn}
>
+
</button>
</div>
{belowLabel && labelEl}
</div>
)
}
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
const ZWAVE_TYPES: NodeType[] = ['zwave_coordinator', 'zwave_router', 'zwave_enddevice']
// Mesh radio devices aren't IP-reachable, so they default to no status check.
const MESH_TYPES: NodeType[] = [...ZIGBEE_TYPES, ...ZWAVE_TYPES]
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
none: 'None',
ping: 'Ping',
http: 'HTTP',
https: 'HTTPS',
tcp: 'TCP',
ssh: 'SSH',
prometheus: 'Prometheus',
health: 'Health',
}
const DEFAULT_DATA: Partial<NodeData> = {
type: 'server',
label: '',
hostname: '',
ip: '',
status: 'unknown',
check_method: 'ping',
services: [],
container_mode: false,
custom_colors: undefined,
custom_icon: undefined,
}
interface ParentCandidate {
id: string
label: string
type: NodeType
/** True when the node has container mode on, so any node can nest inside it. */
container_mode?: boolean
}
interface NodeModalProps {
open: boolean
onClose: () => void
onSubmit: (data: Partial<NodeData>) => void
initial?: Partial<NodeData>
title?: string
parentCandidates?: ParentCandidate[]
currentNodeId?: string
/** Shortcut: open the Custom Style editor for this node's type (canvas-wide). */
onEditTypeStyle?: (type: NodeType) => void
}
// NodeModal is always mounted with a key that changes on open/edit, so useState
// initial value is enough - no need for a reset effect.
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId, onEditTypeStyle }: NodeModalProps) {
const merged = { ...DEFAULT_DATA, ...initial }
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
const [form, setForm] = useState<Partial<NodeData>>(merged)
const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
const [labelError, setLabelError] = useState(false)
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
const showServicesEnabled = form.custom_colors?.show_services === true
const hasAppearanceOverrides = Boolean(
form.custom_colors?.border
|| form.custom_colors?.background
|| form.custom_colors?.icon
)
const set = (key: keyof NodeData, value: unknown) =>
setForm((f) => ({ ...f, [key]: value }))
const customStyle = useThemeStore((s) => s.customStyle)
// Effective default count for a side: the per-type style default if set,
// otherwise the intrinsic side default (top/bottom → 1, left/right → 0).
const effectiveSideDefault = (side: Side): number => {
const styleVal = customStyle.nodes[(form.type ?? 'generic') as NodeType]?.[SIDE_STYLE_KEY[side]]
return clampHandles(side, typeof styleVal === 'number' ? styleVal : sideDefault(side))
}
const sideValue = (side: Side): number =>
clampHandles(side, form[handleCountField(side)] ?? effectiveSideDefault(side))
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!form.label?.trim()) {
setLabelError(true)
return
}
setLabelError(false)
const selectedType = (form.type ?? 'generic') as NodeType
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
const validParentTypes = getValidParentTypes(selectedType)
// A parent is valid either by the type rules (lxc/vm/docker_container) or
// because the candidate is a container-mode node (any child can nest in it).
const isValidParent = (p: ParentCandidate) =>
validParentTypes.includes(p.type) || p.container_mode === true
let safeParentId = form.parent_id
if (safeParentId) {
const parent = parentCandidates.find((n) => n.id === safeParentId)
if (!parent || !isValidParent(parent)) safeParentId = undefined
}
const isGroupType = selectedType === 'groupRect' || selectedType === 'group'
onSubmit({
...form,
// Persist the resolved per-side counts so type-style defaults (and
// untouched sliders) are baked into the node. Skipped for group types.
...(isGroupType ? {} : {
top_handles: sideValue('top'),
bottom_handles: sideValue('bottom'),
left_handles: sideValue('left'),
right_handles: sideValue('right'),
}),
parent_id: safeParentId,
container_mode: canUseContainerMode ? !!form.container_mode : false,
})
onClose()
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
{/* ── LEFT column: identity & network ── */}
<div className="flex flex-col gap-4 min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pb-1 border-b border-[#30363d]">Information</div>
<div className="grid grid-cols-2 gap-3">
{/* Type + Icon on the same row */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => {
const t = v as NodeType
setForm((f) => {
const next: Partial<NodeData> = { ...f, type: t }
if (MESH_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
// Drop the parent only if it's no longer a valid target for the
// new type — keep container-mode parents (any node can nest).
const parent = parentCandidates.find((n) => n.id === f.parent_id)
if (f.parent_id && !(parent && (getValidParentTypes(t).includes(parent.type) || parent.container_mode === true))) {
next.parent_id = undefined
}
return next
})
}}>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{NODE_TYPE_GROUPS.map((group, i) => (
<Fragment key={group.label}>
{i > 0 && <SelectSeparator className="bg-[#30363d]" />}
<SelectGroup>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
{group.label}
</SelectLabel>
{group.types.map((type) => (
<SelectItem key={type} value={type} className="text-sm pl-4">
{NODE_TYPE_LABELS[type]}
</SelectItem>
))}
</SelectGroup>
</Fragment>
))}
</SelectContent>
</Select>
</div>
{/* Icon */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Icon</Label>
{form.custom_icon && (
<button
type="button"
onClick={() => { set('custom_icon', undefined); setIconPickerOpen(false) }}
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<RotateCcw size={10} /> Reset
</button>
)}
</div>
{/* Trigger button */}
<button
type="button"
onClick={() => setIconPickerOpen((o) => !o)}
className={`flex items-center justify-between gap-2 h-8 px-3 bg-[#21262d] border border-[#30363d] text-sm transition-colors w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}
aria-label="Icon picker trigger"
>
<span className="flex items-center gap-2 min-w-0">
{(() => {
if (isBrandIconKey(form.custom_icon)) {
const slug = brandIconSlug(form.custom_icon!)
return <><img src={brandIconUrl(slug)} alt={slug} width={13} height={13} className="shrink-0" style={{ width: 13, height: 13, objectFit: 'contain' }} /><span className="text-foreground truncate">{slug}</span></>
}
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
if (entry) {
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
}
const defaultIcon = NODE_TYPE_DEFAULT_ICONS[form.type as NodeType] ?? NODE_TYPE_DEFAULT_ICONS.generic
return <>{createElement(defaultIcon, { size: 13, className: 'text-muted-foreground shrink-0' })}<span className="text-muted-foreground truncate">Default</span></>
})()}
</span>
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
</button>
</div>
</div>{/* end Type/Icon subgrid */}
{/* Inline icon picker - full width, shown below the type+icon row */}
{iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
<button
type="button"
role="tab"
aria-selected={iconTab === 'generic'}
onClick={() => setIconTab('generic')}
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
iconTab === 'generic' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
}`}
>
Generic
</button>
<button
type="button"
role="tab"
aria-selected={iconTab === 'brand'}
onClick={() => setIconTab('brand')}
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
iconTab === 'brand' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
}`}
>
Brand
</button>
</div>
{iconTab === 'brand' ? (
<BrandIconPicker
value={form.custom_icon}
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
/>
) : (
<>
<Input
value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)}
placeholder="Search icons…"
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
autoFocus
/>
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
{ICON_CATEGORIES.map((cat) => {
const entries = ICON_REGISTRY.filter(
(e) => e.category === cat &&
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
)
if (entries.length === 0) return null
return (
<div key={cat}>
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
<div className="grid grid-cols-7 gap-1">
{entries.map((entry) => {
const isSelected = form.custom_icon === entry.key
return (
<button
key={entry.key}
type="button"
title={entry.label}
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
aria-label={`Select icon ${entry.label}`}
style={{
background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
color: isSelected ? '#00d4ff' : '#8b949e',
}}
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
{createElement(entry.icon, { size: 13 })}
</button>
)
})}
</div>
</div>
)
})}
</div>
</>
)}
</div>
)}
{/* Label */}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Label *</Label>
<Input
value={form.label ?? ''}
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
placeholder="My Server"
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'} ${modalStyles['modal-radius']}`}
/>
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
</div>
<div className="grid grid-cols-2 gap-3">
{/* Hostname */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Hostname</Label>
<Input
value={form.hostname ?? ''}
onChange={(e) => set('hostname', e.target.value)}
placeholder="server.lan"
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
/>
</div>
{/* IP */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">IP Address</Label>
<Input
value={form.ip ?? ''}
onChange={(e) => set('ip', e.target.value)}
placeholder="192.168.1.x, 2001:db8::1"
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
/>
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
</div>
</div>{/* end Hostname/IP subgrid */}
<div className="grid grid-cols-2 gap-3">
{/* Check method — hidden for zigbee nodes (always none/online) */}
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Check Method</Label>
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Check method selector">
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{CHECK_METHODS.map((m) => (
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Check target — hidden for zigbee nodes */}
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Check Target</Label>
<Input
value={form.check_target ?? ''}
onChange={(e) => set('check_target', e.target.value)}
placeholder="http://..."
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
/>
</div>
)}
</div>{/* end Check method/target subgrid */}
{/* Parent Container */}
{(() => {
const childType = (form.type ?? 'generic') as NodeType
const validParentTypes = getValidParentTypes(childType)
// Candidates: type-based parents (lxc/vm/docker_container) plus any
// container-mode node. The current parent is always kept so an
// already-nested node can be re-targeted or detached here.
const validParents = parentCandidates.filter(
(n) => n.id !== currentNodeId &&
(validParentTypes.includes(n.type) || n.container_mode === true || n.id === form.parent_id),
)
if (validParents.length === 0) return null
return (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Parent Container</Label>
<Select
value={form.parent_id ?? 'none'}
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
<SelectValue>
{form.parent_id
? (validParents.find((n) => n.id === form.parent_id)?.label ?? 'None')
: 'None'}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="none" className="text-sm">None</SelectItem>
{validParents.map((n) => (
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
})()}
{/* Container mode */}
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
<div className="flex items-center justify-between col-span-2 py-1">
<div className="flex flex-col gap-0.5">
<Label className="text-xs text-muted-foreground">Container Mode</Label>
<span className="text-[10px] text-muted-foreground/60">
Allow other nodes to nest inside this node
</span>
</div>
<button
type="button"
role="switch"
aria-label="Container Mode"
aria-checked={!!form.container_mode}
onClick={() => set('container_mode', !form.container_mode)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
>
<span
className="pointer-events-none absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-in-out"
style={{
transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)'
}}
/>
</button>
</div>
)}
{/* Notes */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Notes</Label>
<Textarea
value={form.notes ?? ''}
onChange={(e) => set('notes', e.target.value)}
placeholder="Optional notes"
rows={3}
className={`bg-[#21262d] border-[#30363d] text-sm resize-y min-h-16 ${modalStyles['modal-radius']}`}
/>
</div>
</div>{/* ── end LEFT column ── */}
{/* ── RIGHT column: display ── */}
<div className="flex flex-col gap-4 min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pb-1 border-b border-[#30363d]">Design</div>
{/* Service visibility */}
{form.type !== 'groupRect' && form.type !== 'group' && (
<div className="flex items-start justify-between col-span-2 py-1">
<div className="flex flex-col gap-0.5">
<Label className="text-xs text-muted-foreground">Show Services</Label>
<span className="text-[10px] text-muted-foreground/60">Display discovered services on the node card</span>
</div>
<button
type="button"
role="switch"
aria-label="Show Services"
aria-checked={showServicesEnabled}
onClick={() => set('custom_colors', {
...form.custom_colors,
show_services: !showServicesEnabled,
})}
className="relative inline-flex h-5 w-9 mt-1 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none"
style={{ background: showServicesEnabled ? resolvedNodeColors.icon : '#30363d' }}
>
<span
className="pointer-events-none absolute top-px left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-in-out"
style={{ transform: showServicesEnabled ? 'translateX(16px)' : 'translateX(0)' }}
/>
</button>
</div>
)}
{/* Appearance */}
<div className="flex flex-col gap-2 col-span-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Appearance</Label>
{hasAppearanceOverrides && (
<button
type="button"
onClick={() => setForm((f) => {
if (!f.custom_colors) return f
const { border, background, icon, ...rest } = f.custom_colors
void border
void background
void icon
return {
...f,
custom_colors: Object.keys(rest).length > 0 ? rest : undefined,
}
})}
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<RotateCcw size={10} /> Reset to defaults
</button>
)}
</div>
<div className="grid grid-cols-3 gap-2">
{(['border', 'background', 'icon'] as const).map((key) => {
const resolved = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
const currentValue = resolved[key]
const isCustom = !!form.custom_colors?.[key]
return (
<div key={key} className="flex flex-col gap-1 items-center">
<label
className={`relative w-full h-7 rounded-md border cursor-pointer overflow-hidden transition-all ${modalStyles['modal-interactive']}`}
style={{ borderColor: isCustom ? currentValue : '#30363d' }}
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
tabIndex={0}
aria-label={`Color picker for ${key}`}
>
<input
type="color"
value={currentValue}
onChange={(e) => set('custom_colors', { ...form.custom_colors, [key]: e.target.value })}
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
/>
<div className="w-full h-full rounded-sm" style={{ background: currentValue }} />
</label>
<span className="text-[9px] text-muted-foreground/60 capitalize">{key}</span>
</div>
)
})}
</div>
<div className="min-h-3.5">
{!hasAppearanceOverrides && (
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
)}
</div>
{onEditTypeStyle && form.type !== 'group' && form.type !== 'groupRect' && (
<button
type="button"
onClick={() => onEditTypeStyle((form.type ?? 'generic') as NodeType)}
className="flex items-center gap-1 self-start text-[10px] text-[#00d4ff] hover:underline"
>
<Palette size={10} /> Edit {NODE_TYPE_LABELS[form.type ?? 'generic']} style for all nodes on the canvas
</button>
)}
</div>
{/* Connection points per side (not for group containers) */}
{form.type !== 'groupRect' && form.type !== 'group' && (
<div className="flex flex-col gap-2.5 col-span-2">
<Label className="text-xs text-muted-foreground">Connection Points</Label>
{/* Spatial cross: each side's stepper sits where that side is. */}
<div className="grid grid-cols-[1fr_auto_1fr] items-center justify-items-center gap-x-2 gap-y-2 py-1">
<div />
<CPStepper label="Top" side="top" value={sideValue('top')}
onChange={(v) => set('top_handles', v)} />
<div />
<CPStepper label="Left" side="left" value={sideValue('left')}
onChange={(v) => set('left_handles', v)} />
<div
className="flex items-center justify-center rounded-md border text-[9px] uppercase tracking-wide font-medium select-none"
style={{
width: 64, height: 40,
borderColor: resolvedNodeColors.border,
background: `${resolvedNodeColors.background}`,
color: resolvedNodeColors.icon,
}}
>
node
</div>
<CPStepper label="Right" side="right" value={sideValue('right')}
onChange={(v) => set('right_handles', v)} />
<div />
<CPStepper label="Bottom" side="bottom" value={sideValue('bottom')}
onChange={(v) => set('bottom_handles', v)} />
<div />
</div>
<div className="flex items-center justify-between pt-1">
<div className="flex flex-col gap-0.5">
<Label className="text-xs text-muted-foreground">Show Port Numbers</Label>
<span className="text-[10px] text-muted-foreground/60">Label each connection point</span>
</div>
<button
type="button"
role="switch"
aria-checked={!!form.show_port_numbers}
onClick={() => set('show_port_numbers', !form.show_port_numbers)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
tabIndex={0}
aria-label="Toggle port numbers"
style={{ background: form.show_port_numbers ? '#ff6e00' : '#30363d' }}
>
<span
className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
style={{ left: form.show_port_numbers ? 'calc(100% - 18px)' : '2px' }}
/>
</button>
</div>
</div>
)}
</div>{/* ── end RIGHT column ── */}
</div>
<div className="flex justify-between gap-2 pt-1">
{/* Show delete button only for edit mode (not add) */}
{title !== 'Add Node' ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
onClick={() => {
if (window.confirm('Delete this node?')) {
onSubmit({ ...form, _delete: true })
onClose()
}
}}
style={{ minWidth: 64 }}
>
Delete
</Button>
) : <span />}
<div className="flex gap-2">
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer"
>
{title === 'Add Node' ? 'Add' : 'Save'}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
)
}