feat: add logo, favicon, and theme system
- Add custom SVG favicon and Logo component (house + network nodes motif) - Update page title to Homelable with meta description - Show Logo in sidebar header and toolbar - Add theme store and ThemeModal for canvas style switching - Refactor node colors and edge styles for theme support
This commit is contained in:
+3
-2
@@ -2,9 +2,10 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<meta name="description" content="Homelable — Visual homelab infrastructure map with live monitoring" />
|
||||
<title>Homelable</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<!-- Background circle -->
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
|
||||
<!-- House silhouette -->
|
||||
<path d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Door -->
|
||||
<rect x="27" y="40" width="10" height="12" rx="1"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1"/>
|
||||
|
||||
<!-- Network nodes -->
|
||||
<!-- Center node (hub) -->
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff"/>
|
||||
|
||||
<!-- Left node -->
|
||||
<circle cx="22" cy="38" r="2" fill="#39d353"/>
|
||||
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1" opacity="0.7"/>
|
||||
|
||||
<!-- Right node -->
|
||||
<circle cx="42" cy="38" r="2" fill="#39d353"/>
|
||||
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1" opacity="0.7"/>
|
||||
|
||||
<!-- Top node (inside roof area) -->
|
||||
<circle cx="32" cy="24" r="2" fill="#a855f7"/>
|
||||
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" stroke-width="1" opacity="0.7"/>
|
||||
|
||||
<!-- Glow effect on center node -->
|
||||
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" stroke-width="1.5" opacity="0.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
+20
-5
@@ -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<HTMLDivElement>(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<string | null>(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)}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||
@@ -481,6 +489,13 @@ export default function App() {
|
||||
title="Edit Rectangle"
|
||||
/>
|
||||
|
||||
{/* key forces re-mount on open so useState captures current theme as original */}
|
||||
<ThemeModal
|
||||
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
|
||||
open={themeModalOpen}
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
/>
|
||||
|
||||
<Toaster theme="dark" position="bottom-right" />
|
||||
</ReactFlowProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -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<NodeData>) => {
|
||||
setSelectedNode(node.id)
|
||||
}, [setSelectedNode])
|
||||
@@ -39,9 +44,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
|
||||
onEdgeDoubleClick?.(edge)
|
||||
}, [onEdgeDoubleClick])
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
@@ -56,7 +60,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
|
||||
snapToGrid
|
||||
snapGrid={[16, 16]}
|
||||
fitView
|
||||
colorMode="dark"
|
||||
colorMode={theme.colors.reactFlowColorMode}
|
||||
elevateNodesOnSelect={false}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
isValidConnection={(connection) => 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}
|
||||
/>
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
|
||||
@@ -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<EdgeType, React.CSSProperties> = {
|
||||
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<Edge<EdgeData>>) {
|
||||
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<EdgeType, React.CSSProperties> = {
|
||||
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}
|
||||
|
||||
@@ -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<NodeStatus, string> = {
|
||||
online: '#39d353',
|
||||
offline: '#f85149',
|
||||
pending: '#e3b341',
|
||||
unknown: '#8b949e',
|
||||
}
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
|
||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<Handle type="source" position={Position.Top} id="top" className="!bg-[#30363d] !border-[#8b949e]" />
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Top}
|
||||
id="top"
|
||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||
style={{ color: isOnline ? colors.icon : '#8b949e', background: '#161b22' }}
|
||||
style={{
|
||||
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
|
||||
background: theme.colors.nodeIconBackground,
|
||||
}}
|
||||
>
|
||||
{createElement(resolvedIcon, { size: 15 })}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="text-xs font-medium leading-tight truncate max-w-[110px]" title={data.label}>
|
||||
<div
|
||||
className="text-xs font-medium leading-tight truncate max-w-[110px]"
|
||||
style={{ color: theme.colors.nodeLabelColor }}
|
||||
title={data.label}
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
{data.ip && (
|
||||
<div className="font-mono text-[10px] text-[#8b949e] truncate" title={data.ip}>
|
||||
<div
|
||||
className="font-mono text-[10px] truncate"
|
||||
style={{ color: theme.colors.nodeSubtextColor }}
|
||||
title={data.ip}
|
||||
>
|
||||
{data.ip}
|
||||
</div>
|
||||
)}
|
||||
@@ -68,7 +82,12 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
||||
title={data.status}
|
||||
/>
|
||||
|
||||
<Handle type="source" position={Position.Bottom} id="bottom" className="!bg-[#30363d] !border-[#8b949e]" />
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom"
|
||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<NodeStatus, string> = {
|
||||
online: '#39d353',
|
||||
offline: '#f85149',
|
||||
pending: '#e3b341',
|
||||
unknown: '#8b949e',
|
||||
}
|
||||
|
||||
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
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 (
|
||||
<>
|
||||
<BaseNode {...props} icon={Layers} />
|
||||
<Handle type="source" position={Position.Left} id="cluster-left" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} />
|
||||
<Handle type="source" position={Position.Right} id="cluster-right" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} />
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Left}
|
||||
id="cluster-left"
|
||||
title="Same cluster"
|
||||
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="cluster-right"
|
||||
title="Same cluster"
|
||||
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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<Node<NodeData>>) {
|
||||
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<Node<NodeData>>) {
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
|
||||
style={{ background: isOnline ? `${glow}18` : '#161b2288', borderBottom: `1px solid ${isOnline ? `${glow}33` : '#30363d'}` }}
|
||||
style={{
|
||||
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
|
||||
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center w-5 h-5 rounded-md shrink-0"
|
||||
style={{ color: isOnline ? colors.icon : '#8b949e', background: '#161b22' }}
|
||||
style={{
|
||||
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
|
||||
background: theme.colors.nodeIconBackground,
|
||||
}}
|
||||
>
|
||||
<Layers size={12} />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-[11px] font-semibold leading-tight truncate" style={{ color: isOnline ? glow : '#c9d1d9' }}>
|
||||
<span
|
||||
className="text-[11px] font-semibold leading-tight truncate"
|
||||
style={{ color: isOnline ? glow : theme.colors.nodeLabelColor }}
|
||||
>
|
||||
{data.label}
|
||||
</span>
|
||||
{data.ip && (
|
||||
<span className="font-mono text-[9px] text-[#8b949e] truncate">{data.ip}</span>
|
||||
<span
|
||||
className="font-mono text-[9px] truncate"
|
||||
style={{ color: theme.colors.nodeSubtextColor }}
|
||||
>
|
||||
{data.ip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Status dot */}
|
||||
<div className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} title={data.status} />
|
||||
<div
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
title={data.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inner area — React Flow places children here */}
|
||||
<div className="flex-1 relative" />
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Top} id="top" className="!bg-[#30363d] !border-[#8b949e]" />
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Top}
|
||||
id="top"
|
||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||
<Handle type="source" position={Position.Bottom} id="bottom" className="!bg-[#30363d] !border-[#8b949e]" />
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom"
|
||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||
|
||||
{/* Cluster handles — left/right for same-cluster links */}
|
||||
<Handle type="source" position={Position.Left} id="cluster-left" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} />
|
||||
<Handle type="source" position={Position.Right} id="cluster-right" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} />
|
||||
{/* Cluster handles */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Left}
|
||||
id="cluster-left"
|
||||
title="Same cluster"
|
||||
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="cluster-right"
|
||||
title="Same cluster"
|
||||
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full"
|
||||
style={{
|
||||
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
||||
background: c.canvasBackground,
|
||||
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Selected checkmark */}
|
||||
{selected && (
|
||||
<span
|
||||
className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
|
||||
style={{ background: c.nodeAccents.isp.border }}
|
||||
>
|
||||
<Check size={10} style={{ color: c.canvasBackground }} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Mini canvas preview */}
|
||||
<div
|
||||
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
|
||||
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
|
||||
>
|
||||
{/* Node accent dots */}
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{PREVIEW_TYPES.map((type) => (
|
||||
<span
|
||||
key={type}
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: c.nodeAccents[type].border }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Edge line */}
|
||||
<div style={{ height: 2, background: c.edgeColors.ethernet, width: '80%', borderRadius: 2 }} />
|
||||
{/* Wifi dashed line */}
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
width: '55%',
|
||||
backgroundImage: `repeating-linear-gradient(90deg, ${c.edgeColors.wifi} 0 5px, transparent 5px 8px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div
|
||||
className="text-xs font-semibold leading-tight"
|
||||
style={{ color: c.nodeLabelColor }}
|
||||
>
|
||||
{preset.label}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] leading-snug mt-0.5 line-clamp-2"
|
||||
style={{ color: c.nodeSubtextColor }}
|
||||
>
|
||||
{preset.description}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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<ThemeId>(activeTheme)
|
||||
const [selected, setSelected] = useState<ThemeId>(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 (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] w-[90vw] max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-5 gap-3 py-1">
|
||||
{THEME_ORDER.map((id) => (
|
||||
<ThemeCard
|
||||
key={id}
|
||||
themeId={id}
|
||||
selected={selected === id}
|
||||
onClick={() => handleSelect(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
style={
|
||||
selected !== 'default'
|
||||
? { background: THEMES[selected].colors.nodeAccents.isp.border }
|
||||
: undefined
|
||||
}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply Style
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 px-3 py-4 border-b border-border">
|
||||
<div className="flex items-center justify-center w-7 h-7 rounded-md bg-[#00d4ff]/10 text-[#00d4ff] shrink-0">
|
||||
<Network size={16} />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="font-semibold text-sm tracking-wide text-foreground">Homelable</span>
|
||||
)}
|
||||
<div className="flex items-center px-3 py-4 border-b border-border overflow-hidden">
|
||||
<Logo size={28} showText={!collapsed} />
|
||||
</div>
|
||||
|
||||
{/* Views */}
|
||||
|
||||
@@ -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 (
|
||||
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
||||
<Logo size={28} showText={true} />
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
|
||||
<LayoutDashboard size={14} /> Auto Layout
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
||||
<Palette size={14} /> Style
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport}>
|
||||
<Download size={14} /> Export
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
interface LogoProps {
|
||||
size?: number;
|
||||
showText?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Logo({ size = 32, showText = true, className = '' }: LogoProps) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117" />
|
||||
<path
|
||||
d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
|
||||
fill="#161b22"
|
||||
stroke="#00d4ff"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect x="27" y="40" width="10" height="12" rx="1" fill="#0d1117" stroke="#00d4ff" strokeWidth="1" />
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" />
|
||||
<circle cx="22" cy="38" r="2" fill="#39d353" />
|
||||
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
|
||||
<circle cx="42" cy="38" r="2" fill="#39d353" />
|
||||
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
|
||||
<circle cx="32" cy="24" r="2" fill="#a855f7" />
|
||||
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" strokeWidth="1" opacity="0.7" />
|
||||
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" strokeWidth="1.5" opacity="0.4" />
|
||||
</svg>
|
||||
{showText && (
|
||||
<span
|
||||
className="font-bold tracking-tight"
|
||||
style={{ fontSize: size * 0.55, fontFamily: 'Inter, sans-serif' }}
|
||||
>
|
||||
<span style={{ color: '#00d4ff' }}>Home</span>
|
||||
<span style={{ color: '#ffffff' }}>lable</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,7 @@ interface CanvasState {
|
||||
editingGroupRectId: string | null
|
||||
setEditingGroupRectId: (id: string | null) => void
|
||||
markSaved: () => void
|
||||
markUnsaved: () => void
|
||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
||||
notifyScanDeviceFound: () => void
|
||||
}
|
||||
@@ -155,6 +156,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
|
||||
markSaved: () => set({ hasUnsavedChanges: false }),
|
||||
|
||||
markUnsaved: () => set({ hasUnsavedChanges: true }),
|
||||
|
||||
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
||||
|
||||
loadCanvas: (nodes, edges) => {
|
||||
|
||||
@@ -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<ThemeState>((set) => ({
|
||||
activeTheme: 'default',
|
||||
setTheme: (id) => set({ activeTheme: id }),
|
||||
}))
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<NodeType, NodeColors> = {
|
||||
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<NodeType, NodeColors> = 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<NodeType, NodeColors>
|
||||
|
||||
export function resolveNodeColors(data: Pick<NodeData, 'type' | 'custom_colors'>): NodeColors {
|
||||
const defaults = NODE_DEFAULT_COLORS[data.type] ?? NODE_DEFAULT_COLORS.generic
|
||||
export function resolveNodeColors(
|
||||
data: Pick<NodeData, 'type' | 'custom_colors'>,
|
||||
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,
|
||||
|
||||
@@ -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<NodeType, { border: string; icon: string }>
|
||||
// Node surfaces
|
||||
nodeCardBackground: string
|
||||
nodeIconBackground: string
|
||||
nodeLabelColor: string
|
||||
nodeSubtextColor: string
|
||||
// Status indicator dots
|
||||
statusColors: Record<NodeStatus, string>
|
||||
// Edges
|
||||
edgeColors: Record<EdgeType, string>
|
||||
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<ThemeId, ThemePreset> = {
|
||||
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']
|
||||
Reference in New Issue
Block a user