Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b0dbd7a8b | |||
| 3a9b3b2650 | |||
| ff02f3b5db |
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user