diff --git a/docs/custom-card-icon-mockup.html b/docs/custom-card-icon-mockup.html new file mode 100644 index 0000000..5f266af --- /dev/null +++ b/docs/custom-card-icon-mockup.html @@ -0,0 +1,1603 @@ + + + + + +LedGrab · Custom Card Icon · Proposal + + + + + + + + + +
+ +
+
+
Proposal · Card · Custom Icon
+

A face plate for
every module.

+

Cards inherit the channel-color stripe and instrument badges that already define the rack. Adding a user-chosen icon turns each card into something the user actually recognises at a glance — a mouse becomes a mouse, a motherboard becomes a motherboard. The badge stays as the type-of-thing label; the icon answers which thing. Optional, channel-tinted, and slotted in at the leading edge of .mod-head so the rest of the head row's typography is untouched.

+
+
+ + +
+
+ + +
01 Before / After  CH · SIGNAL
+ +
+ + +
+
+
+ WLED · OUT +
Living Room Strip
+
192.168.1.42 · v0.14
+
+ +
+
+
PIXELS
144
+
LAT
8ms
+
CHIP
WS2812B
+
+
+ Bright +
+ 198 +
+
+
ONLINE
+ + + +
+
Before
+
+ + +
+
+ +
+ WLED · OUT +
Living Room Strip
+
192.168.1.42 · v0.14
+
+ +
+
+
PIXELS
144
+
LAT
8ms
+
CHIP
WS2812B
+
+
+ Bright +
+ 198 +
+
+
ONLINE
+ + + +
+
After · Strip
+
+
+ + +
02 Anatomy  Plate
+ +
+
+
+ +
+ 52 × 52 · plate + channel · tint + edit pin · on hover + corner · silkscreen +
+
+

One plate.
Five quiet signals.

+

The plate isn't a box around an icon. It's a small instrument-panel face that re-uses the card's channel color, the corner-bracket silkscreen, and the modular-rack scanline texture. Touch it and it lifts. Live cards make it breathe.

+
    +
  • Size
    52 × 52 px on cards · 40 × 40 on dashboard tiles · 34 × 34 on perf charts. Scales with the card's existing breakpoints — no new layout math.
  • +
  • Tint
    Inherits --ch from the card (same variable that drives stripe, badge, fader). Pick a palette icon → it picks up the channel automatically.
  • +
  • Override
    Optional per-card hex via the picker's color toggle. Stored as device.icon_color; falls back to --ch when null.
  • +
  • States
    Idle · Running (breathing) · Offline (desaturated) · Fault (coral) · Empty (dashed placeholder).
  • +
  • Storage
    Single field on the entity: icon: "motherboard". Backwards-compatible — null hides the plate and the head reverts to today's badge-led layout.
  • +
