diff --git a/frontend/index.html b/frontend/index.html index 072a57e..60fcb1c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,10 @@ - + - frontend + + Homelable
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..567ecc4 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d83ff2d..c38a71d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,8 +15,10 @@ import { NodeModal } from '@/components/modals/NodeModal' import { EdgeModal } from '@/components/modals/EdgeModal' import { ScanConfigModal } from '@/components/modals/ScanConfigModal' import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' +import { ThemeModal } from '@/components/modals/ThemeModal' import { useCanvasStore } from '@/stores/canvasStore' import { useAuthStore } from '@/stores/authStore' +import { useThemeStore } from '@/stores/themeStore' import { canvasApi } from '@/api/client' import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' @@ -29,9 +31,11 @@ export default function App() { const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() + const { activeTheme, setTheme } = useThemeStore() useStatusPolling() + const [themeModalOpen, setThemeModalOpen] = useState(false) const [addNodeOpen, setAddNodeOpen] = useState(false) const [addGroupRectOpen, setAddGroupRectOpen] = useState(false) const [editNodeId, setEditNodeId] = useState(null) @@ -43,7 +47,7 @@ export default function App() { const handleSave = useCallback(async () => { try { if (STANDALONE) { - localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges })) + localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme })) markSaved() toast.success('Canvas saved') return @@ -112,13 +116,13 @@ export default function App() { source_handle: e.sourceHandle === 'top-t' ? 'top' : e.sourceHandle === 'bottom-t' ? 'bottom' : (e.sourceHandle ?? null), target_handle: e.targetHandle === 'top-t' ? 'top' : e.targetHandle === 'bottom-t' ? 'bottom' : (e.targetHandle ?? null), })) - await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: {} }) + await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } }) markSaved() toast.success('Canvas saved') } catch { toast.error('Save failed') } - }, [nodes, edges, markSaved]) + }, [nodes, edges, markSaved, activeTheme]) // Keep a ref so the keydown handler always calls the latest version const handleSaveRef = useRef(handleSave) @@ -130,7 +134,8 @@ export default function App() { try { const saved = localStorage.getItem(STANDALONE_STORAGE_KEY) if (saved) { - const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved) + const { nodes: savedNodes, edges: savedEdges, theme_id } = JSON.parse(saved) + if (theme_id) setTheme(theme_id) loadCanvas(savedNodes, savedEdges) } else { loadCanvas(demoNodes, demoEdges) @@ -185,13 +190,15 @@ export default function App() { targetHandle: e.target_handle ?? null, data: e, })) + const savedTheme = res.data.viewport?.theme_id + if (savedTheme) setTheme(savedTheme) loadCanvas(rfNodes, rfEdges) } else { loadCanvas(demoNodes, demoEdges) } }) .catch(() => loadCanvas(demoNodes, demoEdges)) - }, [isAuthenticated, loadCanvas]) + }, [isAuthenticated, loadCanvas, setTheme]) // Ctrl+S useEffect(() => { @@ -391,6 +398,7 @@ export default function App() { onSave={handleSave} onAutoLayout={handleAutoLayout} onExport={handleExport} + onChangeStyle={() => setThemeModalOpen(true)} />
@@ -481,6 +489,13 @@ export default function App() { title="Edit Rectangle" /> + {/* key forces re-mount on open so useState captures current theme as original */} + setThemeModalOpen(false)} + /> + diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index dddc17c..f6bc77c 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -11,6 +11,8 @@ import { } from '@xyflow/react' import '@xyflow/react/dist/style.css' import { useCanvasStore } from '@/stores/canvasStore' +import { useThemeStore } from '@/stores/themeStore' +import { THEMES } from '@/utils/themes' import { nodeTypes } from './nodes/nodeTypes' import { edgeTypes } from './edges/edgeTypes' import type { NodeData, EdgeData } from '@/types' @@ -27,6 +29,9 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: setSelectedNode, } = useCanvasStore() + const activeTheme = useThemeStore((s) => s.activeTheme) + const theme = THEMES[activeTheme] + const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { setSelectedNode(node.id) }, [setSelectedNode]) @@ -39,9 +44,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: onEdgeDoubleClick?.(edge) }, [onEdgeDoubleClick]) - return ( -
+
connection.source !== connection.target} @@ -65,7 +69,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: variant={BackgroundVariant.Dots} gap={24} size={1} - color="#30363d" + color={theme.colors.canvasDotColor} /> diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index f234982..3d7ddb6 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -7,6 +7,8 @@ import { type Edge, } from '@xyflow/react' import type { EdgeData, EdgeType } from '@/types' +import { useThemeStore } from '@/stores/themeStore' +import { THEMES } from '@/utils/themes' const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] @@ -15,28 +17,33 @@ function getVlanColor(vlanId?: number): string { return VLAN_COLORS[vlanId % VLAN_COLORS.length] } -const EDGE_STYLES: Record = { - ethernet: { stroke: '#30363d', strokeWidth: 2 }, - wifi: { stroke: '#00d4ff', strokeWidth: 1.5, strokeDasharray: '6 3' }, - iot: { stroke: '#e3b341', strokeWidth: 1.5, strokeDasharray: '2 4' }, - vlan: { strokeWidth: 2.5 }, - virtual: { stroke: '#8b949e', strokeWidth: 1, strokeDasharray: '4 4' }, - cluster: { stroke: '#ff6e00', strokeWidth: 2.5, strokeDasharray: '8 3' }, -} - export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps>) { + const activeTheme = useThemeStore((s) => s.activeTheme) + const theme = THEMES[activeTheme] + const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition } const [edgePath, labelX, labelY] = data?.path_style === 'smooth' ? getSmoothStepPath({ ...pathArgs, borderRadius: 8 }) : getBezierPath(pathArgs) const edgeType: EdgeType = data?.type ?? 'ethernet' + const edgeColors = theme.colors.edgeColors + + const BASE_STYLES: Record = { + ethernet: { stroke: edgeColors.ethernet, strokeWidth: 2 }, + wifi: { stroke: edgeColors.wifi, strokeWidth: 1.5, strokeDasharray: '6 3' }, + iot: { stroke: edgeColors.iot, strokeWidth: 1.5, strokeDasharray: '2 4' }, + vlan: { strokeWidth: 2.5 }, + virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' }, + cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' }, + } + const customColor = data?.custom_color as string | undefined const style: React.CSSProperties = { - ...EDGE_STYLES[edgeType], + ...BASE_STYLES[edgeType], ...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}), ...(customColor ? { stroke: customColor } : {}), - ...(selected ? { stroke: '#00d4ff', filter: 'drop-shadow(0 0 4px #00d4ff88)' } : {}), + ...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}), } return ( @@ -48,9 +55,9 @@ export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePo className="absolute pointer-events-none font-mono text-[10px] px-1 rounded" style={{ transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, - background: '#161b22', - color: '#8b949e', - border: '1px solid #30363d', + background: theme.colors.edgeLabelBackground, + color: theme.colors.edgeLabelColor, + border: `1px solid ${theme.colors.edgeLabelBorder}`, }} > {data.label as string} diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index eaf619e..7531319 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -1,25 +1,23 @@ import { createElement } from 'react' import { Handle, Position, type NodeProps, type Node } from '@xyflow/react' import { type LucideIcon } from 'lucide-react' -import type { NodeData, NodeStatus } from '@/types' +import type { NodeData } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeIcon } from '@/utils/nodeIcons' - -const STATUS_COLORS: Record = { - online: '#39d353', - offline: '#f85149', - pending: '#e3b341', - unknown: '#8b949e', -} +import { useThemeStore } from '@/stores/themeStore' +import { THEMES } from '@/utils/themes' interface BaseNodeProps extends NodeProps> { icon: LucideIcon } export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) { + const activeTheme = useThemeStore((s) => s.activeTheme) + const theme = THEMES[activeTheme] + const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon) - const colors = resolveNodeColors(data) - const statusColor = STATUS_COLORS[data.status] + const colors = resolveNodeColors(data, activeTheme) + const statusColor = theme.colors.statusColors[data.status] const isOnline = data.status === 'online' return ( @@ -38,24 +36,40 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) { minWidth: 140, }} > - + {/* Icon */}
{createElement(resolvedIcon, { size: 15 })}
{/* Details */}
-
+
{data.label}
{data.ip && ( -
+
{data.ip}
)} @@ -68,7 +82,12 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) { title={data.status} /> - +
) diff --git a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx index c2e9de0..3f5a8ea 100644 --- a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx +++ b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx @@ -1,34 +1,46 @@ import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react' import { Layers } from 'lucide-react' -import type { NodeData, NodeStatus } from '@/types' +import type { NodeData } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' +import { useThemeStore } from '@/stores/themeStore' +import { THEMES } from '@/utils/themes' import { BaseNode } from './BaseNode' -const STATUS_COLORS: Record = { - online: '#39d353', - offline: '#f85149', - pending: '#e3b341', - unknown: '#8b949e', -} - export function ProxmoxGroupNode(props: NodeProps>) { const { data, selected } = props - const colors = resolveNodeColors(data) + + const activeTheme = useThemeStore((s) => s.activeTheme) + const theme = THEMES[activeTheme] + const colors = resolveNodeColors(data, activeTheme) // Render as a regular node when container mode is disabled if (data.container_mode === false) { + const proxmoxAccent = theme.colors.nodeAccents.proxmox.border return ( <> - - + + ) } - const statusColor = STATUS_COLORS[data.status] + const statusColor = theme.colors.statusColors[data.status] const isOnline = data.status === 'online' const glow = colors.border + const proxmoxAccent = theme.colors.nodeAccents.proxmox.border return ( <> @@ -37,7 +49,7 @@ export function ProxmoxGroupNode(props: NodeProps>) { minHeight={160} isVisible={selected} lineStyle={{ borderColor: glow, opacity: 0.6 }} - handleStyle={{ borderColor: glow, backgroundColor: '#21262d' }} + handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }} /> {/* Group border */} @@ -56,38 +68,78 @@ export function ProxmoxGroupNode(props: NodeProps>) { {/* Header bar */}
- + {data.label} {data.ip && ( - {data.ip} + + {data.ip} + )}
{/* Status dot */} -
+
{/* Inner area — React Flow places children here */}
- + - + - {/* Cluster handles — left/right for same-cluster links */} - - + {/* Cluster handles */} + + ) } diff --git a/frontend/src/components/modals/ThemeModal.tsx b/frontend/src/components/modals/ThemeModal.tsx new file mode 100644 index 0000000..23f1d6c --- /dev/null +++ b/frontend/src/components/modals/ThemeModal.tsx @@ -0,0 +1,167 @@ +import { useState } from 'react' +import { toast } from 'sonner' +import { Check } 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' + +// Node-type accent colors to display as preview swatches +const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const + +interface ThemeCardProps { + themeId: ThemeId + selected: boolean + onClick: () => void +} + +function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) { + const preset = THEMES[themeId] + const c = preset.colors + + return ( + + ) +} + +interface ThemeModalProps { + open: boolean + onClose: () => void +} + +export function ThemeModal({ open, onClose }: ThemeModalProps) { + const { activeTheme, setTheme } = useThemeStore() + const { markUnsaved } = useCanvasStore() + + // Capture the theme that was active when the modal opened + const [originalTheme] = useState(activeTheme) + const [selected, setSelected] = useState(activeTheme) + + const handleSelect = (id: ThemeId) => { + setSelected(id) + // Live-preview the selected theme on the canvas + setTheme(id) + } + + const handleApply = () => { + setTheme(selected) + markUnsaved() + onClose() + 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 + + +
+ {THEME_ORDER.map((id) => ( + handleSelect(id)} + /> + ))} +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index cc28d41..c2e0098 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square } from 'lucide-react' +import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square } from 'lucide-react' +import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' import { scanApi } from '@/api/client' @@ -70,13 +71,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro {/* Logo */} -
-
- -
- {!collapsed && ( - Homelable - )} +
+
{/* Views */} diff --git a/frontend/src/components/panels/Toolbar.tsx b/frontend/src/components/panels/Toolbar.tsx index eb65f59..ed00d7a 100644 --- a/frontend/src/components/panels/Toolbar.tsx +++ b/frontend/src/components/panels/Toolbar.tsx @@ -1,22 +1,28 @@ -import { Save, LayoutDashboard, Download } from 'lucide-react' +import { Save, LayoutDashboard, Download, Palette } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Logo } from '@/components/ui/Logo' import { useCanvasStore } from '@/stores/canvasStore' interface ToolbarProps { onSave: () => void onAutoLayout: () => void onExport: () => void + onChangeStyle: () => void } -export function Toolbar({ onSave, onAutoLayout, onExport }: ToolbarProps) { +export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle }: ToolbarProps) { const { hasUnsavedChanges } = useCanvasStore() return (
+
+ diff --git a/frontend/src/components/ui/Logo.tsx b/frontend/src/components/ui/Logo.tsx new file mode 100644 index 0000000..85a227b --- /dev/null +++ b/frontend/src/components/ui/Logo.tsx @@ -0,0 +1,46 @@ +interface LogoProps { + size?: number; + showText?: boolean; + className?: string; +} + +export function Logo({ size = 32, showText = true, className = '' }: LogoProps) { + return ( +
+ + + + + + + + + + + + + + {showText && ( + + Home + lable + + )} +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 0e83628..c41b4e1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -90,7 +90,7 @@ /* React Flow overrides */ .react-flow__background { - background-color: var(--surface-base) !important; + background-color: transparent; } .react-flow__minimap { background-color: var(--surface-elevated) !important; diff --git a/frontend/src/stores/__tests__/themeStore.test.ts b/frontend/src/stores/__tests__/themeStore.test.ts new file mode 100644 index 0000000..a8639de --- /dev/null +++ b/frontend/src/stores/__tests__/themeStore.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useThemeStore } from '@/stores/themeStore' + +describe('themeStore', () => { + beforeEach(() => { + useThemeStore.setState({ activeTheme: 'default' }) + }) + + it('starts with default theme', () => { + expect(useThemeStore.getState().activeTheme).toBe('default') + }) + + it('setTheme updates activeTheme', () => { + useThemeStore.getState().setTheme('matrix') + expect(useThemeStore.getState().activeTheme).toBe('matrix') + }) + + it('setTheme can switch between all presets', () => { + const themes = ['default', 'dark', 'light', 'neon', 'matrix'] as const + for (const id of themes) { + useThemeStore.getState().setTheme(id) + expect(useThemeStore.getState().activeTheme).toBe(id) + } + }) + + it('setTheme back to default after neon', () => { + useThemeStore.getState().setTheme('neon') + useThemeStore.getState().setTheme('default') + expect(useThemeStore.getState().activeTheme).toBe('default') + }) +}) diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index f0ed5a6..313b4d1 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -32,6 +32,7 @@ interface CanvasState { editingGroupRectId: string | null setEditingGroupRectId: (id: string | null) => void markSaved: () => void + markUnsaved: () => void loadCanvas: (nodes: Node[], edges: Edge[]) => void notifyScanDeviceFound: () => void } @@ -155,6 +156,8 @@ export const useCanvasStore = create((set) => ({ markSaved: () => set({ hasUnsavedChanges: false }), + markUnsaved: () => set({ hasUnsavedChanges: true }), + notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }), loadCanvas: (nodes, edges) => { diff --git a/frontend/src/stores/themeStore.ts b/frontend/src/stores/themeStore.ts new file mode 100644 index 0000000..edf4727 --- /dev/null +++ b/frontend/src/stores/themeStore.ts @@ -0,0 +1,12 @@ +import { create } from 'zustand' +import type { ThemeId } from '@/utils/themes' + +interface ThemeState { + activeTheme: ThemeId + setTheme: (id: ThemeId) => void +} + +export const useThemeStore = create((set) => ({ + activeTheme: 'default', + setTheme: (id) => set({ activeTheme: id }), +})) diff --git a/frontend/src/utils/__tests__/nodeColors.test.ts b/frontend/src/utils/__tests__/nodeColors.test.ts index 46ccc39..ef521c4 100644 --- a/frontend/src/utils/__tests__/nodeColors.test.ts +++ b/frontend/src/utils/__tests__/nodeColors.test.ts @@ -52,4 +52,25 @@ describe('resolveNodeColors', () => { const result = resolveNodeColors({ type: 'unknown' as any, custom_colors: undefined }) expect(result).toEqual(NODE_DEFAULT_COLORS.generic) }) + + it('uses theme colors when themeId is provided', () => { + const result = resolveNodeColors(makeData({ type: 'server' }), 'matrix') + expect(result.border).not.toBe(NODE_DEFAULT_COLORS.server.border) + expect(result.border).toBe('#008822') // matrix server accent + }) + + it('custom_colors override theme colors', () => { + const custom = { border: '#ff0000' } + const result = resolveNodeColors(makeData({ type: 'server', custom_colors: custom }), 'neon') + expect(result.border).toBe('#ff0000') + // background should come from neon theme, not default + expect(result.background).not.toBe(NODE_DEFAULT_COLORS.server.background) + }) + + it('groupRect always has transparent background regardless of theme', () => { + for (const themeId of ['default', 'light', 'matrix', 'neon', 'dark'] as const) { + const result = resolveNodeColors(makeData({ type: 'groupRect' }), themeId) + expect(result.background).toBe('transparent') + } + }) }) diff --git a/frontend/src/utils/__tests__/themes.test.ts b/frontend/src/utils/__tests__/themes.test.ts new file mode 100644 index 0000000..e59fcfa --- /dev/null +++ b/frontend/src/utils/__tests__/themes.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest' +import { THEMES, THEME_ORDER, type ThemeId } from '../themes' +import type { NodeType, EdgeType, NodeStatus } from '@/types' + +const NODE_TYPES: NodeType[] = [ + 'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', + 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'generic', 'groupRect', +] +const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] +const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown'] + +describe('THEME_ORDER', () => { + it('contains all theme IDs', () => { + const keys = Object.keys(THEMES) as ThemeId[] + expect(THEME_ORDER).toHaveLength(keys.length) + expect(new Set(THEME_ORDER)).toEqual(new Set(keys)) + }) +}) + +describe('THEMES', () => { + for (const themeId of Object.keys(THEMES) as ThemeId[]) { + describe(`theme: ${themeId}`, () => { + const preset = THEMES[themeId] + + it('has id, label, description', () => { + expect(preset.id).toBe(themeId) + expect(typeof preset.label).toBe('string') + expect(preset.label.length).toBeGreaterThan(0) + expect(typeof preset.description).toBe('string') + }) + + it('has nodeAccents for all node types', () => { + for (const type of NODE_TYPES) { + expect(preset.colors.nodeAccents[type]).toBeDefined() + expect(typeof preset.colors.nodeAccents[type].border).toBe('string') + expect(typeof preset.colors.nodeAccents[type].icon).toBe('string') + } + }) + + it('has surface colors', () => { + expect(typeof preset.colors.nodeCardBackground).toBe('string') + expect(typeof preset.colors.nodeIconBackground).toBe('string') + expect(typeof preset.colors.nodeLabelColor).toBe('string') + expect(typeof preset.colors.nodeSubtextColor).toBe('string') + }) + + it('has statusColors for all statuses', () => { + for (const status of STATUS_TYPES) { + expect(typeof preset.colors.statusColors[status]).toBe('string') + } + }) + + it('has edgeColors for all edge types', () => { + for (const type of EDGE_TYPES) { + expect(typeof preset.colors.edgeColors[type]).toBe('string') + } + }) + + it('has edge label colors', () => { + expect(typeof preset.colors.edgeSelectedColor).toBe('string') + expect(typeof preset.colors.edgeLabelBackground).toBe('string') + expect(typeof preset.colors.edgeLabelColor).toBe('string') + expect(typeof preset.colors.edgeLabelBorder).toBe('string') + }) + + it('has canvas colors', () => { + expect(typeof preset.colors.canvasBackground).toBe('string') + expect(typeof preset.colors.canvasDotColor).toBe('string') + }) + + it('has handle colors', () => { + expect(typeof preset.colors.handleBackground).toBe('string') + expect(typeof preset.colors.handleBorder).toBe('string') + }) + + it('has valid reactFlowColorMode', () => { + expect(['dark', 'light']).toContain(preset.colors.reactFlowColorMode) + }) + }) + } + + it('default theme matches original hardcoded colors', () => { + const d = THEMES.default.colors + expect(d.nodeAccents.server.border).toBe('#a855f7') + expect(d.nodeAccents.isp.border).toBe('#00d4ff') + expect(d.nodeAccents.proxmox.border).toBe('#ff6e00') + expect(d.nodeCardBackground).toBe('#21262d') + expect(d.nodeIconBackground).toBe('#161b22') + expect(d.canvasBackground).toBe('#0d1117') + expect(d.canvasDotColor).toBe('#30363d') + expect(d.edgeColors.ethernet).toBe('#30363d') + expect(d.edgeColors.wifi).toBe('#00d4ff') + expect(d.statusColors.online).toBe('#39d353') + expect(d.statusColors.offline).toBe('#f85149') + }) + + it('light theme has reactFlowColorMode light', () => { + expect(THEMES.light.colors.reactFlowColorMode).toBe('light') + }) + + it('light theme has light canvas background', () => { + expect(THEMES.light.colors.canvasBackground).toBe('#f6f8fa') + }) + + it('matrix theme uses green accents', () => { + const m = THEMES.matrix.colors + expect(m.nodeAccents.isp.border).toMatch(/^#0/) + expect(m.nodeLabelColor).toBe('#00ff41') + expect(m.canvasBackground).toBe('#000000') + }) +}) diff --git a/frontend/src/utils/nodeColors.ts b/frontend/src/utils/nodeColors.ts index 1ae58ec..19e558f 100644 --- a/frontend/src/utils/nodeColors.ts +++ b/frontend/src/utils/nodeColors.ts @@ -1,4 +1,5 @@ import type { NodeData, NodeType } from '@/types' +import { THEMES, type ThemeId } from './themes' export interface NodeColors { border: string @@ -6,27 +7,31 @@ export interface NodeColors { icon: string } -export const NODE_DEFAULT_COLORS: Record = { - isp: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' }, - router: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' }, - switch: { border: '#39d353', background: '#21262d', icon: '#39d353' }, - server: { border: '#a855f7', background: '#21262d', icon: '#a855f7' }, - proxmox: { border: '#ff6e00', background: '#21262d', icon: '#ff6e00' }, - vm: { border: '#a855f7', background: '#21262d', icon: '#a855f7' }, - lxc: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' }, - nas: { border: '#39d353', background: '#21262d', icon: '#39d353' }, - iot: { border: '#e3b341', background: '#21262d', icon: '#e3b341' }, - ap: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' }, - camera: { border: '#8b949e', background: '#21262d', icon: '#8b949e' }, - printer: { border: '#8b949e', background: '#21262d', icon: '#8b949e' }, - computer: { border: '#a855f7', background: '#21262d', icon: '#a855f7' }, - cpl: { border: '#e3b341', background: '#21262d', icon: '#e3b341' }, - generic: { border: '#8b949e', background: '#21262d', icon: '#8b949e' }, - groupRect: { border: '#00d4ff', background: 'transparent', icon: '#00d4ff' }, -} +// Derived from the default theme — kept for backward compatibility and tests +export const NODE_DEFAULT_COLORS: Record = Object.fromEntries( + (Object.entries(THEMES.default.colors.nodeAccents) as [NodeType, { border: string; icon: string }][]).map( + ([type, accent]) => [ + type, + { + border: accent.border, + background: type === 'groupRect' ? 'transparent' : THEMES.default.colors.nodeCardBackground, + icon: accent.icon, + }, + ] + ) +) as Record -export function resolveNodeColors(data: Pick): NodeColors { - const defaults = NODE_DEFAULT_COLORS[data.type] ?? NODE_DEFAULT_COLORS.generic +export function resolveNodeColors( + data: Pick, + themeId: ThemeId = 'default', +): NodeColors { + const theme = THEMES[themeId] ?? THEMES.default + const accent = theme.colors.nodeAccents[data.type] ?? theme.colors.nodeAccents.generic + const defaults: NodeColors = { + border: accent.border, + background: data.type === 'groupRect' ? 'transparent' : theme.colors.nodeCardBackground, + icon: accent.icon, + } const custom = data.custom_colors return { border: custom?.border ?? defaults.border, diff --git a/frontend/src/utils/themes.ts b/frontend/src/utils/themes.ts new file mode 100644 index 0000000..6989957 --- /dev/null +++ b/frontend/src/utils/themes.ts @@ -0,0 +1,306 @@ +import type { NodeType, EdgeType, NodeStatus } from '@/types' + +export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix' + +export interface ThemeColors { + // Per node-type accent (border + icon) + nodeAccents: Record + // Node surfaces + nodeCardBackground: string + nodeIconBackground: string + nodeLabelColor: string + nodeSubtextColor: string + // Status indicator dots + statusColors: Record + // Edges + edgeColors: Record + edgeSelectedColor: string + edgeLabelBackground: string + edgeLabelColor: string + edgeLabelBorder: string + // Canvas + canvasBackground: string + canvasDotColor: string + // React Flow handles + handleBackground: string + handleBorder: string + // React Flow colorMode for built-in controls + reactFlowColorMode: 'dark' | 'light' +} + +export interface ThemePreset { + id: ThemeId + label: string + description: string + colors: ThemeColors +} + +export const THEMES: Record = { + default: { + id: 'default', + label: 'Default', + description: 'Dark futuristic — the original Homelable look', + 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' }, + generic: { border: '#8b949e', icon: '#8b949e' }, + groupRect:{ 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', + }, + }, + + dark: { + id: 'dark', + label: 'Dark', + description: 'Pure black with maximum contrast', + colors: { + nodeAccents: { + isp: { border: '#22d3ee', icon: '#22d3ee' }, + router: { border: '#22d3ee', icon: '#22d3ee' }, + switch: { border: '#4ade80', icon: '#4ade80' }, + server: { border: '#c084fc', icon: '#c084fc' }, + proxmox: { border: '#fb923c', icon: '#fb923c' }, + vm: { border: '#c084fc', icon: '#c084fc' }, + lxc: { border: '#22d3ee', icon: '#22d3ee' }, + nas: { border: '#4ade80', icon: '#4ade80' }, + iot: { border: '#fbbf24', icon: '#fbbf24' }, + ap: { border: '#22d3ee', icon: '#22d3ee' }, + camera: { border: '#94a3b8', icon: '#94a3b8' }, + printer: { border: '#94a3b8', icon: '#94a3b8' }, + computer: { border: '#c084fc', icon: '#c084fc' }, + cpl: { border: '#fbbf24', icon: '#fbbf24' }, + generic: { border: '#94a3b8', icon: '#94a3b8' }, + groupRect:{ border: '#22d3ee', icon: '#22d3ee' }, + }, + nodeCardBackground: '#0a0a0a', + nodeIconBackground: '#111111', + nodeLabelColor: '#ffffff', + nodeSubtextColor: '#666666', + statusColors: { + online: '#4ade80', + offline: '#ef4444', + pending: '#fbbf24', + unknown: '#6b7280', + }, + edgeColors: { + ethernet: '#1c1c1e', + wifi: '#22d3ee', + iot: '#fbbf24', + vlan: '#22d3ee', + virtual: '#6b7280', + cluster: '#fb923c', + }, + edgeSelectedColor: '#22d3ee', + edgeLabelBackground:'#111111', + edgeLabelColor: '#666666', + edgeLabelBorder: '#1c1c1e', + canvasBackground: '#000000', + canvasDotColor: '#1a1a1a', + handleBackground: '#1c1c1e', + handleBorder: '#444444', + reactFlowColorMode: 'dark', + }, + }, + + light: { + id: 'light', + label: 'Light', + description: 'Clean light theme with dark text', + colors: { + nodeAccents: { + isp: { border: '#0284c7', icon: '#0284c7' }, + router: { border: '#0284c7', icon: '#0284c7' }, + switch: { border: '#16a34a', icon: '#16a34a' }, + server: { border: '#7c3aed', icon: '#7c3aed' }, + proxmox: { border: '#ea580c', icon: '#ea580c' }, + vm: { border: '#7c3aed', icon: '#7c3aed' }, + lxc: { border: '#0284c7', icon: '#0284c7' }, + nas: { border: '#16a34a', icon: '#16a34a' }, + iot: { border: '#b45309', icon: '#b45309' }, + ap: { border: '#0284c7', icon: '#0284c7' }, + camera: { border: '#6b7280', icon: '#6b7280' }, + printer: { border: '#6b7280', icon: '#6b7280' }, + computer: { border: '#7c3aed', icon: '#7c3aed' }, + cpl: { border: '#b45309', icon: '#b45309' }, + generic: { border: '#6b7280', icon: '#6b7280' }, + groupRect:{ border: '#0284c7', icon: '#0284c7' }, + }, + nodeCardBackground: '#ffffff', + nodeIconBackground: '#f0f6ff', + nodeLabelColor: '#1f2328', + nodeSubtextColor: '#57606a', + statusColors: { + online: '#16a34a', + offline: '#dc2626', + pending: '#d97706', + unknown: '#6b7280', + }, + edgeColors: { + ethernet: '#d0d7de', + wifi: '#0284c7', + iot: '#d97706', + vlan: '#0284c7', + virtual: '#9ca3af', + cluster: '#ea580c', + }, + edgeSelectedColor: '#0284c7', + edgeLabelBackground:'#ffffff', + edgeLabelColor: '#57606a', + edgeLabelBorder: '#d0d7de', + canvasBackground: '#f6f8fa', + canvasDotColor: '#d0d7de', + handleBackground: '#d0d7de', + handleBorder: '#9ca3af', + reactFlowColorMode: 'light', + }, + }, + + neon: { + id: 'neon', + label: 'Neon', + description: 'Cyberpunk vibes with vivid glowing accents', + colors: { + nodeAccents: { + isp: { border: '#00ffff', icon: '#00ffff' }, + router: { border: '#00ffff', icon: '#00ffff' }, + switch: { border: '#00ff80', icon: '#00ff80' }, + server: { border: '#ff00ff', icon: '#ff00ff' }, + proxmox: { border: '#ff8800', icon: '#ff8800' }, + vm: { border: '#ff00ff', icon: '#ff00ff' }, + lxc: { border: '#00ffff', icon: '#00ffff' }, + nas: { border: '#00ff80', icon: '#00ff80' }, + iot: { border: '#ffff00', icon: '#ffff00' }, + ap: { border: '#00ffff', icon: '#00ffff' }, + camera: { border: '#8888ff', icon: '#8888ff' }, + printer: { border: '#8888ff', icon: '#8888ff' }, + computer: { border: '#ff00ff', icon: '#ff00ff' }, + cpl: { border: '#ffff00', icon: '#ffff00' }, + generic: { border: '#8888ff', icon: '#8888ff' }, + groupRect:{ border: '#00ffff', icon: '#00ffff' }, + }, + nodeCardBackground: '#0f0f2a', + nodeIconBackground: '#0a0a1a', + nodeLabelColor: '#ffffff', + nodeSubtextColor: '#8888cc', + statusColors: { + online: '#00ff80', + offline: '#ff0040', + pending: '#ffff00', + unknown: '#8888cc', + }, + edgeColors: { + ethernet: '#1a1a3a', + wifi: '#00ffff', + iot: '#ffff00', + vlan: '#00ffff', + virtual: '#8888cc', + cluster: '#ff8800', + }, + edgeSelectedColor: '#00ffff', + edgeLabelBackground:'#0a0a1a', + edgeLabelColor: '#8888cc', + edgeLabelBorder: '#1a1a3a', + canvasBackground: '#05050f', + canvasDotColor: '#1a1a3a', + handleBackground: '#1a1a3a', + handleBorder: '#8888cc', + reactFlowColorMode: 'dark', + }, + }, + + matrix: { + id: 'matrix', + label: 'Matrix', + description: 'Everything in terminal green', + colors: { + nodeAccents: { + isp: { border: '#00ff41', icon: '#00ff41' }, + router: { border: '#00ff41', icon: '#00ff41' }, + switch: { border: '#00cc33', icon: '#00cc33' }, + server: { border: '#008822', icon: '#008822' }, + proxmox: { border: '#33ff66', icon: '#33ff66' }, + vm: { border: '#008822', icon: '#008822' }, + lxc: { border: '#00ff41', icon: '#00ff41' }, + nas: { border: '#00cc33', icon: '#00cc33' }, + iot: { border: '#66ff33', icon: '#66ff33' }, + ap: { border: '#00ff41', icon: '#00ff41' }, + camera: { border: '#005500', icon: '#005500' }, + printer: { border: '#005500', icon: '#005500' }, + computer: { border: '#008822', icon: '#008822' }, + cpl: { border: '#66ff33', icon: '#66ff33' }, + generic: { border: '#006600', icon: '#006600' }, + groupRect:{ border: '#00ff41', icon: '#00ff41' }, + }, + nodeCardBackground: '#001100', + nodeIconBackground: '#002200', + nodeLabelColor: '#00ff41', + nodeSubtextColor: '#006600', + statusColors: { + online: '#00ff41', + offline: '#ff0000', + pending: '#88ff00', + unknown: '#004400', + }, + edgeColors: { + ethernet: '#003300', + wifi: '#00ff41', + iot: '#66ff33', + vlan: '#00cc33', + virtual: '#004400', + cluster: '#33ff66', + }, + edgeSelectedColor: '#00ff41', + edgeLabelBackground:'#001100', + edgeLabelColor: '#006600', + edgeLabelBorder: '#003300', + canvasBackground: '#000000', + canvasDotColor: '#002200', + handleBackground: '#003300', + handleBorder: '#006600', + reactFlowColorMode: 'dark', + }, + }, +} + +// Ordered list for display in the modal +export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix']