feat: multi-design canvas system with electrical nodes/edges

Backend: New Design model + designs table; design_id FK on nodes, edges, canvas_state; migration seeds default 'Network Topology' design; full CRUD API for designs; canvas load/save accept design_id.

Frontend: designStore (Zustand), design switcher in Sidebar, design-aware canvas load/save, auto-save on design switch.

Electrical node types (14): grid, ups, battery, generator, solar_panel, inverter, circuit_breaker, contactor, electrical_switch, socket, light, meter, transformer, load — icons, registrations, accent colors in all 6 themes.

Electrical edge type: registered in edgeTypes, BASE_STYLES, edgeColors, all theme edgeColors, EDGE_DEFAULT_COLORS.

Bug fixes: data corruption on design switch (stale closure), race condition on save-then-load, missing Zap import, missing Electrical group in NodeModal, missing electrical entries in custom theme edgeColors, inline imports hoisted.
This commit is contained in:
Pranjal Joshi
2026-05-31 14:35:22 +05:30
parent 4ba04660c8
commit 46435605eb
19 changed files with 616 additions and 61 deletions
@@ -8,4 +8,5 @@ export const edgeTypes = {
virtual: HomelableEdge,
cluster: HomelableEdge,
fibre: HomelableEdge,
electrical: HomelableEdge,
}
@@ -324,6 +324,7 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
fibre: { stroke: edgeColors.fibre, strokeWidth: 2.5, filter: `drop-shadow(0 0 3px ${edgeColors.fibre}aa)` },
electrical: { stroke: edgeColors.electrical, strokeWidth: 2 },
}
const customColor = data?.custom_color as string | undefined
@@ -2,6 +2,7 @@ import { type NodeProps, type Node } from '@xyflow/react'
import {
Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna,
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
} from 'lucide-react'
import { BaseNode } from './BaseNode'
import type { NodeData } from '@/types'
@@ -32,3 +33,19 @@ export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} />
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
// Electrical node types
export const GridNode = (props: N) => <BaseNode {...props} icon={Grid3x3} />
export const UpsNode = (props: N) => <BaseNode {...props} icon={Battery} />
export const BatteryNode = (props: N) => <BaseNode {...props} icon={Battery} />
export const GeneratorNode = (props: N) => <BaseNode {...props} icon={Fuel} />
export const SolarPanelNode = (props: N) => <BaseNode {...props} icon={Sun} />
export const InverterNode = (props: N) => <BaseNode {...props} icon={Repeat2} />
export const CircuitBreakerNode = (props: N) => <BaseNode {...props} icon={Split} />
export const ContactorNode = (props: N) => <BaseNode {...props} icon={ToggleLeft} />
export const ElectricalSwitchNode = (props: N) => <BaseNode {...props} icon={ToggleLeft} />
export const SocketNode = (props: N) => <BaseNode {...props} icon={Cable} />
export const LightNode = (props: N) => <BaseNode {...props} icon={Lightbulb} />
export const MeterNode = (props: N) => <BaseNode {...props} icon={Gauge} />
export const TransformerNode = (props: N) => <BaseNode {...props} icon={Combine} />
export const LoadNode = (props: N) => <BaseNode {...props} icon={Zap} />
@@ -1,4 +1,12 @@
import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode, MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode, ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode } from './index'
import {
IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode,
NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode,
ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode,
GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode,
CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode,
LightNode, MeterNode, TransformerNode, LoadNode,
} from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode'
@@ -31,4 +39,18 @@ export const nodeTypes = {
zigbee_coordinator: ZigbeeCoordinatorNode,
zigbee_router: ZigbeeRouterNode,
zigbee_enddevice: ZigbeeEndDeviceNode,
grid: GridNode,
ups: UpsNode,
battery: BatteryNode,
generator: GeneratorNode,
solar_panel: SolarPanelNode,
inverter: InverterNode,
circuit_breaker: CircuitBreakerNode,
contactor: ContactorNode,
electrical_switch: ElectricalSwitchNode,
socket: SocketNode,
light: LightNode,
meter: MeterNode,
transformer: TransformerNode,
load: LoadNode,
}
@@ -19,6 +19,7 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_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'] },
]
+66 -2
View File
@@ -1,10 +1,11 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type } from 'lucide-react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type, Zap, PlusCircle } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore'
import { scanApi, settingsApi } from '@/api/client'
import { canvasApi, designsApi, scanApi, settingsApi } from '@/api/client'
import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease'
import {
@@ -50,6 +51,9 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
const [prevForceView, setPrevForceView] = useState(forceView)
const logout = useAuthStore((s) => s.logout)
const { designs, activeDesignId, setActiveDesign } = useDesignStore()
const [creating, setCreating] = useState(false)
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
if (forceView !== prevForceView) {
@@ -88,6 +92,66 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
<Logo size={28} showText={!collapsed} />
</div>
{/* Design Switcher */}
{!collapsed && designs.length > 0 && (
<div className="px-2 pt-2 pb-1 border-b border-border relative">
<button
onClick={() => setDesignSwitcherOpen((o) => !o)}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium bg-[#21262d] border border-border hover:border-[#30363d] transition-colors cursor-pointer"
>
{activeDesignId ? (() => {
const active = designs.find((d) => d.id === activeDesignId)
const Icon = active?.design_type === 'electrical' ? Zap : LayoutDashboard
return <><Icon size={14} className="shrink-0 text-[#00d4ff]" /><span className="truncate text-foreground">{active?.name ?? 'Select Design'}</span></>
})() : <span className="text-muted-foreground">Select Design</span>}
</button>
{designSwitcherOpen && (
<>
{/* Overlay to close */}
<div className="fixed inset-0 z-40" onClick={() => setDesignSwitcherOpen(false)} />
<div className="absolute left-2 right-2 top-full mt-1 z-50 bg-[#21262d] border border-border rounded-md shadow-xl overflow-hidden">
{designs.map((d) => {
const Icon = d.design_type === 'electrical' ? Zap : LayoutDashboard
return (
<button
key={d.id}
onClick={() => { setActiveDesign(d.id); setDesignSwitcherOpen(false) }}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors cursor-pointer ${
d.id === activeDesignId
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
: 'text-muted-foreground hover:text-foreground hover:bg-[#30363d]'
}`}
>
<Icon size={14} className="shrink-0" />
<span className="truncate">{d.name}</span>
</button>
)
})}
<div className="border-t border-border" />
<button
disabled={creating}
onClick={async () => {
setCreating(true)
try {
const name = `Electrical ${designs.filter((d) => d.design_type === 'electrical').length + 1}`
const res = await designsApi.create({ name, design_type: 'electrical' })
useDesignStore.getState().setDesigns([...useDesignStore.getState().designs, res.data])
setActiveDesign(res.data.id)
setDesignSwitcherOpen(false)
} catch { toast.error('Failed to create design') }
finally { setCreating(false) }
}}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-[#00d4ff] hover:bg-[#00d4ff]/10 transition-colors cursor-pointer disabled:opacity-50"
>
<PlusCircle size={14} />
<span>New Electrical Design</span>
</button>
</div>
</>
)}
</div>
)}
{/* Views */}
<nav className="flex flex-col gap-0.5 p-2">
<SidebarItem