Compare commits

...

3 Commits

Author SHA1 Message Date
Pouzor 3b0dbd7a8b feat(icons): add brand icon picker from dashboard-icons
Add a second tab in the node Icon picker to choose from the ~2250 brand
icons hosted by homarr-labs/dashboard-icons (Plex, Sonarr, Home
Assistant, etc.) served via jsDelivr CDN. The Generic tab keeps the
existing lucide picker unchanged.

Storage uses a 'brand:<slug>' prefix on custom_icon, so existing nodes
referencing lucide keys keep working with zero migration. A new
resolveCustomIcon helper returns a discriminated union (lucide | brand)
and a NodeIcon component centralizes rendering for both kinds.

Includes a manifest fetch script (scripts/fetch-dashboard-icons.mjs)
and a checked-in dashboardIcons.json snapshot.
2026-05-11 19:18:01 +02:00
Pouzor 3a9b3b2650 feat(icons): add Smart Home / Sensors icon category
Add 27 new icons covering common IoT/Zigbee endpoints: smart plug,
relay, energy meter, solar, door/window sensor, smart lock, smoke
detector, siren, motion radar, presence, vibration, water leak,
humidity, air quality, HVAC vent, fan, AC, smart light, blinds,
doorbell, speaker, remote, garage, valve, weather station, plus
voice assistant and webhook in the existing Automation category.
2026-05-11 17:08:11 +02:00
Pouzor ff02f3b5db fix(node-modal): cap height at 90vh with scroll
Modal grew taller than viewport when icon picker expanded, hiding
header and footer buttons. Constrain DialogContent to 90vh and add
overflow-y-auto so all controls stay reachable.
2026-05-11 16:54:33 +02:00
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', () => ({ vi.mock('@/utils/nodeIcons', () => ({
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon, resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
isBrandIconKey: (k: string | undefined) => !!k && k.startsWith('brand:'),
})) }))
vi.mock('@/utils/maskIp', () => ({ 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 { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' 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 { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
@@ -98,7 +99,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
background: theme.colors.nodeIconBackground, 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> </div>
{/* Label + IP */} {/* Label + IP */}
@@ -3,7 +3,8 @@ import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflo
import { Layers } from 'lucide-react' import { Layers } from 'lucide-react'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' 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 { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp, splitIps } from '@/utils/maskIp' import { maskIp, splitIps } from '@/utils/maskIp'
@@ -87,7 +88,9 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
background: theme.colors.nodeIconBackground, 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>
<div className="flex flex-col min-w-0 flex-1"> <div className="flex flex-col min-w-0 flex-1">
<span <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 { 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 { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' 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' import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ 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 [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
const [iconSearch, setIconSearch] = useState('') const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false) const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
const [labelError, setLabelError] = useState(false) const [labelError, setLabelError] = useState(false)
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors }) const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
const showServicesEnabled = form.custom_colors?.show_services === true const showServicesEnabled = form.custom_colors?.show_services === true
@@ -95,7 +97,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <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> <DialogHeader>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle> <DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader> </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"> <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) const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
if (entry) { if (entry) {
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></> 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 */} {/* Inline icon picker - full width, shown below the type+icon row */}
{iconPickerOpen && ( {iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2"> <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 <Input
value={iconSearch} value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)} onChange={(e) => setIconSearch(e.target.value)}
@@ -212,6 +249,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
) )
})} })}
</div> </div>
</>
)}
</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('database') // DB services
expect(keys).toContain('cctv') // IP Camera / CCTV 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', () => { describe('ICON_CATEGORIES', () => {
+71 -3
View File
@@ -11,7 +11,12 @@ import {
// Security & Auth // Security & Auth
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame, Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
// Automation & IoT // 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 // Transfers & sync
Download, Upload, RefreshCw, Download, Upload, RefreshCw,
// Containers & Dev // Containers & Dev
@@ -97,6 +102,35 @@ export const ICON_REGISTRY: IconEntry[] = [
{ key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer }, { key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer },
{ key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb }, { key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb },
{ key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio }, { 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 --- // --- Containers & Dev ---
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor }, { 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, 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( export function resolveNodeIcon(
typeIcon: LucideIcon, typeIcon: LucideIcon,
customIconKey?: string, customIconKey?: string,
): LucideIcon { ): LucideIcon {
if (customIconKey && ICON_MAP[customIconKey]) return ICON_MAP[customIconKey] if (customIconKey && !customIconKey.startsWith('brand:') && ICON_MAP[customIconKey]) {
return ICON_MAP[customIconKey]
}
return typeIcon 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]
return icon ? { kind: 'lucide', icon } : null
}