From ced72fc864d30fa4c16f8f8e5c95cb866148d9e9 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 4 May 2026 00:43:55 +0300 Subject: [PATCH] feat(targets): customisable card icon + HA-light stop action Extends the icon-plate work from the device cards to LED and HA-light output targets, and adds finalization behaviour for HA-light targets. Targets: - Add `icon` and `icon_color` fields to OutputTarget (LED + HA-light). - LED target cards inherit the icon from their referenced device when no override is set; the icon picker shows an "inherited" indicator. - Promote the device link from the meta line to a chip with the device's custom icon, leaving the head row free for the icon plate. HA-light: - New `stop_action` field with three modes: `none` / `turn_off` / `restore`. Processor snapshots mapped-entity states at start and applies the chosen action on stop (rgb / hs / color_temp / brightness restored where present). - Editor modal exposes the choice via an IconSelect of three modes. Adjacent fixes: - Fader slider hit-zone now overlays the visible track exactly, regardless of label/value column widths. - Dashboard customise drag-drop indicator splits into before/after rather than highlighting the whole row. - Picture-source EntitySelect resyncs its visible value on load. --- .../src/ledgrab/api/routes/output_targets.py | 11 + .../src/ledgrab/api/schemas/output_targets.py | 30 ++ .../processing/ha_light_target_processor.py | 123 +++++++ .../core/processing/processor_manager.py | 2 + server/src/ledgrab/static/css/cards.css | 16 +- .../static/css/dashboard-customize.css | 14 +- server/src/ledgrab/static/css/dashboard.css | 9 +- server/src/ledgrab/static/css/modal.css | 13 + server/src/ledgrab/static/js/core/mod-card.ts | 6 +- .../static/js/features/color-strips/index.ts | 1 + .../static/js/features/dashboard-customize.ts | 221 +++++++++++-- .../static/js/features/ha-light-targets.ts | 30 ++ .../ledgrab/static/js/features/icon-picker.ts | 299 ++++++++++++++---- .../src/ledgrab/static/js/features/targets.ts | 61 +++- server/src/ledgrab/static/js/types.ts | 7 + server/src/ledgrab/static/locales/en.json | 13 + server/src/ledgrab/static/locales/ru.json | 13 + server/src/ledgrab/static/locales/zh.json | 13 + .../ledgrab/storage/ha_light_output_target.py | 25 +- server/src/ledgrab/storage/output_target.py | 18 +- .../ledgrab/storage/output_target_store.py | 10 + .../src/ledgrab/storage/wled_output_target.py | 12 +- .../templates/modals/ha-light-editor.html | 9 + .../ledgrab/templates/modals/icon-picker.html | 9 +- 24 files changed, 831 insertions(+), 134 deletions(-) diff --git a/server/src/ledgrab/api/routes/output_targets.py b/server/src/ledgrab/api/routes/output_targets.py index bc89e1b..dc924e6 100644 --- a/server/src/ledgrab/api/routes/output_targets.py +++ b/server/src/ledgrab/api/routes/output_targets.py @@ -54,6 +54,8 @@ def _led_target_to_response(target: WledOutputTarget) -> LedOutputTargetResponse protocol=target.protocol, description=target.description, tags=target.tags, + icon=getattr(target, "icon", "") or "", + icon_color=getattr(target, "icon_color", "") or "", created_at=target.created_at, updated_at=target.updated_at, ) @@ -82,8 +84,11 @@ def _ha_light_target_to_response( transition=target.transition.to_dict(), color_tolerance=target.color_tolerance.to_dict(), min_brightness_threshold=target.min_brightness_threshold.to_dict(), + stop_action=target.stop_action, description=target.description, tags=target.tags, + icon=getattr(target, "icon", "") or "", + icon_color=getattr(target, "icon_color", "") or "", created_at=target.created_at, updated_at=target.updated_at, ) @@ -165,6 +170,7 @@ async def create_target( update_rate=getattr(data, "update_rate", 2.0), transition=getattr(data, "transition", 0.5), color_tolerance=getattr(data, "color_tolerance", 5), + stop_action=getattr(data, "stop_action", "none"), ) # Register in processor manager @@ -283,11 +289,14 @@ async def update_target( protocol=getattr(data, "protocol", None), description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ha_source_id=getattr(data, "ha_source_id", None), ha_light_mappings=ha_mappings, update_rate=getattr(data, "update_rate", None), transition=getattr(data, "transition", None), color_tolerance=getattr(data, "color_tolerance", None), + stop_action=getattr(data, "stop_action", None), ) # Sync processor manager (run in thread — css release/acquire can block) @@ -301,6 +310,7 @@ async def update_target( transition = getattr(data, "transition", None) color_tolerance = getattr(data, "color_tolerance", None) brightness = getattr(data, "brightness", None) + stop_action = getattr(data, "stop_action", None) try: await asyncio.to_thread( @@ -317,6 +327,7 @@ async def update_target( or color_tolerance is not None or ha_light_mappings_raw is not None or brightness is not None + or stop_action is not None ), css_changed=color_strip_source_id is not None, brightness_changed=brightness is not None, diff --git a/server/src/ledgrab/api/schemas/output_targets.py b/server/src/ledgrab/api/schemas/output_targets.py index fd88a75..3040219 100644 --- a/server/src/ledgrab/api/schemas/output_targets.py +++ b/server/src/ledgrab/api/schemas/output_targets.py @@ -55,6 +55,8 @@ class _OutputTargetResponseBase(BaseModel): name: str = Field(description="Target name") description: Optional[str] = Field(None, description="Description") tags: List[str] = Field(default_factory=list, description="User-defined tags") + icon: str = Field(default="", description="Custom 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") @@ -98,6 +100,11 @@ class HALightOutputTargetResponse(_OutputTargetResponseBase): min_brightness_threshold: Optional[BindableFloatInput] = Field( default=0, description="Min brightness threshold (bindable, 0=disabled)" ) + stop_action: Literal["none", "turn_off", "restore"] = Field( + default="none", + description="What to do with mapped lights when the target stops: " + "'none' (leave as-is), 'turn_off', or 'restore' (revert to state captured at start).", + ) OutputTargetResponse = Annotated[ @@ -119,6 +126,12 @@ class _OutputTargetCreateBase(BaseModel): name: str = Field(description="Target name", min_length=1, max_length=100) description: Optional[str] = Field(None, description="Optional description", max_length=500) tags: List[str] = Field(default_factory=list, description="User-defined tags") + icon: Optional[str] = Field( + None, max_length=64, description="Custom icon id from the curated icon library" + ) + icon_color: Optional[str] = Field( + None, max_length=32, description="Optional CSS color override for the icon" + ) class LedOutputTargetCreate(_OutputTargetCreateBase): @@ -180,6 +193,10 @@ class HALightOutputTargetCreate(_OutputTargetCreateBase): default=0, description="Min brightness threshold (bindable, 0=disabled); below this -> off", ) + stop_action: Literal["none", "turn_off", "restore"] = Field( + default="none", + description="Finalization on stop: 'none', 'turn_off', or 'restore'.", + ) OutputTargetCreate = Annotated[ @@ -201,6 +218,16 @@ class _OutputTargetUpdateBase(BaseModel): name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100) description: Optional[str] = Field(None, description="Optional description", max_length=500) tags: Optional[List[str]] = None + icon: Optional[str] = Field( + None, + max_length=64, + description="Custom icon id; pass empty string to clear and inherit from device.", + ) + icon_color: Optional[str] = Field( + None, + max_length=32, + description="Optional CSS color override for the icon; empty string clears.", + ) class LedOutputTargetUpdate(_OutputTargetUpdateBase): @@ -246,6 +273,9 @@ class HALightOutputTargetUpdate(_OutputTargetUpdateBase): min_brightness_threshold: Optional[BindableFloatInput] = Field( None, description="Min brightness threshold (bindable, 0=disabled)" ) + stop_action: Optional[Literal["none", "turn_off", "restore"]] = Field( + None, description="Finalization on stop: 'none', 'turn_off', or 'restore'." + ) OutputTargetUpdate = Annotated[ diff --git a/server/src/ledgrab/core/processing/ha_light_target_processor.py b/server/src/ledgrab/core/processing/ha_light_target_processor.py index 576ea47..cd1f9e4 100644 --- a/server/src/ledgrab/core/processing/ha_light_target_processor.py +++ b/server/src/ledgrab/core/processing/ha_light_target_processor.py @@ -35,6 +35,7 @@ class HALightTargetProcessor(TargetProcessor): transition=None, min_brightness_threshold: int = 0, color_tolerance: int = 5, + stop_action: str = "none", ctx: Optional[TargetContext] = None, ): from ledgrab.storage.bindable import BindableFloat, bfloat @@ -56,6 +57,9 @@ class HALightTargetProcessor(TargetProcessor): self._update_rate = max(0.5, min(5.0, bfloat(update_rate, 2.0))) self._min_brightness_threshold = int(bfloat(min_brightness_threshold, 0.0)) self._color_tolerance = int(bfloat(color_tolerance, 5.0)) + self._stop_action = ( + stop_action if stop_action in ("none", "turn_off", "restore") else "none" + ) # Runtime state self._css_stream = None @@ -64,6 +68,8 @@ class HALightTargetProcessor(TargetProcessor): self._previous_colors: Dict[str, Tuple[int, int, int]] = {} self._previous_on: Dict[str, bool] = {} # track on/off state per entity self._latest_entity_colors: Dict[str, Tuple[int, int, int]] = {} + # Snapshot of entity states captured at start() — used by "restore" stop action + self._captured_states: Dict[str, Any] = {} self._ws_clients: List[Any] = [] self._start_time: Optional[float] = None @@ -104,6 +110,10 @@ class HALightTargetProcessor(TargetProcessor): logger.warning(f"HA light {self._target_id}: failed to acquire brightness VS: {e}") self._value_stream = None + # Capture initial entity states for "restore" stop action. + # We always capture (cheap) so changing stop_action while running still works. + self._captured_states = self._snapshot_mapped_entity_states() + self._is_running = True self._start_time = time.monotonic() self._task = asyncio.create_task(self._processing_loop()) @@ -119,6 +129,14 @@ class HALightTargetProcessor(TargetProcessor): pass self._task = None + # Run finalization (turn_off / restore) before releasing the HA runtime. + try: + await self._apply_stop_action() + except Exception as e: + logger.warning( + f"HA light {self._target_id}: stop_action '{self._stop_action}' failed: {e}" + ) + # Release CSS stream if self._css_stream and self._ctx.color_strip_stream_manager: try: @@ -148,6 +166,7 @@ class HALightTargetProcessor(TargetProcessor): self._previous_colors.clear() self._previous_on.clear() self._latest_entity_colors.clear() + self._captured_states.clear() self._ws_clients.clear() logger.info(f"HA light target stopped: {self._target_id}") @@ -177,6 +196,10 @@ class HALightTargetProcessor(TargetProcessor): self._color_tolerance = int(bfloat(settings["color_tolerance"], 5.0)) if "light_mappings" in settings: self._light_mappings = settings["light_mappings"] + if "stop_action" in settings: + sa = settings["stop_action"] + if sa in ("none", "turn_off", "restore"): + self._stop_action = sa def update_css_source(self, color_strip_source_id: str) -> None: """Hot-swap the CSS stream.""" @@ -378,3 +401,103 @@ class HALightTargetProcessor(TargetProcessor): dead.append(ws) for ws in dead: self._ws_clients.remove(ws) + + # ── Stop-action finalization ── + + def _snapshot_mapped_entity_states(self) -> Dict[str, Any]: + """Capture current state of every mapped entity from the HA cache.""" + if not self._ha_runtime: + return {} + snap: Dict[str, Any] = {} + for mapping in self._light_mappings: + eid = mapping.entity_id + if not eid: + continue + state = self._ha_runtime.get_state(eid) + if state is not None: + snap[eid] = state + return snap + + async def _apply_stop_action(self) -> None: + """Run the configured finalization on stop.""" + if self._stop_action == "none": + return + if not self._ha_runtime or not self._ha_runtime.is_connected: + logger.info( + f"HA light {self._target_id}: skipping stop_action " + f"'{self._stop_action}' — HA not connected" + ) + return + + # Unique entity ids (a target may map the same entity twice in theory) + entity_ids = [] + seen = set() + for mapping in self._light_mappings: + eid = mapping.entity_id + if eid and eid not in seen: + seen.add(eid) + entity_ids.append(eid) + + if not entity_ids: + return + + if self._stop_action == "turn_off": + for eid in entity_ids: + await self._ha_runtime.call_service( + domain="light", + service="turn_off", + service_data={}, + target={"entity_id": eid}, + ) + return + + if self._stop_action == "restore": + for eid in entity_ids: + state = self._captured_states.get(eid) + if state is None: + continue + await self._restore_entity(eid, state) + + async def _restore_entity(self, entity_id: str, state: Any) -> None: + """Restore one light entity to a captured HAEntityState.""" + if state.state == "off": + await self._ha_runtime.call_service( + domain="light", + service="turn_off", + service_data={}, + target={"entity_id": entity_id}, + ) + return + + if state.state != "on": + # unknown / unavailable — best effort: do nothing + return + + attrs = state.attributes or {} + service_data: Dict[str, Any] = {} + + # Color: prefer rgb_color, then hs_color, then color_temp, then nothing + rgb = attrs.get("rgb_color") + if isinstance(rgb, (list, tuple)) and len(rgb) >= 3: + service_data["rgb_color"] = [int(rgb[0]), int(rgb[1]), int(rgb[2])] + else: + hs = attrs.get("hs_color") + color_temp = attrs.get("color_temp") + color_temp_kelvin = attrs.get("color_temp_kelvin") + if isinstance(hs, (list, tuple)) and len(hs) >= 2: + service_data["hs_color"] = [float(hs[0]), float(hs[1])] + elif color_temp_kelvin is not None: + service_data["color_temp_kelvin"] = int(color_temp_kelvin) + elif color_temp is not None: + service_data["color_temp"] = int(color_temp) + + brightness = attrs.get("brightness") + if brightness is not None: + service_data["brightness"] = int(brightness) + + await self._ha_runtime.call_service( + domain="light", + service="turn_on", + service_data=service_data, + target={"entity_id": entity_id}, + ) diff --git a/server/src/ledgrab/core/processing/processor_manager.py b/server/src/ledgrab/core/processing/processor_manager.py index b4dda57..b5a732f 100644 --- a/server/src/ledgrab/core/processing/processor_manager.py +++ b/server/src/ledgrab/core/processing/processor_manager.py @@ -437,6 +437,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) transition=None, min_brightness_threshold: int = 0, color_tolerance: int = 5, + stop_action: str = "none", ) -> None: """Register a Home Assistant light target processor.""" if target_id in self._processors: @@ -454,6 +455,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin) transition=transition, min_brightness_threshold=min_brightness_threshold, color_tolerance=color_tolerance, + stop_action=stop_action, ctx=self._build_context(), ) self._processors[target_id] = proc diff --git a/server/src/ledgrab/static/css/cards.css b/server/src/ledgrab/static/css/cards.css index 830b2ab..7d8eef5 100644 --- a/server/src/ledgrab/static/css/cards.css +++ b/server/src/ledgrab/static/css/cards.css @@ -2233,6 +2233,13 @@ ul.section-tip li { color: var(--lux-ink-mute, var(--text-secondary)); min-width: 42px; } +.mod-fader__lane { + flex: 1; + position: relative; + display: flex; + align-items: center; + min-width: 0; +} .mod-fader__track { flex: 1; position: relative; @@ -2254,13 +2261,12 @@ ul.section-tip li { var(--ch)); box-shadow: 0 0 8px color-mix(in srgb, var(--ch) 60%, transparent); } -/* The slider input lays flat over the track, transparent, so the user - drags the visual track without seeing the native control. */ -.mod-fader { position: relative; } +/* The slider input overlays the visible track exactly — same flex slot, + so its hit-zone aligns to the fill regardless of label/value width. */ .mod-fader__slider { position: absolute; - left: 52px; - right: 50px; /* between label and value cells */ + left: 0; + right: 0; top: 50%; transform: translateY(-50%); height: 18px; diff --git a/server/src/ledgrab/static/css/dashboard-customize.css b/server/src/ledgrab/static/css/dashboard-customize.css index 8105eba..9dadedf 100644 --- a/server/src/ledgrab/static/css/dashboard-customize.css +++ b/server/src/ledgrab/static/css/dashboard-customize.css @@ -204,9 +204,17 @@ transform: scale(0.98); } -.dash-cust-row.is-drop-target { - border-color: var(--ch-signal, var(--primary-color)); - background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 8%, transparent); +.dash-cust-row.is-drop-target-before, +.dash-cust-row.is-drop-target-after { + background: color-mix(in srgb, var(--ch-signal, var(--primary-color)) 6%, transparent); +} + +.dash-cust-row.is-drop-target-before { + box-shadow: 0 -2px 0 0 var(--ch-signal, var(--primary-color)); +} + +.dash-cust-row.is-drop-target-after { + box-shadow: 0 2px 0 0 var(--ch-signal, var(--primary-color)); } .dash-cust-row-fixed { diff --git a/server/src/ledgrab/static/css/dashboard.css b/server/src/ledgrab/static/css/dashboard.css index a9a1ce9..a03562d 100644 --- a/server/src/ledgrab/static/css/dashboard.css +++ b/server/src/ledgrab/static/css/dashboard.css @@ -336,8 +336,13 @@ /* ── 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; } + Renders only when ModHeadOpts.iconHtml is supplied. + + The plate sits at the top-left of the head row at its own fixed + size. We deliberately do NOT set align-items:stretch on the head — + that would force sibling slots (LED bezel, kebab) to inherit the + plate's height. Instead each slot keeps its natural compact size. +*/ .mod-icon { --plate-size: 44px; diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index 98cc0ff..822b54b 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -5385,6 +5385,19 @@ body.composite-layer-dragging .composite-layer-drag-handle { background: transparent !important; color: var(--lux-ink-mute, var(--text-secondary)) !important; } +.icon-picker-preview.is-inherited svg { + opacity: 0.7; +} +.icon-picker-sub.is-inherited { + color: var(--ch-cyan, var(--primary-color)); + font-style: italic; +} +.icon-picker-sub.is-inherited::before { + content: '↳'; + margin-right: 4px; + font-style: normal; + opacity: 0.85; +} .icon-picker-meta { flex: 1; min-width: 0; } .icon-picker-eyebrow { diff --git a/server/src/ledgrab/static/js/core/mod-card.ts b/server/src/ledgrab/static/js/core/mod-card.ts index 1e950d0..c7dcefc 100644 --- a/server/src/ledgrab/static/js/core/mod-card.ts +++ b/server/src/ledgrab/static/js/core/mod-card.ts @@ -369,8 +369,10 @@ export function renderModFader(f: ModFaderOpts): string { const disabledAttr = f.disabled ? ' disabled' : ''; return `
${escapeHtml(f.label)} -
- +
+
+ +
${f.value}
`; } diff --git a/server/src/ledgrab/static/js/features/color-strips/index.ts b/server/src/ledgrab/static/js/features/color-strips/index.ts index 5175954..7a2e4f1 100644 --- a/server/src/ledgrab/static/js/features/color-strips/index.ts +++ b/server/src/ledgrab/static/js/features/color-strips/index.ts @@ -1272,6 +1272,7 @@ const _typeHandlers: Record any; reset: (... picture: { load(css, sourceSelect) { sourceSelect.value = css.picture_source_id || ''; + if (_cssPictureSourceEntitySelect) _cssPictureSourceEntitySelect.setValue(sourceSelect.value); (document.getElementById('css-editor-interpolation') as HTMLInputElement).value = css.interpolation_mode || 'average'; if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average'); _ensureSmoothingWidget().setValue(css.smoothing); diff --git a/server/src/ledgrab/static/js/features/dashboard-customize.ts b/server/src/ledgrab/static/js/features/dashboard-customize.ts index ff2e38b..ade25eb 100644 --- a/server/src/ledgrab/static/js/features/dashboard-customize.ts +++ b/server/src/ledgrab/static/js/features/dashboard-customize.ts @@ -89,6 +89,15 @@ const PERF_CELL_LABEL_KEYS: Record = { }; let _unsubscribe: (() => void) | null = null; +// True while the user is mid-drag in the panel. While set, layout-change +// notifications are deferred — re-rendering would replace the dragged DOM +// node and abort the drag. The flag is cleared on `dragend`. +let _activeDrag = false; +let _renderDeferred = false; +// After an arrow-button move, restore focus on the same arrow once the +// panel re-renders. Without this, every press destroys the focus and the +// user has to re-aim for each click. +let _focusAfterRender: string | null = null; export function openDashboardCustomize(): void { let panel = document.getElementById(PANEL_ID); @@ -101,7 +110,10 @@ export function openDashboardCustomize(): void { if (backdrop) backdrop.classList.add('is-open'); _renderPanelBody(); if (!_unsubscribe) { - _unsubscribe = subscribeDashboardLayout(() => _renderPanelBody()); + _unsubscribe = subscribeDashboardLayout(() => { + if (_activeDrag) { _renderDeferred = true; return; } + _renderPanelBody(); + }); } } @@ -161,6 +173,11 @@ function _renderPanelBody(): void { ${_renderActions()} `; _bindHandlers(body); + if (_focusAfterRender) { + const el = body.querySelector(_focusAfterRender); + el?.focus(); + _focusAfterRender = null; + } } // ── Sub-renderers ──────────────────────────────────────────────────────── @@ -398,6 +415,7 @@ function _bindHandlers(root: HTMLElement): void { btn.addEventListener('click', () => { const key = btn.dataset.sectionKey!; const dir = btn.dataset.move as 'up' | 'down'; + _focusAfterRender = `[data-section-key="${key}"][data-move="${dir}"]`; _moveSection(key, dir); }); }); @@ -444,6 +462,7 @@ function _bindHandlers(root: HTMLElement): void { btn.addEventListener('click', () => { const key = btn.dataset.cellKey!; const dir = btn.dataset.cellMove as 'up' | 'down'; + _focusAfterRender = `[data-cell-key="${key}"][data-cell-move="${dir}"]`; _movePerfCell(key, dir); }); }); @@ -503,6 +522,21 @@ function _movePerfCell(key: string, dir: 'up' | 'down'): void { } // ── Hand-rolled drag-and-drop sort ────────────────────────────────────── +// +// Uses event delegation (one listener per list, not per row) so re-renders +// don't accumulate listeners and the cost is constant in row count. +// +// Insertion semantics: the dragged item lands BEFORE or AFTER the row +// under the cursor based on whether the cursor is in the upper or lower +// half of that row. A coloured insertion line shows where it will land. +// +// Drag is suppressed when the press starts on an interactive child +// (button/select/input) so embedded controls — eye toggle, density +// buttons, ↑/↓ arrows — keep working. +// +// While a drag is active, `_activeDrag` is set so the layout subscriber +// defers re-rendering. Without that, a debounced PUT echo mid-drag would +// replace the dragged DOM node and abort the gesture. function _bindDragSort( root: HTMLElement, @@ -512,42 +546,159 @@ function _bindDragSort( ): void { const list = root.querySelector(listSelector); if (!list) return; - let dragKey: string | null = null; - list.querySelectorAll('.dash-cust-row-drag').forEach(row => { - row.addEventListener('dragstart', (e) => { - dragKey = row.getAttribute(keyAttr); - row.classList.add('is-dragging'); - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move'; - // Required by Firefox to enable drag. - e.dataTransfer.setData('text/plain', dragKey || ''); + let dragKey: string | null = null; + let dragRow: HTMLElement | null = null; + let lastIndicatorRow: HTMLElement | null = null; + let lastIndicatorPos: 'before' | 'after' | null = null; + let autoScrollRaf: number | null = null; + let autoScrollDir: -1 | 0 | 1 = 0; + + const scroller = list.closest('.dash-cust-body'); + + const clearIndicator = (): void => { + if (lastIndicatorRow) { + lastIndicatorRow.classList.remove('is-drop-target-before', 'is-drop-target-after'); + } + lastIndicatorRow = null; + lastIndicatorPos = null; + }; + + const stopAutoScroll = (): void => { + if (autoScrollRaf !== null) { + cancelAnimationFrame(autoScrollRaf); + autoScrollRaf = null; + } + autoScrollDir = 0; + }; + + const tickAutoScroll = (): void => { + autoScrollRaf = null; + if (!scroller || autoScrollDir === 0) return; + scroller.scrollTop += autoScrollDir * 12; + autoScrollRaf = requestAnimationFrame(tickAutoScroll); + }; + + const updateAutoScroll = (clientY: number): void => { + if (!scroller) return; + const rect = scroller.getBoundingClientRect(); + const edge = 40; + let dir: -1 | 0 | 1 = 0; + if (clientY < rect.top + edge) dir = -1; + else if (clientY > rect.bottom - edge) dir = 1; + if (dir !== autoScrollDir) { + autoScrollDir = dir; + if (dir !== 0 && autoScrollRaf === null) { + autoScrollRaf = requestAnimationFrame(tickAutoScroll); } - }); - row.addEventListener('dragend', () => { - row.classList.remove('is-dragging'); - dragKey = null; - list.querySelectorAll('.is-drop-target').forEach(el => el.classList.remove('is-drop-target')); - }); - row.addEventListener('dragover', (e) => { - if (!dragKey) return; - e.preventDefault(); - list.querySelectorAll('.is-drop-target').forEach(el => el.classList.remove('is-drop-target')); - row.classList.add('is-drop-target'); - }); - row.addEventListener('drop', (e) => { - e.preventDefault(); - const targetKey = row.getAttribute(keyAttr); - if (!dragKey || !targetKey || dragKey === targetKey) return; - const allRows = Array.from(list.querySelectorAll('.dash-cust-row-drag')); - const orderedKeys = allRows.map(r => r.getAttribute(keyAttr) || ''); - const fromIdx = orderedKeys.indexOf(dragKey); - const toIdx = orderedKeys.indexOf(targetKey); - if (fromIdx < 0 || toIdx < 0) return; - const [moved] = orderedKeys.splice(fromIdx, 1); - orderedKeys.splice(toIdx, 0, moved); - onReorder(orderedKeys.filter(Boolean)); - }); + } + }; + + const getRowFrom = (target: EventTarget | null): HTMLElement | null => { + const el = target as HTMLElement | null; + if (!el) return null; + const row = el.closest('.dash-cust-row-drag'); + return row && list.contains(row) ? row : null; + }; + + list.addEventListener('dragstart', (e) => { + // Don't start drag from interactive controls inside the row. + const interactive = (e.target as HTMLElement | null) + ?.closest('button, select, input, textarea, a'); + if (interactive) { e.preventDefault(); return; } + + const row = getRowFrom(e.target); + if (!row) return; + + dragKey = row.getAttribute(keyAttr); + dragRow = row; + row.classList.add('is-dragging'); + _activeDrag = true; + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + // Required by Firefox to enable drag. + e.dataTransfer.setData('text/plain', dragKey || ''); + } + }); + + list.addEventListener('dragend', () => { + if (dragRow) dragRow.classList.remove('is-dragging'); + dragRow = null; + dragKey = null; + clearIndicator(); + stopAutoScroll(); + _activeDrag = false; + // Run any layout update that was deferred during the drag. + if (_renderDeferred) { + _renderDeferred = false; + _renderPanelBody(); + } + }); + + list.addEventListener('dragover', (e) => { + if (!dragKey) return; + const row = getRowFrom(e.target); + if (!row) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + + const rect = row.getBoundingClientRect(); + const pos: 'before' | 'after' = (e.clientY < rect.top + rect.height / 2) + ? 'before' : 'after'; + + if (row !== lastIndicatorRow || pos !== lastIndicatorPos) { + clearIndicator(); + row.classList.add(pos === 'before' ? 'is-drop-target-before' : 'is-drop-target-after'); + lastIndicatorRow = row; + lastIndicatorPos = pos; + } + updateAutoScroll(e.clientY); + }); + + list.addEventListener('dragleave', (e) => { + // Clear only when leaving the list entirely, not when crossing + // between child rows. + const related = e.relatedTarget as Node | null; + if (!related || !list.contains(related)) { + clearIndicator(); + stopAutoScroll(); + } + }); + + list.addEventListener('drop', (e) => { + e.preventDefault(); + const row = getRowFrom(e.target); + if (!dragKey || !row) { + clearIndicator(); + stopAutoScroll(); + return; + } + const targetKey = row.getAttribute(keyAttr); + if (!targetKey || dragKey === targetKey) { + clearIndicator(); + stopAutoScroll(); + return; + } + const rect = row.getBoundingClientRect(); + const pos: 'before' | 'after' = (e.clientY < rect.top + rect.height / 2) + ? 'before' : 'after'; + + const allRows = Array.from(list.querySelectorAll('.dash-cust-row-drag')); + const orderedKeys = allRows.map(r => r.getAttribute(keyAttr) || '').filter(Boolean); + const fromIdx = orderedKeys.indexOf(dragKey); + if (fromIdx < 0) { clearIndicator(); stopAutoScroll(); return; } + + // Remove the dragged key first, then recompute the target's index + // because the splice may have shifted it. + orderedKeys.splice(fromIdx, 1); + const targetIdx = orderedKeys.indexOf(targetKey); + if (targetIdx < 0) { clearIndicator(); stopAutoScroll(); return; } + const insertAt = pos === 'before' ? targetIdx : targetIdx + 1; + orderedKeys.splice(insertAt, 0, dragKey); + + clearIndicator(); + stopAutoScroll(); + onReorder(orderedKeys); }); } diff --git a/server/src/ledgrab/static/js/features/ha-light-targets.ts b/server/src/ledgrab/static/js/features/ha-light-targets.ts index 9cf2f6d..30a6366 100644 --- a/server/src/ledgrab/static/js/features/ha-light-targets.ts +++ b/server/src/ledgrab/static/js/features/ha-light-targets.ts @@ -11,6 +11,7 @@ import { showToast, showConfirm, formatUptime } from '../core/ui.ts'; import { ICON_EDIT, ICON_START, ICON_STOP, ICON_TRASH, ICON_OK, ICON_WARNING, ICON_CLOCK, ICON_SUN, getColorStripIcon, getValueSourceIcon, getHAEntityIcon } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { EntitySelect } from '../core/entity-palette.ts'; +import { IconSelect } from '../core/icon-select.ts'; import { wrapCard } from '../core/card-colors.ts'; import type { ModCardOpts, ModChipOpts, ModMetricOpts, ModBtnOpts, LedState } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; @@ -34,6 +35,7 @@ let _updateRateWidget: BindableScalarWidget | null = null; let _transitionWidget: BindableScalarWidget | null = null; let _colorToleranceWidget: BindableScalarWidget | null = null; let _minBrightnessThresholdWidget: BindableScalarWidget | null = null; +let _stopActionIconSelect: IconSelect | null = null; class HALightEditorModal extends Modal { constructor() { super('ha-light-editor-modal'); } @@ -47,6 +49,7 @@ class HALightEditorModal extends Modal { if (_transitionWidget) { _transitionWidget.destroy(); _transitionWidget = null; } if (_colorToleranceWidget) { _colorToleranceWidget.destroy(); _colorToleranceWidget = null; } if (_minBrightnessThresholdWidget) { _minBrightnessThresholdWidget.destroy(); _minBrightnessThresholdWidget = null; } + if (_stopActionIconSelect) { _stopActionIconSelect.destroy(); _stopActionIconSelect = null; } _destroyMappingEntitySelects(); } @@ -60,6 +63,7 @@ class HALightEditorModal extends Modal { transition: _transitionWidget ? JSON.stringify(_transitionWidget.getValue()) : '0.5', color_tolerance: _colorToleranceWidget ? JSON.stringify(_colorToleranceWidget.getValue()) : '5', min_brightness_threshold: _minBrightnessThresholdWidget ? JSON.stringify(_minBrightnessThresholdWidget.getValue()) : '0', + stop_action: (document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement).value, mappings: _getMappingsJSON(), tags: JSON.stringify(_haLightTagsInput ? _haLightTagsInput.getValue() : []), }; @@ -271,6 +275,22 @@ function _ensureColorToleranceWidget(): BindableScalarWidget { return _colorToleranceWidget; } +function _stopActionItems() { + return [ + { value: 'none', icon: _icon(P.circleOff), label: t('ha_light.stop_action.none'), desc: t('ha_light.stop_action.none.desc') }, + { value: 'turn_off', icon: _icon(P.power), label: t('ha_light.stop_action.turn_off'), desc: t('ha_light.stop_action.turn_off.desc') }, + { value: 'restore', icon: _icon(P.rotateCcw), label: t('ha_light.stop_action.restore'), desc: t('ha_light.stop_action.restore.desc') }, + ]; +} + +function _ensureStopActionIconSelect(): void { + const sel = document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement | null; + if (!sel) return; + const items = _stopActionItems(); + if (_stopActionIconSelect) { _stopActionIconSelect.updateItems(items); return; } + _stopActionIconSelect = new IconSelect({ target: sel, items, columns: 3 }); +} + function _ensureMinBrightnessThresholdWidget(): BindableScalarWidget { if (!_minBrightnessThresholdWidget) { _minBrightnessThresholdWidget = new BindableScalarWidget({ @@ -344,6 +364,9 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat _ensureTransitionWidget().setValue(editData.transition ?? 0.5); _ensureColorToleranceWidget().setValue(editData.color_tolerance ?? 5); _ensureMinBrightnessThresholdWidget().setValue(editData.min_brightness_threshold ?? 0); + (document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement).value = editData.stop_action ?? 'none'; + _ensureStopActionIconSelect(); + if (_stopActionIconSelect) _stopActionIconSelect.setValue(editData.stop_action ?? 'none', false); (document.getElementById('ha-light-editor-description') as HTMLInputElement).value = editData.description || ''; // Fetch entities from the selected HA source before loading mappings @@ -359,6 +382,9 @@ export async function showHALightEditor(targetId: string | null = null, cloneDat _ensureTransitionWidget().setValue(0.5); _ensureColorToleranceWidget().setValue(5); _ensureMinBrightnessThresholdWidget().setValue(0); + (document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement).value = 'none'; + _ensureStopActionIconSelect(); + if (_stopActionIconSelect) _stopActionIconSelect.setValue('none', false); (document.getElementById('ha-light-editor-description') as HTMLInputElement).value = ''; // Fetch entities from the first HA source @@ -418,6 +444,9 @@ export async function saveHALightEditor(): Promise { const transition = _transitionWidget ? _transitionWidget.getValue() : 0.5; const colorTolerance = _colorToleranceWidget ? _colorToleranceWidget.getValue() : 5; const minBrightnessThreshold = _minBrightnessThresholdWidget ? _minBrightnessThresholdWidget.getValue() : 0; + const stopActionRaw = (document.getElementById('ha-light-editor-stop-action') as HTMLSelectElement).value; + const stopAction: 'none' | 'turn_off' | 'restore' = + stopActionRaw === 'turn_off' || stopActionRaw === 'restore' ? stopActionRaw : 'none'; const description = (document.getElementById('ha-light-editor-description') as HTMLInputElement).value.trim() || null; if (!name) { @@ -444,6 +473,7 @@ export async function saveHALightEditor(): Promise { transition, color_tolerance: colorTolerance, min_brightness_threshold: minBrightnessThreshold, + stop_action: stopAction, description, tags: _haLightTagsInput ? _haLightTagsInput.getValue() : [], }; diff --git a/server/src/ledgrab/static/js/features/icon-picker.ts b/server/src/ledgrab/static/js/features/icon-picker.ts index 73c1f74..99cca7a 100644 --- a/server/src/ledgrab/static/js/features/icon-picker.ts +++ b/server/src/ledgrab/static/js/features/icon-picker.ts @@ -1,16 +1,22 @@ /** * 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. + * Generic over entity types (devices, LED targets, …). The picker is + * opened by document-level click delegation matching: + * + * data-icon-picker-trigger=":" + * + * Each registered entity type provides: cache lookup, PATCH endpoint, + * reload callback, and an optional ``inheritedFrom`` resolver — used by + * LED targets to fall back to the related device's icon when the target + * doesn't have its own. */ 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 { devicesCache, outputTargetsCache } from '../core/state.ts'; import { DEVICE_ICONS, CATEGORIES, @@ -25,12 +31,114 @@ import { const RECENT_KEY = 'ledgrab.icon-picker.recent'; const RECENT_MAX = 10; +// ──────────────────────────────────────────────────────────────── +// Entity-type registry +// ──────────────────────────────────────────────────────────────── + +type EntityType = 'device' | 'target'; + +interface EntityRecord { + id: string; + name: string; + icon: string; + icon_color: string; +} + +interface InheritedIcon { + /** The icon id we'd render if this entity has no own icon. */ + iconId: string; + /** Effective color for the inherited icon. */ + color: string; + /** Display name of the source (e.g. parent device name). */ + fromName: string; +} + +interface EntityTypeAdapter { + /** Look up the entity by id. Returns null when missing from the cache. */ + lookup(id: string): EntityRecord | null; + /** Build the PUT endpoint URL for icon updates. */ + endpoint(id: string): string; + /** Invalidate the cache and reload the relevant view. */ + reload(): Promise; + /** Optional fallback icon (e.g. LED target → parent device). */ + inheritedFrom(id: string): InheritedIcon | null; + /** Display label like "Device" / "LED target". */ + typeLabel(): string; +} + +function _readDevice(id: string): EntityRecord | null { + const dev = (devicesCache.data ?? []).find((d: any) => d.id === id); + if (!dev) return null; + return { + id: dev.id, + name: dev.name ?? dev.id, + icon: (dev.icon as string | undefined) ?? '', + icon_color: (dev.icon_color as string | undefined) ?? '', + }; +} + +function _readTarget(id: string): EntityRecord | null { + const tgt = (outputTargetsCache.data ?? []).find((t: any) => t.id === id); + if (!tgt) return null; + return { + id: tgt.id, + name: tgt.name ?? tgt.id, + icon: (tgt.icon as string | undefined) ?? '', + icon_color: (tgt.icon_color as string | undefined) ?? '', + }; +} + +const _adapters: Record = { + device: { + lookup: _readDevice, + endpoint: (id) => `/devices/${id}`, + reload: async () => { + devicesCache.invalidate(); + await window.loadDevices?.(); + }, + inheritedFrom: () => null, + typeLabel: () => t('device.icon.entity.device') || 'Device', + }, + target: { + lookup: _readTarget, + endpoint: (id) => `/output-targets/${id}`, + reload: async () => { + outputTargetsCache.invalidate(); + await window.loadTargetsTab?.(); + }, + inheritedFrom: (id) => { + const tgt = (outputTargetsCache.data ?? []).find((t: any) => t.id === id); + if (!tgt) return null; + // Only LED targets inherit from a device — HA-light targets don't. + const deviceId = (tgt as any).device_id; + if (!deviceId) return null; + const dev = (devicesCache.data ?? []).find((d: any) => d.id === deviceId); + const iconId = (dev?.icon as string | undefined) ?? ''; + if (!iconId) return null; + return { + iconId, + color: (dev?.icon_color as string | undefined) || '', + fromName: dev?.name ?? deviceId, + }; + }, + typeLabel: () => t('device.icon.entity.target') || 'LED target', + }, +}; + +// ──────────────────────────────────────────────────────────────── +// Picker state +// ──────────────────────────────────────────────────────────────── + interface PickerContext { - deviceId: string; + entityType: EntityType; + entityId: string; + entityName: string; initialIconId: string; initialColor: string; /** CSS color used for the live channel preview (e.g. '#4CAF50'). */ channelColor: string; + /** Optional inherited icon (LED target → device). */ + inherited: InheritedIcon | null; } let _ctx: PickerContext | null = null; @@ -71,20 +179,33 @@ function _pushRecent(iconId: string): void { // 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) ?? ''; +/** Open the picker for the given entity. Reads current icon from cache. */ +export function openIconPicker(entityType: EntityType, entityId: string): void { + if (!entityId) return; + const adapter = _adapters[entityType]; + if (!adapter) return; + + const rec = adapter.lookup(entityId); + const initialIconId = rec?.icon ?? ''; + const initialColor = rec?.icon_color ?? ''; + const inherited = adapter.inheritedFrom(entityId); // 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 cardAttr = entityType === 'device' ? 'data-device-id' : 'data-target-id'; + const card = document.querySelector(`[${cardAttr}="${CSS.escape(entityId)}"]`) as HTMLElement | null; const channelColor = card ? (getComputedStyle(card).getPropertyValue('--ch') || '').trim() || _fallbackChannel() : _fallbackChannel(); - _ctx = { deviceId, initialIconId, initialColor, channelColor }; + _ctx = { + entityType, + entityId, + entityName: rec?.name ?? entityId, + initialIconId, + initialColor, + channelColor, + inherited, + }; _selectedIconId = initialIconId; _selectedColor = initialColor; _activeCategory = 'all'; @@ -96,13 +217,17 @@ export function openDeviceIconPicker(deviceId: string): void { _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(); }); } +/** Back-compat shim — early callers still import this name. */ +export function openDeviceIconPicker(deviceId: string): void { + openIconPicker('device', deviceId); +} + /** Close the picker without applying changes. */ export function closeIconPicker(): void { _modalInstance?.close(); @@ -118,11 +243,29 @@ function _fallbackChannel(): string { return (root.getPropertyValue('--ch-signal') || '#4CAF50').trim(); } +/** What the preview displays right now (resolves inheritance). */ +function _resolveDisplayIcon(): { iconId: string; color: string; isInherited: boolean } { + if (!_ctx) return { iconId: '', color: '', isInherited: false }; + if (_selectedIconId) { + return { iconId: _selectedIconId, color: _selectedColor || _ctx.channelColor, isInherited: false }; + } + if (_ctx.inherited) { + return { + iconId: _ctx.inherited.iconId, + color: _ctx.inherited.color || _ctx.channelColor, + isInherited: true, + }; + } + return { iconId: '', color: _ctx.channelColor, isInherited: false }; +} + 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 eyebrowEl = document.getElementById('icon-picker-eyebrow') as HTMLElement | null; + const subEl = document.getElementById('icon-picker-sub') 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; @@ -132,20 +275,31 @@ function _renderModal(): void { if (!previewEl || !tabsEl || !gridEl) return; - // Resolve effective color for the preview (override > channel) - const effectiveColor = _selectedColor || _ctx.channelColor; + const display = _resolveDisplayIcon(); + const effectiveColor = display.color; 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'); + previewEl.classList.toggle('is-inherited', display.isInherited && !!display.iconId); + previewEl.classList.toggle('is-empty', !display.iconId); + previewEl.innerHTML = display.iconId + ? renderDeviceIconSvg(display.iconId, { size: 30 }) + : ``; - // Device name in the header - if (titleNameEl) { - const device = (devicesCache.data ?? []).find((d: any) => d.id === _ctx!.deviceId); - titleNameEl.textContent = device?.name ?? _ctx.deviceId; + // Header — entity type + name, plus inherited hint when applicable. + const adapter = _adapters[_ctx.entityType]; + if (eyebrowEl) { + eyebrowEl.textContent = adapter.typeLabel(); + } + if (titleNameEl) titleNameEl.textContent = _ctx.entityName; + if (subEl) { + if (display.isInherited) { + const tmpl = t('device.icon.inherited_from') || 'Inherited from %s'; + subEl.textContent = tmpl.replace('%s', _ctx.inherited!.fromName); + subEl.classList.add('is-inherited'); + } else { + subEl.textContent = ''; + subEl.classList.remove('is-inherited'); + } } // Swatch reflects current effective color @@ -154,15 +308,12 @@ function _renderModal(): void { 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) { @@ -176,12 +327,20 @@ function _renderModal(): void { } } - // Grid (filtered + grouped or flat depending on query/category) gridEl.innerHTML = _renderGridHtml(); - // Remove button — disabled when there's no current icon + // Remove button — meaning depends on whether the entity has an own icon + // and whether an inherited fallback exists. if (removeBtn) { - removeBtn.disabled = !_selectedIconId && !_ctx.initialIconId; + const hasOwn = !!_selectedIconId || !!_ctx.initialIconId; + removeBtn.disabled = !hasOwn; + // When clearing would reveal an inherited icon, label the button + // "Use inherited" instead of "Remove icon" so the user knows what + // happens next. + const willInherit = _ctx.inherited != null; + const labelKey = willInherit ? 'device.icon.use_inherited' : 'device.icon.remove'; + const fallback = willInherit ? 'Use inherited' : 'Remove icon'; + removeBtn.textContent = t(labelKey) !== labelKey ? t(labelKey) : fallback; } } @@ -209,7 +368,6 @@ function _renderGridHtml(): string { 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('')}
`; } @@ -234,34 +392,38 @@ function _iconTileHtml(def: DeviceIconDef | null): string { } // ──────────────────────────────────────────────────────────────── -// Apply / events +// Apply / remove // ──────────────────────────────────────────────────────────────── -async function _applyToDevice(): Promise { +async function _applyChange(nextIconId: string, nextColor: string): Promise { if (!_ctx) return; - const { deviceId, initialIconId, initialColor } = _ctx; - if (_selectedIconId === initialIconId && _selectedColor === initialColor) { + const { entityType, entityId, initialIconId, initialColor } = _ctx; + if (nextIconId === initialIconId && nextColor === initialColor) { closeIconPicker(); return; } + const adapter = _adapters[entityType]; try { - const resp = await fetchWithAuth(`/devices/${deviceId}`, { + const body: Record = { icon: nextIconId, icon_color: nextColor }; + // The output-targets endpoint requires the discriminator field. + if (entityType === 'target') { + const tgt = (outputTargetsCache.data ?? []).find((x: any) => x.id === entityId); + const targetType = (tgt as any)?.target_type ?? 'led'; + body.target_type = targetType; + } + const resp = await fetchWithAuth(adapter.endpoint(entityId), { method: 'PUT', - body: JSON.stringify({ - icon: _selectedIconId, - icon_color: _selectedColor, - }), + body: JSON.stringify(body), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); - showToast((err && err.detail) || t('device.icon.error.save_failed'), 'error'); + showToast((err && (err as any).detail) || t('device.icon.error.save_failed'), 'error'); return; } - if (_selectedIconId) _pushRecent(_selectedIconId); + if (nextIconId) _pushRecent(nextIconId); showToast(t('device.icon.saved') || 'Icon saved', 'success'); - devicesCache.invalidate(); - await window.loadDevices?.(); + await adapter.reload(); closeIconPicker(); } catch (error: any) { if (error?.isAuth) return; @@ -269,11 +431,14 @@ async function _applyToDevice(): Promise { } } -async function _removeIcon(): Promise { - if (!_ctx) return; - _selectedIconId = ''; - _selectedColor = ''; - await _applyToDevice(); +async function _applyCurrentSelection(): Promise { + await _applyChange(_selectedIconId, _selectedColor); +} + +async function _removeOwnIcon(): Promise { + // Clear the entity's own icon — for targets with an inherited fallback, + // the card will then render the device's icon. + await _applyChange('', ''); } function _selectIcon(iconId: string): void { @@ -288,30 +453,22 @@ function _setCategory(cat: IconCategory | 'all'): void { 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 +// Modal-internal event wiring // ──────────────────────────────────────────────────────────────── let _wired = false; @@ -343,7 +500,7 @@ function _wireEvents(): void { return; } if (target.closest('#icon-picker-apply')) { - _applyToDevice(); + _applyCurrentSelection(); return; } if (target.closest('#icon-picker-cancel') || target.closest('.icon-picker-close')) { @@ -351,7 +508,7 @@ function _wireEvents(): void { return; } if (target.closest('#icon-picker-remove')) { - _removeIcon(); + _removeOwnIcon(); return; } }); @@ -364,12 +521,11 @@ function _wireEvents(): void { root.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.target as HTMLElement).tagName !== 'BUTTON') { e.preventDefault(); - _applyToDevice(); + _applyCurrentSelection(); } }); } -// 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 { @@ -377,19 +533,22 @@ if (document.readyState === 'loading') { } // ──────────────────────────────────────────────────────────────── -// 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. +// Document-level click delegation. Triggers match +// ``[data-icon-picker-trigger=":"]``. Legacy +// triggers without a colon default to ``device:`` so older cards +// keep working during the rollout. // ──────────────────────────────────────────────────────────────── 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; + const raw = el.getAttribute('data-icon-picker-trigger') || ''; + if (!raw) return; + const [typeOrId, id] = raw.includes(':') ? raw.split(':', 2) : ['device', raw]; + if (!id) return; + if (typeOrId !== 'device' && typeOrId !== 'target') return; e.stopPropagation(); - openDeviceIconPicker(deviceId); + openIconPicker(typeOrId as EntityType, id); } document.addEventListener('click', _onDocumentClick); diff --git a/server/src/ledgrab/static/js/features/targets.ts b/server/src/ledgrab/static/js/features/targets.ts index 30ce20d..693f57d 100644 --- a/server/src/ledgrab/static/js/features/targets.ts +++ b/server/src/ledgrab/static/js/features/targets.ts @@ -30,8 +30,9 @@ import { EntitySelect } from '../core/entity-palette.ts'; import { IconSelect } from '../core/icon-select.ts'; import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; -import type { ModCardOpts, ModChipOpts, ModMetricOpts, ModBtnOpts, LedState } from '../core/mod-card.ts'; +import type { ModCardOpts, ModChipOpts, ModMetricOpts, ModBtnOpts, ModMenuItemOpts, LedState } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; +import { renderDeviceIconSvg } from '../core/device-icons.ts'; import { createFpsSparkline } from '../core/chart-utils.ts'; import { CardSection } from '../core/card-sections.ts'; import { TreeNav } from '../core/tree-nav.ts'; @@ -1015,20 +1016,34 @@ export function createTargetCard(target: LedOutputTarget & { state?: any; metric // instrument routed through the device. ── const badgeText = 'LED · TGT'; - // ── Meta line: device link · protocol · target FPS · pixel count. - // Protocol carries device-type-specific richness (OpenRGB SDK, - // Adalight serial, etc.) — _protocolBadge() returns icon + label. ── - const deviceLink = target.device_id - ? `${escapeHtml(deviceName)}` - : escapeHtml(deviceName); + // ── Meta line: protocol · target FPS · pixel count. + // The device link used to live here too; it now appears as a + // content chip below (mirrors how the color-strip-source link is + // rendered, and gives space for the device's custom icon). ── const targetFps = bindableValue(target.fps, 30); - const metaParts: string[] = [deviceLink, _protocolBadge(device, target), `${targetFps} fps`]; + const metaParts: string[] = [_protocolBadge(device, target), `${targetFps} fps`]; const ledCount = device?.state?.device_led_count || device?.led_count; if (ledCount) metaParts.push(`${ledCount} px`); const metaHtml = metaParts.join(' · '); - // ── Chips: CSS source link, brightness override, threshold ── + // ── Chips: device link, CSS source link, brightness override, threshold ── const chips: ModChipOpts[] = []; + // Device chip — leads the row so the user reads "this target → + // {device} → {color-strip source}". When the device has a custom + // icon it wins over the generic device-type fallback. + if (target.device_id) { + const deviceIconHtml = device?.icon + ? renderDeviceIconSvg(device.icon, { size: 11, strokeWidth: 1.7 }) + : getDeviceTypeIcon(device?.device_type || 'wled'); + chips.push({ + icon: deviceIconHtml, + text: deviceName, + title: t('targets.device'), + onclick: device + ? `event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')` + : undefined, + }); + } if (cssSource) { chips.push({ icon: ICON_FILM, @@ -1163,6 +1178,29 @@ export function createTargetCard(target: LedOutputTarget & { state?: any; metric title: t('common.edit'), }); + // ── Custom icon plate (target-own > inherited from device) ── + const targetIconId = (target as LedOutputTarget).icon || ''; + const targetIconColor = (target as LedOutputTarget).icon_color || ''; + const inheritedIconId = !targetIconId ? (device?.icon as string | undefined) || '' : ''; + const effectiveIconId = targetIconId || inheritedIconId; + const effectiveIconColor = targetIconColor || (inheritedIconId ? (device?.icon_color || '') : ''); + const iconHtml = effectiveIconId ? renderDeviceIconSvg(effectiveIconId, { size: 24 }) : ''; + const iconTitle = targetIconId + ? (t('device.icon.change') || 'Change icon…') + : inheritedIconId + ? (t('device.icon.override_inherited') || 'Override inherited icon…') + : (t('device.icon.choose') || 'Choose icon…'); + + const targetMenuExtraItems: ModMenuItemOpts[] = [ + { + label: targetIconId + ? (t('device.icon.change') || 'Change icon…') + : (t('device.icon.choose') || 'Choose icon…'), + icon: ICON_EDIT, + dataAttrs: { 'data-icon-picker-trigger': `target:${target.id}` }, + }, + ]; + const mod: ModCardOpts = { head: { badge: { text: badgeText }, @@ -1170,7 +1208,12 @@ export function createTargetCard(target: LedOutputTarget & { state?: any; metric metaHtml, healthDot, leds, + iconHtml, + iconColor: effectiveIconColor, + iconAttrs: { 'data-icon-picker-trigger': `target:${target.id}` }, + iconTitle, menu: { + extraItems: targetMenuExtraItems, duplicateOnclick: `cloneTarget('${target.id}')`, hideOnclick: `toggleCardHidden('led-targets','${target.id}')`, deleteOnclick: `deleteTarget('${target.id}')`, diff --git a/server/src/ledgrab/static/js/types.ts b/server/src/ledgrab/static/js/types.ts index 8f0cc11..03aa307 100644 --- a/server/src/ledgrab/static/js/types.ts +++ b/server/src/ledgrab/static/js/types.ts @@ -105,6 +105,13 @@ interface OutputTargetBase { target_type: TargetType; description?: string; tags: string[]; + /** Optional id from the curated icon library. Empty/missing → + * for LED targets, the card inherits the device's icon; for + * HA-light targets, no plate is rendered. */ + icon?: string; + /** Optional CSS color override for the icon. Empty/missing → + * inherits the device color (LED targets) or --ch (others). */ + 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 e0c9107..16920f4 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -582,6 +582,11 @@ "device.icon.cat.media": "Media", "device.icon.cat.signal": "Signal", "device.icon.cat.ambience": "Ambience", + "device.icon.entity.device": "Device", + "device.icon.entity.target": "LED target", + "device.icon.inherited_from": "Inherited from %s", + "device.icon.override_inherited": "Override inherited icon…", + "device.icon.use_inherited": "Use inherited", "validation.required": "This field is required", "bulk.processing": "Processing…", "api.error.timeout": "Request timed out — please try again", @@ -2198,6 +2203,14 @@ "ha_light.updated": "HA light target updated", "ha_light.mapping.select_entity": "Select a light entity...", "ha_light.mapping.search_entity": "Search light entities...", + "ha_light.stop_action": "On Stop:", + "ha_light.stop_action.hint": "What to do with the mapped lights when this target stops streaming.", + "ha_light.stop_action.none": "None", + "ha_light.stop_action.none.desc": "Leave lights as-is", + "ha_light.stop_action.turn_off": "Turn Off", + "ha_light.stop_action.turn_off.desc": "Switch all mapped lights off", + "ha_light.stop_action.restore": "Restore", + "ha_light.stop_action.restore.desc": "Revert to state captured at start", "section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.", "automations.rule.home_assistant": "Home Assistant", "automations.rule.home_assistant.desc": "HA entity state", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index eb76489..5e4128a 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -6,6 +6,14 @@ "ha_light.color_tolerance.hint": "Пропускать обновление цвета, если разница RGB ниже этого порога. Снижает нагрузку на HA для статичных сцен.", "ha_light.min_brightness_threshold": "Мин. порог яркости:", "ha_light.min_brightness_threshold.hint": "Эффективная яркость ниже этого значения выключает свет полностью (0 = отключено).", + "ha_light.stop_action": "При остановке:", + "ha_light.stop_action.hint": "Что делать с привязанными лампами, когда цель прекращает стриминг.", + "ha_light.stop_action.none": "Ничего", + "ha_light.stop_action.none.desc": "Оставить лампы как есть", + "ha_light.stop_action.turn_off": "Выключить", + "ha_light.stop_action.turn_off.desc": "Выключить все привязанные лампы", + "ha_light.stop_action.restore": "Восстановить", + "ha_light.stop_action.restore.desc": "Вернуть состояние на момент запуска", "app.version": "Версия:", "app.api_docs": "Документация API", "app.connection_lost": "Сервер недоступен", @@ -586,6 +594,11 @@ "device.icon.cat.media": "Медиа", "device.icon.cat.signal": "Сигнал", "device.icon.cat.ambience": "Атмосфера", + "device.icon.entity.device": "Устройство", + "device.icon.entity.target": "LED-цель", + "device.icon.inherited_from": "Унаследовано от %s", + "device.icon.override_inherited": "Заменить унаследованную иконку…", + "device.icon.use_inherited": "Использовать унаследованную", "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 ad808cb..479a644 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -6,6 +6,14 @@ "ha_light.color_tolerance.hint": "当RGB差异低于此阈值时跳过颜色更新,减少HA流量。", "ha_light.min_brightness_threshold": "最低亮度阈值:", "ha_light.min_brightness_threshold.hint": "有效输出亮度低于此值时完全关灯(0=禁用)。", + "ha_light.stop_action": "停止时:", + "ha_light.stop_action.hint": "此目标停止流式传输时对映射的灯执行的操作。", + "ha_light.stop_action.none": "无", + "ha_light.stop_action.none.desc": "保持灯的当前状态", + "ha_light.stop_action.turn_off": "关闭", + "ha_light.stop_action.turn_off.desc": "关闭所有映射的灯", + "ha_light.stop_action.restore": "恢复", + "ha_light.stop_action.restore.desc": "恢复到启动时捕获的状态", "app.version": "版本:", "app.api_docs": "API 文档", "app.connection_lost": "服务器不可达", @@ -586,6 +594,11 @@ "device.icon.cat.media": "媒体", "device.icon.cat.signal": "信号", "device.icon.cat.ambience": "氛围", + "device.icon.entity.device": "设备", + "device.icon.entity.target": "LED 目标", + "device.icon.inherited_from": "继承自 %s", + "device.icon.override_inherited": "覆盖继承的图标…", + "device.icon.use_inherited": "使用继承的", "validation.required": "此字段为必填项", "bulk.processing": "处理中…", "api.error.timeout": "请求超时 — 请重试", diff --git a/server/src/ledgrab/storage/ha_light_output_target.py b/server/src/ledgrab/storage/ha_light_output_target.py index 8f25c8f..8ac162d 100644 --- a/server/src/ledgrab/storage/ha_light_output_target.py +++ b/server/src/ledgrab/storage/ha_light_output_target.py @@ -36,6 +36,9 @@ class HALightMapping: ) +VALID_STOP_ACTIONS = ("none", "turn_off", "restore") + + @dataclass class HALightOutputTarget(OutputTarget): """Output target that casts LED colors to Home Assistant lights via service calls.""" @@ -48,6 +51,7 @@ class HALightOutputTarget(OutputTarget): transition: BindableFloat = field(default_factory=lambda: BindableFloat(0.5)) min_brightness_threshold: BindableFloat = field(default_factory=lambda: BindableFloat(0.0)) color_tolerance: BindableFloat = field(default_factory=lambda: BindableFloat(5.0)) + stop_action: str = "none" # one of VALID_STOP_ACTIONS def register_with_manager(self, manager) -> None: """Register this HA light target with the processor manager.""" @@ -62,6 +66,7 @@ class HALightOutputTarget(OutputTarget): transition=self.transition, min_brightness_threshold=self.min_brightness_threshold, color_tolerance=self.color_tolerance, + stop_action=self.stop_action, ) def sync_with_manager( @@ -85,6 +90,7 @@ class HALightOutputTarget(OutputTarget): "min_brightness_threshold": self.min_brightness_threshold, "color_tolerance": self.color_tolerance, "light_mappings": self.light_mappings, + "stop_action": self.stop_action, }, ) if css_changed: @@ -104,12 +110,21 @@ class HALightOutputTarget(OutputTarget): transition=None, min_brightness_threshold=None, color_tolerance=None, + stop_action=None, description=None, tags: Optional[List[str]] = None, + icon: Optional[str] = None, + icon_color: Optional[str] = None, **_kwargs, ) -> None: """Apply mutable field updates.""" - super().update_fields(name=name, description=description, tags=tags) + super().update_fields( + name=name, + description=description, + tags=tags, + icon=icon, + icon_color=icon_color, + ) if ha_source_id is not None: self.ha_source_id = resolve_ref(ha_source_id, self.ha_source_id) if color_strip_source_id is not None: @@ -135,6 +150,8 @@ class HALightOutputTarget(OutputTarget): ) if color_tolerance is not None: self.color_tolerance = self.color_tolerance.apply_update(color_tolerance) + if stop_action is not None and stop_action in VALID_STOP_ACTIONS: + self.stop_action = stop_action def to_dict(self) -> dict: d = super().to_dict() @@ -146,6 +163,7 @@ class HALightOutputTarget(OutputTarget): d["transition"] = self.transition.to_dict() d["min_brightness_threshold"] = self.min_brightness_threshold.to_dict() d["color_tolerance"] = self.color_tolerance.to_dict() + d["stop_action"] = self.stop_action return d @classmethod @@ -171,8 +189,13 @@ class HALightOutputTarget(OutputTarget): data.get("min_brightness_threshold"), default=0.0 ), color_tolerance=BindableFloat.from_raw(data.get("color_tolerance"), default=5.0), + stop_action=( + data["stop_action"] if data.get("stop_action") in VALID_STOP_ACTIONS else "none" + ), description=data.get("description"), tags=data.get("tags", []), + icon=data.get("icon", ""), + icon_color=data.get("icon_color", ""), created_at=datetime.fromisoformat( data.get("created_at", datetime.now(timezone.utc).isoformat()) ), diff --git a/server/src/ledgrab/storage/output_target.py b/server/src/ledgrab/storage/output_target.py index ea7d130..100de8c 100644 --- a/server/src/ledgrab/storage/output_target.py +++ b/server/src/ledgrab/storage/output_target.py @@ -16,6 +16,11 @@ class OutputTarget: updated_at: datetime description: Optional[str] = None tags: List[str] = field(default_factory=list) + # Custom card icon (frontend display only). When empty, the LED target + # card inherits the icon from its referenced device; HA-light targets + # have no inheritance source and just show the empty plate placeholder. + icon: str = "" + icon_color: str = "" def register_with_manager(self, manager) -> None: """Register this target with the processor manager. Subclasses override.""" @@ -34,6 +39,8 @@ class OutputTarget: device_id=None, description=None, tags: Optional[List[str]] = None, + icon: Optional[str] = None, + icon_color: Optional[str] = None, **_kwargs, ) -> None: """Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones.""" @@ -43,10 +50,14 @@ class OutputTarget: self.description = description if tags is not None: self.tags = tags + if icon is not None: + self.icon = icon + if icon_color is not None: + self.icon_color = icon_color def to_dict(self) -> dict: """Convert to dictionary.""" - return { + d = { "id": self.id, "name": self.name, "target_type": self.target_type, @@ -55,6 +66,11 @@ class OutputTarget: "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } + if self.icon: + d["icon"] = self.icon + if self.icon_color: + d["icon_color"] = self.icon_color + return d @classmethod def from_dict(cls, data: dict) -> "OutputTarget": diff --git a/server/src/ledgrab/storage/output_target_store.py b/server/src/ledgrab/storage/output_target_store.py index f8ffd31..9d08c36 100644 --- a/server/src/ledgrab/storage/output_target_store.py +++ b/server/src/ledgrab/storage/output_target_store.py @@ -54,6 +54,7 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): update_rate: float = 2.0, transition=None, color_tolerance: int = 5, + stop_action: str = "none", # legacy compat brightness_value_source_id: str = "", ) -> OutputTarget: @@ -126,6 +127,9 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): min_brightness_threshold, default=0.0 ), color_tolerance=BindableFloat.from_raw(color_tolerance, default=5.0), + stop_action=( + stop_action if stop_action in ("none", "turn_off", "restore") else "none" + ), description=description, created_at=now, updated_at=now, @@ -155,11 +159,14 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): protocol=None, description=None, tags=None, + icon=None, + icon_color=None, ha_source_id=None, ha_light_mappings=None, update_rate=None, transition=None, color_tolerance=None, + stop_action=None, # legacy compat brightness_value_source_id=None, ) -> OutputTarget: @@ -193,11 +200,14 @@ class OutputTargetStore(BaseSqliteStore[OutputTarget]): protocol=protocol, description=description, tags=tags, + icon=icon, + icon_color=icon_color, ha_source_id=ha_source_id, light_mappings=ha_light_mappings, update_rate=update_rate, transition=transition, color_tolerance=color_tolerance, + stop_action=stop_action, ) target.updated_at = datetime.now(timezone.utc) diff --git a/server/src/ledgrab/storage/wled_output_target.py b/server/src/ledgrab/storage/wled_output_target.py index 2266e8f..727f078 100644 --- a/server/src/ledgrab/storage/wled_output_target.py +++ b/server/src/ledgrab/storage/wled_output_target.py @@ -83,10 +83,18 @@ class WledOutputTarget(OutputTarget): protocol=None, description=None, tags: Optional[List[str]] = None, + icon: Optional[str] = None, + icon_color: Optional[str] = None, **_kwargs, ) -> None: """Apply mutable field updates for WLED targets.""" - super().update_fields(name=name, description=description, tags=tags) + super().update_fields( + name=name, + description=description, + tags=tags, + icon=icon, + icon_color=icon_color, + ) if device_id is not None: self.device_id = resolve_ref(device_id, self.device_id) if color_strip_source_id is not None: @@ -159,6 +167,8 @@ class WledOutputTarget(OutputTarget): protocol=data.get("protocol", "ddp"), description=data.get("description"), tags=data.get("tags", []), + icon=data.get("icon", ""), + icon_color=data.get("icon_color", ""), created_at=datetime.fromisoformat( data.get("created_at", datetime.now(timezone.utc).isoformat()) ), diff --git a/server/src/ledgrab/templates/modals/ha-light-editor.html b/server/src/ledgrab/templates/modals/ha-light-editor.html index edcac67..9463b6d 100644 --- a/server/src/ledgrab/templates/modals/ha-light-editor.html +++ b/server/src/ledgrab/templates/modals/ha-light-editor.html @@ -114,6 +114,15 @@
+ +
+
+ + +
+ + +
diff --git a/server/src/ledgrab/templates/modals/icon-picker.html b/server/src/ledgrab/templates/modals/icon-picker.html index a000bc8..15b7ef0 100644 --- a/server/src/ledgrab/templates/modals/icon-picker.html +++ b/server/src/ledgrab/templates/modals/icon-picker.html @@ -10,12 +10,11 @@
-
Card icon
-

Choose an icon

-
- for +
Card icon
+

-

+ +