diff --git a/backend/app/api/routes/canvas.py b/backend/app/api/routes/canvas.py index 4e841f6..cc7b342 100644 --- a/backend/app/api/routes/canvas.py +++ b/backend/app/api/routes/canvas.py @@ -25,6 +25,7 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c nodes=[NodeResponse.model_validate(n) for n in nodes], edges=[EdgeResponse.model_validate(e) for e in edges], viewport=viewport, + custom_style=state.custom_style if state else None, ) @@ -67,13 +68,14 @@ async def save_canvas( else: db.add(Edge(**edge_data.model_dump())) - # Upsert viewport + # Upsert viewport + custom style state = await db.get(CanvasState, 1) if state: state.viewport = body.viewport + state.custom_style = body.custom_style state.saved_at = datetime.now(timezone.utc) else: - db.add(CanvasState(id=1, viewport=body.viewport)) + db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style)) await db.commit() return {"saved": True} diff --git a/backend/app/db/database.py b/backend/app/db/database.py index f7b3ac2..73c1c61 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -84,6 +84,8 @@ async def init_db() -> None: await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON") with suppress(OperationalError): 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) with suppress(OperationalError): rows = await conn.exec_driver_sql( diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 5c004fb..9eb0c88 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -78,6 +78,7 @@ class CanvasState(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 1eccd96..0715c4d 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -62,9 +62,11 @@ class CanvasSaveRequest(BaseModel): nodes: list[NodeSave] = [] edges: list[EdgeSave] = [] viewport: dict[str, Any] = {} + custom_style: dict[str, Any] | None = None class CanvasStateResponse(BaseModel): nodes: list[NodeResponse] edges: list[EdgeResponse] viewport: dict[str, Any] + custom_style: dict[str, Any] | None = None diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 3d7dadf..ac0ce1e 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -557,3 +557,42 @@ async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: di edge = canvas["edges"][0] assert edge["label"] == "updated" assert edge["custom_color"] == "#ff0000" + + +# ── custom_style ────────────────────────────────────────────────────────────── + +async def test_save_and_load_custom_style(client: AsyncClient, headers: dict): + custom_style = { + "nodes": { + "server": {"borderColor": "#ff0000", "borderOpacity": 0.8, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#ff0000", "iconOpacity": 1, "width": 200, "height": 80}, + }, + "edges": { + "ethernet": {"color": "#00ff00", "opacity": 1, "pathStyle": "bezier", "animated": "none"}, + }, + } + payload = {"nodes": [], "edges": [], "viewport": {"theme_id": "custom"}, "custom_style": custom_style} + res = await client.post("/api/v1/canvas/save", json=payload, headers=headers) + assert res.status_code == 200 + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["custom_style"] is not None + assert canvas["custom_style"]["nodes"]["server"]["borderColor"] == "#ff0000" + assert canvas["custom_style"]["edges"]["ethernet"]["color"] == "#00ff00" + + +async def test_load_canvas_custom_style_null_by_default(client: AsyncClient, headers: dict): + res = await client.get("/api/v1/canvas", headers=headers) + assert res.status_code == 200 + assert res.json()["custom_style"] is None + + +async def test_save_canvas_custom_style_overwrite(client: AsyncClient, headers: dict): + style_v1 = {"nodes": {"server": {"borderColor": "#aabbcc", "borderOpacity": 1, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#aabbcc", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}} + style_v2 = {"nodes": {"proxmox": {"borderColor": "#ff6e00", "borderOpacity": 1, "bgColor": "#111111", "bgOpacity": 1, "iconColor": "#ff6e00", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}} + + await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v1}, headers=headers) + await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v2}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert "proxmox" in canvas["custom_style"]["nodes"] + assert "server" not in canvas["custom_style"]["nodes"] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6b10007..477f2e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,7 +29,7 @@ import { useThemeStore } from '@/stores/themeStore' import { canvasApi } from '@/api/client' import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' -import type { NodeData, EdgeData } from '@/types' +import type { NodeData, EdgeData, CustomStyleDef } from '@/types' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' @@ -39,7 +39,7 @@ export default function App() { const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() - const { activeTheme, setTheme } = useThemeStore() + const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() useStatusPolling() @@ -60,20 +60,20 @@ export default function App() { const handleSave = useCallback(async () => { try { if (STANDALONE) { - localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme })) + localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle })) markSaved() toast.success('Canvas saved') return } const nodesToSave = nodes.map(serializeNode) const edgesToSave = edges.map(serializeEdge) - await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } }) + await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle }) markSaved() toast.success('Canvas saved') } catch { toast.error('Save failed') } - }, [nodes, edges, markSaved, activeTheme]) + }, [nodes, edges, markSaved, activeTheme, customStyle]) // Keep a ref so the keydown handler always calls the latest version const handleSaveRef = useRef(handleSave) @@ -85,8 +85,9 @@ export default function App() { try { const saved = localStorage.getItem(STANDALONE_STORAGE_KEY) if (saved) { - const { nodes: savedNodes, edges: savedEdges, theme_id } = JSON.parse(saved) + const { nodes: savedNodes, edges: savedEdges, theme_id, custom_style } = JSON.parse(saved) if (theme_id) setTheme(theme_id) + if (custom_style) setCustomStyle(custom_style) loadCanvas(savedNodes, savedEdges) } else { loadCanvas(demoNodes, demoEdges) @@ -111,13 +112,14 @@ export default function App() { 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)) - }, [isAuthenticated, loadCanvas, setTheme]) + }, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // Keep refs for store actions so keydown handler is always up-to-date without re-registering const undoRef = useRef(undo) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 62aed11..b0ccdce 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -33,6 +33,7 @@ export const canvasApi = { nodes: object[] edges: object[] viewport: object + custom_style?: object | null }) => api.post('/canvas/save', payload), } diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx new file mode 100644 index 0000000..a0e0cb3 --- /dev/null +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -0,0 +1,484 @@ +import { useState, useCallback } from 'react' +import { toast } from 'sonner' +import { + Globe, Router, Network, Server, Layers, Box, Container, HardDrive, + Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, + type LucideIcon, +} from 'lucide-react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useThemeStore } from '@/stores/themeStore' +import { useCanvasStore } from '@/stores/canvasStore' +import { THEMES } from '@/utils/themes' +import { applyOpacity } from '@/utils/colorUtils' +import type { + NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle, +} from '@/types' +import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types' + +// ── Node types exposed for custom style (skip groupRect/group) ─────────────── + +const EDITABLE_NODE_TYPES: NodeType[] = [ + 'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', + 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', + 'docker_container', 'generic', +] + +const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] + +const NODE_ICONS: Record = { + isp: Globe, router: Router, switch: Network, server: Server, proxmox: Layers, + vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi, + camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap, + docker_host: Anchor, docker_container: Package, generic: Circle, +} + +// ── Default style for a node type (from default theme) ───────────────────── + +function defaultNodeStyle(nodeType: NodeType): NodeTypeStyle { + const accent = THEMES.default.colors.nodeAccents[nodeType] ?? THEMES.default.colors.nodeAccents.generic + return { + borderColor: accent.border, + borderOpacity: 1, + bgColor: THEMES.default.colors.nodeCardBackground, + bgOpacity: 1, + iconColor: accent.icon, + iconOpacity: 1, + width: 0, + height: 0, + } +} + +function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle { + return { + color: THEMES.default.colors.edgeColors[edgeType], + opacity: 1, + pathStyle: 'bezier', + animated: 'none', + } +} + +// ── Color + opacity row ────────────────────────────────────────────────────── + +interface ColorRowProps { + label: string + color: string + opacity: number + onColorChange: (v: string) => void + onOpacityChange: (v: number) => void +} + +function ColorRow({ label, color, opacity, onColorChange, onOpacityChange }: ColorRowProps) { + return ( +
+ {label} + onColorChange(e.target.value)} + className="w-7 h-7 rounded cursor-pointer border border-[#30363d] bg-transparent p-0.5" + /> +
+ onOpacityChange(parseFloat(e.target.value))} + className="flex-1 h-1 accent-[#00d4ff]" + /> + + {Math.round(opacity * 100)}% + +
+
+
+ ) +} + +// ── Node type editor ───────────────────────────────────────────────────────── + +interface NodeEditorProps { + nodeType: NodeType + style: NodeTypeStyle + onChange: (s: NodeTypeStyle) => void + onApplyToExisting: () => void +} + +function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditorProps) { + const set = useCallback((k: K, v: NodeTypeStyle[K]) => { + onChange({ ...style, [k]: v }) + }, [style, onChange]) + + return ( +
+
{NODE_TYPE_LABELS[nodeType]}
+
+ set('borderColor', v)} + onOpacityChange={(v) => set('borderOpacity', v)} + /> + set('bgColor', v)} + onOpacityChange={(v) => set('bgOpacity', v)} + /> + set('iconColor', v)} + onOpacityChange={(v) => set('iconOpacity', v)} + /> +
+ +
+
Default size
+
0 = auto (min 140 × 50 px, grows with content)
+
+
+ W + set('width', parseInt(e.target.value) || 0)} + className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]" + /> +
+
+ H + set('height', parseInt(e.target.value) || 0)} + className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]" + /> +
+
+
+ + +
+ ) +} + +// ── Edge type editor ───────────────────────────────────────────────────────── + +interface EdgeEditorProps { + edgeType: EdgeType + style: EdgeTypeStyle + onChange: (s: EdgeTypeStyle) => void + onApplyToExisting: () => void +} + +function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditorProps) { + const set = useCallback((k: K, v: EdgeTypeStyle[K]) => { + onChange({ ...style, [k]: v }) + }, [style, onChange]) + + return ( +
+
{EDGE_TYPE_LABELS[edgeType]}
+
+ set('color', v)} + onOpacityChange={(v) => set('opacity', v)} + /> +
+ +
+
+
Path style
+
+ {(['bezier', 'smooth'] as EdgePathStyle[]).map((ps) => ( + + ))} +
+
+ +
+
Animation
+ +
+
+ + +
+ ) +} + +// ── Main modal ─────────────────────────────────────────────────────────────── + +type Tab = 'nodes' | 'edges' +type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeType } | null + +interface CustomStyleModalProps { + open: boolean + onClose: () => void +} + +export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) { + const { customStyle, setCustomStyle } = useThemeStore() + const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore() + + const [tab, setTab] = useState('nodes') + const [selection, setSelection] = useState(null) + const [draft, setDraft] = useState(() => ({ + nodes: { ...customStyle.nodes }, + edges: { ...customStyle.edges }, + })) + + const handleOpen = (isOpen: boolean) => { + if (isOpen) { + // Reset draft to current saved customStyle on open + setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } }) + setSelection(null) + } else { + onClose() + } + } + + const getNodeStyle = (t: NodeType): NodeTypeStyle => + draft.nodes[t] ?? defaultNodeStyle(t) + + const getEdgeStyle = (t: EdgeType): EdgeTypeStyle => + draft.edges[t] ?? defaultEdgeStyle(t) + + const handleNodeChange = (t: NodeType, s: NodeTypeStyle) => + setDraft((d) => ({ ...d, nodes: { ...d.nodes, [t]: s } })) + + const handleEdgeChange = (t: EdgeType, s: EdgeTypeStyle) => + setDraft((d) => ({ ...d, edges: { ...d.edges, [t]: s } })) + + const handleApplyNodeType = (t: NodeType) => { + const style = getNodeStyle(t) + applyTypeNodeStyle(t, style) + toast.success(`Applied style to all ${NODE_TYPE_LABELS[t]} nodes`) + } + + const handleApplyEdgeType = (t: EdgeType) => { + const style = getEdgeStyle(t) + applyTypeEdgeStyle(t, style) + toast.success(`Applied style to all ${EDGE_TYPE_LABELS[t]} edges`) + } + + const handleSave = () => { + setCustomStyle(draft) + markUnsaved() + toast.success('Custom style saved — save your canvas to persist') + onClose() + } + + const handleApplyAll = () => { + setCustomStyle(draft) + applyAllCustomStyles(draft) + markUnsaved() + toast.success('Custom style applied to all nodes and edges') + onClose() + } + + const selectedNodeStyle = selection?.kind === 'node' ? getNodeStyle(selection.type) : null + const selectedEdgeStyle = selection?.kind === 'edge' ? getEdgeStyle(selection.type) : null + + return ( + + + + Custom Style Editor + + +
+ {/* Left panel — type list */} +
+ {/* Tabs */} +
+ {(['nodes', 'edges'] as Tab[]).map((t) => ( + + ))} +
+ + {/* Type list */} +
+ {tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => { + const Icon = NODE_ICONS[t] ?? Circle + const style = draft.nodes[t] + const isSelected = selection?.kind === 'node' && selection.type === t + const swatchColor = style + ? applyOpacity(style.borderColor, style.borderOpacity) + : THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e' + + return ( + + ) + })} + + {tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => { + const style = draft.edges[t] + const isSelected = selection?.kind === 'edge' && selection.type === t + const swatchColor = style + ? applyOpacity(style.color, style.opacity) + : THEMES.default.colors.edgeColors[t] + + return ( + + ) + })} +
+
+ + {/* Right panel — editor */} +
+ {!selection && ( +
+ Select a {tab === 'nodes' ? 'node type' : 'edge type'} from the list to edit its style +
+ )} + + {selection?.kind === 'node' && selectedNodeStyle && ( + handleNodeChange(selection.type, s)} + onApplyToExisting={() => handleApplyNodeType(selection.type)} + /> + )} + + {selection?.kind === 'edge' && selectedEdgeStyle && ( + handleEdgeChange(selection.type, s)} + onApplyToExisting={() => handleApplyEdgeType(selection.type)} + /> + )} +
+
+ + {/* Footer */} +
+ +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx index 5059a48..720572d 100644 --- a/frontend/src/components/modals/EdgeModal.tsx +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -70,7 +70,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,