feat(ui): customisable card icon plate for devices

A user-chosen icon ("mouse", "motherboard", "keyboard"…) renders as a
44x44 instrument-panel face plate at the leading edge of .mod-head on
device cards. Optional per-card; null hides the plate and reverts to
the existing badge-only head.

- Storage/schema: new icon, icon_color fields on Device + DeviceUpdate /
  DeviceResponse. SQLite stores entities as a JSON blob, so no migration
  is needed; from_dict defaults handle existing rows.
- Curated 47-icon library across six categories (Hardware / Lighting /
  Rooms / Media / Signal / Ambience), reusing the existing Lucide path
  module; adds circuit-board, bed, armchair, leaf paths.
- mod-card.ts: ModHeadOpts gains iconHtml / iconColor / iconAttrs;
  ModMenuItemOpts gains optional dataAttrs. The plate is rendered when
  iconHtml is supplied; otherwise no layout change.
- Picker modal (icon-picker.html + features/icon-picker.ts): live
  preview, search, six category tabs, recent strip, channel-color
  override toggle. Wired through document-level click delegation on
  [data-icon-picker-trigger="<deviceId>"] — no window globals, no
  inline onclick string. Sets the precedent for migrating other card
  actions off window in a follow-up.
- en/ru/zh locales for picker UI + categories.

