merge feat/brand-icons into fix/visual
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -179,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]
|
||||
return icon ? { kind: 'lucide', icon } : null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user