Merge pull request #142 from Pouzor/fix/visual

Node modal polish + Smart Home and Brand icon pickers
This commit is contained in:
Remy
2026-05-11 19:53:48 +02:00
committed by GitHub
11 changed files with 344 additions and 9 deletions
@@ -0,0 +1,27 @@
#!/usr/bin/env node
// Regenerate frontend/src/data/dashboardIcons.json from the upstream
// homarr-labs/dashboard-icons repo. Run manually to refresh the manifest.
//
// node scripts/fetch-dashboard-icons.mjs
import { writeFileSync, mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const TREE_URL = 'https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/tree.json'
const OUT = resolve(dirname(fileURLToPath(import.meta.url)), '../src/data/dashboardIcons.json')
const res = await fetch(TREE_URL)
if (!res.ok) {
console.error(`fetch failed: ${res.status} ${res.statusText}`)
process.exit(1)
}
const tree = await res.json()
const slugs = (tree.svg ?? [])
.filter((f) => f.endsWith('.svg'))
.map((f) => f.slice(0, -4))
.sort()
mkdirSync(dirname(OUT), { recursive: true })
writeFileSync(OUT, JSON.stringify(slugs))
console.log(`wrote ${slugs.length} slugs → ${OUT}`)
@@ -44,6 +44,7 @@ vi.mock('@/utils/nodeColors', () => ({
vi.mock('@/utils/nodeIcons', () => ({
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
isBrandIconKey: (k: string | undefined) => !!k && k.startsWith('brand:'),
}))
vi.mock('@/utils/maskIp', () => ({
@@ -3,7 +3,8 @@ import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, typ
import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons'
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
import { NodeIcon } from '@/components/ui/NodeIcon'
import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
@@ -98,7 +99,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
background: theme.colors.nodeIconBackground,
}}
>
{createElement(resolvedIcon, { size: 15 })}
{isBrandIconKey(data.custom_icon)
? <NodeIcon typeIcon={typeIcon} customIconKey={data.custom_icon} size={15} />
: createElement(resolvedIcon, { size: 15 })}
</div>
{/* Label + IP */}
@@ -3,7 +3,8 @@ import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflo
import { Layers } from 'lucide-react'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons'
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
import { NodeIcon } from '@/components/ui/NodeIcon'
import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp, splitIps } from '@/utils/maskIp'
@@ -87,7 +88,9 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
background: theme.colors.nodeIconBackground,
}}
>
{createElement(resolvedIcon, { size: 12 })}
{isBrandIconKey(data.custom_icon)
? <NodeIcon typeIcon={Layers} customIconKey={data.custom_icon} size={12} />
: createElement(resolvedIcon, { size: 12 })}
</div>
<div className="flex flex-col min-w-0 flex-1">
<span
@@ -0,0 +1,85 @@
import { useMemo, useState } from 'react'
import { Input } from '@/components/ui/input'
import { brandIconUrl, BRAND_ICON_PREFIX } from '@/utils/nodeIcons'
import dashboardIcons from '@/data/dashboardIcons.json'
const SLUGS: string[] = dashboardIcons as string[]
const PAGE = 120
interface BrandIconPickerProps {
value?: string
onSelect: (key: string) => void
}
export function BrandIconPicker({ value, onSelect }: BrandIconPickerProps) {
const [query, setQuery] = useState('')
const [limit, setLimit] = useState(PAGE)
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return SLUGS
return SLUGS.filter((s) => s.includes(q))
}, [query])
const visible = filtered.slice(0, limit)
const selectedSlug = value?.startsWith(BRAND_ICON_PREFIX) ? value.slice(BRAND_ICON_PREFIX.length) : null
return (
<div className="flex flex-col gap-2">
<Input
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setLimit(PAGE) }}
placeholder={`Search ${SLUGS.length} brand icons...`}
className="bg-[#0d1117] border-[#30363d] text-xs h-7"
aria-label="Brand icon search"
/>
<div className="text-[10px] text-muted-foreground/60">
{filtered.length} match{filtered.length === 1 ? '' : 'es'} · icons served via jsDelivr CDN
</div>
<div className="max-h-52 overflow-y-auto pr-1">
<div className="grid grid-cols-7 gap-1">
{visible.map((slug) => {
const selected = slug === selectedSlug
return (
<button
key={slug}
type="button"
onClick={() => onSelect(`${BRAND_ICON_PREFIX}${slug}`)}
title={slug}
aria-label={slug}
aria-pressed={selected}
className={`flex items-center justify-center aspect-square rounded-md border transition-colors cursor-pointer ${
selected
? 'border-[#00d4ff] bg-[#00d4ff]/10'
: 'border-[#30363d] hover:border-[#484f58] bg-[#0d1117]'
}`}
>
<img
src={brandIconUrl(slug)}
alt={slug}
loading="lazy"
width={20}
height={20}
style={{ width: 20, height: 20, objectFit: 'contain' }}
/>
</button>
)
})}
</div>
{filtered.length > limit && (
<button
type="button"
onClick={() => setLimit((l) => l + PAGE)}
className="mt-2 w-full text-[11px] text-muted-foreground hover:text-foreground py-1"
>
Load more ({filtered.length - limit} remaining)
</button>
)}
{filtered.length === 0 && (
<div className="text-center text-[11px] text-muted-foreground py-4">No icons match.</div>
)}
</div>
</div>
)
}
+41 -2
View File
@@ -8,7 +8,8 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { BrandIconPicker } from './BrandIconPicker'
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
@@ -61,6 +62,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
const [labelError, setLabelError] = useState(false)
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
const showServicesEnabled = form.custom_colors?.show_services === true
@@ -95,7 +97,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader>
@@ -152,6 +154,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
>
<span className="flex items-center gap-2 min-w-0">
{(() => {
if (isBrandIconKey(form.custom_icon)) {
const slug = brandIconSlug(form.custom_icon!)
return <><img src={brandIconUrl(slug)} alt={slug} width={13} height={13} className="shrink-0" style={{ width: 13, height: 13, objectFit: 'contain' }} /><span className="text-foreground truncate">{slug}</span></>
}
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
if (entry) {
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
@@ -167,6 +173,37 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{/* Inline icon picker - full width, shown below the type+icon row */}
{iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
<button
type="button"
role="tab"
aria-selected={iconTab === 'generic'}
onClick={() => setIconTab('generic')}
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
iconTab === 'generic' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
}`}
>
Generic
</button>
<button
type="button"
role="tab"
aria-selected={iconTab === 'brand'}
onClick={() => setIconTab('brand')}
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
iconTab === 'brand' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
}`}
>
Brand
</button>
</div>
{iconTab === 'brand' ? (
<BrandIconPicker
value={form.custom_icon}
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
/>
) : (
<>
<Input
value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)}
@@ -212,6 +249,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
)
})}
</div>
</>
)}
</div>
)}
+35
View File
@@ -0,0 +1,35 @@
import { createElement } from 'react'
import type { LucideIcon } from 'lucide-react'
import { resolveCustomIcon, brandIconUrl, isBrandIconKey } from '@/utils/nodeIcons'
interface NodeIconProps {
/** Default icon for the node type (lucide). Used when no customIconKey or unknown key. */
typeIcon: LucideIcon
/** Optional override key. Legacy lucide keys or `brand:<slug>` for dashboard-icons. */
customIconKey?: string
size?: number
className?: string
/** Optional inline color (lucide only — ignored for brand icons). */
color?: string
}
export function NodeIcon({ typeIcon, customIconKey, size = 16, className, color }: NodeIconProps) {
const resolved = resolveCustomIcon(customIconKey)
if (resolved?.kind === 'brand') {
return (
<img
src={resolved.url}
alt={resolved.slug}
width={size}
height={size}
loading="lazy"
className={className}
style={{ width: size, height: size, objectFit: 'contain' }}
/>
)
}
const Icon = resolved?.kind === 'lucide' ? resolved.icon : typeIcon
return createElement(Icon, { size, className, color })
}
export { brandIconUrl, isBrandIconKey }
File diff suppressed because one or more lines are too long
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import {
BRAND_ICON_PREFIX,
isBrandIconKey,
brandIconSlug,
brandIconUrl,
resolveCustomIcon,
ICON_MAP,
} from '../nodeIcons'
describe('brand icon helpers', () => {
it('isBrandIconKey returns true only for prefixed keys', () => {
expect(isBrandIconKey('brand:plex')).toBe(true)
expect(isBrandIconKey('plex')).toBe(false)
expect(isBrandIconKey('plug')).toBe(false)
expect(isBrandIconKey(undefined)).toBe(false)
expect(isBrandIconKey(null)).toBe(false)
expect(isBrandIconKey('')).toBe(false)
})
it('brandIconSlug strips the prefix', () => {
expect(brandIconSlug(`${BRAND_ICON_PREFIX}home-assistant`)).toBe('home-assistant')
})
it('brandIconUrl points at jsDelivr CDN', () => {
expect(brandIconUrl('plex')).toBe(
'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg',
)
})
})
describe('resolveCustomIcon', () => {
it('returns null when no key', () => {
expect(resolveCustomIcon(undefined)).toBeNull()
expect(resolveCustomIcon('')).toBeNull()
})
it('resolves legacy lucide keys', () => {
const r = resolveCustomIcon('plug')
expect(r?.kind).toBe('lucide')
if (r?.kind === 'lucide') expect(r.icon).toBe(ICON_MAP['plug'])
})
it('resolves brand-prefixed keys to a CDN url', () => {
const r = resolveCustomIcon('brand:plex')
expect(r?.kind).toBe('brand')
if (r?.kind === 'brand') {
expect(r.slug).toBe('plex')
expect(r.url).toContain('cdn.jsdelivr.net')
expect(r.url).toContain('/plex.svg')
}
})
it('returns null for unknown legacy key', () => {
expect(resolveCustomIcon('definitely-not-a-known-icon-key')).toBeNull()
})
})
@@ -33,6 +33,22 @@ describe('ICON_REGISTRY', () => {
expect(keys).toContain('database') // DB services
expect(keys).toContain('cctv') // IP Camera / CCTV
})
it('contains Smart Home / Sensors icons', () => {
const keys = ICON_REGISTRY.map((e) => e.key)
expect(keys).toContain('plug')
expect(keys).toContain('smoke')
expect(keys).toContain('door')
expect(keys).toContain('motion')
expect(keys).toContain('leak')
expect(keys).toContain('lock-smart')
expect(keys).toContain('battery-charging')
})
it('exposes the Smart Home / Sensors category', () => {
const categories = ICON_REGISTRY.map((e) => e.category)
expect(categories).toContain('Smart Home / Sensors')
})
})
describe('ICON_CATEGORIES', () => {
+71 -3
View File
@@ -11,7 +11,12 @@ import {
// Security & Auth
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
// Automation & IoT
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio,
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, BotMessageSquare, Webhook,
// Smart Home / Sensors
Plug, Power, BatteryCharging, Sun, DoorOpen, KeyRound, AlarmSmoke, Siren,
Radar, PersonStanding, Vibrate, Droplet, Droplets, Wind, AirVent, Fan,
Snowflake, LampCeiling, Blinds, BellRing, Speaker, Joystick, Warehouse,
CircleDot, CloudSun,
// Transfers & sync
Download, Upload, RefreshCw,
// Containers & Dev
@@ -97,6 +102,35 @@ export const ICON_REGISTRY: IconEntry[] = [
{ key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer },
{ key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb },
{ key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio },
{ key: 'voice', label: 'Voice Assistant', category: 'Automation', icon: BotMessageSquare },
{ key: 'webhook', label: 'Webhook / IFTTT', category: 'Automation', icon: Webhook },
// --- Smart Home / Sensors ---
{ key: 'plug', label: 'Smart Plug / Outlet', category: 'Smart Home / Sensors', icon: Plug },
{ key: 'power', label: 'Switch / Relay', category: 'Smart Home / Sensors', icon: Power },
{ key: 'battery-charging', label: 'Energy Meter / EV', category: 'Smart Home / Sensors', icon: BatteryCharging },
{ key: 'solar', label: 'Solar Panel', category: 'Smart Home / Sensors', icon: Sun },
{ key: 'door', label: 'Door / Window Sensor', category: 'Smart Home / Sensors', icon: DoorOpen },
{ key: 'lock-smart', label: 'Smart Lock', category: 'Smart Home / Sensors', icon: KeyRound },
{ key: 'smoke', label: 'Smoke Detector', category: 'Smart Home / Sensors', icon: AlarmSmoke },
{ key: 'siren', label: 'Siren / Alarm', category: 'Smart Home / Sensors', icon: Siren },
{ key: 'motion', label: 'Motion / Radar', category: 'Smart Home / Sensors', icon: Radar },
{ key: 'presence', label: 'Presence Sensor', category: 'Smart Home / Sensors', icon: PersonStanding },
{ key: 'vibration', label: 'Vibration Sensor', category: 'Smart Home / Sensors', icon: Vibrate },
{ key: 'leak', label: 'Water Leak', category: 'Smart Home / Sensors', icon: Droplet },
{ key: 'humidity', label: 'Humidity', category: 'Smart Home / Sensors', icon: Droplets },
{ key: 'air-quality', label: 'Air Quality / VOC', category: 'Smart Home / Sensors', icon: Wind },
{ key: 'air-vent', label: 'HVAC Vent', category: 'Smart Home / Sensors', icon: AirVent },
{ key: 'fan', label: 'Fan', category: 'Smart Home / Sensors', icon: Fan },
{ key: 'snowflake', label: 'AC / Cooling', category: 'Smart Home / Sensors', icon: Snowflake },
{ key: 'lamp', label: 'Smart Light', category: 'Smart Home / Sensors', icon: LampCeiling },
{ key: 'blinds', label: 'Blinds / Cover', category: 'Smart Home / Sensors', icon: Blinds },
{ key: 'doorbell', label: 'Doorbell', category: 'Smart Home / Sensors', icon: BellRing },
{ key: 'speaker', label: 'Smart Speaker', category: 'Smart Home / Sensors', icon: Speaker },
{ key: 'remote', label: 'Remote / Button', category: 'Smart Home / Sensors', icon: Joystick },
{ key: 'garage', label: 'Garage Door', category: 'Smart Home / Sensors', icon: Warehouse },
{ key: 'valve', label: 'Smart Valve', category: 'Smart Home / Sensors', icon: CircleDot },
{ key: 'weather', label: 'Weather Station', category: 'Smart Home / Sensors', icon: CloudSun },
// --- Containers & Dev ---
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
@@ -145,11 +179,45 @@ export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
text: Type,
}
/** Resolve the display icon for a node — custom_icon takes priority over type default. */
/** Resolve the display icon for a node — custom_icon takes priority over type default.
* Legacy: returns a LucideIcon component. Brand icons must use `resolveCustomIcon`. */
export function resolveNodeIcon(
typeIcon: LucideIcon,
customIconKey?: string,
): LucideIcon {
if (customIconKey && ICON_MAP[customIconKey]) return ICON_MAP[customIconKey]
if (customIconKey && !customIconKey.startsWith('brand:') && ICON_MAP[customIconKey]) {
return ICON_MAP[customIconKey]
}
return typeIcon
}
export const BRAND_ICON_PREFIX = 'brand:'
export function isBrandIconKey(key: string | undefined | null): boolean {
return !!key && key.startsWith(BRAND_ICON_PREFIX)
}
export function brandIconSlug(key: string): string {
return key.slice(BRAND_ICON_PREFIX.length)
}
export function brandIconUrl(slug: string): string {
return `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${slug}.svg`
}
export type ResolvedIcon =
| { kind: 'lucide'; icon: LucideIcon }
| { kind: 'brand'; slug: string; url: string }
/** Resolve a node's icon to either a lucide component or a brand CDN URL.
* Used by renderers that support brand icons. Backwards-compatible with legacy
* string keys (no prefix → lucide lookup). Returns null when key unknown. */
export function resolveCustomIcon(customIconKey?: string): ResolvedIcon | null {
if (!customIconKey) return null
if (isBrandIconKey(customIconKey)) {
const slug = brandIconSlug(customIconKey)
return { kind: 'brand', slug, url: brandIconUrl(slug) }
}
const icon = ICON_MAP[customIconKey] as LucideIcon | undefined
return icon ? { kind: 'lucide', icon } : null
}