From 3b0dbd7a8bc90eeb1ceda50bfe24c9c374add8f9 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 11 May 2026 19:18:01 +0200 Subject: [PATCH] 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:' 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. --- frontend/scripts/fetch-dashboard-icons.mjs | 27 ++++++ .../canvas/__tests__/BaseNode.test.tsx | 1 + .../src/components/canvas/nodes/BaseNode.tsx | 7 +- .../canvas/nodes/ProxmoxGroupNode.tsx | 7 +- .../src/components/modals/BrandIconPicker.tsx | 85 +++++++++++++++++++ frontend/src/components/modals/NodeModal.tsx | 41 ++++++++- frontend/src/components/ui/NodeIcon.tsx | 35 ++++++++ frontend/src/data/dashboardIcons.json | 1 + .../src/utils/__tests__/brandIcons.test.ts | 57 +++++++++++++ frontend/src/utils/nodeIcons.ts | 38 ++++++++- 10 files changed, 292 insertions(+), 7 deletions(-) create mode 100644 frontend/scripts/fetch-dashboard-icons.mjs create mode 100644 frontend/src/components/modals/BrandIconPicker.tsx create mode 100644 frontend/src/components/ui/NodeIcon.tsx create mode 100644 frontend/src/data/dashboardIcons.json create mode 100644 frontend/src/utils/__tests__/brandIcons.test.ts diff --git a/frontend/scripts/fetch-dashboard-icons.mjs b/frontend/scripts/fetch-dashboard-icons.mjs new file mode 100644 index 0000000..7718f86 --- /dev/null +++ b/frontend/scripts/fetch-dashboard-icons.mjs @@ -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}`) diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index a1fe392..91ec10b 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -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', () => ({ diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 2a3babd..1c8c398 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -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) + ? + : createElement(resolvedIcon, { size: 15 })} {/* Label + IP */} diff --git a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx index 8f4cc72..1286164 100644 --- a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx +++ b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx @@ -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>) { background: theme.colors.nodeIconBackground, }} > - {createElement(resolvedIcon, { size: 12 })} + {isBrandIconKey(data.custom_icon) + ? + : createElement(resolvedIcon, { size: 12 })}
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 ( +
+ { 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" + /> +
+ {filtered.length} match{filtered.length === 1 ? '' : 'es'} · icons served via jsDelivr CDN +
+
+
+ {visible.map((slug) => { + const selected = slug === selectedSlug + return ( + + ) + })} +
+ {filtered.length > limit && ( + + )} + {filtered.length === 0 && ( +
No icons match.
+ )} +
+
+ ) +} diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 714d67b..da28e01 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -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>({ ...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' > {(() => { + if (isBrandIconKey(form.custom_icon)) { + const slug = brandIconSlug(form.custom_icon!) + return <>{slug}{slug} + } const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon) if (entry) { return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}{entry.label} @@ -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 && (
+
+ + +
+ {iconTab === 'brand' ? ( + { set('custom_icon', key); setIconPickerOpen(false) }} + /> + ) : ( + <> setIconSearch(e.target.value)} @@ -212,6 +249,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' ) })}
+ + )}
)} diff --git a/frontend/src/components/ui/NodeIcon.tsx b/frontend/src/components/ui/NodeIcon.tsx new file mode 100644 index 0000000..308d32e --- /dev/null +++ b/frontend/src/components/ui/NodeIcon.tsx @@ -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:` 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 ( + {resolved.slug} + ) + } + const Icon = resolved?.kind === 'lucide' ? resolved.icon : typeIcon + return createElement(Icon, { size, className, color }) +} + +export { brandIconUrl, isBrandIconKey } diff --git a/frontend/src/data/dashboardIcons.json b/frontend/src/data/dashboardIcons.json new file mode 100644 index 0000000..851d765 --- /dev/null +++ b/frontend/src/data/dashboardIcons.json @@ -0,0 +1 @@ +["1337x", "13ft", "1panel", "1password", "1password-dark", "20i", "20i-dark", "2fauth", "2fauth-light", "3cx", "3cx-light", "4chan", "5etools", "5etools-dark", "7zip", "a-mule", "aboard", "act", "action1", "activepieces", "actual-budget", "adguard-home", "adguard-home-sync", "adminer", "adobe", "advanzia", "adventure-log", "affine", "affine-light", "agregarr", "ai-on-the-edge-device", "air-trail", "airsonic", "airtable", "airtel", "airvpn", "akamai", "akaunting", "akkoma", "akkoma-light", "albert-heijn", "alertmanager", "alexa", "alexandrie", "alexandrie-dark", "aliexpress", "alist", "aliyun", "alloy", "alma-linux", "alpine-linux", "amazon", "amazon-light", "amazon-prime", "amazon-web-services", "amazon-web-services-light", "amd", "amd-light", "android", "android-auto", "android-auto-dark", "android-robot", "angular", "anime-kai", "anonaddy", "ansible", "ansible-light", "any-listen", "anything-llm", "anything-llm-light", "apache", "apache-airflow", "apache-answer", "apache-cassandra", "apache-cloudstack", "apache-druid", "apache-openoffice", "apache-solr", "apache-subversion", "apache-tomcat", "apache-tomcat-light", "apc", "apiscp", "app-store", "appflowy", "apple", "apple-alt", "apple-light", "apple-maps", "apple-music", "apple-podcasts", "apple-tv-plus", "apple-tv-plus-light", "appwrite", "ara-records-ansible", "arcane", "arch-linux", "archidekt", "arduino", "argo-cd", "arm", "artifacthub", "artifactory", "aruba", "asana", "asciinema", "asrock-rack", "asrock-rack-ipmi", "astral", "astuto", "astuto-light", "asus", "asus-full", "asus-rog", "asus-router", "asustor", "at-t", "atlassian", "atlassian-bamboo", "atlassian-bitbucket", "atlassian-confluence", "atlassian-jira", "atlassian-opsgenie", "atlassian-trello", "atuin", "atuin-light", "audacity", "audiobookshelf", "audora", "aura", "auracast", "authelia", "authentik", "authman", "auto-cad", "autobangumi", "autobangumi-dark", "autobrr", "automad", "automad-light", "avg", "avigilon", "avm-fritzbox", "avm-fritzbox-light", "aws", "aws-ecs", "aws-light", "awwesome", "axis", "azuracast", "azure", "azure-container-instances", "azure-container-service", "azure-devops", "azure-dns", "bab-technologie", "bab-technologie-dark", "backblaze", "backrest", "backrest-light", "bale", "balena-cloud", "balena-etcher", "ballerina", "bandcamp", "bar-assistant", "baserow", "bazarr", "bazarr-dark", "bazecor", "be-quiet", "beaver-habit-tracker", "beaver-habit-tracker-light", "bechtle", "beef", "beef-light", "bentopdf", "beszel", "beszel-light", "bewcloud", "biblioreads", "biblioreads-light", "bigcapital", "bilibili", "bing", "binner", "binner-dark", "bitbucket", "bitcoin", "bitmagnet", "bitwarden", "blender", "blocky", "blogger", "bluesky", "bluetooth", "boeing", "book-lore", "booklogr", "booklogr-light", "bookstack", "bootstrap", "borg", "borgmatic", "borgmatic-light", "bottom", "bottom-dark", "boundary", "box", "brave", "brick-tracker", "bright-move", "broadcastchannel", "broadcastchannel-light", "brocade", "brother", "browserless", "browserless-light", "browsh", "budget-board", "budgetbee", "budgetbee-light", "budibase", "build-better", "build-better-dark", "buildium", "bunkerweb", "bunny", "bytestash", "c", "cabernet", "cachyos-linux", "cacti", "caddy", "cal-com", "cal-com-light", "calibre", "calibre-web", "canonical", "canvas-lms", "cap-cut", "cap-cut-dark", "capacities", "capacities-dark", "caprover", "carrefour", "casaos", "castopod", "cc", "cc-light", "centos", "ceph", "cert-manager", "cert-warden", "cert-warden-light", "cessna", "chainguard", "changedetection", "channels-dvr", "chaptarr", "chatgpt", "chatpad-ai", "chatwoot", "check-cle", "checkmate", "checkmk", "chess", "chevereto", "chhoto-url", "chibisafe", "chirpy", "chroma", "chrome", "chrome-canary", "chrome-dev", "chrome-devtools", "chrome-remote-desktop", "chromium", "cilium", "cilium-light", "cinny", "cinny-light", "ciphermail", "cisco", "clam-av", "claude-ai", "claude-ai-light", "cleanuperr", "clickhouse", "clickup", "cloud66", "cloud9", "cloud9-light", "cloudbeaver", "cloudflare", "cloudflare-pages", "cloudflare-zero-trust", "cloudpanel", "cloudreve", "cloudstream", "cobalt", "cobalt-dark", "cockpit", "cockpit-cms", "cockpit-cms-light", "cockpit-light", "code", "code-cademy", "code-cademy-dark", "codeberg", "codellm", "coder", "coder-light", "codestats", "codestats-light", "codex", "codex-light", "collabora-online", "commafeed", "commafeed-light", "commento", "commento-light", "compreface", "concourse", "confix", "confluence", "consul", "contabo", "control-d", "control-d-dark", "converse", "converse-light", "convex", "cooler-control", "coolify", "copyparty", "copyq", "coredns", "coreos", "cosign", "costco", "couchdb", "counter-analytics", "cozy", "cpanel", "cpp", "crafty-controller", "cronicle", "crowdin", "crowdin-dark", "crowdsec", "crunchyroll", "cryptomator", "cryptpad", "csharp", "css", "css-light", "ctfreak", "cup", "cups", "cups-light", "cura", "cyber-power-full", "cyberchef", "czkawka", "d-link", "dagster-dark", "dagster-light", "dalibo", "dart", "dashboard-icons", "dashboard-icons-dark", "dashwise", "datadog", "davical", "dawarich", "dc-os", "ddclient", "ddns-updater", "debian-linux", "deepl", "deepl-dark", "deepseek", "deezer", "defguard", "dell", "deluge", "deno", "deno-light", "denodo", "denon", "denon-light", "deployarr", "dia", "diagrams-net", "diamond-aircraft", "digi-kam", "digikey", "digital-ocean", "dilg", "dillinger", "dillinger-light", "diners-club", "directadmin", "directus", "discord", "discourse", "discourse-light", "disney-plus", "dispatcharr", "distribution", "dixa", "dlna", "docassemble", "docassemble-light", "docker", "docker-engine", "docker-mailserver", "docker-mailserver-light", "docker-moby", "docker-volume-backup", "dockge", "docking-station", "dockpeek", "dockpeek-dark", "docsify", "docspell", "documenso", "docusaurus", "docuseal", "dokemon", "dokploy", "dokploy-dark", "dokuwiki", "donetick", "doplarr", "doppler", "double-commander", "double-take", "double-take-dark", "dovecot", "dozzle", "draw-io", "draytek", "dream-host", "dream-host-dark", "drop", "dropbox", "dropout", "dropout-light", "droppy", "droppy-dark", "dub", "dub-light", "duckdns", "duckdns-light", "duckduckgo", "dumbassets", "dumbpad", "duo", "duplicati", "easy-gate", "easy-gate-light", "ebay", "eblocker", "edge", "edge-dev", "eitaa", "elastic", "elastic-beats", "elastic-kibana", "elastic-logstash", "elasticsearch", "electron", "electronic-arts", "electrum", "element", "eleventy", "eleventy-light", "elgato-wave-link", "eliza-os", "elysian", "emacs", "embraer", "emby", "emq", "emq-light", "emqx", "emsesp", "emulatorjs", "enbizcard", "enclosed", "enclosed-light", "endeavouros-linux", "endel", "endel-dark", "endless", "endless-light", "endurain", "enhance", "ente-auth", "entergy", "epic-games", "epic-games-light", "erste", "erste-george", "esphome", "esphome-alt", "esphome-alt-light", "esphome-light", "espocrm", "espressif", "etcd", "etesync", "ethereum", "etherpad", "evcc", "evernote", "excalidraw", "exercism", "exercism-dark", "f-droid", "f1-dash", "facebook", "facebook-messenger", "falcon-player", "falcon-player-dark", "fast-com", "fast-com-light", "fasten-health", "fastmail", "fedora", "fedora-alt", "feedbase", "feedbase-light", "feedbin", "feedbin-light", "feedly", "feedlynx", "feedlynx-light", "fenrus", "fenrus-light", "ferdium", "ferretdb", "fidelity", "fider", "figma", "filebot", "filebrowser", "filebrowser-quantum", "filecloud", "fileflows", "filegator", "filerun", "files", "files-community", "filestash", "filezilla", "finamp", "findroid", "fios", "fios-light", "firebase", "firefly", "firefly-iii", "firefox", "firefox-beta", "firefox-developer-edition", "firefox-lite", "firefox-nightly", "firefox-reality", "firefox-send", "firewalla", "fittrackee", "fladder", "flaresolverr", "flarum", "flathub", "flathub-dark", "flatnotes", "flatpak", "fleetdm", "flightradar24", "flightradar24-light", "floatplane", "flood", "floorp", "flowise", "flowtunes", "fluent-reader", "fluffychat", "fluffychat-dark", "fluidd", "flux-cd", "fly-io", "fmd", "fnos", "focalboard", "foldingathome", "fontawesome", "foreflight", "foreflight-dark", "forgejo", "formbricks", "forte", "forte-light", "fortinet", "fossil", "franz", "free-sas", "freedombox", "freeipa", "freenas", "freenom", "freepbx", "freshping", "freshping-dark", "freshrss", "friendica", "frigate", "frigate-light", "fritzbox", "fritzbox-light", "fronius", "frp", "fulcio", "funkwhale", "funkwhale-light", "fusionauth", "fusionauth-light", "garage", "garuda-linux", "gatsby", "gatus", "gboard", "geckoview", "gentoo-linux", "geo-guessr", "gerbera", "gerrit", "get-iplayer", "ghostfolio", "ghostty", "gigaset", "gimp", "git", "gitbook", "gitea", "gitee", "github", "github-copilot", "github-copilot-dark", "github-light", "gitlab", "gitsign", "gladys-assistant", "glance", "glances", "glances-light", "glinet", "glinet-dark", "glitchtip", "glpi", "gluetun", "gmail", "go", "goaccess", "goaccess-light", "godaddy", "godaddy-alt", "godot", "golink", "golink-dark", "gollum", "gomft", "gonic", "goodreads", "google", "google-admin", "google-admob", "google-alerts", "google-analytics", "google-assistant", "google-calendar", "google-chat", "google-chrome", "google-classroom", "google-cloud-platform", "google-cloud-print", "google-colab", "google-compute-engine", "google-contacts", "google-docs", "google-domains", "google-drive", "google-earth", "google-fi", "google-fit", "google-fonts", "google-forms", "google-gemini", "google-home", "google-jules", "google-keep", "google-lens", "google-maps", "google-meet", "google-messages", "google-news", "google-one", "google-pay", "google-photos", "google-play", "google-play-books", "google-play-games", "google-podcasts", "google-scholar", "google-search-console", "google-sheets", "google-shopping", "google-sites", "google-slides", "google-street-view", "google-tag-manager", "google-tasks", "google-translate", "google-tv", "google-voice", "google-wallet", "google-wifi", "gopeed", "gose", "gotify", "gotosocial", "gpt4free", "grafana", "gramps", "gramps-web", "grandstream", "grav", "grav-light", "graylog", "greenbone", "greenbone-light", "grimoire", "grist", "grocy", "grok", "grok-dark", "guacamole", "guacamole-light", "habitica", "habitica-dark", "hacker-news", "hammond", "hammond-light", "handbrake", "haproxy", "haptic", "haptic-light", "harbor", "harvester", "hasheous", "hashicorp-boundary", "hashicorp-consul", "hashicorp-nomad", "hashicorp-packer", "hashicorp-terraform", "hashicorp-vagrant", "hashicorp-vault", "hashicorp-waypoint", "hastypaste", "hasura", "hathway", "hatsh", "hatsh-light", "hbo", "hbo-light", "hdhomerun", "hdhomerun-light", "headlamp", "headlamp-dark", "headscale", "healthchecks", "hedgedoc", "heimdall", "heimdall-light", "helium-token", "helm", "hemmelig", "hemmelig-light", "hestia", "hetzner", "hetzner-h", "hexo", "hexos", "heyform", "hi-anime", "hifiberry", "hikvision", "hilook", "hivedav", "hoarder", "hoarder-light", "hollo", "hollo-light", "homarr", "home-assistant", "home-assistant-alt", "homebox", "homebridge", "homelabids", "homer", "homey", "honda-jet", "hoobs", "hoppscotch", "hostinger", "hotio", "hp", "html", "html-light", "huawei", "hubitat", "hubzilla", "hugging-face", "huginn", "hugo", "hulu", "humhub", "hydra", "hyperpipe", "hyperpipe-light", "hyprland", "i-librarian", "i2p", "i2p-light", "i2pd", "ical", "icecast", "icinga", "icinga-full", "icinga-full-light", "icinga-light", "icloud", "idealo", "ideco", "idrac", "idrive", "ilo", "immich", "immich-frame", "immich-frame-light", "immich-kiosk", "immich-kiosk-light", "immich-power-tools", "immich-public-proxy", "incus", "infinite-craft", "infisical", "influxdb", "infoblox", "infomaniak-k", "infomaniak-kdrive-dark", "infomaniak-kdrive-light", "infomaniak-kmeet-dark", "infomaniak-kmeet-light", "infomaniak-swisstransfer-dark", "infomaniak-swisstransfer-light", "inoreader", "instagram", "intellij", "inventree", "invidious", "invisioncommunity", "invoice-ninja", "invoice-ninja-light", "invoke-ai", "iobroker", "ionos", "ipboard", "ipfs", "ipfs-light", "ispconfig", "issabel-pbx", "issabel-pbx-dark", "issabel-pbx-wordmark", "issabel-pbx-wordmark-dark", "it-tools", "it-tools-light", "italki", "jackett", "jackett-light", "jaeger", "jamf", "jamstack", "java", "javascript", "javascript-light", "jeedom", "jekyll", "jellyfin", "jellyfin-vue", "jellyseerr", "jellystat", "jellystat-dark", "jelu", "jenkins", "jetbrains-toolbox", "jetbrains-youtrack", "jetkvm", "jetkvm-full", "jfrog", "jio", "jira", "jitsi", "jitsi-meet", "joomla", "joplin", "jotty", "jujutsu-vcs", "julia", "jupyter", "jwt-io", "jwt-io-light", "kagi", "kali-linux", "kamatera", "kanboard", "kanboard-light", "kanidm", "kapowarr", "karakeep", "karakeep-dark", "kasm", "kasm-workspaces", "kasten-k10", "kaufland", "kavita", "kbin", "keenetic", "keenetic-alt", "keepassxc", "keeper-security", "keila", "kerberos", "kestra", "keycloak", "keyoxide", "keyoxide-alt", "kibana", "kick", "kick-light", "kimai", "kimi-ai", "kinto", "kitana", "kitchenowl", "kiwix", "kiwix-light", "kleinanzeigen", "kleopatra", "klipper", "knx", "ko-fi", "ko-insight", "kodi", "koel", "koillection", "koillection-light", "komga", "komodo", "kontoj", "kook", "kopia", "kotlin", "kpn", "krakend", "krusader", "ksuite", "kubernetes", "kubernetes-dashboard", "kubuntu-linux", "kutt", "kyoo", "lancommander", "lancommander-light", "laracasts", "laracasts-dark", "laravel", "lark", "lastpass", "leanote", "leantime", "leetcode", "leetcode-dark", "lemmy", "lemmy-light", "lets-encrypt", "lexmark", "libation", "librechat", "libreddit", "libreddit-light", "librenms", "libreoffice", "libreoffice-light", "librespeed", "librespeed-light", "libretranslate", "libretranslate-dark", "librewolf", "librum", "lichess", "lichess-dark", "lidarr", "lidl", "lighttpd", "limesurvey", "linear", "linear-dark", "linguacafe", "linkace", "linkding", "linkedin", "linkstack", "linode", "linux", "linux-mint", "linuxdo", "linuxgsm", "linuxserver-io", "liremdb", "listenbrainz", "listmonk", "lite-speed", "littlelink-custom", "livebook", "lldap", "lldap-dark", "lms-mixtape", "lnbits", "lobe-chat", "local-content-share", "local-xpose", "locals", "locals-light", "lockheed-martin", "lodestone", "logitech", "logitech-gaming", "logitech-legacy", "logitech-light", "logseq", "logstash", "logto", "loki", "longhorn", "lostack", "loxone", "loxone-full", "lua", "lubuntu-linux", "ludus", "ludus-dark", "lunalytics", "lunasea", "luxriot", "lynx", "lynx-light", "lyrion", "lyrion-dark", "macmon", "mail-archiver", "mail-in-a-box", "mailchimp", "mailchimp-light", "mailcow", "mailfence", "mailgun", "mailjet", "mailpit", "mainsail", "maintainerr", "maker-world", "maker-world-dark", "manga-dex", "manjaro-linux", "mantrae", "many-notes", "manyfold", "maptiler", "mariadb", "marimo", "marktplaats", "mastodon", "matomo", "matrix", "matrix-light", "matrix-synapse", "matrix-synapse-light", "matter", "matter-light", "matterbridge", "mattermost", "mautic", "max", "mayan-edms", "mayan-edms-light", "maybe", "mbin", "mealie", "medama", "media-manager", "mediathekview", "mediawiki", "medium-dark", "medium-light", "mediux", "medusa", "medusa-light", "mega-nz", "mega-nz-dark", "meilisearch", "memories", "memories-light", "meraki", "mercusys", "mergeable", "mergeable-dark", "meshping", "meshping-light", "meshtastic", "meta", "metabase", "metabrainz", "metallb", "metube", "microsoft", "microsoft-365", "microsoft-365-admin-center", "microsoft-access", "microsoft-azure", "microsoft-bing", "microsoft-copilot", "microsoft-defender", "microsoft-edge", "microsoft-excel", "microsoft-exchange", "microsoft-intune", "microsoft-office", "microsoft-onedrive", "microsoft-onenote", "microsoft-outlook", "microsoft-power-automate", "microsoft-powerpoint", "microsoft-sharepoint", "microsoft-sql-server", "microsoft-sql-server-light", "microsoft-teams", "microsoft-to-do", "microsoft-windows", "microsoft-word", "mikrotik", "mikrotik-light", "minecraft", "miniflux", "miniflux-light", "minio", "minio-light", "miro", "misskey", "misskey-light", "mistral-ai", "mitra", "mixpost", "mkdocs", "mkdocs-light", "ml-flow-wordmark", "ml-flow-wordmark-dark", "mobilizon", "mobotix", "mobotix-light", "modrinth", "mojeek", "monero", "mongodb", "monica", "monica-light", "monkeytype", "moodist", "moodist-dark", "moodle", "moodle-light", "morphos", "morss", "mosquitto", "motioneye", "motioneye-dark", "mousehole", "mousehole-dark", "movie-pilot", "mqtt", "mstream", "mtlynch-picoshare", "mubi", "mubi-dark", "mullvad", "mullvad-browser", "mullvad-vpn", "multi-scrobbler", "mumble", "mumble-light", "musescore", "music-assistant", "music-assistant-light", "musicbrainz", "myheats", "myheats-light", "mympd", "myspeed", "mysql", "mysterium", "n8n", "nagios", "name-silo", "namecheap", "nasa", "natwest", "navidrome", "navidrome-light", "neko", "neko-light", "neo4j", "neocities", "neodb", "neon-tech", "neonlink", "netalertx", "netalertx-light", "netapp", "netapp-light", "netatmo", "netbird", "netboot", "netbootxyz", "netbox", "netbox-dark", "netbox-full", "netbox-full-dark", "netdata", "netflix", "netgear", "netgear-light", "netlify", "netsurf", "netsurf-light", "network-ups-tools", "networking-toolbox", "networking-toolbox-dark", "newegg", "newsblur", "newshosting", "newshosting-dark", "nextcloud", "nextcloud-blue", "nextcloud-calendar", "nextcloud-contacts", "nextcloud-cookbook", "nextcloud-cospend", "nextcloud-deck", "nextcloud-files", "nextcloud-ncdownloader", "nextcloud-news", "nextcloud-notes", "nextcloud-photos", "nextcloud-social", "nextcloud-tables", "nextcloud-tasks", "nextcloud-timemanager", "nextcloud-white", "nextcloudpi", "nextdns", "nexterm", "nextjs", "nextjs-light", "nexus", "nexus-dark", "nginx", "nginx-proxy-manager", "nicotine-plus", "nightscout", "nightscout-light", "nintendo-switch", "nitter", "nixos", "nocodb", "node-red", "nodebb", "nodejs", "nodejs-alt", "noisedash", "nomad", "nomie", "nordvpn", "note-mark", "notebook-lm", "notebook-lm-dark", "notediscovery", "notesnook", "notesnook-light", "notifiarr", "notion", "notion-calendar", "notion-light", "notion-mail", "npm", "ntfy", "nu-nl", "nut", "nut-webgui", "nutanix", "nvidia", "nzbget", "nzbhydra2", "nzbhydra2-light", "oauth2-proxy", "obico", "obsidian", "obtainium", "octoprint", "ocular", "oculus", "oculus-light", "odoo", "odysee", "odysee-full-dark", "odysee-full-light", "office-365", "oh-my-posh", "oh-my-posh-dark", "okta", "okta-dark", "olivetin", "olivetin-light", "ollama", "ollama-dark", "omada", "ombi", "omnic-forge", "omnic-forge-dark", "omnidb", "omnivore", "onedev", "onedev-light", "oneuptime", "oneuptime-light", "onlyfans", "onlyfans-dark", "onlyoffice", "onshape", "onshape-dark", "ookla-speedtest", "open-classrooms", "open-cloud", "open-cloud-dark", "open-observe", "open-regex", "open-resume", "open-router", "open-router-dark", "open-source-initiative", "open-wb", "open-webui", "open-webui-light", "openai", "openai-light", "openchangelog", "openchangelog-light", "opencode", "opencode-dark", "opencost", "openeats", "openeats-light", "openemr", "openemr-light", "opengist", "opengist-light", "openhab", "openldap", "openlist", "openmediavault", "openoffice", "openpanel", "openpanel-light", "openproject", "openreads", "opensearch", "openshift", "openshift-dark", "openspeedtest", "openstack", "openstreetmap", "opensuse", "opentalk", "opentofu", "openvas", "openvpn", "openwebrx-plus", "openwebrx-plus-dark", "openwrt", "openziti", "opera", "opera-touch", "opnform", "opnsense", "oracle", "oracle-cloud", "orange", "orb", "oreilly", "oreilly-dark", "origin", "oscarr", "oscarr-light", "osticket", "osu", "otter-wiki", "otter-wiki-dark", "our-shopping-list", "outline", "outline-light", "overleaf", "overseerr", "ovh", "ovirt", "ovirt-light", "owncast", "owncloud", "owntone", "owntracks", "oxker", "oxker-light", "p-cal", "p1ib", "packetfence", "packetfence-dark", "packetfence-full", "packetfence-full-dark", "pagerduty", "palemoon", "palo-alto", "pangolin", "paperless", "paperless-ng", "paperless-ngx", "papermark", "papermark-light", "papermerge", "papermerge-light", "papra", "parseable", "part-db", "part-db-light", "partkeepr", "passbolt", "passwork", "pastebin", "pastebin-dark", "patchmon", "patreon", "patreon-light", "payload", "payload-light", "paymenter", "paypal", "pdfding", "pdfding-light", "peacock", "peacock-light", "peanut", "peanut-light", "peertube", "pelican-panel", "penpot", "penpot-light", "pepperbox-tv", "pepperbox-tv-dark", "peppermint", "pepperminty-wiki", "perlite", "perplexity-book-dark", "perplexity-book-light", "perplexity-dark", "perplexity-light", "pfsense", "pfsense-light", "pg-back-web", "pgadmin", "pgbackweb-dark", "pgbackweb-light", "phanpy", "phase", "phase-dark", "phoneinfoga", "phoneinfoga-light", "phorge", "phorge-light", "phoscon", "phoscon-light", "photonix", "photonix-light", "photopea", "photoprism", "photoprism-light", "photostructure", "photostructure-dark", "photoview", "php", "php-light", "phpmyadmin", "pi-hole", "pia", "picsur", "picsur-light", "pigallery2", "pigallery2-dark", "pikapods", "pikvm", "pikvm-light", "pingdom", "pingdom-light", "pingvin", "pingvin-dark", "pingvin-share", "pingvin-share-dark", "pinia", "pinkary", "pinterest", "pioneer", "pioneer-light", "piped", "pirate-proxy", "piwigo", "pixelfed", "plane", "plane-finder", "planka", "planka-dark", "plantuml", "platzi", "plausible", "playstation", "pleroma", "plesk", "plex", "plex-alt", "plex-alt-light", "plex-light", "plex-rewind", "plexrequests", "plexrequests-light", "plume", "pluralsight", "pocket-casts", "pocket-casts-dark", "pocket-id", "pocket-id-light", "pocketbase", "pocketbase-dark", "podfetch", "podfetch-light", "podify", "podman", "policycontroller", "poly", "polywork", "portainer", "portainer-alt", "portainer-be", "portainer-be-dark", "portainer-dark", "portracker", "portracker-dark", "portus", "postal", "poste", "posteria", "postgres", "postgresql", "postgresus", "posthog", "posthog-light", "postiz", "postiz-dark", "powerbi", "powerdns", "premiumize", "pretix", "price-buddy", "primal", "prime-video", "prime-video-alt", "prime-video-alt-dark", "prime-video-light", "printables", "printer", "pritunl", "privacyidea", "private-internet-access", "privatebin", "profilarr", "projectsend", "prometheus", "proton-calendar", "proton-docs", "proton-drive", "proton-lumo", "proton-mail", "proton-pass", "proton-vpn", "proton-wallet", "prowlarr", "proxmenu", "proxmox", "proxmox-light", "prtg", "prusa-research", "pterodactyl", "public-pool", "pufferpanel", "pulsarr", "pulse", "purelymail", "pushfish", "pushover", "putty", "pydio", "pyload", "python", "qbittorrent", "qd-today", "qdirstat", "qdrant", "qinglong", "qnap", "quay", "questdb", "quetre", "qui", "quickshare", "quickwit", "quizlet", "qutebrowser", "qwen", "qwik", "r", "rabbitmq", "radarr", "radarr-4k", "radicale", "rainloop", "rallly", "ramp", "ramp-dark", "rancher", "raspberry-pi", "raspberry-pi-light", "rclone", "rdt-client", "reactive-resume", "reactive-resume-light", "reactjs", "readarr", "readeck", "readthedocs", "readthedocs-light", "readwise-reader", "readwise-reader-dark", "real-debrid", "recalbox", "receipt-wrangler", "recipesage", "recyclarr", "reddit", "redhat-linux", "redict", "redis", "redlib", "redlib-light", "redmine", "rekor", "release-argus", "remmina", "remnawave", "remnote", "renovate", "reolink", "requestly", "requestrr", "resiliosync", "resiliosync-full", "resiliosync-full-dark", "restreamer", "retrom", "revanced-manager", "revolt", "revolt-light", "rhasspy", "rhasspy-dark", "rhodecode", "richy", "rimgo", "rimgo-light", "riot", "ripe", "riverside-fm", "riverside-fm-light", "robinhood", "rocket-chat", "rocky-linux", "romm", "rook", "root-me", "root-me-dark", "rotki", "roundcube", "router", "rport", "rss-bridge", "rss-translator", "rstudio", "rstudioserver", "ruby", "ruby-on-rails", "rumble", "rundeck", "runonflux", "runson", "runson-light", "rust", "rust-dark", "rustdesk", "rutorrent", "rybbit", "rybbit-dark", "ryot", "ryot-light", "sabnzbd", "sabnzbd-light", "safari", "safari-ios", "safeline", "salt-project", "saltcorn", "samba-server", "samsung-internet", "sandstorm", "scanopy", "schedulearn", "schedulearn-dark", "scrcpy", "screenconnect", "scrutiny", "scrutiny-light", "seafile", "searx", "searxng", "secureai-tools", "secureai-tools-light", "security-onion", "security-onion-dark", "seelf", "selenium", "self-hosted-gateway", "selfh-st", "selfh-st-light", "semaphore", "semaphore-dark", "send", "sendgrid", "sendinblue", "sensu", "sentry", "sentry-light", "series-troxide", "servarr", "servarr-light", "serviio", "serviio-light", "session", "seznam", "shaarli", "shell", "shell-light", "shell-tips", "shell-tips-light", "shellhub", "shellngn", "shelly", "shinkro", "shiori", "shlink", "shoko", "shoko-server", "shopify", "shortcut", "sickbeard", "signal", "signoz", "sigstore", "silae", "simplelogin", "simplex-chat", "sinusbot", "sipeed", "siyuan", "skype", "slack", "slash", "slash-light", "slice", "slidev", "slskd", "smartfox", "snapcast", "snapchat", "snapchat-dark", "snapdrop", "snappymail", "snappymail-light", "snikket", "socialhome", "sogo", "solaar", "solidtime", "solidtime-light", "sonarqube", "sonarr", "sonarr-4k", "sonarr-dark", "sophos", "sophos-dark", "soundcloud", "soundcloud-dark", "sourcegraph", "spamassassin", "spark", "sparkleshare", "specifically-clementines", "sphinx", "sphinx-doc", "sphinx-relay", "spiceworks", "spliit", "splunk", "spoolman", "spotify", "spree", "spree-dark", "springboot-initializer", "sqlitebrowser", "squidex", "squirrel-servers-manager", "sshwifty", "sst-dev", "sst-dev-dark", "stalwart", "stalwart-mail-server", "standard-notes", "startpage", "stash", "stb-proxy", "steam", "step-ca", "stirling-pdf", "storj", "storm", "stormkit", "strapi", "strava", "stremio", "stump", "stump-alt", "sub-store", "subatic", "sun-panel", "sunsama", "sunshine", "supabase", "supermicro", "surveymonkey", "suwayomi", "suwayomi-light", "svelte", "swagger", "swarmpit", "swift", "swingmusic", "symmetricom", "symmetricom-light", "synapse", "synapse-light", "syncplay", "syncthing", "syncthing-dark", "synology", "synology-dsm", "synology-light", "sysreptor", "tabula", "tacticalrmm", "taiga", "tailscale", "tailscale-light", "tailwind", "talos", "tandoor-recipes", "tangerine-ui", "taskcafe", "tasmocompiler", "tasmota", "tasmota-light", "tautulli", "team-viewer", "teamcity", "teamcity-light", "teamspeak", "teamtailor", "telegraf", "telegram", "telekom", "teleport", "tenable", "tenable-dark", "tenda", "terminal", "termix", "terraform", "teslamate", "teslamate-light", "thanos", "the-onion", "the-pirate-bay", "the-proxy-bay", "theia", "theia-light", "thelounge", "theodinproject", "thin-linc", "thingsboard", "threadfin", "threads", "threads-light", "thunderbird", "tianji", "tianji-light", "ticky", "tidal", "tidal-dark", "tiddlywiki", "tiddlywiki-light", "tiktok", "tiktok-light", "timetagger", "timetagger-light", "ting-isp", "tiny-media-manager", "tinyauth", "title-card-maker", "title-card-maker-dark", "tmdb", "todoist", "todoist-dark", "toggl", "toggl-dark", "tolgee", "tooljet", "tooljet-dark", "topdesk", "touitomamout", "tp-link", "tpdb", "traccar", "traccar-dark", "tracearr", "trading-view", "trading-view-dark", "traefik", "traefik-proxy", "traggo", "trailarr", "trakt", "transmission", "trellix", "trellix-dark", "trilium", "triliumnext", "trmnl", "truecommand", "truenas", "truenas-core", "truenas-scale", "tryhackme", "tsd-proxy", "tubesync", "tubesync-light", "tumblr", "tunarr", "tunnelix", "turbopack", "turbopack-light", "tuta", "tux", "tvdb", "tvheadend", "tweakers", "twingate", "twingate-light", "twitch", "twitter", "typemill", "typemill-light", "typescript", "typesense", "typo3", "ubiquiti", "ubiquiti-networks", "ubiquiti-unifi", "ubuntu-linux", "ubuntu-linux-alt", "uc-browser", "udemy", "udemy-light", "ugreen", "ultimate-guitar", "ultimate-guitar-light", "umami", "umami-light", "umbrel", "unbound", "uncomplicated-alert-receiver", "undb", "unifi", "unifi-dark", "unifi-drive", "unifi-voucher-site", "unimus", "unity", "unity-dark", "university-applied-sciences-brandenburg", "unraid", "unreal-engine", "unreal-engine-dark", "untangle", "ups", "upsnap", "uptime-kuma", "uptimerobot", "upvote-rss", "upwork", "usermin", "valetudo", "valkey", "vault", "vault-light", "vaultwarden", "vaultwarden-light", "vector", "veeam", "vera-crypt", "vercel", "vercel-light", "verdaccio", "verdaccio-dark", "verizon", "verriflo", "vertiv", "vertiv-dark", "vi", "viber", "victorialogs", "victoriametrics", "victoriametrics-light", "victron-energy", "vidzy", "viewtube", "vikunja", "vinchin-backup", "virgin-media", "virtualmin", "viseron", "viseron-light", "visual-studio-code", "vitalpbx", "vite", "vitest", "vito-deploy", "vivaldi", "vmware", "vmware-esxi", "vmware-workstation", "vn-stat", "vodafone", "voilib", "voip-ms", "voltaserve", "voltaserve-light", "volumio", "volumio-light", "voron", "vouchervault", "vscode", "vtvgo", "vue-js", "vuetorrent", "vultr", "wakapi", "wakatime", "wakatime-light", "wallabag", "wallabag-light", "wanderer", "wanderer-light", "warpgate", "watcharr", "watcharr-light", "watchtower", "waze", "wazuh", "wd-mycloud", "web-check", "web-check-dark", "webdb", "webex", "webhook", "webhookd", "webkit", "webmin", "webtorrent", "webtrees", "wekan", "wero", "wero-dark", "western-digital", "wevr-labs", "wger", "whatnot", "whats-up-docker", "whatsapp", "whisparr", "wiki-go", "wikidocs", "wikijs", "wikijs-alt", "wikipedia", "wikipedia-light", "willow", "windmill", "windows-10", "windows-95", "windows-95-light", "windows-retro", "windows-retro-light", "wireguard", "wizarr", "wolfi", "wolfi-light", "woocommerce", "woodpecker-ci", "wooting", "wooting-dark", "wordpress", "worklenz", "wotdle", "wotdle-light", "wownero", "writefreely", "writefreely-light", "x", "x-light", "xbackbone", "xbox", "xbox-game-pass", "xbrowsersync", "xcp-ng", "xen-orchestra", "xiaomi-global", "xmr", "xmrig", "xpipe", "xubuntu-linux", "xwiki", "yac-reader", "yacd-blue", "yacht", "yahoo", "yamtrack", "yamtrack-light", "yandex", "yarr", "yarr-light", "ycombinator", "ycombinator-dark", "ynab", "your-spotify", "yourls", "youtube", "youtube-dl", "youtube-kids", "youtube-music", "youtube-tv", "yt-dlp", "yts", "yuno-host-light", "yunohost", "z-ai", "z-wave-js-ui", "zabbix", "zabka", "zalo", "zammad", "zapier", "zapier-dark", "zashboard", "zashboard-dark", "zen-browser", "zen-browser-dark", "zenarmor", "zendesk", "zerotier", "zerotier-light", "zigbee2mqtt", "zigbee2mqtt-light", "zimbra", "zipcaptions", "zipline", "zipline-diced", "zipline-light", "zitadel", "zitadel-light", "zohomail", "zomro", "zoom", "zoom-alt", "zoraxy", "zorin-linux", "zot-registry", "zulip", "zyxel-communications", "zyxel-communications-light", "zyxel-networks", "zyxel-networks-light"] \ No newline at end of file diff --git a/frontend/src/utils/__tests__/brandIcons.test.ts b/frontend/src/utils/__tests__/brandIcons.test.ts new file mode 100644 index 0000000..6a6eb99 --- /dev/null +++ b/frontend/src/utils/__tests__/brandIcons.test.ts @@ -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() + }) +}) diff --git a/frontend/src/utils/nodeIcons.ts b/frontend/src/utils/nodeIcons.ts index 80630dc..9ec55b0 100644 --- a/frontend/src/utils/nodeIcons.ts +++ b/frontend/src/utils/nodeIcons.ts @@ -179,11 +179,45 @@ export const NODE_TYPE_DEFAULT_ICONS: Record = { 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 +}