Includes a docs/ mockup that's the source-of-truth for the design.
This commit is contained in:
2026-05-03 15:08:17 +03:00
parent a026f0b349
commit 49ddabbc36
18 changed files with 2848 additions and 9 deletions
File diff suppressed because it is too large Load Diff
+4
View File
@@ -71,6 +71,8 @@ def _device_to_response(device) -> DeviceResponse:
default_css_processing_template_id=device.default_css_processing_template_id,
group_device_ids=device.group_device_ids,
group_mode=device.group_mode,
icon=getattr(device, "icon", "") or "",
icon_color=getattr(device, "icon_color", "") or "",
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -439,6 +441,8 @@ async def update_device(
ble_govee_key=update_data.ble_govee_key,
group_device_ids=update_data.group_device_ids,
group_mode=update_data.group_mode,
icon=update_data.icon,
icon_color=update_data.icon_color,
)
# Sync connection info in processor manager
+24
View File
@@ -86,6 +86,17 @@ class DeviceCreate(BaseModel):
None,
description="Group mode: sequence (LEDs concatenated) or independent (each child gets full strip resampled)",
)
# Custom card icon (frontend display only)
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library (e.g. 'mouse', 'motherboard'). Empty/null hides the plate.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon (e.g. '#4CAF50'). Empty/null inherits the card's channel accent.",
)
class DeviceUpdate(BaseModel):
@@ -140,6 +151,17 @@ class DeviceUpdate(BaseModel):
None, description="Ordered list of child device IDs (for group device type)"
)
group_mode: Optional[str] = Field(None, description="Group mode: sequence or independent")
# Custom card icon
icon: Optional[str] = Field(
None,
max_length=64,
description="Icon id from the curated icon library. Pass empty string to clear.",
)
icon_color: Optional[str] = Field(
None,
max_length=32,
description="Optional CSS color override for the icon. Pass empty string to inherit the channel accent.",
)
class CalibrationLineSchema(BaseModel):
@@ -295,6 +317,8 @@ class DeviceResponse(BaseModel):
default_factory=list, description="Ordered list of child device IDs (for group device type)"
)
group_mode: str = Field(default="sequence", description="Group mode: sequence or independent")
icon: str = Field(default="", description="Icon id from the curated icon library")
icon_color: str = Field(default="", description="Optional CSS color override for the icon")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
+103
View File
@@ -333,6 +333,109 @@
flex-shrink: 0;
}
/* ── Custom card icon plate ──────────────────────────────────────
A 44x44 instrument-panel face plate at the leading edge of the
head row. Channel-tinted; clickable to open the icon picker.
Renders only when ModHeadOpts.iconHtml is supplied. */
.mod-head--with-icon { align-items: stretch; }
.mod-icon {
--plate-size: 44px;
flex: 0 0 var(--plate-size);
width: var(--plate-size);
height: var(--plate-size);
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
background: linear-gradient(180deg,
color-mix(in srgb, var(--ch) 10%, var(--lux-bg-0, var(--bg-color))) 0%,
var(--lux-bg-0, var(--bg-color)) 100%);
border: var(--lux-hairline, 1px) solid color-mix(in srgb, var(--ch) 28%, var(--lux-line, var(--border-color)));
border-radius: var(--lux-r-sm, 3px);
color: var(--ch);
cursor: default;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--ch) 14%, transparent),
inset 0 -8px 14px color-mix(in srgb, var(--ch) 6%, transparent);
overflow: hidden;
isolation: isolate;
font: inherit;
line-height: 0;
}
button.mod-icon { cursor: pointer; }
.mod-icon::before {
content: '';
position: absolute;
top: 3px;
right: 3px;
width: 6px;
height: 6px;
border-top: 1px solid color-mix(in srgb, var(--ch) 55%, var(--lux-line, var(--border-color)));
border-right: 1px solid color-mix(in srgb, var(--ch) 55%, var(--lux-line, var(--border-color)));
opacity: 0.7;
pointer-events: none;
}
.mod-icon::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(180deg,
rgba(255, 255, 255, 0.02) 0 1px,
transparent 1px 3px);
mix-blend-mode: overlay;
opacity: 0.45;
}
.mod-icon svg {
width: 24px;
height: 24px;
stroke: currentColor;
fill: none;
z-index: 1;
transition: transform 0.25s ease;
filter: drop-shadow(0 0 5px color-mix(in srgb, var(--ch) 30%, transparent));
}
button.mod-icon:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--ch) 60%, var(--lux-line, var(--border-color)));
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--ch) 24%, transparent),
inset 0 -10px 18px color-mix(in srgb, var(--ch) 10%, transparent),
0 0 0 3px color-mix(in srgb, var(--ch) 18%, transparent),
0 4px 12px color-mix(in srgb, var(--ch) 22%, transparent);
}
button.mod-icon:hover svg { transform: scale(1.06); }
button.mod-icon:focus-visible {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch) 60%, transparent);
}
/* Running cards: the plate breathes with the live indicator. */
.is-running .mod-icon,
.card-running .mod-icon {
animation: modIconPulse 2.6s ease-in-out infinite;
}
@keyframes modIconPulse {
0%, 100% {
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--ch) 14%, transparent),
inset 0 -8px 14px color-mix(in srgb, var(--ch) 6%, transparent);
}
50% {
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--ch) 22%, transparent),
inset 0 -8px 18px color-mix(in srgb, var(--ch) 12%, transparent),
0 0 14px color-mix(in srgb, var(--ch) 28%, transparent);
}
}
.mod-leds .led {
width: 6px;
height: 6px;
+335
View File
@@ -5349,3 +5349,338 @@ body.composite-layer-dragging .composite-layer-drag-handle {
.status-card-actions .btn { flex: 1; }
}
/* ===================================================================
Icon picker modal (#icon-picker-modal)
See: features/icon-picker.ts and modals/icon-picker.html
=================================================================== */
#icon-picker-modal .modal-content {
--modal-ch: var(--ch-cyan, var(--primary-color));
max-width: 720px;
width: 100%;
}
.icon-picker-head {
display: flex !important;
align-items: center;
gap: 14px;
padding: 18px 22px 14px !important;
}
.icon-picker-preview-wrap {
flex: 0 0 56px;
}
.icon-picker-preview {
--plate-size: 56px;
width: 56px;
height: 56px;
cursor: default !important;
--ch: var(--ch-cyan, var(--primary-color));
}
.icon-picker-preview svg {
width: 30px;
height: 30px;
}
.icon-picker-preview.is-empty {
border-style: dashed !important;
background: transparent !important;
color: var(--lux-ink-mute, var(--text-secondary)) !important;
}
.icon-picker-meta { flex: 1; min-width: 0; }
.icon-picker-eyebrow {
font-family: var(--font-mono, monospace);
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--ch-cyan, var(--primary-color));
margin-bottom: 4px;
}
.icon-picker-title {
font-family: var(--font-display, inherit);
font-size: 1.4rem !important;
font-weight: 800 !important;
letter-spacing: -0.005em;
margin: 0;
color: var(--lux-ink, var(--text-color)) !important;
}
.icon-picker-sub {
font-family: var(--font-mono, monospace);
font-size: 0.66rem;
color: var(--lux-ink-mute, var(--text-secondary));
letter-spacing: 0.06em;
margin-top: 3px;
}
.icon-picker-sub strong {
color: var(--lux-ink, var(--text-color));
font-weight: 700;
}
.icon-picker-body {
padding: 0 !important;
background: var(--lux-bg-1, var(--card-bg));
}
.icon-picker-toolbar {
display: flex;
gap: 10px;
padding: 12px 22px;
background: var(--lux-bg-0, var(--bg-color));
border-bottom: 1px solid var(--lux-line, var(--border-color));
align-items: center;
}
.icon-picker-search-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--lux-bg-1, var(--card-bg));
border: 1px solid var(--lux-line-bold, var(--border-color));
border-radius: var(--lux-r-sm, var(--radius-sm));
}
.icon-picker-search-wrap:focus-within {
border-color: color-mix(in srgb, var(--ch-cyan) 50%, var(--lux-line-bold));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ch-cyan) 15%, transparent);
}
.icon-picker-search-icon { color: var(--lux-ink-mute, var(--text-secondary)); flex-shrink: 0; }
.icon-picker-search-wrap input {
flex: 1;
background: none;
border: none;
outline: none;
font-family: var(--font-mono, monospace);
font-size: 0.78rem;
letter-spacing: 0.04em;
color: var(--lux-ink, var(--text-color));
}
.icon-picker-search-wrap input::placeholder {
color: var(--lux-ink-mute, var(--text-secondary));
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.66rem;
}
.icon-picker-color-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--lux-bg-1, var(--card-bg));
border: 1px solid var(--lux-line-bold, var(--border-color));
border-radius: var(--lux-r-sm, var(--radius-sm));
cursor: pointer;
font-family: var(--font-mono, monospace);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--lux-ink-dim, var(--text-color));
transition: color 0.15s, background 0.15s;
}
.icon-picker-color-toggle:hover { color: var(--lux-ink, var(--text-color)); }
.icon-picker-swatch {
width: 14px;
height: 14px;
border-radius: 2px;
background: var(--ch-cyan, var(--primary-color));
border: 1px solid var(--ch-cyan, var(--primary-color));
}
.icon-picker-tabs {
display: flex;
gap: 0;
padding: 0 22px;
background: var(--lux-bg-0, var(--bg-color));
border-bottom: 1px solid var(--lux-line, var(--border-color));
overflow-x: auto;
}
.icon-picker-tab {
appearance: none;
background: none;
border: none;
padding: 11px 16px;
font-family: var(--font-mono, monospace);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.icon-picker-tab .count {
font-size: 0.55rem;
color: var(--lux-ink-faint, var(--text-muted));
letter-spacing: 0.05em;
background: var(--lux-bg-2, var(--card-bg));
padding: 1px 5px;
border-radius: 99px;
}
.icon-picker-tab:hover { color: var(--lux-ink-dim, var(--text-color)); }
.icon-picker-tab.is-active {
color: var(--ch-cyan, var(--primary-color));
border-bottom-color: var(--ch-cyan, var(--primary-color));
}
.icon-picker-tab.is-active .count {
background: color-mix(in srgb, var(--ch-cyan) 14%, transparent);
color: var(--ch-cyan, var(--primary-color));
}
.icon-picker-recent {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 22px;
border-bottom: 1px solid var(--lux-line, var(--border-color));
background: var(--lux-bg-0, var(--bg-color));
}
.icon-picker-recent__label {
font-family: var(--font-mono, monospace);
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
flex-shrink: 0;
}
.icon-picker-recent__strip {
display: flex;
gap: 6px;
flex: 1;
overflow-x: auto;
}
.icon-picker-recent .icon-tile { width: 30px; height: 30px; }
.icon-picker-recent .icon-tile svg { width: 16px; height: 16px; }
.icon-picker-grid-wrap {
padding: 16px 22px 14px;
max-height: 380px;
overflow-y: auto;
background: var(--lux-bg-1, var(--card-bg));
}
.icon-picker-cat {
font-family: var(--font-mono, monospace);
font-size: 0.58rem;
font-weight: 600;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
margin: 14px 0 10px;
display: flex;
align-items: center;
gap: 10px;
}
.icon-picker-cat:first-child { margin-top: 0; }
.icon-picker-cat::after {
content: '';
flex: 1;
height: 1px;
background: var(--lux-line, var(--border-color));
}
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 6px;
}
.icon-tile {
--ch: var(--ch-cyan, var(--primary-color));
appearance: none;
border: 1px solid var(--lux-line, var(--border-color));
width: 100%;
aspect-ratio: 1;
background: var(--lux-bg-0, var(--bg-color));
border-radius: 3px;
color: var(--lux-ink-dim, var(--text-color));
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s, box-shadow 0.15s;
position: relative;
padding: 0;
}
.icon-tile svg {
width: 20px;
height: 20px;
stroke: currentColor;
stroke-width: 1.6;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.icon-tile:hover {
color: var(--ch);
border-color: color-mix(in srgb, var(--ch) 50%, var(--lux-line));
background: color-mix(in srgb, var(--ch) 8%, var(--lux-bg-0));
box-shadow: 0 0 12px color-mix(in srgb, var(--ch) 22%, transparent);
}
.icon-tile.is-selected {
color: var(--ch);
border-color: var(--ch);
background: color-mix(in srgb, var(--ch) 16%, var(--lux-bg-0));
box-shadow:
0 0 16px color-mix(in srgb, var(--ch) 35%, transparent),
inset 0 0 0 1px color-mix(in srgb, var(--ch) 35%, transparent);
}
.icon-tile.is-selected::after {
content: '';
position: absolute;
top: 3px;
right: 3px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ch);
box-shadow: 0 0 6px var(--ch);
}
.icon-picker-empty {
padding: 28px 0;
text-align: center;
font-family: var(--font-mono, monospace);
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
}
.icon-picker-foot {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 22px !important;
background: var(--lux-bg-0, var(--bg-color));
border-top: 1px solid var(--lux-line, var(--border-color));
}
.icon-picker-hint {
font-family: var(--font-mono, monospace);
font-size: 0.6rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--lux-ink-mute, var(--text-secondary));
margin-right: auto;
}
.icon-picker-foot .btn { flex: 0 0 auto; min-width: 0; }
.icon-picker-btn-remove {
color: var(--ch-coral, var(--danger-color)) !important;
border-color: color-mix(in srgb, var(--ch-coral) 40%, var(--lux-line-bold)) !important;
}
.icon-picker-btn-remove:hover:not(:disabled) {
background: color-mix(in srgb, var(--ch-coral) 12%, transparent) !important;
}
@media (max-width: 600px) {
#icon-picker-modal .modal-content { max-width: 96%; }
.icon-picker-head { flex-wrap: wrap; }
.icon-picker-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.icon-picker-toolbar { flex-direction: column; align-items: stretch; }
}
+3
View File
@@ -45,6 +45,9 @@ import {
turnOffDevice, pingDevice, removeDevice, loadDevices,
updateSettingsBaudFpsHint, copyWsUrl,
} from './features/devices.ts';
// Side-effect import: attaches the document-level click delegation
// for [data-icon-picker-trigger="<deviceId>"] (icon plate + kebab item).
import './features/icon-picker.ts';
import {
loadDashboard, stopUptimeTimer,
dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
@@ -0,0 +1,151 @@
/**
* Curated icon library for the card-icon picker.
*
* Each entry is `{ id, paths, label, aliases, category }` where ``id`` is the
* stable string persisted on the entity (e.g. ``device.icon = "mouse"``) and
* ``paths`` is the inner SVG markup (re-uses ``icon-paths.ts``).
*
* To add a new icon: import its path constant from ``icon-paths.ts`` (or add
* it there first), then append a new entry below with a stable id, label,
* search aliases, and category.
*
* The id is the contract — never rename without a migration.
*/
import * as P from './icon-paths.ts';
export type IconCategory =
| 'hardware'
| 'lighting'
| 'rooms'
| 'media'
| 'signal'
| 'ambience';
export interface DeviceIconDef {
/** Stable identifier — persisted on the entity. */
id: string;
/** Inner SVG markup (no <svg> wrapper). */
paths: string;
/** Human-readable label (i18n key under ``device.icon.<id>`` if present). */
label: string;
/** Lowercase search aliases — matched as substrings against the query. */
aliases: string[];
/** Category bucket for the picker tabs. */
category: IconCategory;
}
export const CATEGORIES: { id: IconCategory; label: string; i18n: string }[] = [
{ id: 'hardware', label: 'Hardware', i18n: 'device.icon.cat.hardware' },
{ id: 'lighting', label: 'Lighting', i18n: 'device.icon.cat.lighting' },
{ id: 'rooms', label: 'Rooms', i18n: 'device.icon.cat.rooms' },
{ id: 'media', label: 'Media', i18n: 'device.icon.cat.media' },
{ id: 'signal', label: 'Signal', i18n: 'device.icon.cat.signal' },
{ id: 'ambience', label: 'Ambience', i18n: 'device.icon.cat.ambience' },
];
export const DEVICE_ICONS: DeviceIconDef[] = [
// Hardware
{ id: 'motherboard', paths: P.circuitBoard, label: 'Motherboard', aliases: ['mainboard', 'pcb', 'circuit', 'board', 'mobo'], category: 'hardware' },
{ id: 'cpu', paths: P.cpu, label: 'CPU', aliases: ['processor', 'chip'], category: 'hardware' },
{ id: 'ram', paths: P.layers, label: 'RAM', aliases: ['memory', 'dimm', 'stick'], category: 'hardware' },
{ id: 'ssd', paths: P.hardDrive, label: 'Storage', aliases: ['ssd', 'hdd', 'drive', 'disk'], category: 'hardware' },
{ id: 'mouse', paths: P.mouse, label: 'Mouse', aliases: ['rodent', 'pointer'], category: 'hardware' },
{ id: 'keyboard', paths: P.keyboard, label: 'Keyboard', aliases: ['keys', 'kbd'], category: 'hardware' },
{ id: 'controller', paths: P.gamepad2, label: 'Controller', aliases: ['gamepad', 'pad', 'joystick'], category: 'hardware' },
{ id: 'headphones', paths: P.headphones, label: 'Headphones', aliases: ['headset', 'cans'], category: 'hardware' },
{ id: 'usb', paths: P.usb, label: 'USB', aliases: ['cable', 'connector'], category: 'hardware' },
{ id: 'plug', paths: P.plug, label: 'Power plug', aliases: ['outlet', 'socket'], category: 'hardware' },
// Lighting
{ id: 'bulb', paths: P.lightbulb, label: 'Bulb', aliases: ['lamp', 'light', 'lightbulb'], category: 'lighting' },
{ id: 'strip', paths: P.rainbow, label: 'LED strip', aliases: ['strip', 'tape', 'rgb', 'wled'], category: 'lighting' },
{ id: 'panel', paths: P.layoutDashboard, label: 'LED panel', aliases: ['matrix', 'tile', 'wall'], category: 'lighting' },
{ id: 'spot', paths: P.zap, label: 'Spotlight', aliases: ['flash', 'beam', 'flood'], category: 'lighting' },
{ id: 'lamp', paths: P.flaskConical, label: 'Floor lamp', aliases: ['standing', 'pendant'], category: 'lighting' },
{ id: 'power', paths: P.power, label: 'Power', aliases: ['onoff', 'switch', 'standby'], category: 'lighting' },
{ id: 'palette', paths: P.palette, label: 'Palette', aliases: ['color', 'colour', 'paint'], category: 'lighting' },
// Rooms
{ id: 'bed', paths: P.bed, label: 'Bedroom', aliases: ['sleep', 'bedroom'], category: 'rooms' },
{ id: 'sofa', paths: P.armchair, label: 'Living room', aliases: ['armchair', 'couch', 'lounge'], category: 'rooms' },
{ id: 'desk', paths: P.layoutDashboard, label: 'Desk', aliases: ['office', 'workstation'], category: 'rooms' },
{ id: 'door', paths: P.doorOpen, label: 'Door', aliases: ['entry', 'doorway'], category: 'rooms' },
{ id: 'home', paths: P.home, label: 'Home', aliases: ['house', 'household'], category: 'rooms' },
{ id: 'fan', paths: P.fan, label: 'Fan', aliases: ['cooling', 'air'], category: 'rooms' },
{ id: 'thermostat', paths: P.thermometer, label: 'Thermostat', aliases: ['temperature', 'heating', 'climate'], category: 'rooms' },
// Media
{ id: 'monitor', paths: P.monitor, label: 'Monitor', aliases: ['display', 'screen'], category: 'media' },
{ id: 'tv', paths: P.tv, label: 'TV', aliases: ['television'], category: 'media' },
{ id: 'camera', paths: P.camera, label: 'Camera', aliases: ['cam', 'webcam'], category: 'media' },
{ id: 'mic', paths: P.mic, label: 'Microphone', aliases: ['mic', 'audio in'], category: 'media' },
{ id: 'speaker', paths: P.volume2, label: 'Speaker', aliases: ['audio', 'output', 'monitor'], category: 'media' },
{ id: 'music', paths: P.music, label: 'Music', aliases: ['note', 'audio'], category: 'media' },
{ id: 'film', paths: P.film, label: 'Film', aliases: ['video', 'movie', 'reel'], category: 'media' },
// Signal
{ id: 'wifi', paths: P.wifi, label: 'Wi-Fi', aliases: ['wireless', 'network'], category: 'signal' },
{ id: 'bluetooth', paths: P.bluetooth, label: 'Bluetooth', aliases: ['bt', 'wireless'], category: 'signal' },
{ id: 'radio', paths: P.radio, label: 'Radio', aliases: ['rf', 'antenna', 'broadcast'], category: 'signal' },
{ id: 'globe', paths: P.globe, label: 'Network', aliases: ['internet', 'web', 'world'], category: 'signal' },
{ id: 'cloud', paths: P.cloudSun, label: 'Cloud', aliases: ['weather', 'mqtt'], category: 'signal' },
{ id: 'gps', paths: P.mapPin, label: 'Location', aliases: ['map', 'gps', 'pin', 'place'], category: 'signal' },
// Ambience
{ id: 'sun', paths: P.sun, label: 'Sun', aliases: ['daylight', 'sunny', 'bright'], category: 'ambience' },
{ id: 'moon', paths: P.moon, label: 'Moon', aliases: ['night', 'dark'], category: 'ambience' },
{ id: 'flame', paths: P.flame, label: 'Flame', aliases: ['fire', 'candle', 'warm'], category: 'ambience' },
{ id: 'leaf', paths: P.leaf, label: 'Leaf', aliases: ['plant', 'eco', 'nature', 'green'], category: 'ambience' },
{ id: 'star', paths: P.star, label: 'Star', aliases: ['favorite', 'special'], category: 'ambience' },
{ id: 'sparkles', paths: P.sparkles, label: 'Sparkles', aliases: ['effect', 'magic', 'glow'], category: 'ambience' },
{ id: 'gamepad', paths: P.gamepad2, label: 'Game', aliases: ['gaming', 'play'], category: 'ambience' },
{ id: 'heart', paths: P.heart, label: 'Heart', aliases: ['love', 'favorite'], category: 'ambience' },
];
const _byId: Record<string, DeviceIconDef> = Object.fromEntries(
DEVICE_ICONS.map((d) => [d.id, d]),
);
/** Lookup an icon definition by its persisted id. Returns null if unknown. */
export function getDeviceIconDef(iconId: string | undefined | null): DeviceIconDef | null {
if (!iconId) return null;
return _byId[iconId] ?? null;
}
/** Render an icon by id as inline SVG, or empty string if the id is unknown.
* Caller decides the wrapper — typically wraps it in an instrument-panel
* ``.mod-icon`` plate. */
export function renderDeviceIconSvg(iconId: string | undefined | null, opts: { size?: number; strokeWidth?: number } = {}): string {
const def = getDeviceIconDef(iconId);
if (!def) return '';
const size = opts.size ?? 28;
const sw = opts.strokeWidth ?? 1.6;
return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${def.paths}</svg>`;
}
/** All icons as a flat list, ordered by category then by definition order. */
export function allIcons(): DeviceIconDef[] {
return DEVICE_ICONS.slice();
}
/** Filter icons by a free-text query (matches id, label, aliases). */
export function filterIcons(query: string): DeviceIconDef[] {
const q = query.trim().toLowerCase();
if (!q) return DEVICE_ICONS.slice();
return DEVICE_ICONS.filter((d) => {
if (d.id.includes(q)) return true;
if (d.label.toLowerCase().includes(q)) return true;
return d.aliases.some((a) => a.includes(q));
});
}
/** Group icons by category. Returns categories in display order. */
export function iconsByCategory(): { category: IconCategory; label: string; i18n: string; items: DeviceIconDef[] }[] {
return CATEGORIES.map((c) => ({
category: c.id,
label: c.label,
i18n: c.i18n,
items: DEVICE_ICONS.filter((d) => d.category === c.id),
}));
}
@@ -125,6 +125,15 @@ export const plus = '<path d="M5 12h14"/><path d="M12 5v14"/>';
// Lucide: git-merge (sequence mode icon)
export const gitMerge = '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>';
// Lucide: circuit-board (motherboard / mainboard)
export const circuitBoard = '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M11 9h4a2 2 0 0 0 2-2V3"/><circle cx="9" cy="9" r="2"/><path d="M7 21v-4a2 2 0 0 1 2-2h4"/><circle cx="15" cy="15" r="2"/>';
// Lucide: bed
export const bed = '<path d="M2 4v16"/><path d="M2 8h18a2 2 0 0 1 2 2v10"/><path d="M2 17h20"/><path d="M6 8v9"/>';
// Lucide: armchair (sofa)
export const armchair = '<path d="M19 9V6a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v3"/><path d="M3 16a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM5 21v2M19 21v2"/>';
// Lucide: leaf
export const leaf = '<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19.2 2.96c1 4.34.06 9.65-3.4 13.04A6.96 6.96 0 0 1 11 20z"/><path d="M2 21c0-3 1.85-5.36 5.08-6"/>';
// Easing curve glyphs — custom mini-charts that draw the actual curve.
// Curve travels from (4, 20) to (20, 4); each path renders the easing
// function directly so the picker shows the shape, not a metaphor.
+53 -8
View File
@@ -125,8 +125,13 @@ export interface ModMenuItemOpts {
label: string;
/** Icon HTML */
icon?: string;
/** Inline onclick. The menu auto-closes on click. */
onclick: string;
/** Inline onclick. The menu auto-closes on click. Optional when
* ``dataAttrs`` is provided and the caller binds via delegation. */
onclick?: string;
/** Extra data attributes (e.g. ``{ 'data-icon-picker-trigger': id }``)
* used by document-level event delegation in lieu of an inline
* onclick string. */
dataAttrs?: Record<string, string>;
/** Mark as destructive (coral colour, separator above) */
danger?: boolean;
}
@@ -169,6 +174,24 @@ export interface ModHeadOpts {
* propagate the chosen colour to every card representing this
* entity. */
cardAttr?: string;
/** Optional custom-icon plate placed before .mod-id at the leading
* edge of the head row. Channel-tinted by default; set ``iconColor``
* to override with a hex string. When ``iconHtml`` is empty, no
* plate is rendered and the head reverts to the legacy badge-led
* layout.
*
* Two ways to make the plate interactive:
* - ``iconOnclick`` (legacy string onclick — kept for parity with
* other mod-card slots; will be migrated in a follow-up).
* - ``iconAttrs`` (preferred): emit data attributes on the plate
* so callers can attach event delegation at the document level
* instead of polluting ``window`` with global onclick targets.
*/
iconHtml?: string;
iconColor?: string;
iconOnclick?: string;
iconAttrs?: Record<string, string>;
iconTitle?: string;
}
export interface ModBodyOpts {
@@ -219,7 +242,11 @@ function _menuHtml(menu: ModMenuOpts | null | undefined): string {
if (m.extraItems) {
for (const it of m.extraItems) {
const cls = it.danger ? 'mod-menu__item mod-menu__item--danger' : 'mod-menu__item';
items.push(`<button type="button" class="${cls}" role="menuitem" onclick="${it.onclick}">${it.icon || ''} <span>${escapeHtml(it.label)}</span></button>`);
const onclickAttr = it.onclick ? ` onclick="${it.onclick}"` : '';
const dataAttrs = it.dataAttrs
? ' ' + Object.entries(it.dataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
: '';
items.push(`<button type="button" class="${cls}" role="menuitem"${onclickAttr}${dataAttrs}>${it.icon || ''} <span>${escapeHtml(it.label)}</span></button>`);
}
}
@@ -250,7 +277,24 @@ function _badgeHtml(badge: ModBadgeOpts, entityId?: string, cardAttr?: string):
return `<span class="mod-badge">${dot}${escapeHtml(badge.text)}</span>`;
}
function _iconPlateHtml(head: ModHeadOpts): string {
if (!head.iconHtml) return '';
const styleAttr = head.iconColor
? ` style="--ch:${escapeHtml(head.iconColor)};color:${escapeHtml(head.iconColor)}"`
: '';
const onclickAttr = head.iconOnclick ? ` onclick="${head.iconOnclick}"` : '';
const dataAttrs = head.iconAttrs
? ' ' + Object.entries(head.iconAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ')
: '';
const titleAttr = head.iconTitle ? ` title="${escapeHtml(head.iconTitle)}" aria-label="${escapeHtml(head.iconTitle)}"` : '';
const interactive = !!(head.iconOnclick || head.iconAttrs);
const tag = interactive ? 'button' : 'div';
const typeAttr = interactive ? ' type="button"' : '';
return `<${tag} class="mod-icon"${typeAttr}${styleAttr}${onclickAttr}${dataAttrs}${titleAttr}>${head.iconHtml}</${tag}>`;
}
export function renderModHead(head: ModHeadOpts): string {
const iconHtml = _iconPlateHtml(head);
const badgeHtml = _badgeHtml(head.badge, head.entityId, head.cardAttr);
const nameHtml = `<div class="mod-name"><span>${escapeHtml(head.name)}</span>${head.healthDot || ''}</div>`;
const metaHtml = head.metaHtml ? `<div class="mod-meta">${head.metaHtml}</div>`
@@ -258,12 +302,13 @@ export function renderModHead(head: ModHeadOpts): string {
: '';
const ledsHtml = _ledsHtml(head.leds);
const menuHtml = _menuHtml(head.menu);
const headCls = iconHtml ? 'mod-head mod-head--with-icon' : 'mod-head';
// Order: id (flex:1) → kebab → LED bezel. LED status is the
// running/idle indicator and lives at the far-right corner where
// it doubles as the visual anchor of the head row. Kebab sits to
// its left as the second-most-discreet element.
return `<div class="mod-head">
// Order: optional icon plate → id (flex:1) → kebab → LED bezel.
// LED status is the running/idle indicator and lives at the far-right
// corner where it doubles as the visual anchor of the head row.
return `<div class="${headCls}">
${iconHtml}
<div class="mod-id">
${badgeHtml}
${nameHtml}
@@ -14,11 +14,13 @@ import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ModCardOpts, LedState, ModMetricOpts, ModChipOpts, ModBtnOpts } from '../core/mod-card.ts';
import type { ModCardOpts, LedState, ModMetricOpts, ModChipOpts, ModBtnOpts, ModMenuItemOpts } from '../core/mod-card.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { getBaseOrigin } from './settings.ts';
import type { Device } from '../types.ts';
import { renderDeviceIconSvg } from '../core/device-icons.ts';
import { ICON_EDIT } from '../core/icons.ts';
let _deviceTagsInput: any = null;
let _settingsCsptEntitySelect: any = null;
@@ -279,6 +281,24 @@ export function createDeviceCard(device: Device & { state?: any }) {
title: t('device.button.settings'),
});
// ── Custom icon plate (optional, set via the picker) ──
const iconId = (device as Device).icon;
const iconColor = (device as Device).icon_color;
const iconHtml = iconId ? renderDeviceIconSvg(iconId, { size: 24 }) : '';
const iconTitle = iconId ? t('device.icon.change') : t('device.icon.choose');
// Kebab menu — add "Change icon…" as the first extra item so the
// picker is reachable from anywhere on the card, not only the plate.
// Wired via document-level delegation (data-icon-picker-trigger),
// not an inline onclick string — see features/icon-picker.ts.
const menuExtraItems: ModMenuItemOpts[] = [
{
label: iconId ? t('device.icon.change') : t('device.icon.choose'),
icon: ICON_EDIT,
dataAttrs: { 'data-icon-picker-trigger': device.id },
},
];
const mod: ModCardOpts = {
head: {
badge: { text: badgeText },
@@ -286,7 +306,12 @@ export function createDeviceCard(device: Device & { state?: any }) {
metaHtml: metaPartsHtml.length ? metaPartsHtml.join(' · ') : undefined,
healthDot,
leds,
iconHtml,
iconColor,
iconAttrs: { 'data-icon-picker-trigger': device.id },
iconTitle,
menu: {
extraItems: menuExtraItems,
duplicateOnclick: `cloneDevice('${device.id}')`,
hideOnclick: `toggleCardHidden('led-devices','${device.id}')`,
deleteOnclick: `removeDevice('${device.id}')`,
@@ -0,0 +1,395 @@
/**
* Icon picker modal — choose a custom icon for an entity card.
*
* Currently wired for devices (PATCH /devices/:id { icon, icon_color }).
* The plumbing is generic so other entity types can opt in later by
* registering a new ``onApply`` handler.
*/
import { Modal } from '../core/modal.ts';
import { t } from '../core/i18n.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { showToast } from '../core/ui.ts';
import { devicesCache } from '../core/state.ts';
import {
DEVICE_ICONS,
CATEGORIES,
iconsByCategory,
filterIcons,
getDeviceIconDef,
renderDeviceIconSvg,
type IconCategory,
type DeviceIconDef,
} from '../core/device-icons.ts';
const RECENT_KEY = 'ledgrab.icon-picker.recent';
const RECENT_MAX = 10;
interface PickerContext {
deviceId: string;
initialIconId: string;
initialColor: string;
/** CSS color used for the live channel preview (e.g. '#4CAF50'). */
channelColor: string;
}
let _ctx: PickerContext | null = null;
let _selectedIconId: string = '';
let _selectedColor: string = '';
let _activeCategory: IconCategory | 'all' = 'all';
let _query: string = '';
let _modalInstance: Modal | null = null;
// ────────────────────────────────────────────────────────────────
// Recent-icons persistence
// ────────────────────────────────────────────────────────────────
function _readRecent(): string[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((x): x is string => typeof x === 'string').slice(0, RECENT_MAX);
} catch {
return [];
}
}
function _pushRecent(iconId: string): void {
if (!iconId) return;
try {
const list = _readRecent().filter((x) => x !== iconId);
list.unshift(iconId);
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
} catch {
/* ignore quota / disabled storage */
}
}
// ────────────────────────────────────────────────────────────────
// Public entry points
// ────────────────────────────────────────────────────────────────
/** Open the picker for the given device. Reads current icon from cache. */
export function openDeviceIconPicker(deviceId: string): void {
if (!deviceId) return;
const device = (devicesCache.data ?? []).find((d: any) => d.id === deviceId) ?? null;
const initialIconId = (device?.icon as string | undefined) ?? '';
const initialColor = (device?.icon_color as string | undefined) ?? '';
// Resolve channel color from the live card so the preview matches.
const card = document.querySelector(`[data-device-id="${CSS.escape(deviceId)}"]`) as HTMLElement | null;
const channelColor = card
? (getComputedStyle(card).getPropertyValue('--ch') || '').trim() || _fallbackChannel()
: _fallbackChannel();
_ctx = { deviceId, initialIconId, initialColor, channelColor };
_selectedIconId = initialIconId;
_selectedColor = initialColor;
_activeCategory = 'all';
_query = '';
if (!_modalInstance) {
_modalInstance = new Modal('icon-picker-modal');
}
_renderModal();
_modalInstance.open();
// Focus search after open — done in the next frame so the modal is visible.
requestAnimationFrame(() => {
const search = document.getElementById('icon-picker-search') as HTMLInputElement | null;
search?.focus();
});
}
/** Close the picker without applying changes. */
export function closeIconPicker(): void {
_modalInstance?.close();
_ctx = null;
}
// ────────────────────────────────────────────────────────────────
// Rendering
// ────────────────────────────────────────────────────────────────
function _fallbackChannel(): string {
const root = getComputedStyle(document.documentElement);
return (root.getPropertyValue('--ch-signal') || '#4CAF50').trim();
}
function _renderModal(): void {
if (!_ctx) return;
const previewEl = document.getElementById('icon-picker-preview') as HTMLElement | null;
const titleNameEl = document.getElementById('icon-picker-device-name') as HTMLElement | null;
const swatchEl = document.getElementById('icon-picker-swatch') as HTMLElement | null;
const tabsEl = document.getElementById('icon-picker-tabs') as HTMLElement | null;
const recentEl = document.getElementById('icon-picker-recent') as HTMLElement | null;
const gridEl = document.getElementById('icon-picker-grid') as HTMLElement | null;
const searchEl = document.getElementById('icon-picker-search') as HTMLInputElement | null;
const removeBtn = document.getElementById('icon-picker-remove') as HTMLButtonElement | null;
if (!previewEl || !tabsEl || !gridEl) return;
// Resolve effective color for the preview (override > channel)
const effectiveColor = _selectedColor || _ctx.channelColor;
previewEl.style.setProperty('--ch', effectiveColor);
previewEl.style.color = effectiveColor;
previewEl.innerHTML = _selectedIconId
? renderDeviceIconSvg(_selectedIconId, { size: 30 })
: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>`;
if (!_selectedIconId) previewEl.classList.add('is-empty');
else previewEl.classList.remove('is-empty');
// Device name in the header
if (titleNameEl) {
const device = (devicesCache.data ?? []).find((d: any) => d.id === _ctx!.deviceId);
titleNameEl.textContent = device?.name ?? _ctx.deviceId;
}
// Swatch reflects current effective color
if (swatchEl) {
swatchEl.style.background = effectiveColor;
swatchEl.style.borderColor = effectiveColor;
}
// Search input value (only set if not focused — preserves caret)
if (searchEl && document.activeElement !== searchEl) {
searchEl.value = _query;
}
// Tabs
tabsEl.innerHTML = _renderTabsHtml();
// Recent strip
if (recentEl) {
const recent = _readRecent();
if (recent.length === 0) {
recentEl.style.display = 'none';
} else {
recentEl.style.display = '';
recentEl.querySelector('.icon-picker-recent__strip')!.innerHTML = recent
.map((id) => _iconTileHtml(getDeviceIconDef(id)))
.filter(Boolean)
.join('');
}
}
// Grid (filtered + grouped or flat depending on query/category)
gridEl.innerHTML = _renderGridHtml();
// Remove button — disabled when there's no current icon
if (removeBtn) {
removeBtn.disabled = !_selectedIconId && !_ctx.initialIconId;
}
}
function _renderTabsHtml(): string {
const total = DEVICE_ICONS.length;
const tabs: string[] = [];
const allCls = _activeCategory === 'all' ? 'icon-picker-tab is-active' : 'icon-picker-tab';
tabs.push(`<button type="button" class="${allCls}" data-cat="all">${escapeHtml(t('device.icon.cat.all') || 'All')} <span class="count">${total}</span></button>`);
for (const cat of CATEGORIES) {
const count = DEVICE_ICONS.filter((d) => d.category === cat.id).length;
const cls = _activeCategory === cat.id ? 'icon-picker-tab is-active' : 'icon-picker-tab';
const label = t(cat.i18n) || cat.label;
tabs.push(`<button type="button" class="${cls}" data-cat="${cat.id}">${escapeHtml(label)} <span class="count">${count}</span></button>`);
}
return tabs.join('');
}
function _renderGridHtml(): string {
const filtered = _query ? filterIcons(_query) : DEVICE_ICONS;
const inCat = _activeCategory === 'all'
? filtered
: filtered.filter((d) => d.category === _activeCategory);
if (inCat.length === 0) {
return `<div class="icon-picker-empty">${escapeHtml(t('device.icon.empty') || 'No icons match.')}</div>`;
}
// When searching or on a single category, render flat. Otherwise group.
if (_query || _activeCategory !== 'all') {
return `<div class="icon-picker-grid">${inCat.map(_iconTileHtml).join('')}</div>`;
}
const groups = iconsByCategory();
return groups
.filter((g) => g.items.length > 0)
.map((g) => {
const label = t(g.i18n) || g.label;
return `<div class="icon-picker-cat">${escapeHtml(label)}</div>
<div class="icon-picker-grid">${g.items.map(_iconTileHtml).join('')}</div>`;
})
.join('');
}
function _iconTileHtml(def: DeviceIconDef | null): string {
if (!def) return '';
const selected = def.id === _selectedIconId ? ' is-selected' : '';
const labelKey = `device.icon.${def.id}`;
const label = t(labelKey) !== labelKey ? t(labelKey) : def.label;
return `<button type="button" class="icon-tile${selected}" data-icon-id="${def.id}" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">${renderDeviceIconSvg(def.id, { size: 20 })}</button>`;
}
// ────────────────────────────────────────────────────────────────
// Apply / events
// ────────────────────────────────────────────────────────────────
async function _applyToDevice(): Promise<void> {
if (!_ctx) return;
const { deviceId, initialIconId, initialColor } = _ctx;
if (_selectedIconId === initialIconId && _selectedColor === initialColor) {
closeIconPicker();
return;
}
try {
const resp = await fetchWithAuth(`/devices/${deviceId}`, {
method: 'PUT',
body: JSON.stringify({
icon: _selectedIconId,
icon_color: _selectedColor,
}),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
showToast((err && err.detail) || t('device.icon.error.save_failed'), 'error');
return;
}
if (_selectedIconId) _pushRecent(_selectedIconId);
showToast(t('device.icon.saved') || 'Icon saved', 'success');
devicesCache.invalidate();
await window.loadDevices?.();
closeIconPicker();
} catch (error: any) {
if (error?.isAuth) return;
showToast(t('device.icon.error.save_failed') || 'Failed to save icon', 'error');
}
}
async function _removeIcon(): Promise<void> {
if (!_ctx) return;
_selectedIconId = '';
_selectedColor = '';
await _applyToDevice();
}
function _selectIcon(iconId: string): void {
_selectedIconId = iconId;
_renderModal();
}
function _setCategory(cat: IconCategory | 'all'): void {
_activeCategory = cat;
_renderModal();
}
function _setQuery(q: string): void {
_query = q;
// Switch to "all" tab when a query is typed so search reaches all icons.
if (q && _activeCategory !== 'all') _activeCategory = 'all';
_renderModal();
}
function _toggleColorOverride(): void {
if (!_ctx) return;
// Cycle: channel default → muted accent → channel default. We keep the
// override surface minimal — power users can drop in a hex via dev-tools
// until a full color picker is added.
if (_selectedColor) {
_selectedColor = '';
} else {
// Use the channel color as a starting override so the user sees
// immediate feedback that the toggle does something. This is also
// the simplest "yes I want a custom color" affordance — they can
// refine later.
_selectedColor = _ctx.channelColor;
}
_renderModal();
}
// ────────────────────────────────────────────────────────────────
// Event delegation — bound once on first import
// ────────────────────────────────────────────────────────────────
let _wired = false;
function _wireEvents(): void {
if (_wired) return;
_wired = true;
const root = document.getElementById('icon-picker-modal');
if (!root) return;
root.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const tile = target.closest('.icon-tile') as HTMLElement | null;
if (tile && tile.dataset.iconId) {
_selectIcon(tile.dataset.iconId);
return;
}
const tab = target.closest('.icon-picker-tab') as HTMLElement | null;
if (tab && tab.dataset.cat) {
_setCategory(tab.dataset.cat as IconCategory | 'all');
return;
}
if (target.closest('#icon-picker-color-toggle')) {
_toggleColorOverride();
return;
}
if (target.closest('#icon-picker-apply')) {
_applyToDevice();
return;
}
if (target.closest('#icon-picker-cancel') || target.closest('.icon-picker-close')) {
closeIconPicker();
return;
}
if (target.closest('#icon-picker-remove')) {
_removeIcon();
return;
}
});
const search = document.getElementById('icon-picker-search') as HTMLInputElement | null;
if (search) {
search.addEventListener('input', () => _setQuery(search.value));
}
root.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'BUTTON') {
e.preventDefault();
_applyToDevice();
}
});
}
// Wire as soon as the DOM is ready (or immediately if it already is).
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _wireEvents, { once: true });
} else {
_wireEvents();
}
// ────────────────────────────────────────────────────────────────
// Document-level click delegation — opens the picker for any element
// matching ``[data-icon-picker-trigger="<deviceId>"]`` (the icon plate
// on each card and the "Change icon…" item in the kebab menu). Avoids
// polluting ``window`` with an inline onclick target.
// ────────────────────────────────────────────────────────────────
function _onDocumentClick(e: MouseEvent): void {
const el = (e.target as HTMLElement | null)?.closest('[data-icon-picker-trigger]') as HTMLElement | null;
if (!el) return;
const deviceId = el.getAttribute('data-icon-picker-trigger') || '';
if (!deviceId) return;
e.stopPropagation();
openDeviceIconPicker(deviceId);
}
document.addEventListener('click', _onDocumentClick);
+5
View File
@@ -79,6 +79,11 @@ export interface Device {
default_css_processing_template_id: string;
group_device_ids: string[];
group_mode: string;
/** Optional id from the curated icon library (e.g. 'mouse', 'motherboard').
* Empty/missing → no plate is rendered, head reverts to badge-only layout. */
icon?: string;
/** Optional CSS color override for the icon. Empty/missing inherits --ch. */
icon_color?: string;
created_at: string;
updated_at: string;
}
+22
View File
@@ -560,6 +560,28 @@
"common.none_no_input": "None (no input source)",
"common.none_own_speed": "None (no sync)",
"common.undo": "Undo",
"common.cancel": "Cancel",
"common.apply": "Apply",
"device.icon.eyebrow": "Card icon",
"device.icon.title": "Choose an icon",
"device.icon.for": "for",
"device.icon.choose": "Choose icon…",
"device.icon.change": "Change icon…",
"device.icon.remove": "Remove icon",
"device.icon.search.placeholder": "Search icons…",
"device.icon.color_toggle": "Color",
"device.icon.recent": "Recent",
"device.icon.empty": "No icons match.",
"device.icon.hint": "↵ Apply · Esc Cancel",
"device.icon.saved": "Icon saved",
"device.icon.error.save_failed": "Failed to save icon",
"device.icon.cat.all": "All",
"device.icon.cat.hardware": "Hardware",
"device.icon.cat.lighting": "Lighting",
"device.icon.cat.rooms": "Rooms",
"device.icon.cat.media": "Media",
"device.icon.cat.signal": "Signal",
"device.icon.cat.ambience": "Ambience",
"validation.required": "This field is required",
"bulk.processing": "Processing…",
"api.error.timeout": "Request timed out — please try again",
+22
View File
@@ -564,6 +564,28 @@
"common.none_no_input": "Нет (без источника)",
"common.none_own_speed": "Нет (своя скорость)",
"common.undo": "Отменить",
"common.cancel": "Отмена",
"common.apply": "Применить",
"device.icon.eyebrow": "Иконка карточки",
"device.icon.title": "Выберите иконку",
"device.icon.for": "для",
"device.icon.choose": "Выбрать иконку…",
"device.icon.change": "Изменить иконку…",
"device.icon.remove": "Удалить иконку",
"device.icon.search.placeholder": "Поиск иконок…",
"device.icon.color_toggle": "Цвет",
"device.icon.recent": "Недавние",
"device.icon.empty": "Иконки не найдены.",
"device.icon.hint": "↵ Применить · Esc Отмена",
"device.icon.saved": "Иконка сохранена",
"device.icon.error.save_failed": "Не удалось сохранить иконку",
"device.icon.cat.all": "Все",
"device.icon.cat.hardware": "Оборудование",
"device.icon.cat.lighting": "Освещение",
"device.icon.cat.rooms": "Комнаты",
"device.icon.cat.media": "Медиа",
"device.icon.cat.signal": "Сигнал",
"device.icon.cat.ambience": "Атмосфера",
"validation.required": "Обязательное поле",
"bulk.processing": "Обработка…",
"api.error.timeout": "Превышено время ожидания — попробуйте снова",
+22
View File
@@ -564,6 +564,28 @@
"common.none_no_input": "无(无输入源)",
"common.none_own_speed": "无(使用自身速度)",
"common.undo": "撤销",
"common.cancel": "取消",
"common.apply": "应用",
"device.icon.eyebrow": "卡片图标",
"device.icon.title": "选择图标",
"device.icon.for": "用于",
"device.icon.choose": "选择图标…",
"device.icon.change": "更换图标…",
"device.icon.remove": "移除图标",
"device.icon.search.placeholder": "搜索图标…",
"device.icon.color_toggle": "颜色",
"device.icon.recent": "最近使用",
"device.icon.empty": "无匹配的图标。",
"device.icon.hint": "↵ 应用 · Esc 取消",
"device.icon.saved": "图标已保存",
"device.icon.error.save_failed": "保存图标失败",
"device.icon.cat.all": "全部",
"device.icon.cat.hardware": "硬件",
"device.icon.cat.lighting": "照明",
"device.icon.cat.rooms": "房间",
"device.icon.cat.media": "媒体",
"device.icon.cat.signal": "信号",
"device.icon.cat.ambience": "氛围",
"validation.required": "此字段为必填项",
"bulk.processing": "处理中…",
"api.error.timeout": "请求超时 — 请重试",
@@ -63,6 +63,9 @@ class Device:
# Group device fields
group_device_ids: Optional[List[str]] = None,
group_mode: str = "sequence",
# Custom card icon (frontend display only)
icon: str = "",
icon_color: str = "",
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
):
@@ -96,6 +99,8 @@ class Device:
self.default_css_processing_template_id = default_css_processing_template_id
self.group_device_ids = group_device_ids or []
self.group_mode = group_mode
self.icon = icon or ""
self.icon_color = icon_color or ""
self.created_at = created_at or datetime.now(timezone.utc)
self.updated_at = updated_at or datetime.now(timezone.utc)
@@ -250,6 +255,10 @@ class Device:
d["group_device_ids"] = self.group_device_ids
if self.group_mode != "sequence":
d["group_mode"] = self.group_mode
if self.icon:
d["icon"] = self.icon
if self.icon_color:
d["icon_color"] = self.icon_color
return d
@classmethod
@@ -286,6 +295,8 @@ class Device:
default_css_processing_template_id=data.get("default_css_processing_template_id", ""),
group_device_ids=data.get("group_device_ids", []),
group_mode=data.get("group_mode", "sequence"),
icon=data.get("icon", ""),
icon_color=data.get("icon_color", ""),
created_at=datetime.fromisoformat(
data.get("created_at", datetime.now(timezone.utc).isoformat())
),
@@ -327,6 +338,8 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
"default_css_processing_template_id",
"group_device_ids",
"group_mode",
"icon",
"icon_color",
}
)
+1
View File
@@ -225,6 +225,7 @@
{% include 'modals/calibration.html' %}
{% include 'modals/advanced-calibration.html' %}
{% include 'modals/device-settings.html' %}
{% include 'modals/icon-picker.html' %}
{% include 'modals/target-editor.html' %}
{% include 'modals/css-editor.html' %}
{% include 'modals/gradient-editor.html' %}
@@ -0,0 +1,57 @@
<!-- Icon picker modal — choose a custom icon for a card.
Wired by static/js/features/icon-picker.ts. Markup is intentionally
minimal; the dynamic content (preview, tabs, recent, grid) is rendered
by the TS module on every state change. -->
<div id="icon-picker-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="icon-picker-title">
<div class="modal-content icon-picker">
<div class="modal-header icon-picker-head">
<div class="icon-picker-preview-wrap">
<div id="icon-picker-preview" class="mod-icon icon-picker-preview" aria-hidden="true"></div>
</div>
<div class="icon-picker-meta">
<div class="icon-picker-eyebrow" data-i18n="device.icon.eyebrow">Card icon</div>
<h2 id="icon-picker-title" class="icon-picker-title" data-i18n="device.icon.title">Choose an icon</h2>
<div class="icon-picker-sub">
<span data-i18n="device.icon.for">for</span>
<strong id="icon-picker-device-name"></strong>
</div>
</div>
<button class="modal-close-btn icon-picker-close" type="button" data-i18n-aria-label="aria.close" aria-label="Close">&#x2715;</button>
</div>
<div class="modal-body icon-picker-body">
<div class="icon-picker-toolbar">
<label class="icon-picker-search-wrap">
<svg class="icon-picker-search-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input id="icon-picker-search" type="text" autocomplete="off"
data-i18n-placeholder="device.icon.search.placeholder"
placeholder="Search icons…">
</label>
<button id="icon-picker-color-toggle" type="button" class="icon-picker-color-toggle">
<span id="icon-picker-swatch" class="icon-picker-swatch" aria-hidden="true"></span>
<span data-i18n="device.icon.color_toggle">Color</span>
</button>
</div>
<div id="icon-picker-tabs" class="icon-picker-tabs" role="tablist"></div>
<div id="icon-picker-recent" class="icon-picker-recent" style="display:none">
<span class="icon-picker-recent__label" data-i18n="device.icon.recent">Recent</span>
<div class="icon-picker-recent__strip"></div>
</div>
<div id="icon-picker-grid" class="icon-picker-grid-wrap"></div>
</div>
<div class="modal-footer icon-picker-foot">
<div class="icon-picker-hint" data-i18n="device.icon.hint">↵ Apply &nbsp;·&nbsp; Esc Cancel</div>
<button id="icon-picker-remove" type="button" class="btn btn-secondary icon-picker-btn-remove" data-i18n="device.icon.remove">Remove icon</button>
<button id="icon-picker-cancel" type="button" class="btn btn-secondary" data-i18n="common.cancel">Cancel</button>
<button id="icon-picker-apply" type="button" class="btn btn-primary" data-i18n="common.apply">Apply</button>
</div>
</div>
</div>