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
+37 -16
View File
@@ -1,13 +1,13 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.db.database import get_db from app.db.database import get_db
from app.db.models import CanvasState, Edge, Node from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
from app.schemas.edges import EdgeResponse from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse from app.schemas.nodes import NodeResponse
@@ -16,10 +16,20 @@ router = APIRouter()
@router.get("", response_model=CanvasStateResponse) @router.get("", response_model=CanvasStateResponse)
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse: async def load_canvas(
nodes = (await db.execute(select(Node))).scalars().all() design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"),
edges = (await db.execute(select(Edge))).scalars().all() db: AsyncSession = Depends(get_db),
state = await db.get(CanvasState, 1) _: str = Depends(get_current_user),
) -> CanvasStateResponse:
if design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
design_id = first.id if first else None
if design_id is None:
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
state = await db.get(CanvasState, design_id)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1} viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
return CanvasStateResponse( return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes], nodes=[NodeResponse.model_validate(n) for n in nodes],
@@ -33,17 +43,24 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
async def save_canvas( async def save_canvas(
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user) body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]: ) -> dict[str, bool]:
design_id = body.design_id
if design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
design_id = first.id if first else None
if design_id is None:
return {"saved": False, "error": "No design found"}
incoming_node_ids = {n.id for n in body.nodes} incoming_node_ids = {n.id for n in body.nodes}
incoming_edge_ids = {e.id for e in body.edges} incoming_edge_ids = {e.id for e in body.edges}
# Delete nodes removed from canvas # Delete nodes removed from canvas (only within this design)
existing_nodes = (await db.execute(select(Node))).scalars().all() existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
for node in existing_nodes: for node in existing_nodes:
if node.id not in incoming_node_ids: if node.id not in incoming_node_ids:
await db.delete(node) await db.delete(node)
# Delete edges removed from canvas # Delete edges removed from canvas (only within this design)
existing_edges = (await db.execute(select(Edge))).scalars().all() existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
for edge in existing_edges: for edge in existing_edges:
if edge.id not in incoming_edge_ids: if edge.id not in incoming_edge_ids:
await db.delete(edge) await db.delete(edge)
@@ -53,29 +70,33 @@ async def save_canvas(
# Upsert nodes # Upsert nodes
for node_data in body.nodes: for node_data in body.nodes:
db_node = await db.get(Node, node_data.id) db_node = await db.get(Node, node_data.id)
payload = node_data.model_dump()
payload["design_id"] = design_id
if db_node: if db_node:
for field, value in node_data.model_dump().items(): for field, value in payload.items():
setattr(db_node, field, value) setattr(db_node, field, value)
else: else:
db.add(Node(**node_data.model_dump())) db.add(Node(**payload))
# Upsert edges # Upsert edges
for edge_data in body.edges: for edge_data in body.edges:
db_edge = await db.get(Edge, edge_data.id) db_edge = await db.get(Edge, edge_data.id)
payload = edge_data.model_dump()
payload["design_id"] = design_id
if db_edge: if db_edge:
for field, value in edge_data.model_dump().items(): for field, value in payload.items():
setattr(db_edge, field, value) setattr(db_edge, field, value)
else: else:
db.add(Edge(**edge_data.model_dump())) db.add(Edge(**payload))
# Upsert viewport + custom style # Upsert viewport + custom style
state = await db.get(CanvasState, 1) state = await db.get(CanvasState, design_id)
if state: if state:
state.viewport = body.viewport state.viewport = body.viewport
state.custom_style = body.custom_style state.custom_style = body.custom_style
state.saved_at = datetime.now(timezone.utc) state.saved_at = datetime.now(timezone.utc)
else: else:
db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style)) db.add(CanvasState(design_id=design_id, viewport=body.viewport, custom_style=body.custom_style))
await db.commit() await db.commit()
return {"saved": True} return {"saved": True}
+79
View File
@@ -0,0 +1,79 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.designs import DesignCreate, DesignResponse, DesignUpdate
router = APIRouter()
@router.get("", response_model=list[DesignResponse])
async def list_designs(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> list[DesignResponse]:
designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all()
return [DesignResponse.model_validate(d) for d in designs]
@router.post("", response_model=DesignResponse, status_code=201)
async def create_design(
body: DesignCreate,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> DesignResponse:
design = Design(name=body.name, design_type=body.design_type)
db.add(design)
await db.flush()
# Create empty canvas state for the new design
db.add(CanvasState(design_id=design.id))
await db.commit()
await db.refresh(design)
return DesignResponse.model_validate(design)
@router.put("/{design_id}", response_model=DesignResponse)
async def update_design(
design_id: str,
body: DesignUpdate,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> DesignResponse:
design = await db.get(Design, design_id)
if not design:
raise HTTPException(404, "Design not found")
if body.name is not None:
design.name = body.name
await db.commit()
await db.refresh(design)
return DesignResponse.model_validate(design)
@router.delete("/{design_id}", status_code=204)
async def delete_design(
design_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> None:
design = await db.get(Design, design_id)
if not design:
raise HTTPException(404, "Design not found")
# Count remaining designs — prevent deleting the last one
count = (await db.execute(select(Design))).scalars().all()
if len(count) <= 1:
raise HTTPException(400, "Cannot delete the only design")
# Delete associated canvas state, edges, nodes
cs = await db.get(CanvasState, design_id)
if cs:
await db.delete(cs)
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
for e in edges:
await db.delete(e)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
for n in nodes:
await db.delete(n)
await db.delete(design)
await db.commit()
+83 -3
View File
@@ -1,5 +1,7 @@
import json as _json
import logging import logging
import shutil import shutil
import uuid as _uuid_mod
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
@@ -168,12 +170,91 @@ async def init_db() -> None:
except OperationalError as exc: except OperationalError as exc:
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc) logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
# --- end Zigbee schema migrations ------------------------------------- # --- end Zigbee schema migrations -------------------------------------
# --- Electrical designs schema migrations -----------------------------
# Create designs table (idempotent)
await _try_migrate(
conn,
"CREATE TABLE IF NOT EXISTS designs ("
"id VARCHAR PRIMARY KEY,"
"name VARCHAR NOT NULL,"
"design_type VARCHAR NOT NULL DEFAULT 'network',"
"created_at DATETIME,"
"updated_at DATETIME"
")",
label="designs.table",
)
# Seed default Network Topology design if designs table is empty
_default_design_id = str(_uuid_mod.uuid4())
row = await conn.exec_driver_sql("SELECT COUNT(*) FROM designs")
count = (await row.fetchone())[0]
if count == 0:
await conn.exec_driver_sql(
"INSERT INTO designs (id, name, design_type, created_at, updated_at) "
"VALUES (?, 'Network Topology', 'network', datetime('now'), datetime('now'))",
(_default_design_id,),
)
else:
row2 = await conn.exec_driver_sql("SELECT id FROM designs WHERE design_type = 'network' LIMIT 1")
default = await row2.fetchone()
_default_design_id = default[0] if default else _default_design_id
# Add design_id to nodes
await _try_migrate(
conn, "ALTER TABLE nodes ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
label="nodes.design_id",
)
# Assign existing nodes to default design
await conn.exec_driver_sql(
"UPDATE nodes SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
)
# Add design_id to edges
await _try_migrate(
conn, "ALTER TABLE edges ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
label="edges.design_id",
)
# Assign existing edges to default design
await conn.exec_driver_sql(
"UPDATE edges SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
)
# Migrate canvas_state from id=1 to design_id PK (SQLite rebuild)
try:
info = await conn.exec_driver_sql("PRAGMA table_info(canvas_state)")
cols = info.fetchall()
has_design_id = any(c[1] == "design_id" for c in cols)
if not has_design_id:
logger.info("Migrating canvas_state: switching to design_id primary key")
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
await conn.exec_driver_sql(
"CREATE TABLE canvas_state_new ("
"design_id VARCHAR PRIMARY KEY REFERENCES designs(id) ON DELETE CASCADE,"
"viewport JSON,"
"custom_style JSON,"
"saved_at DATETIME"
")"
)
# Copy existing row(s), mapping id=1 to default design_id
old_rows = await conn.exec_driver_sql("SELECT id, viewport, custom_style, saved_at FROM canvas_state")
for old in old_rows.fetchall():
cs_id, viewport, custom_style, saved_at = old
target_design = _default_design_id
await conn.exec_driver_sql(
"INSERT INTO canvas_state_new (design_id, viewport, custom_style, saved_at) "
"VALUES (?, ?, ?, ?)",
(target_design, viewport, custom_style, saved_at),
)
await conn.exec_driver_sql("DROP TABLE canvas_state")
await conn.exec_driver_sql("ALTER TABLE canvas_state_new RENAME TO canvas_state")
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
except OperationalError as exc:
logger.warning("canvas_state migration failed: %s", exc)
# --- end Electrical designs schema migrations --------------------------
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE canvas_state ADD COLUMN custom_style JSON")
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL) # Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
with suppress(OperationalError): with suppress(OperationalError):
rows = await conn.exec_driver_sql( rows = await conn.exec_driver_sql(
@@ -192,7 +273,6 @@ async def init_db() -> None:
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible}) props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
if disk_gb is not None: if disk_gb is not None:
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible}) props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
import json as _json
await conn.exec_driver_sql( await conn.exec_driver_sql(
"UPDATE nodes SET properties = ? WHERE id = ?", "UPDATE nodes SET properties = ? WHERE id = ?",
(_json.dumps(props), node_id), (_json.dumps(props), node_id),
+13 -1
View File
@@ -16,12 +16,23 @@ def _uuid() -> str:
return str(uuid.uuid4()) return str(uuid.uuid4())
class Design(Base):
__tablename__ = "designs"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
name: Mapped[str] = mapped_column(String, nullable=False)
design_type: Mapped[str] = mapped_column(String, nullable=False, default="network")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
class Node(Base): class Node(Base):
__tablename__ = "nodes" __tablename__ = "nodes"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
type: Mapped[str] = mapped_column(String, nullable=False) type: Mapped[str] = mapped_column(String, nullable=False)
label: Mapped[str] = mapped_column(String, nullable=False) label: Mapped[str] = mapped_column(String, nullable=False)
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
hostname: Mapped[str | None] = mapped_column(String) hostname: Mapped[str | None] = mapped_column(String)
ip: Mapped[str | None] = mapped_column(String) ip: Mapped[str | None] = mapped_column(String)
mac: Mapped[str | None] = mapped_column(String) mac: Mapped[str | None] = mapped_column(String)
@@ -61,6 +72,7 @@ class Edge(Base):
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE")) source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE")) target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
type: Mapped[str] = mapped_column(String, default="ethernet") type: Mapped[str] = mapped_column(String, default="ethernet")
label: Mapped[str | None] = mapped_column(String) label: Mapped[str | None] = mapped_column(String)
vlan_id: Mapped[int | None] = mapped_column(Integer) vlan_id: Mapped[int | None] = mapped_column(Integer)
@@ -77,7 +89,7 @@ class Edge(Base):
class CanvasState(Base): class CanvasState(Base):
__tablename__ = "canvas_state" __tablename__ = "canvas_state"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), primary_key=True)
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+2 -1
View File
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, stats, status, zigbee from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee
from app.api.routes import settings as settings_routes from app.api.routes import settings as settings_routes
from app.core.config import settings from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler from app.core.scheduler import start_scheduler, stop_scheduler
@@ -51,6 +51,7 @@ app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"]) app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"]) app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"]) app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
app.include_router(designs.router, prefix="/api/v1/designs", tags=["designs"])
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"]) app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
app.include_router(status.router, prefix="/api/v1/status", tags=["status"]) app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"]) app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
+1
View File
@@ -63,6 +63,7 @@ class CanvasSaveRequest(BaseModel):
edges: list[EdgeSave] = [] edges: list[EdgeSave] = []
viewport: dict[str, Any] = {} viewport: dict[str, Any] = {}
custom_style: dict[str, Any] | None = None custom_style: dict[str, Any] | None = None
design_id: str | None = None
class CanvasStateResponse(BaseModel): class CanvasStateResponse(BaseModel):
+22
View File
@@ -0,0 +1,22 @@
from datetime import datetime
from pydantic import BaseModel
class DesignCreate(BaseModel):
name: str
design_type: str = "electrical"
class DesignUpdate(BaseModel):
name: str | None = None
class DesignResponse(BaseModel):
id: str
name: str
design_type: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+74 -26
View File
@@ -28,9 +28,10 @@ import { SearchModal } from '@/components/modals/SearchModal'
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
import { ShortcutsModal } from '@/components/modals/ShortcutsModal' import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { canvasApi } from '@/api/client' import { canvasApi, designsApi } from '@/api/client'
import { demoNodes, demoEdges } from '@/utils/demoData' import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling' import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData, CustomStyleDef } from '@/types' import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
@@ -44,6 +45,7 @@ export default function App() {
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
const { designs, activeDesignId, activeDesignType, setDesigns, setActiveDesign } = useDesignStore()
useStatusPolling() useStatusPolling()
@@ -71,8 +73,9 @@ export default function App() {
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false) const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope // Declare handleSave before the Ctrl+S effect so it is in scope
const handleSave = useCallback(async () => { const handleSave = useCallback(async (designIdOverride?: string) => {
try { try {
const saveDesignId = designIdOverride ?? activeDesignId
if (STANDALONE) { if (STANDALONE) {
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle })) localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
markSaved() markSaved()
@@ -81,18 +84,59 @@ export default function App() {
} }
const nodesToSave = nodes.map(serializeNode) const nodesToSave = nodes.map(serializeNode)
const edgesToSave = edges.map(serializeEdge) const edgesToSave = edges.map(serializeEdge)
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle }) await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId })
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
} catch { } catch {
toast.error('Save failed') toast.error('Save failed')
} }
}, [nodes, edges, markSaved, activeTheme, customStyle]) }, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId])
// Keep a ref so the keydown handler always calls the latest version // Keep a ref so the keydown handler always calls the latest version
const handleSaveRef = useRef(handleSave) const handleSaveRef = useRef(handleSave)
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave]) useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
const loadCanvasFromApi = useCallback(async (designId?: string) => {
try {
const res = await canvasApi.load(designId)
const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) {
const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, true])
)
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
loadCanvas(rfNodes, rfEdges)
} else {
loadCanvas(demoNodes, demoEdges)
}
} catch {
loadCanvas(demoNodes, demoEdges)
}
}, [loadCanvas, setTheme, setCustomStyle, demoNodes, demoEdges])
const loadDesignsAndCanvas = useCallback(async () => {
if (STANDALONE) return
try {
const res = await designsApi.list()
const loadedDesigns = res.data
setDesigns(loadedDesigns)
const targetId = activeDesignId ?? loadedDesigns[0]?.id
if (targetId) {
setActiveDesign(targetId)
await loadCanvasFromApi(targetId)
}
} catch {
// If API fails (e.g. fresh DB with no designs), fall back to demo data
loadCanvas(demoNodes, demoEdges)
}
}, [setDesigns, setActiveDesign, loadCanvasFromApi, activeDesignId, demoNodes, demoEdges])
// Load canvas on auth (or immediately in standalone mode) // Load canvas on auth (or immediately in standalone mode)
useEffect(() => { useEffect(() => {
if (STANDALONE) { if (STANDALONE) {
@@ -112,28 +156,32 @@ export default function App() {
return return
} }
if (!isAuthenticated) return if (!isAuthenticated) return
canvasApi.load() loadDesignsAndCanvas()
.then((res) => { }, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change
const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) { // Reload canvas when active design changes (after initial load)
// Build a map of container mode nodes to know if children should be nested const initialLoadDone = useRef(false)
const proxmoxContainerMap = new Map<string, boolean>( const prevDesignRef = useRef<string | null>(null)
(apiNodes as ApiNode[]) useEffect(() => {
.filter((n) => n.type === 'group' || n.container_mode === true) if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) {
.map((n) => [n.id, true]) const oldId = prevDesignRef.current
) if (oldId && oldId !== activeDesignId) {
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) // Save current (old) canvas data under the old design ID before switching.
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) // We call handleSave directly (not via ref) so it runs in this effect's
const savedTheme = res.data.viewport?.theme_id // closure where activeDesignId is already the NEW value — the override
if (savedTheme) setTheme(savedTheme) // ensures data is stored under the correct design_id.
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) handleSave(oldId).then(() => {
loadCanvas(rfNodes, rfEdges) loadCanvasFromApi(activeDesignId)
} else { })
loadCanvas(demoNodes, demoEdges) } else {
} loadCanvasFromApi(activeDesignId)
}) }
.catch(() => loadCanvas(demoNodes, demoEdges)) }
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) if (activeDesignId) {
prevDesignRef.current = activeDesignId
initialLoadDone.current = true
}
}, [activeDesignId])
// Keep refs for store actions so keydown handler is always up-to-date without re-registering // Keep refs for store actions so keydown handler is always up-to-date without re-registering
const undoRef = useRef(undo) const undoRef = useRef(undo)
+14 -1
View File
@@ -28,12 +28,16 @@ export const authApi = {
} }
export const canvasApi = { export const canvasApi = {
load: () => api.get('/canvas'), load: (design_id?: string) => {
const params = design_id ? { design_id } : {}
return api.get('/canvas', { params })
},
save: (payload: { save: (payload: {
nodes: object[] nodes: object[]
edges: object[] edges: object[]
viewport: object viewport: object
custom_style?: object | null custom_style?: object | null
design_id?: string | null
}) => api.post('/canvas/save', payload), }) => api.post('/canvas/save', payload),
} }
@@ -89,6 +93,15 @@ export const settingsApi = {
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data), save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
} }
export const designsApi = {
list: () => api.get<import('@/types').Design[]>('/designs'),
create: (data: { name: string; design_type: string }) =>
api.post<import('@/types').Design>('/designs', data),
update: (id: string, data: { name?: string }) =>
api.put<import('@/types').Design>(`/designs/${id}`, data),
delete: (id: string) => api.delete(`/designs/${id}`),
}
export const zigbeeApi = { export const zigbeeApi = {
testConnection: (data: { testConnection: (data: {
mqtt_host: string mqtt_host: string
@@ -8,4 +8,5 @@ export const edgeTypes = {
virtual: HomelableEdge, virtual: HomelableEdge,
cluster: HomelableEdge, cluster: HomelableEdge,
fibre: 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' }, virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' }, 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)` }, 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 const customColor = data?.custom_color as string | undefined
@@ -2,6 +2,7 @@ import { type NodeProps, type Node } from '@xyflow/react'
import { import {
Globe, Router, Network, Server, Layers, Box, Container, Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, 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' } from 'lucide-react'
import { BaseNode } from './BaseNode' import { BaseNode } from './BaseNode'
import type { NodeData } from '@/types' 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 ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} />
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} /> export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} /> 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 { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode' import { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode' import { GroupNode } from './GroupNode'
@@ -31,4 +39,18 @@ export const nodeTypes = {
zigbee_coordinator: ZigbeeCoordinatorNode, zigbee_coordinator: ZigbeeCoordinatorNode,
zigbee_router: ZigbeeRouterNode, zigbee_router: ZigbeeRouterNode,
zigbee_enddevice: ZigbeeEndDeviceNode, 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: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] }, { label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] }, { 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'] }, { label: 'Generic', types: ['generic', 'groupRect'] },
] ]
+66 -2
View File
@@ -1,10 +1,11 @@
import { useState, useCallback, useEffect, useRef } from 'react' 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 { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { scanApi, settingsApi } from '@/api/client' import { canvasApi, designsApi, scanApi, settingsApi } from '@/api/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease' import { useLatestRelease } from '@/hooks/useLatestRelease'
import { import {
@@ -50,6 +51,9 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas') const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
const [prevForceView, setPrevForceView] = useState(forceView) const [prevForceView, setPrevForceView] = useState(forceView)
const logout = useAuthStore((s) => s.logout) 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. // forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
if (forceView !== prevForceView) { if (forceView !== prevForceView) {
@@ -88,6 +92,66 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
<Logo size={28} showText={!collapsed} /> <Logo size={28} showText={!collapsed} />
</div> </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 */} {/* Views */}
<nav className="flex flex-col gap-0.5 p-2"> <nav className="flex flex-col gap-0.5 p-2">
<SidebarItem <SidebarItem
+42
View File
@@ -0,0 +1,42 @@
import { create } from 'zustand'
import type { Design, DesignType } from '@/types'
interface DesignState {
designs: Design[]
activeDesignId: string | null
activeDesignType: DesignType | null
loaded: boolean
setDesigns: (designs: Design[]) => void
setActiveDesign: (id: string) => void
getActiveDesign: () => Design | null
}
export const useDesignStore = create<DesignState>((set, get) => ({
designs: [],
activeDesignId: null,
activeDesignType: null,
loaded: false,
setDesigns: (designs) =>
set((state) => {
const nextId = state.activeDesignId && designs.find((d) => d.id === state.activeDesignId)
? state.activeDesignId
: designs[0]?.id ?? null
const nextType = nextId ? designs.find((d) => d.id === nextId)?.design_type ?? null : null
return { designs, activeDesignId: nextId, activeDesignType: nextType, loaded: true }
}),
setActiveDesign: (id) =>
set((state) => {
const design = state.designs.find((d) => d.id === id)
return {
activeDesignId: id,
activeDesignType: design?.design_type ?? null,
}
}),
getActiveDesign: () => {
const { designs, activeDesignId } = get()
return designs.find((d) => d.id === activeDesignId) ?? null
},
}))
+40 -1
View File
@@ -1,3 +1,13 @@
export type DesignType = 'network' | 'electrical'
export interface Design {
id: string
name: string
design_type: DesignType
created_at: string
updated_at: string
}
export type NodeType = export type NodeType =
| 'isp' | 'isp'
| 'router' | 'router'
@@ -25,6 +35,20 @@ export type NodeType =
| 'zigbee_coordinator' | 'zigbee_coordinator'
| 'zigbee_router' | 'zigbee_router'
| 'zigbee_enddevice' | 'zigbee_enddevice'
| 'grid'
| 'ups'
| 'battery'
| 'generator'
| 'solar_panel'
| 'inverter'
| 'circuit_breaker'
| 'contactor'
| 'electrical_switch'
| 'socket'
| 'light'
| 'meter'
| 'transformer'
| 'load'
export type TextPosition = export type TextPosition =
| 'top-left' | 'top-left'
@@ -37,7 +61,7 @@ export type TextPosition =
| 'bottom-center' | 'bottom-center'
| 'bottom-right' | 'bottom-right'
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' | 'fibre' export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' | 'fibre' | 'electrical'
export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown' export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
@@ -159,6 +183,20 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
zigbee_coordinator: 'Zigbee Coordinator', zigbee_coordinator: 'Zigbee Coordinator',
zigbee_router: 'Zigbee Router', zigbee_router: 'Zigbee Router',
zigbee_enddevice: 'Zigbee End Device', zigbee_enddevice: 'Zigbee End Device',
grid: 'Grid Connection',
ups: 'UPS',
battery: 'Battery',
generator: 'Generator',
solar_panel: 'Solar Panel',
inverter: 'Inverter',
circuit_breaker: 'Circuit Breaker',
contactor: 'Contactor',
electrical_switch: 'Switch',
socket: 'Socket / Outlet',
light: 'Light Fixture',
meter: 'Energy Meter',
transformer: 'Transformer',
load: 'Electrical Load',
} }
export const STATUS_COLORS: Record<NodeStatus, string> = { export const STATUS_COLORS: Record<NodeStatus, string> = {
@@ -176,6 +214,7 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
virtual: 'Virtual', virtual: 'Virtual',
cluster: 'Cluster', cluster: 'Cluster',
fibre: 'Fibre', fibre: 'Fibre',
electrical: 'Electrical Wire',
} }
export interface NodeTypeStyle { export interface NodeTypeStyle {
+1
View File
@@ -8,4 +8,5 @@ export const EDGE_DEFAULT_COLORS: Record<EdgeType, string> = {
virtual: '#8b949e', virtual: '#8b949e',
cluster: '#ff6e00', cluster: '#ff6e00',
fibre: '#22d3ee', fibre: '#22d3ee',
electrical: '#e3b341',
} }
+99 -9
View File
@@ -68,6 +68,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00d4ff', icon: '#00d4ff' }, groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' }, group: { border: '#00d4ff', icon: '#00d4ff' },
text: { border: '#30363d', icon: '#e6edf3' }, text: { border: '#30363d', icon: '#e6edf3' },
grid: { border: '#ff6e00', icon: '#ff6e00' },
ups: { border: '#39d353', icon: '#39d353' },
battery: { border: '#39d353', icon: '#39d353' },
generator: { border: '#e3b341', icon: '#e3b341' },
solar_panel: { border: '#e3b341', icon: '#e3b341' },
inverter: { border: '#a855f7', icon: '#a855f7' },
circuit_breaker: { border: '#f85149', icon: '#f85149' },
contactor: { border: '#f85149', icon: '#f85149' },
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
socket: { border: '#8b949e', icon: '#8b949e' },
light: { border: '#e3b341', icon: '#e3b341' },
meter: { border: '#00d4ff', icon: '#00d4ff' },
transformer: { border: '#a855f7', icon: '#a855f7' },
load: { border: '#ec4899', icon: '#ec4899' },
}, },
nodeCardBackground: '#21262d', nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22', nodeIconBackground: '#161b22',
@@ -87,6 +101,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
virtual: '#8b949e', virtual: '#8b949e',
cluster: '#ff6e00', cluster: '#ff6e00',
fibre: '#22d3ee', fibre: '#22d3ee',
electrical:'#e3b341',
}, },
edgeSelectedColor: '#00d4ff', edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22', edgeLabelBackground:'#161b22',
@@ -132,6 +147,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#22d3ee', icon: '#22d3ee' }, groupRect: { border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' }, group: { border: '#22d3ee', icon: '#22d3ee' },
text: { border: '#404040', icon: '#ffffff' }, text: { border: '#404040', icon: '#ffffff' },
grid: { border: '#ff6e00', icon: '#ff6e00' },
ups: { border: '#39d353', icon: '#39d353' },
battery: { border: '#39d353', icon: '#39d353' },
generator: { border: '#e3b341', icon: '#e3b341' },
solar_panel: { border: '#e3b341', icon: '#e3b341' },
inverter: { border: '#a855f7', icon: '#a855f7' },
circuit_breaker: { border: '#f85149', icon: '#f85149' },
contactor: { border: '#f85149', icon: '#f85149' },
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
socket: { border: '#8b949e', icon: '#8b949e' },
light: { border: '#e3b341', icon: '#e3b341' },
meter: { border: '#00d4ff', icon: '#00d4ff' },
transformer: { border: '#a855f7', icon: '#a855f7' },
load: { border: '#ec4899', icon: '#ec4899' },
}, },
nodeCardBackground: '#0a0a0a', nodeCardBackground: '#0a0a0a',
nodeIconBackground: '#111111', nodeIconBackground: '#111111',
@@ -151,15 +180,16 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
virtual: '#6b7280', virtual: '#6b7280',
cluster: '#fb923c', cluster: '#fb923c',
fibre: '#06b6d4', fibre: '#06b6d4',
electrical:'#e3b341',
}, },
edgeSelectedColor: '#22d3ee', edgeSelectedColor: '#22d3ee',
edgeLabelBackground:'#111111', edgeLabelBackground:'#161b22',
edgeLabelColor: '#666666', edgeLabelColor: '#9ca3af',
edgeLabelBorder: '#1c1c1e', edgeLabelBorder: '#374151',
canvasBackground: '#000000', canvasBackground: '#030712',
canvasDotColor: '#1a1a1a', canvasDotColor: '#374151',
handleBackground: '#1c1c1e', handleBackground: '#374151',
handleBorder: '#444444', handleBorder: '#9ca3af',
reactFlowColorMode: 'dark', reactFlowColorMode: 'dark',
}, },
}, },
@@ -196,6 +226,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#0284c7', icon: '#0284c7' }, groupRect: { border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' }, group: { border: '#0284c7', icon: '#0284c7' },
text: { border: '#cbd5e1', icon: '#1f2328' }, text: { border: '#cbd5e1', icon: '#1f2328' },
grid: { border: '#ff6e00', icon: '#ff6e00' },
ups: { border: '#39d353', icon: '#39d353' },
battery: { border: '#39d353', icon: '#39d353' },
generator: { border: '#e3b341', icon: '#e3b341' },
solar_panel: { border: '#e3b341', icon: '#e3b341' },
inverter: { border: '#a855f7', icon: '#a855f7' },
circuit_breaker: { border: '#f85149', icon: '#f85149' },
contactor: { border: '#f85149', icon: '#f85149' },
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
socket: { border: '#8b949e', icon: '#8b949e' },
light: { border: '#e3b341', icon: '#e3b341' },
meter: { border: '#00d4ff', icon: '#00d4ff' },
transformer: { border: '#a855f7', icon: '#a855f7' },
load: { border: '#ec4899', icon: '#ec4899' },
}, },
nodeCardBackground: '#ffffff', nodeCardBackground: '#ffffff',
nodeIconBackground: '#f0f6ff', nodeIconBackground: '#f0f6ff',
@@ -215,15 +259,16 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
virtual: '#9ca3af', virtual: '#9ca3af',
cluster: '#ea580c', cluster: '#ea580c',
fibre: '#0891b2', fibre: '#0891b2',
electrical:'#d97706',
}, },
edgeSelectedColor: '#0284c7', edgeSelectedColor: '#0284c7',
edgeLabelBackground:'#ffffff', edgeLabelBackground:'#ffffff',
edgeLabelColor: '#57606a', edgeLabelColor: '#6b7280',
edgeLabelBorder: '#d0d7de', edgeLabelBorder: '#d0d7de',
canvasBackground: '#f6f8fa', canvasBackground: '#f6f8fa',
canvasDotColor: '#d0d7de', canvasDotColor: '#d0d7de',
handleBackground: '#d0d7de', handleBackground: '#d0d7de',
handleBorder: '#9ca3af', handleBorder: '#6b7280',
reactFlowColorMode: 'light', reactFlowColorMode: 'light',
}, },
}, },
@@ -260,6 +305,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00ffff', icon: '#00ffff' }, groupRect: { border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' }, group: { border: '#00ffff', icon: '#00ffff' },
text: { border: '#3a3a6a', icon: '#ffffff' }, text: { border: '#3a3a6a', icon: '#ffffff' },
grid: { border: '#ff6e00', icon: '#ff6e00' },
ups: { border: '#39d353', icon: '#39d353' },
battery: { border: '#39d353', icon: '#39d353' },
generator: { border: '#e3b341', icon: '#e3b341' },
solar_panel: { border: '#e3b341', icon: '#e3b341' },
inverter: { border: '#a855f7', icon: '#a855f7' },
circuit_breaker: { border: '#f85149', icon: '#f85149' },
contactor: { border: '#f85149', icon: '#f85149' },
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
socket: { border: '#8b949e', icon: '#8b949e' },
light: { border: '#e3b341', icon: '#e3b341' },
meter: { border: '#00d4ff', icon: '#00d4ff' },
transformer: { border: '#a855f7', icon: '#a855f7' },
load: { border: '#ec4899', icon: '#ec4899' },
}, },
nodeCardBackground: '#0f0f2a', nodeCardBackground: '#0f0f2a',
nodeIconBackground: '#0a0a1a', nodeIconBackground: '#0a0a1a',
@@ -279,6 +338,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
virtual: '#8888cc', virtual: '#8888cc',
cluster: '#ff8800', cluster: '#ff8800',
fibre: '#00e5ff', fibre: '#00e5ff',
electrical:'#ffff00',
}, },
edgeSelectedColor: '#00ffff', edgeSelectedColor: '#00ffff',
edgeLabelBackground:'#0a0a1a', edgeLabelBackground:'#0a0a1a',
@@ -324,6 +384,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00ff41', icon: '#00ff41' }, groupRect: { border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', icon: '#00ff41' }, group: { border: '#00ff41', icon: '#00ff41' },
text: { border: '#003311', icon: '#00ff41' }, text: { border: '#003311', icon: '#00ff41' },
grid: { border: '#cc6600', icon: '#cc6600' },
ups: { border: '#00ff41', icon: '#00ff41' },
battery: { border: '#00ff41', icon: '#00ff41' },
generator: { border: '#ffcc00', icon: '#ffcc00' },
solar_panel: { border: '#ffcc00', icon: '#ffcc00' },
inverter: { border: '#aa00ff', icon: '#aa00ff' },
circuit_breaker: { border: '#ff0033', icon: '#ff0033' },
contactor: { border: '#ff0033', icon: '#ff0033' },
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
socket: { border: '#005500', icon: '#005500' },
light: { border: '#ffcc00', icon: '#ffcc00' },
meter: { border: '#00d4ff', icon: '#00d4ff' },
transformer: { border: '#aa00ff', icon: '#aa00ff' },
load: { border: '#ff69b4', icon: '#ff69b4' },
}, },
nodeCardBackground: '#001100', nodeCardBackground: '#001100',
nodeIconBackground: '#002200', nodeIconBackground: '#002200',
@@ -343,6 +417,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
virtual: '#004400', virtual: '#004400',
cluster: '#33ff66', cluster: '#33ff66',
fibre: '#00ffcc', fibre: '#00ffcc',
electrical:'#66ff33',
}, },
edgeSelectedColor: '#00ff41', edgeSelectedColor: '#00ff41',
edgeLabelBackground:'#001100', edgeLabelBackground:'#001100',
@@ -388,6 +463,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00d4ff', icon: '#00d4ff' }, groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' }, group: { border: '#00d4ff', icon: '#00d4ff' },
text: { border: '#30363d', icon: '#e6edf3' }, text: { border: '#30363d', icon: '#e6edf3' },
grid: { border: '#ff6e00', icon: '#ff6e00' },
ups: { border: '#39d353', icon: '#39d353' },
battery: { border: '#39d353', icon: '#39d353' },
generator: { border: '#e3b341', icon: '#e3b341' },
solar_panel: { border: '#e3b341', icon: '#e3b341' },
inverter: { border: '#a855f7', icon: '#a855f7' },
circuit_breaker: { border: '#f85149', icon: '#f85149' },
contactor: { border: '#f85149', icon: '#f85149' },
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
socket: { border: '#8b949e', icon: '#8b949e' },
light: { border: '#e3b341', icon: '#e3b341' },
meter: { border: '#00d4ff', icon: '#00d4ff' },
transformer: { border: '#a855f7', icon: '#a855f7' },
load: { border: '#ec4899', icon: '#ec4899' },
}, },
nodeCardBackground: '#21262d', nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22', nodeIconBackground: '#161b22',
@@ -407,6 +496,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
virtual: '#8b949e', virtual: '#8b949e',
cluster: '#ff6e00', cluster: '#ff6e00',
fibre: '#22d3ee', fibre: '#22d3ee',
electrical: '#e3b341',
}, },
edgeSelectedColor: '#00d4ff', edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22', edgeLabelBackground:'#161b22',