+
+
+ + +
03 States
+ +
+
+
IDLE · default
+
+
+ +
+
+
Resting state · channel-tinted · corner bracket visible · scanline texture.
+
+
+
RUNNING · live
+
+
+
+ +
+
+
+
2.6s breath syncs to the patch dot pulse · widens the stripe in lockstep.
+
+
+
OFFLINE
+
+
+
+ +
+
+
+
Glyph desaturates to --lux-ink-mute · plate keeps its outline so the slot stays anchored.
+
+
+
FAULT / EMPTY
+
+
+
+ +
+
+
+ +
+
+
Fault tints to coral. Empty state is dashed — a discoverable invitation, not a hole.
+
+
+ + +
04 Coverage  All Card Types
+ +
+ + +
+
+ +
+ WLED · OUT +
Couch Underglow
+
192.168.1.81 · v0.15
+
+
+
+
+
PIXELS
90
+
LAT
12ms
+
CHIP
SK6812
+
+
+
LIVE · OUT-2
+ + +
+
+ + +
+
+ +
+ OPENRGB · OUT +
ROG Strix Z790-E
+
openrgb://localhost:6742/3
+
+
+
+
+
PIXELS
22
+
LAT
3ms
+
ZONE
AURA
+
+
+
STANDBY
+ + +
+
+ + +
+
+ +
+ OPENRGB · OUT +
G502 Lightspeed
+
openrgb://localhost:6742/4
+
+
+
+
+
PIXELS
3
+
LAT
2ms
+
+
+
READY
+ + +
+
+ + +
+
+ +
+ OPENRGB · OUT +
Keychron Q1 Pro
+
openrgb://localhost:6742/2
+
+
+
+
+
PIXELS
81
+
LAT
4ms
+
ZONE
PER-KEY
+
+
+
LIVE · OUT-3
+ + +
+
+ + +
+
+ +
+ HA · LIGHT +
Hue Bedside Lamp
+
light.bedside_lamp · 2700K
+
+
+
+
+
STANDBY
+ + +
+
+ + +
+
+ +
+ SCREEN · IN +
Primary Display
+
3440×1440 · DXGI · HDR
+
+
+
+
+
FPS
59.7
+
RES
3440
+
CPU
3%
+
+
+
CAPTURING
+ + +
+
+ + +
+
+ +
+ AUDIO · IN +
Røde NT-USB Mini
+
48kHz · stereo · −12.4dB
+
+
+
+
+ Level +
+ 62 +
+
+
STREAMING
+ +
+
+ + +
+
+ +
+ OPENRGB · OUT +
RTX 4080 Aorus Master
+
openrgb://localhost:6742/1
+
+
+
+
+
PIXELS
12
+
TEMP
52°C
+
+
+
STANDBY
+ + +
+
+ + +
+
+ +
+ GAME · IN +
Cyberpunk 2077
+
SDK · health · ammo · radio
+
+
+
+
+
WAITING
+ + +
+
+ + +
+
+ +
+ CLOCK · SYNC +
Studio Tempo
+
120 BPM · 4/4 · master
+
+
+
+
+
NO ICON
+ +
+
+ + +
+
+ +
+ WLED · OUT +
Garage Strip
+
10.0.4.18 · timeout
+
+
+
+
+
OFFLINE · 2h 14m
+ + +
+
+ + +
+
+ +
+ AUDIO · IN +
Studio Monitor L+R
+
loopback · 44.1kHz
+
+
+
+
+
STANDBY
+ + +
+
+
+ + +
05 Picker  Modal · Open
+ +
+
+
+
+
+ +
+
+
+
Card · ROG Strix Z790-E
+

Choose an icon

