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..fe8a255 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,6 +112,7 @@ 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) 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..70c8d75 --- /dev/null +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -0,0 +1,485 @@ +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)
+
+
+ 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/ThemeModal.tsx b/frontend/src/components/modals/ThemeModal.tsx index 27aa58c..3100f27 100644 --- a/frontend/src/components/modals/ThemeModal.tsx +++ b/frontend/src/components/modals/ThemeModal.tsx @@ -1,11 +1,12 @@ import { useRef, useState, type KeyboardEvent } from 'react' import { toast } from 'sonner' -import { Check } from 'lucide-react' +import { Check, Pencil } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' +import { CustomStyleModal } from './CustomStyleModal' // Node-type accent colors to display as preview swatches const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const @@ -16,76 +17,106 @@ interface ThemeCardProps { onClick: () => void onKeyDown?: (event: KeyboardEvent) => void buttonRef?: (element: HTMLButtonElement | null) => void + onEdit?: () => void } -function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef }: ThemeCardProps) { +function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef, onEdit }: ThemeCardProps) { + const { customStyle } = useThemeStore() const preset = THEMES[themeId] const c = preset.colors + const isCustom = themeId === 'custom' + + // For custom theme, use defined node colors for preview swatches + const swatchColors = isCustom + ? PREVIEW_TYPES.map((t) => customStyle.nodes[t]?.borderColor ?? c.nodeAccents[t].border) + : PREVIEW_TYPES.map((t) => c.nodeAccents[t].border) + + const ethernetColor = isCustom + ? (customStyle.edges['ethernet']?.color ?? c.edgeColors.ethernet) + : c.edgeColors.ethernet + const wifiColor = isCustom + ? (customStyle.edges['wifi']?.color ?? c.edgeColors.wifi) + : c.edgeColors.wifi return ( - +
+ {preset.label} +
+
+ {preset.description} +
+ + + {/* Edit button — only for custom theme */} + {isCustom && onEdit && ( + + )} +
) } @@ -98,6 +129,7 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) { const { activeTheme, setTheme } = useThemeStore() const { markUnsaved } = useCanvasStore() const cardRefs = useRef>([]) + const [customStyleOpen, setCustomStyleOpen] = useState(false) // Capture the theme that was active when the modal opened const [originalTheme] = useState(activeTheme) @@ -105,7 +137,6 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) { const handleSelect = (id: ThemeId) => { setSelected(id) - // Live-preview the selected theme on the canvas setTheme(id) } @@ -137,65 +168,65 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) { setTheme(selected) markUnsaved() onClose() - toast.info('Style applied — save your canvas to make it permanent', { - duration: 5000, - }) + toast.info('Style applied — save your canvas to make it permanent', { duration: 5000 }) } const handleCancel = () => { - // Revert to the original theme setTheme(originalTheme) onClose() } return ( - { if (!o) handleCancel() }}> - - - Choose Canvas Style - + <> + { if (!o) handleCancel() }}> + + + Choose Canvas Style + -
- {THEME_ORDER.map((id, index) => ( -
- handleSelect(id)} - onKeyDown={handleCardKeyDown(index)} - buttonRef={(element) => { - cardRefs.current[index] = element - }} - /> -
- ))} -
+
+ {THEME_ORDER.map((id, index) => ( +
+ handleSelect(id)} + onKeyDown={handleCardKeyDown(index)} + buttonRef={(element) => { cardRefs.current[index] = element }} + onEdit={id === 'custom' ? () => setCustomStyleOpen(true) : undefined} + /> +
+ ))} +
-
- - -
-
-
+
+ + +
+
+
+ + setCustomStyleOpen(false)} /> + ) } diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 5e8c9ff..893ccc9 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -722,3 +722,98 @@ describe('canvasStore', () => { expect(updated?.sourceHandle).toBe('bottom') }) }) + +describe('canvasStore — custom style apply', () => { + beforeEach(() => { + useCanvasStore.setState({ + nodes: [], + edges: [], + hasUnsavedChanges: false, + selectedNodeId: null, + selectedNodeIds: [], + editingGroupRectId: null, + past: [], + future: [], + clipboard: [], + }) + }) + + const serverStyle = { + borderColor: '#ff0000', + borderOpacity: 1, + bgColor: '#111111', + bgOpacity: 1, + iconColor: '#ff0000', + iconOpacity: 1, + width: 220, + height: 90, + } + + it('applyTypeNodeStyle updates matching nodes custom_colors', () => { + useCanvasStore.setState({ + nodes: [makeNode('n1', { type: 'server' }), makeNode('n2', { type: 'proxmox' })], + edges: [], + }) + useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle) + + const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')! + const n2 = useCanvasStore.getState().nodes.find((n) => n.id === 'n2')! + expect(n1.data.custom_colors?.border).toBe('#ff0000') + expect(n1.width).toBe(220) + expect(n1.height).toBe(90) + expect(n2.data.custom_colors?.border).toBeUndefined() + }) + + it('applyTypeNodeStyle with opacity < 1 produces rgba', () => { + useCanvasStore.setState({ nodes: [makeNode('n1', { type: 'server' })], edges: [] }) + useCanvasStore.getState().applyTypeNodeStyle('server', { ...serverStyle, borderOpacity: 0.5 }) + + const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')! + expect(n1.data.custom_colors?.border).toMatch(/^rgba\(/) + }) + + it('applyTypeNodeStyle marks canvas unsaved', () => { + useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [] }) + useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + + it('applyTypeEdgeStyle updates matching edges', () => { + const e1: Edge = { id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } } + const e2: Edge = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } } + useCanvasStore.setState({ nodes: [], edges: [e1, e2] }) + + useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow' }) + + const updated1 = useCanvasStore.getState().edges.find((e) => e.id === 'e1')! + const updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')! + expect(updated1.data?.custom_color).toBe('#00ff00') + expect(updated1.data?.path_style).toBe('smooth') + expect(updated1.data?.animated).toBe('flow') + expect(updated2.data?.custom_color).toBeUndefined() + }) + + it('applyAllCustomStyles applies all defined types', () => { + const proxmoxNode = makeNode('np', { type: 'proxmox' }) + const serverNode = makeNode('ns', { type: 'server' }) + const e1: Edge = { id: 'e1', source: 'np', target: 'ns', type: 'ethernet', data: { type: 'ethernet' } } + useCanvasStore.setState({ nodes: [proxmoxNode, serverNode], edges: [e1] }) + + useCanvasStore.getState().applyAllCustomStyles({ + nodes: { + proxmox: { borderColor: '#ff6e00', borderOpacity: 1, bgColor: '#111', bgOpacity: 1, iconColor: '#ff6e00', iconOpacity: 1, width: 0, height: 0 }, + }, + edges: { + ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none' }, + }, + }) + + const np = useCanvasStore.getState().nodes.find((n) => n.id === 'np')! + const ns = useCanvasStore.getState().nodes.find((n) => n.id === 'ns')! + const e = useCanvasStore.getState().edges.find((e) => e.id === 'e1')! + expect(np.data.custom_colors?.border).toBe('#ff6e00') + expect(ns.data.custom_colors?.border).toBeUndefined() + expect(e.data?.custom_color).toBe('#aabbcc') + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) +}) diff --git a/frontend/src/stores/__tests__/themeStore.test.ts b/frontend/src/stores/__tests__/themeStore.test.ts index a8639de..ee07407 100644 --- a/frontend/src/stores/__tests__/themeStore.test.ts +++ b/frontend/src/stores/__tests__/themeStore.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, beforeEach } from 'vitest' import { useThemeStore } from '@/stores/themeStore' +import type { CustomStyleDef } from '@/types' describe('themeStore', () => { beforeEach(() => { - useThemeStore.setState({ activeTheme: 'default' }) + useThemeStore.setState({ activeTheme: 'default', customStyle: { nodes: {}, edges: {} } }) }) it('starts with default theme', () => { @@ -15,8 +16,8 @@ describe('themeStore', () => { expect(useThemeStore.getState().activeTheme).toBe('matrix') }) - it('setTheme can switch between all presets', () => { - const themes = ['default', 'dark', 'light', 'neon', 'matrix'] as const + it('setTheme can switch between all presets including custom', () => { + const themes = ['default', 'dark', 'light', 'neon', 'matrix', 'custom'] as const for (const id of themes) { useThemeStore.getState().setTheme(id) expect(useThemeStore.getState().activeTheme).toBe(id) @@ -28,4 +29,26 @@ describe('themeStore', () => { useThemeStore.getState().setTheme('default') expect(useThemeStore.getState().activeTheme).toBe('default') }) + + it('starts with empty customStyle', () => { + const { customStyle } = useThemeStore.getState() + expect(customStyle.nodes).toEqual({}) + expect(customStyle.edges).toEqual({}) + }) + + it('setCustomStyle replaces the entire definition', () => { + const def: CustomStyleDef = { + nodes: { server: { borderColor: '#ff0000', borderOpacity: 1, bgColor: '#000000', bgOpacity: 1, iconColor: '#ff0000', iconOpacity: 1, width: 200, height: 80 } }, + edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none' } }, + } + useThemeStore.getState().setCustomStyle(def) + expect(useThemeStore.getState().customStyle.nodes.server?.borderColor).toBe('#ff0000') + expect(useThemeStore.getState().customStyle.edges.ethernet?.color).toBe('#00ff00') + }) + + it('setCustomStyle with empty def clears styles', () => { + useThemeStore.getState().setCustomStyle({ nodes: { server: { borderColor: '#aaa', borderOpacity: 1, bgColor: '#000', bgOpacity: 1, iconColor: '#aaa', iconOpacity: 1, width: 0, height: 0 } }, edges: {} }) + useThemeStore.getState().setCustomStyle({ nodes: {}, edges: {} }) + expect(useThemeStore.getState().customStyle.nodes).toEqual({}) + }) }) diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 5e1c043..f1af04a 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -9,9 +9,10 @@ import { applyEdgeChanges, addEdge, } from '@xyflow/react' -import type { NodeData, EdgeData } from '@/types' +import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types' import { generateUUID } from '@/utils/uuid' import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' +import { applyOpacity } from '@/utils/colorUtils' type HistoryEntry = { nodes: Node[]; edges: Edge[] } @@ -58,6 +59,9 @@ interface CanvasState { notifyScanDeviceFound: () => void hideIp: boolean toggleHideIp: () => void + applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void + applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void + applyAllCustomStyles: (def: CustomStyleDef) => void } export const useCanvasStore = create((set) => ({ @@ -468,4 +472,82 @@ export const useCanvasStore = create((set) => ({ }, clearFitViewPending: () => set({ fitViewPending: false }), + + applyTypeNodeStyle: (nodeType, style) => + set((state) => ({ + nodes: state.nodes.map((n) => { + if (n.data.type !== nodeType) return n + return { + ...n, + width: style.width > 0 ? style.width : n.width, + height: style.height > 0 ? style.height : n.height, + data: { + ...n.data, + custom_colors: { + ...n.data.custom_colors, + border: applyOpacity(style.borderColor, style.borderOpacity), + background: applyOpacity(style.bgColor, style.bgOpacity), + icon: applyOpacity(style.iconColor, style.iconOpacity), + }, + }, + } + }), + hasUnsavedChanges: true, + })), + + applyTypeEdgeStyle: (edgeType, style) => + set((state) => ({ + edges: state.edges.map((e) => { + if ((e.data?.type ?? 'ethernet') !== edgeType) return e + return { + ...e, + data: { + ...e.data, + type: edgeType, + custom_color: applyOpacity(style.color, style.opacity), + path_style: style.pathStyle, + animated: style.animated, + } as EdgeData, + } + }), + hasUnsavedChanges: true, + })), + + applyAllCustomStyles: (def) => + set((state) => { + const nodes = state.nodes.map((n) => { + const style = def.nodes[n.data.type] + if (!style) return n + return { + ...n, + width: style.width > 0 ? style.width : n.width, + height: style.height > 0 ? style.height : n.height, + data: { + ...n.data, + custom_colors: { + ...n.data.custom_colors, + border: applyOpacity(style.borderColor, style.borderOpacity), + background: applyOpacity(style.bgColor, style.bgOpacity), + icon: applyOpacity(style.iconColor, style.iconOpacity), + }, + }, + } + }) + const edges = state.edges.map((e) => { + const edgeType = (e.data?.type ?? 'ethernet') as EdgeType + const style = def.edges[edgeType] + if (!style) return e + return { + ...e, + data: { + ...e.data, + type: edgeType, + custom_color: applyOpacity(style.color, style.opacity), + path_style: style.pathStyle, + animated: style.animated, + } as EdgeData, + } + }) + return { nodes, edges, hasUnsavedChanges: true } + }), })) diff --git a/frontend/src/stores/themeStore.ts b/frontend/src/stores/themeStore.ts index edf4727..3e4e4dd 100644 --- a/frontend/src/stores/themeStore.ts +++ b/frontend/src/stores/themeStore.ts @@ -1,12 +1,17 @@ import { create } from 'zustand' import type { ThemeId } from '@/utils/themes' +import type { CustomStyleDef } from '@/types' interface ThemeState { activeTheme: ThemeId setTheme: (id: ThemeId) => void + customStyle: CustomStyleDef + setCustomStyle: (def: CustomStyleDef) => void } export const useThemeStore = create((set) => ({ activeTheme: 'default', setTheme: (id) => set({ activeTheme: id }), + customStyle: { nodes: {}, edges: {} }, + setCustomStyle: (def) => set({ customStyle: def }), })) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b4e0f59..b37ca34 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -150,3 +150,26 @@ export const EDGE_TYPE_LABELS: Record = { virtual: 'Virtual', cluster: 'Cluster', } + +export interface NodeTypeStyle { + borderColor: string + borderOpacity: number + bgColor: string + bgOpacity: number + iconColor: string + iconOpacity: number + width: number + height: number +} + +export interface EdgeTypeStyle { + color: string + opacity: number + pathStyle: EdgePathStyle + animated: 'none' | 'snake' | 'flow' | 'basic' +} + +export interface CustomStyleDef { + nodes: Partial> + edges: Partial> +} diff --git a/frontend/src/utils/colorUtils.ts b/frontend/src/utils/colorUtils.ts index 0e62436..dd6c8ea 100644 --- a/frontend/src/utils/colorUtils.ts +++ b/frontend/src/utils/colorUtils.ts @@ -27,3 +27,17 @@ export function rgbaToHex8(hex6: string, alpha: number): string { const alphaHex = alphaByte.toString(16).padStart(2, '0') return `${hex6}${alphaHex}` } + +/** + * Combine a hex color and opacity (0–1) into a CSS rgba() string. + * Returns the plain hex when opacity is 1. + */ +export function applyOpacity(hex: string, opacity: number): string { + if (opacity >= 1) return hex + let h = hex.replace('#', '') + if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2] + const r = parseInt(h.slice(0, 2), 16) + const g = parseInt(h.slice(2, 4), 16) + const b = parseInt(h.slice(4, 6), 16) + return `rgba(${r}, ${g}, ${b}, ${Math.round(opacity * 100) / 100})` +} diff --git a/frontend/src/utils/themes.ts b/frontend/src/utils/themes.ts index 7f1be3c..583aa29 100644 --- a/frontend/src/utils/themes.ts +++ b/frontend/src/utils/themes.ts @@ -1,6 +1,6 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types' -export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix' +export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix' | 'custom' export interface ThemeColors { // Per node-type accent (border + icon) @@ -315,7 +315,63 @@ export const THEMES: Record = { reactFlowColorMode: 'dark', }, }, + + custom: { + id: 'custom', + label: 'Custom', + description: 'Your own colors per node and edge type', + colors: { + nodeAccents: { + isp: { border: '#00d4ff', icon: '#00d4ff' }, + router: { border: '#00d4ff', icon: '#00d4ff' }, + switch: { border: '#39d353', icon: '#39d353' }, + server: { border: '#a855f7', icon: '#a855f7' }, + proxmox: { border: '#ff6e00', icon: '#ff6e00' }, + vm: { border: '#a855f7', icon: '#a855f7' }, + lxc: { border: '#00d4ff', icon: '#00d4ff' }, + nas: { border: '#39d353', icon: '#39d353' }, + iot: { border: '#e3b341', icon: '#e3b341' }, + ap: { border: '#00d4ff', icon: '#00d4ff' }, + camera: { border: '#8b949e', icon: '#8b949e' }, + printer: { border: '#8b949e', icon: '#8b949e' }, + computer: { border: '#a855f7', icon: '#a855f7' }, + cpl: { border: '#e3b341', icon: '#e3b341' }, + docker_host: { border: '#2496ED', icon: '#2496ED' }, + docker_container: { border: '#0ea5e9', icon: '#0ea5e9' }, + generic: { border: '#8b949e', icon: '#8b949e' }, + groupRect: { border: '#00d4ff', icon: '#00d4ff' }, + group: { border: '#00d4ff', icon: '#00d4ff' }, + }, + nodeCardBackground: '#21262d', + nodeIconBackground: '#161b22', + nodeLabelColor: '#e6edf3', + nodeSubtextColor: '#8b949e', + statusColors: { + online: '#39d353', + offline: '#f85149', + pending: '#e3b341', + unknown: '#8b949e', + }, + edgeColors: { + ethernet: '#30363d', + wifi: '#00d4ff', + iot: '#e3b341', + vlan: '#00d4ff', + virtual: '#8b949e', + cluster: '#ff6e00', + }, + edgeSelectedColor: '#00d4ff', + edgeLabelBackground:'#161b22', + edgeLabelColor: '#8b949e', + edgeLabelBorder: '#30363d', + canvasBackground: '#0d1117', + canvasDotColor: '#30363d', + handleBackground: '#30363d', + handleBorder: '#8b949e', + reactFlowColorMode: 'dark', + }, + }, } // Ordered list for display in the modal -export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix'] +export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix', 'custom']