feat: add custom icon picker to node create/edit modal
- Add 65+ icons across 7 categories (Infrastructure, Media, Monitoring, Storage, Security, Automation, Dev & Containers, Communications) covering popular self-hosted apps: Home Assistant, Jellyfin, Plex, Grafana, Portainer, Pi-hole, Vaultwarden, Gitea, Nextcloud, Node-RED, Frigate, etc. - New nodeIcons.ts utility with ICON_REGISTRY, ICON_MAP and resolveNodeIcon() - Inline icon picker in NodeModal: collapsible panel with search + grid grouped by category; click to select, click again or Reset to revert to type default - BaseNode uses resolveNodeIcon() so custom icon renders live on canvas - Add custom_icon field to NodeData type, NodeBase/NodeUpdate schemas, Node ORM model, and database.py idempotent ALTER TABLE migration
This commit is contained in:
@@ -30,6 +30,8 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT")
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT")
|
||||||
|
with suppress(Exception):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_icon TEXT")
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class Node(Base):
|
|||||||
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id"))
|
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id"))
|
||||||
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class NodeBase(BaseModel):
|
|||||||
parent_id: str | None = None
|
parent_id: str | None = None
|
||||||
container_mode: bool = False
|
container_mode: bool = False
|
||||||
custom_colors: dict[str, Any] | None = None
|
custom_colors: dict[str, Any] | None = None
|
||||||
|
custom_icon: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeCreate(NodeBase):
|
class NodeCreate(NodeBase):
|
||||||
@@ -43,6 +44,7 @@ class NodeUpdate(BaseModel):
|
|||||||
pos_y: float | None = None
|
pos_y: float | None = None
|
||||||
container_mode: bool | None = None
|
container_mode: bool | None = None
|
||||||
custom_colors: dict[str, Any] | None = None
|
custom_colors: dict[str, Any] | None = None
|
||||||
|
custom_icon: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeResponse(NodeBase):
|
class NodeResponse(NodeBase):
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { createElement } from 'react'
|
||||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { type LucideIcon } from 'lucide-react'
|
import { type LucideIcon } from 'lucide-react'
|
||||||
import type { NodeData, NodeStatus } from '@/types'
|
import type { NodeData, NodeStatus } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
|
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
||||||
|
|
||||||
const STATUS_COLORS: Record<NodeStatus, string> = {
|
const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||||
online: '#39d353',
|
online: '#39d353',
|
||||||
@@ -14,7 +16,8 @@ interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
|||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BaseNode({ data, selected, icon: Icon }: BaseNodeProps) {
|
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
||||||
|
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
|
||||||
const colors = resolveNodeColors(data)
|
const colors = resolveNodeColors(data)
|
||||||
const statusColor = STATUS_COLORS[data.status]
|
const statusColor = STATUS_COLORS[data.status]
|
||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
@@ -42,7 +45,7 @@ export function BaseNode({ data, selected, icon: Icon }: BaseNodeProps) {
|
|||||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||||
style={{ color: isOnline ? colors.icon : '#8b949e', background: '#161b22' }}
|
style={{ color: isOnline ? colors.icon : '#8b949e', background: '#161b22' }}
|
||||||
>
|
>
|
||||||
<Icon size={15} />
|
{createElement(resolvedIcon, { size: 15 })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details */}
|
{/* Details */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { createElement, useState } from 'react'
|
||||||
import { RotateCcw } from 'lucide-react'
|
import { RotateCcw, ChevronDown } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
|
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons'
|
||||||
|
|
||||||
const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][]
|
const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][]
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
|||||||
services: [],
|
services: [],
|
||||||
container_mode: true,
|
container_mode: true,
|
||||||
custom_colors: undefined,
|
custom_colors: undefined,
|
||||||
|
custom_icon: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeModalProps {
|
interface NodeModalProps {
|
||||||
@@ -39,6 +41,8 @@ const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
|
|||||||
// initial value is enough — no need for a reset effect.
|
// initial value is enough — no need for a reset effect.
|
||||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
|
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
|
||||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||||
|
const [iconSearch, setIconSearch] = useState('')
|
||||||
|
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||||
|
|
||||||
const set = (key: keyof NodeData, value: unknown) =>
|
const set = (key: keyof NodeData, value: unknown) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }))
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
@@ -76,6 +80,88 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">Icon</Label>
|
||||||
|
{form.custom_icon && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { set('custom_icon', undefined); setIconPickerOpen(false) }}
|
||||||
|
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw size={10} /> Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Trigger button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIconPickerOpen((o) => !o)}
|
||||||
|
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{(() => {
|
||||||
|
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
||||||
|
if (entry) {
|
||||||
|
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff]' })}<span className="text-foreground">{entry.label}</span></>
|
||||||
|
}
|
||||||
|
return <span className="text-muted-foreground">Default (from type)</span>
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||||
|
</button>
|
||||||
|
{/* Inline picker panel */}
|
||||||
|
{iconPickerOpen && (
|
||||||
|
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||||
|
<Input
|
||||||
|
value={iconSearch}
|
||||||
|
onChange={(e) => setIconSearch(e.target.value)}
|
||||||
|
placeholder="Search icons…"
|
||||||
|
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
||||||
|
{ICON_CATEGORIES.map((cat) => {
|
||||||
|
const entries = ICON_REGISTRY.filter(
|
||||||
|
(e) => e.category === cat &&
|
||||||
|
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
|
||||||
|
)
|
||||||
|
if (entries.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div key={cat}>
|
||||||
|
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const isSelected = form.custom_icon === entry.key
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.key}
|
||||||
|
type="button"
|
||||||
|
title={entry.label}
|
||||||
|
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
||||||
|
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||||
|
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
|
||||||
|
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
{createElement(entry.icon, { size: 13 })}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
<Label className="text-xs text-muted-foreground">Label *</Label>
|
<Label className="text-xs text-muted-foreground">Label *</Label>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
parent_id?: string
|
parent_id?: string
|
||||||
container_mode?: boolean
|
container_mode?: boolean
|
||||||
custom_colors?: { border?: string; background?: string; icon?: string }
|
custom_colors?: { border?: string; background?: string; icon?: string }
|
||||||
|
custom_icon?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EdgePathStyle = 'bezier' | 'smooth'
|
export type EdgePathStyle = 'bezier' | 'smooth'
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
// Infrastructure (node types)
|
||||||
|
Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle,
|
||||||
|
// Media
|
||||||
|
Play, Film, Tv, Tv2, Music, Camera, Video, Headphones, Clapperboard,
|
||||||
|
// Monitoring & Observability
|
||||||
|
Activity, BarChart2, LineChart, Eye, Bell, Gauge, Monitor,
|
||||||
|
// Storage & Databases
|
||||||
|
Database, Archive, Cloud, FolderOpen,
|
||||||
|
// Security & Auth
|
||||||
|
Shield, ShieldCheck, Lock, Key, Users, UserCheck,
|
||||||
|
// Automation & IoT
|
||||||
|
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio,
|
||||||
|
// Transfers & sync
|
||||||
|
Download, Upload, RefreshCw,
|
||||||
|
// Containers & Dev
|
||||||
|
Anchor, GitBranch, Terminal, Code2, Settings,
|
||||||
|
// Communications
|
||||||
|
Mail, MessageSquare, Phone,
|
||||||
|
// Misc devices
|
||||||
|
Printer, Smartphone, Search, Filter, BookOpen,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface IconEntry {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
category: string
|
||||||
|
icon: LucideIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ICON_REGISTRY: IconEntry[] = [
|
||||||
|
// --- Infrastructure ---
|
||||||
|
{ key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe },
|
||||||
|
{ key: 'router', label: 'Router', category: 'Infrastructure', icon: Router },
|
||||||
|
{ key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network },
|
||||||
|
{ key: 'server', label: 'Server', category: 'Infrastructure', icon: Server },
|
||||||
|
{ key: 'layers', label: 'Proxmox / Hypervisor', category: 'Infrastructure', icon: Layers },
|
||||||
|
{ key: 'box', label: 'VM', category: 'Infrastructure', icon: Box },
|
||||||
|
{ key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container },
|
||||||
|
{ key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive },
|
||||||
|
{ key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu },
|
||||||
|
{ key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi },
|
||||||
|
{ key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle },
|
||||||
|
{ key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor },
|
||||||
|
{ key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone },
|
||||||
|
{ key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer },
|
||||||
|
|
||||||
|
// --- Media ---
|
||||||
|
{ key: 'play', label: 'Jellyfin / Emby', category: 'Media', icon: Play },
|
||||||
|
{ key: 'tv2', label: 'Plex', category: 'Media', icon: Tv2 },
|
||||||
|
{ key: 'film', label: 'Sonarr / Movies', category: 'Media', icon: Film },
|
||||||
|
{ key: 'clapperboard', label: 'Radarr', category: 'Media', icon: Clapperboard },
|
||||||
|
{ key: 'music', label: 'Lidarr / Music', category: 'Media', icon: Music },
|
||||||
|
{ key: 'book', label: 'Readarr / Books', category: 'Media', icon: BookOpen },
|
||||||
|
{ key: 'tv', label: 'IPTV / Live TV', category: 'Media', icon: Tv },
|
||||||
|
{ key: 'headphones', label: 'Audiobookshelf', category: 'Media', icon: Headphones },
|
||||||
|
{ key: 'video', label: 'Video / Streaming', category: 'Media', icon: Video },
|
||||||
|
{ key: 'camera', label: 'Camera / Frigate', category: 'Media', icon: Camera },
|
||||||
|
|
||||||
|
// --- Monitoring ---
|
||||||
|
{ key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity },
|
||||||
|
{ key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 },
|
||||||
|
{ key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart },
|
||||||
|
{ key: 'eye', label: 'Overseerr / Watchlist', category: 'Monitoring', icon: Eye },
|
||||||
|
{ key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell },
|
||||||
|
{ key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge },
|
||||||
|
|
||||||
|
// --- Storage & Databases ---
|
||||||
|
{ key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database },
|
||||||
|
{ key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive },
|
||||||
|
{ key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud },
|
||||||
|
{ key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen },
|
||||||
|
{ key: 'download', label: 'Downloader (Torrent/NZB)', category: 'Storage', icon: Download },
|
||||||
|
{ key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload },
|
||||||
|
{ key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw },
|
||||||
|
|
||||||
|
// --- Security & Auth ---
|
||||||
|
{ key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield },
|
||||||
|
{ key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck },
|
||||||
|
{ key: 'lock', label: 'Authelia / Authentik', category: 'Security', icon: Lock },
|
||||||
|
{ key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key },
|
||||||
|
{ key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users },
|
||||||
|
{ key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck },
|
||||||
|
{ key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter },
|
||||||
|
{ key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search },
|
||||||
|
|
||||||
|
// --- Automation & Smart Home ---
|
||||||
|
{ key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home },
|
||||||
|
{ key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap },
|
||||||
|
{ key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow },
|
||||||
|
{ key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot },
|
||||||
|
{ 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 },
|
||||||
|
|
||||||
|
// --- Containers & Dev ---
|
||||||
|
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
|
||||||
|
{ key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch },
|
||||||
|
{ key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal },
|
||||||
|
{ key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 },
|
||||||
|
{ key: 'settings', label: 'Config / Admin', category: 'Dev & Containers', icon: Settings },
|
||||||
|
|
||||||
|
// --- Communications ---
|
||||||
|
{ key: 'mail', label: 'Mail Server', category: 'Communications', icon: Mail },
|
||||||
|
{ key: 'chat', label: 'Chat / Synapse', category: 'Communications', icon: MessageSquare },
|
||||||
|
{ key: 'phone', label: 'VoIP / FreePBX', category: 'Communications', icon: Phone },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ICON_CATEGORIES = [...new Set(ICON_REGISTRY.map((e) => e.category))]
|
||||||
|
|
||||||
|
export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
|
||||||
|
ICON_REGISTRY.map((e) => [e.key, e.icon]),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Resolve the display icon for a node — custom_icon takes priority over type default. */
|
||||||
|
export function resolveNodeIcon(
|
||||||
|
typeIcon: LucideIcon,
|
||||||
|
customIconKey?: string,
|
||||||
|
): LucideIcon {
|
||||||
|
if (customIconKey && ICON_MAP[customIconKey]) return ICON_MAP[customIconKey]
|
||||||
|
return typeIcon
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user