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.
This commit is contained in:
Pouzor
2026-05-11 19:18:01 +02:00
parent 3a9b3b2650
commit 3b0dbd7a8b
10 changed files with 292 additions and 7 deletions
+40 -1
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
@@ -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>
)}