+
Tinted with the card's channel · click any tile to apply.
+
+ +
+ +
+ + +
+ +
+ + + + + + + +
+ +
+ Recent +
+ + + + + + + +
+
+ +
+
Hardware
+
+ + + + + + + + + + +
+ +
Lighting
+
+ + + + + + + +
+ +
Rooms & Furniture
+
+ + + + + + + +
+ +
Media
+
+ + + + + +
+ +
Signal
+
+ + + + + +
+ +
Ambience
+
+ + + + + + + + +
+
+ +
+
Apply  ·  Esc Cancel
+ + + +
+
+
+ + + + +
+ + + + + diff --git a/server/src/ledgrab/api/routes/devices.py b/server/src/ledgrab/api/routes/devices.py index 51dc5db..5111b3f 100644 --- a/server/src/ledgrab/api/routes/devices.py +++ b/server/src/ledgrab/api/routes/devices.py @@ -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 diff --git a/server/src/ledgrab/api/schemas/devices.py b/server/src/ledgrab/api/schemas/devices.py index db25b0a..f12500e 100644 --- a/server/src/ledgrab/api/schemas/devices.py +++ b/server/src/ledgrab/api/schemas/devices.py @@ -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") diff --git a/server/src/ledgrab/static/css/dashboard.css b/server/src/ledgrab/static/css/dashboard.css index 455bd0d..a9a1ce9 100644 --- a/server/src/ledgrab/static/css/dashboard.css +++ b/server/src/ledgrab/static/css/dashboard.css @@ -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; diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index 47fbefe..98cc0ff 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -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; } +} + diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 9c49274..2f0960d 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -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=""] (icon plate + kebab item). +import './features/icon-picker.ts'; import { loadDashboard, stopUptimeTimer, dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, dashboardStopAll, diff --git a/server/src/ledgrab/static/js/core/device-icons.ts b/server/src/ledgrab/static/js/core/device-icons.ts new file mode 100644 index 0000000..637b0d4 --- /dev/null +++ b/server/src/ledgrab/static/js/core/device-icons.ts @@ -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 wrapper). */ + paths: string; + /** Human-readable label (i18n key under ``device.icon.`` 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 = 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 ``; +} + +/** 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), + })); +} diff --git a/server/src/ledgrab/static/js/core/icon-paths.ts b/server/src/ledgrab/static/js/core/icon-paths.ts index af0c1aa..b28a4b9 100644 --- a/server/src/ledgrab/static/js/core/icon-paths.ts +++ b/server/src/ledgrab/static/js/core/icon-paths.ts @@ -125,6 +125,15 @@ export const plus = ''; // Lucide: git-merge (sequence mode icon) export const gitMerge = ''; +// Lucide: circuit-board (motherboard / mainboard) +export const circuitBoard = ''; +// Lucide: bed +export const bed = ''; +// Lucide: armchair (sofa) +export const armchair = ''; +// Lucide: leaf +export const leaf = ''; + // 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. diff --git a/server/src/ledgrab/static/js/core/mod-card.ts b/server/src/ledgrab/static/js/core/mod-card.ts index c51e617..1e950d0 100644 --- a/server/src/ledgrab/static/js/core/mod-card.ts +++ b/server/src/ledgrab/static/js/core/mod-card.ts @@ -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; /** 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; + 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(`${escapeHtml(it.label)}`); + const onclickAttr = it.onclick ? ` onclick="${it.onclick}"` : ''; + const dataAttrs = it.dataAttrs + ? ' ' + Object.entries(it.dataAttrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(' ') + : ''; + items.push(``); } } @@ -250,7 +277,24 @@ function _badgeHtml(badge: ModBadgeOpts, entityId?: string, cardAttr?: string): return `${dot}${escapeHtml(badge.text)}`; } +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}`; +} + export function renderModHead(head: ModHeadOpts): string { + const iconHtml = _iconPlateHtml(head); const badgeHtml = _badgeHtml(head.badge, head.entityId, head.cardAttr); const nameHtml = `
${escapeHtml(head.name)}${head.healthDot || ''}
`; const metaHtml = head.metaHtml ? `
${head.metaHtml}
` @@ -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 `
+ // 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 `
+ ${iconHtml}
${badgeHtml} ${nameHtml} diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts index 11e3fc1..385a55a 100644 --- a/server/src/ledgrab/static/js/features/devices.ts +++ b/server/src/ledgrab/static/js/features/devices.ts @@ -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}')`, diff --git a/server/src/ledgrab/static/js/features/icon-picker.ts b/server/src/ledgrab/static/js/features/icon-picker.ts new file mode 100644 index 0000000..73c1f74 --- /dev/null +++ b/server/src/ledgrab/static/js/features/icon-picker.ts @@ -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 }) + : ``; + 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(``); + 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(``); + } + 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 `
${escapeHtml(t('device.icon.empty') || 'No icons match.')}
`; + } + + // When searching or on a single category, render flat. Otherwise group. + if (_query || _activeCategory !== 'all') { + return `
${inCat.map(_iconTileHtml).join('')}
`; + } + + const groups = iconsByCategory(); + return groups + .filter((g) => g.items.length > 0) + .map((g) => { + const label = t(g.i18n) || g.label; + return `
${escapeHtml(label)}
+
${g.items.map(_iconTileHtml).join('')}
`; + }) + .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 ``; +} + +// ──────────────────────────────────────────────────────────────── +// Apply / events +// ──────────────────────────────────────────────────────────────── + +async function _applyToDevice(): Promise { + 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 { + 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=""]`` (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); diff --git a/server/src/ledgrab/static/js/types.ts b/server/src/ledgrab/static/js/types.ts index d5c347e..8f0cc11 100644 --- a/server/src/ledgrab/static/js/types.ts +++ b/server/src/ledgrab/static/js/types.ts @@ -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; } diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 9bbe82a..e0c9107 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -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", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index cba137b..eb76489 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -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": "Превышено время ожидания — попробуйте снова", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 5fc2161..ad808cb 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -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": "请求超时 — 请重试", diff --git a/server/src/ledgrab/storage/device_store.py b/server/src/ledgrab/storage/device_store.py index a82e9a7..7a3e37e 100644 --- a/server/src/ledgrab/storage/device_store.py +++ b/server/src/ledgrab/storage/device_store.py @@ -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", } ) diff --git a/server/src/ledgrab/templates/index.html b/server/src/ledgrab/templates/index.html index 30a13e9..44fa3ef 100644 --- a/server/src/ledgrab/templates/index.html +++ b/server/src/ledgrab/templates/index.html @@ -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' %} diff --git a/server/src/ledgrab/templates/modals/icon-picker.html b/server/src/ledgrab/templates/modals/icon-picker.html new file mode 100644 index 0000000..a000bc8 --- /dev/null +++ b/server/src/ledgrab/templates/modals/icon-picker.html @@ -0,0 +1,57 @@ + +