From ff02f3b5db2c0d8789f2ec0e4430940ec4aa38c2 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 11 May 2026 16:54:33 +0200 Subject: [PATCH 1/4] fix(node-modal): cap height at 90vh with scroll Modal grew taller than viewport when icon picker expanded, hiding header and footer buttons. Constrain DialogContent to 90vh and add overflow-y-auto so all controls stay reachable. --- frontend/src/components/modals/NodeModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 487c951..714d67b 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -95,7 +95,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' return ( !o && onClose()}> - + {title} From 3a9b3b2650525c4049381172a789db32db696467 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 11 May 2026 17:08:11 +0200 Subject: [PATCH 2/4] feat(icons): add Smart Home / Sensors icon category Add 27 new icons covering common IoT/Zigbee endpoints: smart plug, relay, energy meter, solar, door/window sensor, smart lock, smoke detector, siren, motion radar, presence, vibration, water leak, humidity, air quality, HVAC vent, fan, AC, smart light, blinds, doorbell, speaker, remote, garage, valve, weather station, plus voice assistant and webhook in the existing Automation category. --- .../src/utils/__tests__/nodeIcons.test.ts | 16 +++++++++ frontend/src/utils/nodeIcons.ts | 36 ++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/frontend/src/utils/__tests__/nodeIcons.test.ts b/frontend/src/utils/__tests__/nodeIcons.test.ts index dab21d1..ffd4002 100644 --- a/frontend/src/utils/__tests__/nodeIcons.test.ts +++ b/frontend/src/utils/__tests__/nodeIcons.test.ts @@ -33,6 +33,22 @@ describe('ICON_REGISTRY', () => { expect(keys).toContain('database') // DB services 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', () => { diff --git a/frontend/src/utils/nodeIcons.ts b/frontend/src/utils/nodeIcons.ts index 16700cc..80630dc 100644 --- a/frontend/src/utils/nodeIcons.ts +++ b/frontend/src/utils/nodeIcons.ts @@ -11,7 +11,12 @@ import { // Security & Auth Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame, // 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 Download, Upload, RefreshCw, // Containers & Dev @@ -97,6 +102,35 @@ export const ICON_REGISTRY: IconEntry[] = [ { key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer }, { key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb }, { 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 --- { key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor }, From 3b0dbd7a8bc90eeb1ceda50bfe24c9c374add8f9 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 11 May 2026 19:18:01 +0200 Subject: [PATCH 3/4] 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 +} From 928f63df0f788a8949d6e56f5ecf50b863d457a2 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 11 May 2026 19:44:20 +0200 Subject: [PATCH 4/4] fix(icons): narrow ICON_MAP lookup type for strict build tsc -b (used in npm run build) flagged TS2774 because LucideIcon is a function and therefore always truthy. Cast the lookup result to LucideIcon | undefined so the falsy branch becomes meaningful. --- frontend/src/utils/nodeIcons.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/utils/nodeIcons.ts b/frontend/src/utils/nodeIcons.ts index 9ec55b0..c781d0a 100644 --- a/frontend/src/utils/nodeIcons.ts +++ b/frontend/src/utils/nodeIcons.ts @@ -218,6 +218,6 @@ export function resolveCustomIcon(customIconKey?: string): ResolvedIcon | null { const slug = brandIconSlug(customIconKey) return { kind: 'brand', slug, url: brandIconUrl(slug) } } - const icon = ICON_MAP[customIconKey] + const icon = ICON_MAP[customIconKey] as LucideIcon | undefined return icon ? { kind: 'lucide', icon } : null }