diff --git a/TODO.md b/TODO.md index c352057..2e43a88 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,95 @@ # LedGrab TODO +## Custom card icons — extend to all card types + +Migrate the existing icon-plate work (devices, LED targets, HA-light targets) +to all remaining card types. ~17 entity types. Branch: `feat/icons-everywhere`. + +### Foundation + +- [x] Refactor `icon-picker.ts` — replace hardcoded 2-entry `_adapters` + record with a `Map` and expose + `registerIconEntityType()` for feature modules to register their + own. Added `makeSimpleIconAdapter()` helper that reduces a + registration to ~6 lines. +- [x] Generalised `bodyExtras` for discriminated routes (output-targets + `target_type` etc.) — now keyed off id, adapter does its own + lookup. +- [x] `_onDocumentClick` accepts any registered type instead of + hardcoded device/target check. +- [x] Locale entity-type labels added to en/ru/zh for 18 new types + (picture_source, audio_source, weather_source, value_source, + mqtt_source, ha_source, automation, scene_preset, sync_clock, + game_integration, audio_processing_template, pattern_template, + capture_template, pp_template, cspt, audio_template, gradient, + color_strip_source, asset). + +### Backend (storage + schemas + routes per entity) + +Recipe: add `icon: str = ""` + `icon_color: str = ""` to dataclass, +emit-when-truthy in `to_dict`, default `""` in `from_dict`; add 3 +`Optional[str]` Field defs to Create/Response/Update schemas; thread +`getattr(entity, "icon", "") or ""` into the response builder. +SQLite JSON-blob storage means **no migration required**. + +- [x] Integrations (6): weather_sources, value_sources, mqtt_source, + home_assistant_source, sync_clocks, game_integration +- [x] Streams (10): picture_source, audio_source, audio_template, + audio_processing_template, pattern_template, postprocessing_template, + color_strip_processing_template, color_strip_source, gradient, + capture_template (`storage/template.py` — was missed by initial pass) +- [x] Other (3): automation, scene_preset, asset + +### Frontend (per feature module) + +For each card render call: + +- Use the new `core/card-icon.ts` helper: + `...makeCardIconFields('', entity.id, entity)` spread into the + mod-card head — computes `iconHtml`/`iconColor`/`iconAttrs` in one go. +- Register the entity type in the feature module via + `registerIconEntityType('', makeSimpleIconAdapter({ … }))`. + +Modules wired: + +- [x] streams.ts (7 cards: picture, capture, pp, cspt, audio source, + audio template, gradient — built-in gradients skip the plate) +- [x] automations.ts +- [x] scene-presets.ts +- [x] sync-clocks.ts +- [x] weather-sources.ts +- [x] value-sources.ts (bodyExtras propagates `source_type`) +- [x] mqtt-sources.ts +- [x] home-assistant-sources.ts +- [x] game-integration.ts +- [x] audio-processing-templates.ts +- [x] assets.ts +- [x] color-strips/cards.ts (bodyExtras propagates `source_type`) +- [WONTDO] pattern-templates.ts — uses legacy `wrapCard({content, actions})` + string API, not the mod-card system. Migration would be a separate + effort and the cards are tiny (name + rect count) so the value is low. + +### Discriminated routes + +Adapters provide `bodyExtras` to inject the discriminator field on PUT +so the Pydantic discriminated-union route validators don't reject the +icon-only update: + +- output-targets → `target_type` (already wired before) +- color-strip-sources → `source_type` +- audio-sources → `source_type` +- value-sources → `source_type` +- picture-sources → `stream_type` + +### Verification + +- [x] `cd server && ruff check src/ tests/` clean +- [x] `cd server && npx tsc --noEmit` clean +- [x] `cd server && npm run build` produces 2.6 MB bundle +- [x] `cd server && py -3.13 -m pytest tests/ --no-cov -q` — 949 passed +- [ ] Manual: open picker on each card type, confirm save persists, + confirm channel-color preview matches the live card + ## Device Event Notifications Notify the user when LED devices come online/go offline (configured targets), and when new diff --git a/server/src/ledgrab/api/routes/assets.py b/server/src/ledgrab/api/routes/assets.py index 77a5bc3..8cf6fed 100644 --- a/server/src/ledgrab/api/routes/assets.py +++ b/server/src/ledgrab/api/routes/assets.py @@ -142,6 +142,8 @@ async def update_asset( name=body.name, description=body.description, tags=body.tags, + icon=body.icon, + icon_color=body.icon_color, ) except EntityNotFoundError: raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}") diff --git a/server/src/ledgrab/api/routes/audio_processing_templates.py b/server/src/ledgrab/api/routes/audio_processing_templates.py index 0427532..522c898 100644 --- a/server/src/ledgrab/api/routes/audio_processing_templates.py +++ b/server/src/ledgrab/api/routes/audio_processing_templates.py @@ -36,6 +36,8 @@ def _apt_to_response(t) -> AudioProcessingTemplateResponse: updated_at=t.updated_at, description=t.description, tags=t.tags, + icon=getattr(t, "icon", "") or "", + icon_color=getattr(t, "icon_color", "") or "", ) @@ -73,6 +75,8 @@ async def create_audio_processing_template( filters=filters, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("audio_processing_template", "created", template.id) return _apt_to_response(template) @@ -129,6 +133,8 @@ async def update_audio_processing_template( filters=filters, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("audio_processing_template", "updated", template_id) # Hot-update: rebuild filter pipelines for running streams using this template diff --git a/server/src/ledgrab/api/routes/audio_sources.py b/server/src/ledgrab/api/routes/audio_sources.py index bae1d5b..5dd5436 100644 --- a/server/src/ledgrab/api/routes/audio_sources.py +++ b/server/src/ledgrab/api/routes/audio_sources.py @@ -46,6 +46,8 @@ _RESPONSE_MAP = { tags=s.tags, created_at=s.created_at, updated_at=s.updated_at, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", device_index=s.device_index, is_loopback=s.is_loopback, audio_template_id=s.audio_template_id, @@ -57,6 +59,8 @@ _RESPONSE_MAP = { tags=s.tags, created_at=s.created_at, updated_at=s.updated_at, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", audio_source_id=s.audio_source_id, audio_processing_template_id=s.audio_processing_template_id, ), @@ -75,6 +79,8 @@ def _to_response(source: AudioSource) -> AudioSourceResponse: tags=source.tags, created_at=source.created_at, updated_at=source.updated_at, + icon=getattr(source, "icon", "") or "", + icon_color=getattr(source, "icon_color", "") or "", device_index=getattr(source, "device_index", -1), is_loopback=getattr(source, "is_loopback", True), audio_template_id=getattr(source, "audio_template_id", None), diff --git a/server/src/ledgrab/api/routes/audio_templates.py b/server/src/ledgrab/api/routes/audio_templates.py index 2327838..ffb6273 100644 --- a/server/src/ledgrab/api/routes/audio_templates.py +++ b/server/src/ledgrab/api/routes/audio_templates.py @@ -53,6 +53,8 @@ async def list_audio_templates( created_at=t.created_at, updated_at=t.updated_at, description=t.description, + icon=getattr(t, "icon", "") or "", + icon_color=getattr(t, "icon_color", "") or "", ) for t in templates ] @@ -81,6 +83,8 @@ async def create_audio_template( engine_config=data.engine_config, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("audio_template", "created", template.id) return AudioTemplateResponse( @@ -92,6 +96,8 @@ async def create_audio_template( created_at=template.created_at, updated_at=template.updated_at, description=template.description, + icon=getattr(template, "icon", "") or "", + icon_color=getattr(template, "icon_color", "") or "", ) except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -127,6 +133,8 @@ async def get_audio_template( created_at=t.created_at, updated_at=t.updated_at, description=t.description, + icon=getattr(t, "icon", "") or "", + icon_color=getattr(t, "icon_color", "") or "", ) @@ -150,6 +158,8 @@ async def update_audio_template( engine_config=data.engine_config, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("audio_template", "updated", template_id) return AudioTemplateResponse( @@ -161,6 +171,8 @@ async def update_audio_template( created_at=t.created_at, updated_at=t.updated_at, description=t.description, + icon=getattr(t, "icon", "") or "", + icon_color=getattr(t, "icon_color", "") or "", ) except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) diff --git a/server/src/ledgrab/api/routes/automations.py b/server/src/ledgrab/api/routes/automations.py index 1dfd275..7c3e3fb 100644 --- a/server/src/ledgrab/api/routes/automations.py +++ b/server/src/ledgrab/api/routes/automations.py @@ -122,6 +122,8 @@ def _automation_to_response( last_activated_at=state.get("last_activated_at"), last_deactivated_at=state.get("last_deactivated_at"), tags=automation.tags, + icon=getattr(automation, "icon", "") or "", + icon_color=getattr(automation, "icon_color", "") or "", created_at=automation.created_at, updated_at=automation.updated_at, ) @@ -191,6 +193,8 @@ async def create_automation( deactivation_mode=data.deactivation_mode, deactivation_scene_preset_id=data.deactivation_scene_preset_id, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) if automation.enabled: @@ -285,6 +289,8 @@ async def update_automation( rules=rules, deactivation_mode=data.deactivation_mode, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) if data.scene_preset_id is not None: update_kwargs["scene_preset_id"] = data.scene_preset_id diff --git a/server/src/ledgrab/api/routes/color_strip_processing.py b/server/src/ledgrab/api/routes/color_strip_processing.py index 33910ee..17f529f 100644 --- a/server/src/ledgrab/api/routes/color_strip_processing.py +++ b/server/src/ledgrab/api/routes/color_strip_processing.py @@ -43,6 +43,8 @@ def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse: updated_at=t.updated_at, description=t.description, tags=t.tags, + icon=getattr(t, "icon", "") or "", + icon_color=getattr(t, "icon_color", "") or "", ) @@ -84,6 +86,8 @@ async def create_cspt( filters=filters, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("cspt", "created", template.id) return _cspt_to_response(template) @@ -141,6 +145,8 @@ async def update_cspt( filters=filters, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("cspt", "updated", template_id) return _cspt_to_response(template) diff --git a/server/src/ledgrab/api/routes/color_strip_sources/_helpers.py b/server/src/ledgrab/api/routes/color_strip_sources/_helpers.py index 757dac4..4a18d2f 100644 --- a/server/src/ledgrab/api/routes/color_strip_sources/_helpers.py +++ b/server/src/ledgrab/api/routes/color_strip_sources/_helpers.py @@ -65,6 +65,8 @@ def _common_response_kwargs(source, overlay_active: bool = False) -> dict: tags=source.tags, created_at=source.created_at, updated_at=source.updated_at, + icon=getattr(source, "icon", "") or "", + icon_color=getattr(source, "icon_color", "") or "", ) diff --git a/server/src/ledgrab/api/routes/game_integration.py b/server/src/ledgrab/api/routes/game_integration.py index f49bb52..b07f7e5 100644 --- a/server/src/ledgrab/api/routes/game_integration.py +++ b/server/src/ledgrab/api/routes/game_integration.py @@ -158,6 +158,8 @@ def _config_to_response(config: Any) -> GameIntegrationResponse: updated_at=config.updated_at, description=config.description, tags=config.tags, + icon=getattr(config, "icon", "") or "", + icon_color=getattr(config, "icon_color", "") or "", ) @@ -255,6 +257,8 @@ async def create_integration( event_mappings=mappings, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("game_integration", "created", config.id) @@ -323,6 +327,8 @@ async def update_integration( event_mappings=mappings, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("game_integration", "updated", integration_id) diff --git a/server/src/ledgrab/api/routes/gradients.py b/server/src/ledgrab/api/routes/gradients.py index 881bc84..c8d6e3e 100644 --- a/server/src/ledgrab/api/routes/gradients.py +++ b/server/src/ledgrab/api/routes/gradients.py @@ -35,6 +35,8 @@ def _to_response(gradient: Gradient) -> GradientResponse: tags=gradient.tags, created_at=gradient.created_at, updated_at=gradient.updated_at, + icon=getattr(gradient, "icon", "") or "", + icon_color=getattr(gradient, "icon_color", "") or "", ) @@ -66,6 +68,8 @@ async def create_gradient( stops=[s.model_dump() for s in data.stops], description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("gradient", "created", gradient.id) return _to_response(gradient) @@ -103,6 +107,8 @@ async def update_gradient( stops=stops, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("gradient", "updated", gradient_id) return _to_response(gradient) diff --git a/server/src/ledgrab/api/routes/home_assistant.py b/server/src/ledgrab/api/routes/home_assistant.py index eb94b28..d982831 100644 --- a/server/src/ledgrab/api/routes/home_assistant.py +++ b/server/src/ledgrab/api/routes/home_assistant.py @@ -55,6 +55,8 @@ def _to_response( entity_count=len(runtime.get_all_states()) if runtime else 0, description=source.description, tags=source.tags, + icon=getattr(source, "icon", "") or "", + icon_color=getattr(source, "icon_color", "") or "", created_at=source.created_at, updated_at=source.updated_at, token=token_field, @@ -105,6 +107,8 @@ async def create_ha_source( entity_filters=data.entity_filters, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -158,6 +162,8 @@ async def update_ha_source( entity_filters=data.entity_filters, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) except EntityNotFoundError: raise HTTPException(status_code=404, detail=f"Home Assistant source {source_id} not found") diff --git a/server/src/ledgrab/api/routes/mqtt.py b/server/src/ledgrab/api/routes/mqtt.py index 28a4fae..347d21f 100644 --- a/server/src/ledgrab/api/routes/mqtt.py +++ b/server/src/ledgrab/api/routes/mqtt.py @@ -45,6 +45,8 @@ def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse connected=runtime.is_connected if runtime else False, description=source.description, tags=source.tags, + icon=getattr(source, "icon", "") or "", + icon_color=getattr(source, "icon_color", "") or "", created_at=source.created_at, updated_at=source.updated_at, ) @@ -90,6 +92,8 @@ async def create_mqtt_source( base_topic=data.base_topic, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -139,6 +143,8 @@ async def update_mqtt_source( base_topic=data.base_topic, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) except EntityNotFoundError: raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found") diff --git a/server/src/ledgrab/api/routes/pattern_templates.py b/server/src/ledgrab/api/routes/pattern_templates.py index ead3941..2877456 100644 --- a/server/src/ledgrab/api/routes/pattern_templates.py +++ b/server/src/ledgrab/api/routes/pattern_templates.py @@ -39,6 +39,8 @@ def _pat_template_to_response(t) -> PatternTemplateResponse: updated_at=t.updated_at, description=t.description, tags=t.tags, + icon=getattr(t, "icon", "") or "", + icon_color=getattr(t, "icon_color", "") or "", ) @@ -83,6 +85,8 @@ async def create_pattern_template( rectangles=rectangles, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("pattern_template", "created", template.id) return _pat_template_to_response(template) @@ -139,6 +143,8 @@ async def update_pattern_template( rectangles=rectangles, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("pattern_template", "updated", template_id) return _pat_template_to_response(template) diff --git a/server/src/ledgrab/api/routes/picture_sources.py b/server/src/ledgrab/api/routes/picture_sources.py index 2179af7..1d38520 100644 --- a/server/src/ledgrab/api/routes/picture_sources.py +++ b/server/src/ledgrab/api/routes/picture_sources.py @@ -65,6 +65,8 @@ _RESPONSE_MAP = { tags=s.tags, created_at=s.created_at, updated_at=s.updated_at, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", display_index=s.display_index, capture_template_id=s.capture_template_id, target_fps=s.target_fps, @@ -76,6 +78,8 @@ _RESPONSE_MAP = { tags=s.tags, created_at=s.created_at, updated_at=s.updated_at, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", source_stream_id=s.source_stream_id, postprocessing_template_id=s.postprocessing_template_id, ), @@ -86,6 +90,8 @@ _RESPONSE_MAP = { tags=s.tags, created_at=s.created_at, updated_at=s.updated_at, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", image_asset_id=s.image_asset_id, ), VideoCaptureSource: lambda s: VideoPictureSourceResponse( @@ -95,6 +101,8 @@ _RESPONSE_MAP = { tags=s.tags, created_at=s.created_at, updated_at=s.updated_at, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", video_asset_id=s.video_asset_id, loop=s.loop, playback_speed=s.playback_speed, diff --git a/server/src/ledgrab/api/routes/postprocessing.py b/server/src/ledgrab/api/routes/postprocessing.py index abe5efe..3bf9e69 100644 --- a/server/src/ledgrab/api/routes/postprocessing.py +++ b/server/src/ledgrab/api/routes/postprocessing.py @@ -49,6 +49,8 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse: updated_at=t.updated_at, description=t.description, tags=t.tags, + icon=getattr(t, "icon", "") or "", + icon_color=getattr(t, "icon_color", "") or "", ) @@ -86,6 +88,8 @@ async def create_pp_template( filters=filters, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("pp_template", "created", template.id) return _pp_template_to_response(template) @@ -143,6 +147,8 @@ async def update_pp_template( filters=filters, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("pp_template", "updated", template_id) return _pp_template_to_response(template) diff --git a/server/src/ledgrab/api/routes/scene_presets.py b/server/src/ledgrab/api/routes/scene_presets.py index 391d5e2..8e05ac8 100644 --- a/server/src/ledgrab/api/routes/scene_presets.py +++ b/server/src/ledgrab/api/routes/scene_presets.py @@ -51,6 +51,8 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse: ], order=preset.order, tags=preset.tags, + icon=getattr(preset, "icon", "") or "", + icon_color=getattr(preset, "icon_color", "") or "", created_at=preset.created_at, updated_at=preset.updated_at, ) @@ -84,6 +86,8 @@ async def create_scene_preset( targets=targets, order=store.count(), tags=data.tags if data.tags is not None else [], + icon=data.icon or "", + icon_color=data.icon_color or "", created_at=now, updated_at=now, ) @@ -182,6 +186,8 @@ async def update_scene_preset( order=data.order, targets=new_targets, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) except ValueError as e: raise HTTPException( diff --git a/server/src/ledgrab/api/routes/sync_clocks.py b/server/src/ledgrab/api/routes/sync_clocks.py index e0c1448..13de0e5 100644 --- a/server/src/ledgrab/api/routes/sync_clocks.py +++ b/server/src/ledgrab/api/routes/sync_clocks.py @@ -38,6 +38,8 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon speed=rt.speed if rt else clock.speed, description=clock.description, tags=clock.tags, + icon=getattr(clock, "icon", "") or "", + icon_color=getattr(clock, "icon_color", "") or "", is_running=rt.is_running if rt else True, elapsed_time=rt.get_time() if rt else 0.0, created_at=clock.created_at, @@ -75,6 +77,8 @@ async def create_sync_clock( speed=data.speed, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) fire_entity_event("sync_clock", "created", clock.id) return _to_response(clock, manager) @@ -120,6 +124,8 @@ async def update_sync_clock( speed=data.speed, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) # Hot-update runtime speed if data.speed is not None: diff --git a/server/src/ledgrab/api/routes/templates.py b/server/src/ledgrab/api/routes/templates.py index e17184e..3296d18 100644 --- a/server/src/ledgrab/api/routes/templates.py +++ b/server/src/ledgrab/api/routes/templates.py @@ -45,6 +45,21 @@ logger = get_logger(__name__) router = APIRouter() +def _template_to_response(t) -> TemplateResponse: + return TemplateResponse( + id=t.id, + name=t.name, + engine_type=t.engine_type, + engine_config=t.engine_config, + tags=t.tags, + created_at=t.created_at, + updated_at=t.updated_at, + description=t.description, + icon=getattr(t, "icon", "") or "", + icon_color=getattr(t, "icon_color", "") or "", + ) + + # ===== CAPTURE TEMPLATE ENDPOINTS ===== @@ -57,19 +72,7 @@ async def list_templates( try: templates = template_store.get_all_templates() - template_responses = [ - TemplateResponse( - id=t.id, - name=t.name, - engine_type=t.engine_type, - engine_config=t.engine_config, - tags=t.tags, - created_at=t.created_at, - updated_at=t.updated_at, - description=t.description, - ) - for t in templates - ] + template_responses = [_template_to_response(t) for t in templates] return TemplateListResponse( templates=template_responses, @@ -100,19 +103,12 @@ async def create_template( engine_config=template_data.engine_config, description=template_data.description, tags=template_data.tags, + icon=template_data.icon, + icon_color=template_data.icon_color, ) fire_entity_event("capture_template", "created", template.id) - return TemplateResponse( - id=template.id, - name=template.name, - engine_type=template.engine_type, - engine_config=template.engine_config, - tags=template.tags, - created_at=template.created_at, - updated_at=template.updated_at, - description=template.description, - ) + return _template_to_response(template) except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -138,16 +134,7 @@ async def get_template( except ValueError: raise HTTPException(status_code=404, detail=f"Template {template_id} not found") - return TemplateResponse( - id=template.id, - name=template.name, - engine_type=template.engine_type, - engine_config=template.engine_config, - tags=template.tags, - created_at=template.created_at, - updated_at=template.updated_at, - description=template.description, - ) + return _template_to_response(template) @router.put( @@ -168,19 +155,12 @@ async def update_template( engine_config=update_data.engine_config, description=update_data.description, tags=update_data.tags, + icon=update_data.icon, + icon_color=update_data.icon_color, ) fire_entity_event("capture_template", "updated", template_id) - return TemplateResponse( - id=template.id, - name=template.name, - engine_type=template.engine_type, - engine_config=template.engine_config, - tags=template.tags, - created_at=template.created_at, - updated_at=template.updated_at, - description=template.description, - ) + return _template_to_response(template) except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) diff --git a/server/src/ledgrab/api/routes/value_sources.py b/server/src/ledgrab/api/routes/value_sources.py index a488ee8..95530a2 100644 --- a/server/src/ledgrab/api/routes/value_sources.py +++ b/server/src/ledgrab/api/routes/value_sources.py @@ -64,6 +64,8 @@ _RESPONSE_MAP = { name=s.name, description=s.description, tags=s.tags, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", created_at=s.created_at, updated_at=s.updated_at, value=s.value, @@ -73,6 +75,8 @@ _RESPONSE_MAP = { name=s.name, description=s.description, tags=s.tags, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", created_at=s.created_at, updated_at=s.updated_at, waveform=s.waveform, @@ -85,6 +89,8 @@ _RESPONSE_MAP = { name=s.name, description=s.description, tags=s.tags, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", created_at=s.created_at, updated_at=s.updated_at, audio_source_id=s.audio_source_id, @@ -100,6 +106,8 @@ _RESPONSE_MAP = { name=s.name, description=s.description, tags=s.tags, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", created_at=s.created_at, updated_at=s.updated_at, speed=s.speed, @@ -114,6 +122,8 @@ _RESPONSE_MAP = { name=s.name, description=s.description, tags=s.tags, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", created_at=s.created_at, updated_at=s.updated_at, color=list(s.color), @@ -123,6 +133,8 @@ _RESPONSE_MAP = { name=s.name, description=s.description, tags=s.tags, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", created_at=s.created_at, updated_at=s.updated_at, colors=[list(c) for c in s.colors], @@ -135,6 +147,8 @@ _RESPONSE_MAP = { name=s.name, description=s.description, tags=s.tags, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", created_at=s.created_at, updated_at=s.updated_at, schedule=s.schedule, @@ -144,6 +158,8 @@ _RESPONSE_MAP = { name=s.name, description=s.description, tags=s.tags, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", created_at=s.created_at, updated_at=s.updated_at, ha_source_id=s.ha_source_id, @@ -158,6 +174,8 @@ _RESPONSE_MAP = { name=s.name, description=s.description, tags=s.tags, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", created_at=s.created_at, updated_at=s.updated_at, value_source_id=s.value_source_id, @@ -169,6 +187,8 @@ _RESPONSE_MAP = { name=s.name, description=s.description, tags=s.tags, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", created_at=s.created_at, updated_at=s.updated_at, color_strip_source_id=s.color_strip_source_id, @@ -180,6 +200,8 @@ _RESPONSE_MAP = { name=s.name, description=s.description, tags=s.tags, + icon=getattr(s, "icon", "") or "", + icon_color=getattr(s, "icon_color", "") or "", created_at=s.created_at, updated_at=s.updated_at, metric=s.metric, @@ -204,6 +226,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse: name=source.name, description=source.description, tags=source.tags, + icon=getattr(source, "icon", "") or "", + icon_color=getattr(source, "icon_color", "") or "", created_at=source.created_at, updated_at=source.updated_at, picture_source_id=source.picture_source_id, @@ -218,6 +242,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse: name=source.name, description=source.description, tags=source.tags, + icon=getattr(source, "icon", "") or "", + icon_color=getattr(source, "icon_color", "") or "", created_at=source.created_at, updated_at=source.updated_at, schedule=source.schedule, @@ -233,6 +259,8 @@ def _to_response(source: ValueSource) -> ValueSourceResponse: name=source.name, description=source.description, tags=source.tags, + icon=getattr(source, "icon", "") or "", + icon_color=getattr(source, "icon_color", "") or "", created_at=source.created_at, updated_at=source.updated_at, value=getattr(source, "value", 1.0), diff --git a/server/src/ledgrab/api/routes/weather_sources.py b/server/src/ledgrab/api/routes/weather_sources.py index aba410a..3edc470 100644 --- a/server/src/ledgrab/api/routes/weather_sources.py +++ b/server/src/ledgrab/api/routes/weather_sources.py @@ -39,6 +39,8 @@ def _to_response(source: WeatherSource) -> WeatherSourceResponse: update_interval=d["update_interval"], description=d.get("description"), tags=d.get("tags", []), + icon=getattr(source, "icon", "") or "", + icon_color=getattr(source, "icon_color", "") or "", created_at=source.created_at, updated_at=source.updated_at, ) @@ -79,6 +81,8 @@ async def create_weather_source( update_interval=data.update_interval, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -125,6 +129,8 @@ async def update_weather_source( update_interval=data.update_interval, description=data.description, tags=data.tags, + icon=data.icon, + icon_color=data.icon_color, ) except EntityNotFoundError: raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found") diff --git a/server/src/ledgrab/api/schemas/assets.py b/server/src/ledgrab/api/schemas/assets.py index 7d33e76..8304b82 100644 --- a/server/src/ledgrab/api/schemas/assets.py +++ b/server/src/ledgrab/api/schemas/assets.py @@ -12,6 +12,16 @@ class AssetUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name") description: Optional[str] = Field(None, max_length=500, description="Optional description") tags: Optional[List[str]] = Field(None, description="User-defined tags") + 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. Empty/null inherits the channel accent.", + ) class AssetResponse(BaseModel): @@ -26,6 +36,16 @@ class AssetResponse(BaseModel): description: Optional[str] = Field(None, description="Description") tags: List[str] = Field(default_factory=list, description="User-defined tags") prebuilt: bool = Field(False, description="Whether this is a shipped prebuilt asset") + 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. Empty/null inherits the channel accent.", + ) created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") diff --git a/server/src/ledgrab/api/schemas/audio_processing.py b/server/src/ledgrab/api/schemas/audio_processing.py index 0bf070c..8775eae 100644 --- a/server/src/ledgrab/api/schemas/audio_processing.py +++ b/server/src/ledgrab/api/schemas/audio_processing.py @@ -17,6 +17,16 @@ class AudioProcessingTemplateCreate(BaseModel): ) description: Optional[str] = Field(None, description="Template description", max_length=500) tags: List[str] = Field(default_factory=list, description="User-defined tags") + 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. Empty/null inherits the channel accent.", + ) class AudioProcessingTemplateUpdate(BaseModel): @@ -28,6 +38,16 @@ class AudioProcessingTemplateUpdate(BaseModel): ) description: Optional[str] = Field(None, description="Template description", max_length=500) tags: Optional[List[str]] = None + 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. Empty/null inherits the channel accent.", + ) class AudioProcessingTemplateResponse(BaseModel): @@ -42,6 +62,16 @@ class AudioProcessingTemplateResponse(BaseModel): created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Template description") + 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. Empty/null inherits the channel accent.", + ) class AudioProcessingTemplateListResponse(BaseModel): diff --git a/server/src/ledgrab/api/schemas/audio_sources.py b/server/src/ledgrab/api/schemas/audio_sources.py index d3f6e5c..6718921 100644 --- a/server/src/ledgrab/api/schemas/audio_sources.py +++ b/server/src/ledgrab/api/schemas/audio_sources.py @@ -19,6 +19,16 @@ class _AudioSourceResponseBase(BaseModel): tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") + 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. Empty/null inherits the channel accent.", + ) class CaptureAudioSourceResponse(_AudioSourceResponseBase): @@ -53,6 +63,16 @@ class _AudioSourceCreateBase(BaseModel): name: str = Field(description="Source 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="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. Empty/null inherits the channel accent.", + ) class CaptureAudioSourceCreate(_AudioSourceCreateBase): @@ -87,6 +107,16 @@ class _AudioSourceUpdateBase(BaseModel): name: Optional[str] = Field(None, description="Source 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="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. Empty/null inherits the channel accent.", + ) class CaptureAudioSourceUpdate(_AudioSourceUpdateBase): diff --git a/server/src/ledgrab/api/schemas/audio_templates.py b/server/src/ledgrab/api/schemas/audio_templates.py index 41ec934..aad07a8 100644 --- a/server/src/ledgrab/api/schemas/audio_templates.py +++ b/server/src/ledgrab/api/schemas/audio_templates.py @@ -16,6 +16,16 @@ class AudioTemplateCreate(BaseModel): engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration") description: Optional[str] = Field(None, description="Template description", max_length=500) tags: List[str] = Field(default_factory=list, description="User-defined tags") + 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. Empty/null inherits the channel accent.", + ) class AudioTemplateUpdate(BaseModel): @@ -26,6 +36,16 @@ class AudioTemplateUpdate(BaseModel): engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") description: Optional[str] = Field(None, description="Template description", max_length=500) tags: Optional[List[str]] = None + 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. Empty/null inherits the channel accent.", + ) class AudioTemplateResponse(BaseModel): @@ -39,6 +59,16 @@ class AudioTemplateResponse(BaseModel): created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Template description") + 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. Empty/null inherits the channel accent.", + ) class AudioTemplateListResponse(BaseModel): diff --git a/server/src/ledgrab/api/schemas/automations.py b/server/src/ledgrab/api/schemas/automations.py index b116968..75d8897 100644 --- a/server/src/ledgrab/api/schemas/automations.py +++ b/server/src/ledgrab/api/schemas/automations.py @@ -67,6 +67,16 @@ class AutomationCreate(BaseModel): None, description="Scene preset for fallback deactivation" ) tags: List[str] = Field(default_factory=list, description="User-defined tags") + 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. Empty/null inherits the channel accent.", + ) class AutomationUpdate(BaseModel): @@ -84,6 +94,16 @@ class AutomationUpdate(BaseModel): None, description="Scene preset for fallback deactivation" ) tags: Optional[List[str]] = None + 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. Empty/null inherits the channel accent.", + ) class AutomationResponse(BaseModel): @@ -108,6 +128,16 @@ class AutomationResponse(BaseModel): last_deactivated_at: Optional[datetime] = Field( None, description="Last time this automation was deactivated" ) + 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. Empty/null inherits the channel accent.", + ) created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") diff --git a/server/src/ledgrab/api/schemas/color_strip_processing.py b/server/src/ledgrab/api/schemas/color_strip_processing.py index f1cf27b..16a09fd 100644 --- a/server/src/ledgrab/api/schemas/color_strip_processing.py +++ b/server/src/ledgrab/api/schemas/color_strip_processing.py @@ -17,6 +17,16 @@ class ColorStripProcessingTemplateCreate(BaseModel): ) description: Optional[str] = Field(None, description="Template description", max_length=500) tags: List[str] = Field(default_factory=list, description="User-defined tags") + 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. Empty/null inherits the channel accent.", + ) class ColorStripProcessingTemplateUpdate(BaseModel): @@ -28,6 +38,16 @@ class ColorStripProcessingTemplateUpdate(BaseModel): ) description: Optional[str] = Field(None, description="Template description", max_length=500) tags: Optional[List[str]] = None + 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. Empty/null inherits the channel accent.", + ) class ColorStripProcessingTemplateResponse(BaseModel): @@ -40,6 +60,16 @@ class ColorStripProcessingTemplateResponse(BaseModel): created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Template description") + 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. Empty/null inherits the channel accent.", + ) class ColorStripProcessingTemplateListResponse(BaseModel): diff --git a/server/src/ledgrab/api/schemas/color_strip_sources.py b/server/src/ledgrab/api/schemas/color_strip_sources.py index 53f6820..6a19f56 100644 --- a/server/src/ledgrab/api/schemas/color_strip_sources.py +++ b/server/src/ledgrab/api/schemas/color_strip_sources.py @@ -95,6 +95,16 @@ class _CSSResponseBase(BaseModel): tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") + 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. Empty/null inherits the channel accent.", + ) class PictureCSSResponse(_CSSResponseBase): @@ -266,6 +276,16 @@ class _CSSCreateBase(BaseModel): description: Optional[str] = Field(None, description="Optional description", max_length=500) clock_id: Optional[str] = Field(None, description="Optional sync clock ID") tags: List[str] = Field(default_factory=list, description="User-defined tags") + 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. Empty/null inherits the channel accent.", + ) class PictureCSSCreate(_CSSCreateBase): @@ -450,6 +470,16 @@ class _CSSUpdateBase(BaseModel): description: Optional[str] = Field(None, description="Optional description", max_length=500) clock_id: Optional[str] = Field(None, description="Optional sync clock ID") tags: Optional[List[str]] = None + 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. Empty/null inherits the channel accent.", + ) class PictureCSSUpdate(_CSSUpdateBase): diff --git a/server/src/ledgrab/api/schemas/game_integration.py b/server/src/ledgrab/api/schemas/game_integration.py index d7206ca..425b64a 100644 --- a/server/src/ledgrab/api/schemas/game_integration.py +++ b/server/src/ledgrab/api/schemas/game_integration.py @@ -42,6 +42,16 @@ class GameIntegrationCreate(BaseModel): ) description: Optional[str] = Field(None, description="Integration description", max_length=500) tags: List[str] = Field(default_factory=list, description="User-defined tags") + 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 (e.g. '#4CAF50'). Empty/null inherits the channel accent.", + ) class GameIntegrationUpdate(BaseModel): @@ -56,6 +66,16 @@ class GameIntegrationUpdate(BaseModel): ) description: Optional[str] = Field(None, description="Integration description", max_length=500) tags: Optional[List[str]] = Field(None, description="User-defined tags") + 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 GameIntegrationResponse(BaseModel): @@ -71,6 +91,16 @@ class GameIntegrationResponse(BaseModel): updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Integration description") tags: List[str] = Field(default_factory=list, description="User-defined tags") + icon: Optional[str] = Field( + None, + max_length=64, + description="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 GameIntegrationListResponse(BaseModel): diff --git a/server/src/ledgrab/api/schemas/gradients.py b/server/src/ledgrab/api/schemas/gradients.py index b2104d0..f4b27e7 100644 --- a/server/src/ledgrab/api/schemas/gradients.py +++ b/server/src/ledgrab/api/schemas/gradients.py @@ -20,6 +20,16 @@ class GradientCreate(BaseModel): stops: List[GradientStopSchema] = Field(description="Color stops", min_length=2) 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="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. Empty/null inherits the channel accent.", + ) class GradientUpdate(BaseModel): @@ -29,6 +39,16 @@ class GradientUpdate(BaseModel): stops: Optional[List[GradientStopSchema]] = Field(None, description="Color stops", min_length=2) 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="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. Empty/null inherits the channel accent.", + ) class GradientResponse(BaseModel): @@ -42,6 +62,16 @@ class GradientResponse(BaseModel): tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") + 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. Empty/null inherits the channel accent.", + ) class GradientListResponse(BaseModel): diff --git a/server/src/ledgrab/api/schemas/home_assistant.py b/server/src/ledgrab/api/schemas/home_assistant.py index d742e40..aac74c5 100644 --- a/server/src/ledgrab/api/schemas/home_assistant.py +++ b/server/src/ledgrab/api/schemas/home_assistant.py @@ -18,6 +18,16 @@ class HomeAssistantSourceCreate(BaseModel): ) 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="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 (e.g. '#4CAF50'). Empty/null inherits the channel accent.", + ) class HomeAssistantSourceUpdate(BaseModel): @@ -30,6 +40,16 @@ class HomeAssistantSourceUpdate(BaseModel): entity_filters: Optional[List[str]] = Field(None, description="Entity ID filter patterns") 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="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 HomeAssistantSourceResponse(BaseModel): @@ -44,6 +64,16 @@ class HomeAssistantSourceResponse(BaseModel): entity_count: int = Field(default=0, description="Number of cached entities") description: Optional[str] = Field(None, description="Description") tags: List[str] = Field(default_factory=list, description="User-defined tags") + icon: Optional[str] = Field( + None, + max_length=64, + description="Icon id from the curated icon library.", + ) + icon_color: Optional[str] = Field( + None, + max_length=32, + description="Optional CSS color override for the icon.", + ) created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") token: Optional[str] = Field( diff --git a/server/src/ledgrab/api/schemas/mqtt.py b/server/src/ledgrab/api/schemas/mqtt.py index ed33451..0b2b2f6 100644 --- a/server/src/ledgrab/api/schemas/mqtt.py +++ b/server/src/ledgrab/api/schemas/mqtt.py @@ -18,6 +18,16 @@ class MQTTSourceCreate(BaseModel): base_topic: str = Field(default="ledgrab", description="Base topic prefix") 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="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 (e.g. '#4CAF50'). Empty/null inherits the channel accent.", + ) class MQTTSourceUpdate(BaseModel): @@ -32,6 +42,16 @@ class MQTTSourceUpdate(BaseModel): base_topic: Optional[str] = Field(None, description="Base topic prefix") 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="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 MQTTSourceResponse(BaseModel): @@ -48,6 +68,16 @@ class MQTTSourceResponse(BaseModel): connected: bool = Field(default=False, description="Whether the broker connection is active") description: Optional[str] = Field(None, description="Description") tags: List[str] = Field(default_factory=list, description="User-defined tags") + icon: Optional[str] = Field( + None, + max_length=64, + description="Icon id from the curated icon library.", + ) + icon_color: Optional[str] = Field( + None, + max_length=32, + 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/api/schemas/pattern_templates.py b/server/src/ledgrab/api/schemas/pattern_templates.py index 573ec79..32c5cef 100644 --- a/server/src/ledgrab/api/schemas/pattern_templates.py +++ b/server/src/ledgrab/api/schemas/pattern_templates.py @@ -17,6 +17,16 @@ class PatternTemplateCreate(BaseModel): ) description: Optional[str] = Field(None, description="Template description", max_length=500) tags: List[str] = Field(default_factory=list, description="User-defined tags") + 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. Empty/null inherits the channel accent.", + ) class PatternTemplateUpdate(BaseModel): @@ -28,6 +38,16 @@ class PatternTemplateUpdate(BaseModel): ) description: Optional[str] = Field(None, description="Template description", max_length=500) tags: Optional[List[str]] = None + 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. Empty/null inherits the channel accent.", + ) class PatternTemplateResponse(BaseModel): @@ -40,6 +60,16 @@ class PatternTemplateResponse(BaseModel): created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Template description") + 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. Empty/null inherits the channel accent.", + ) class PatternTemplateListResponse(BaseModel): diff --git a/server/src/ledgrab/api/schemas/picture_sources.py b/server/src/ledgrab/api/schemas/picture_sources.py index f9de864..cc46425 100644 --- a/server/src/ledgrab/api/schemas/picture_sources.py +++ b/server/src/ledgrab/api/schemas/picture_sources.py @@ -19,6 +19,16 @@ class _PictureSourceResponseBase(BaseModel): tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") + 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. Empty/null inherits the channel accent.", + ) class RawPictureSourceResponse(_PictureSourceResponseBase): @@ -72,6 +82,16 @@ class _PictureSourceCreateBase(BaseModel): name: str = Field(description="Stream name", min_length=1, max_length=100) description: Optional[str] = Field(None, description="Stream description", max_length=500) tags: List[str] = Field(default_factory=list, description="User-defined tags") + 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. Empty/null inherits the channel accent.", + ) class RawPictureSourceCreate(_PictureSourceCreateBase): @@ -127,6 +147,16 @@ class _PictureSourceUpdateBase(BaseModel): name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100) description: Optional[str] = Field(None, description="Stream description", max_length=500) tags: Optional[List[str]] = None + 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. Empty/null inherits the channel accent.", + ) class RawPictureSourceUpdate(_PictureSourceUpdateBase): diff --git a/server/src/ledgrab/api/schemas/postprocessing.py b/server/src/ledgrab/api/schemas/postprocessing.py index f0809dd..3b36883 100644 --- a/server/src/ledgrab/api/schemas/postprocessing.py +++ b/server/src/ledgrab/api/schemas/postprocessing.py @@ -17,6 +17,16 @@ class PostprocessingTemplateCreate(BaseModel): ) description: Optional[str] = Field(None, description="Template description", max_length=500) tags: List[str] = Field(default_factory=list, description="User-defined tags") + 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. Empty/null inherits the channel accent.", + ) class PostprocessingTemplateUpdate(BaseModel): @@ -28,6 +38,16 @@ class PostprocessingTemplateUpdate(BaseModel): ) description: Optional[str] = Field(None, description="Template description", max_length=500) tags: Optional[List[str]] = None + 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. Empty/null inherits the channel accent.", + ) class PostprocessingTemplateResponse(BaseModel): @@ -40,6 +60,16 @@ class PostprocessingTemplateResponse(BaseModel): created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Template description") + 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. Empty/null inherits the channel accent.", + ) class PostprocessingTemplateListResponse(BaseModel): diff --git a/server/src/ledgrab/api/schemas/scene_presets.py b/server/src/ledgrab/api/schemas/scene_presets.py index f71c6e5..8f33166 100644 --- a/server/src/ledgrab/api/schemas/scene_presets.py +++ b/server/src/ledgrab/api/schemas/scene_presets.py @@ -23,6 +23,16 @@ class ScenePresetCreate(BaseModel): None, description="Target IDs to capture (all if omitted)" ) tags: List[str] = Field(default_factory=list, description="User-defined tags") + 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. Empty/null inherits the channel accent.", + ) class ScenePresetUpdate(BaseModel): @@ -36,6 +46,16 @@ class ScenePresetUpdate(BaseModel): description="Update target list: keep state for existing, capture fresh for new, drop removed", ) tags: Optional[List[str]] = None + 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. Empty/null inherits the channel accent.", + ) class ScenePresetResponse(BaseModel): @@ -47,6 +67,16 @@ class ScenePresetResponse(BaseModel): targets: List[TargetSnapshotSchema] order: int tags: List[str] = Field(default_factory=list, description="User-defined tags") + 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. Empty/null inherits the channel accent.", + ) created_at: datetime updated_at: datetime diff --git a/server/src/ledgrab/api/schemas/sync_clocks.py b/server/src/ledgrab/api/schemas/sync_clocks.py index 300bd9e..7ca6745 100644 --- a/server/src/ledgrab/api/schemas/sync_clocks.py +++ b/server/src/ledgrab/api/schemas/sync_clocks.py @@ -13,6 +13,16 @@ class SyncClockCreate(BaseModel): speed: float = Field(default=1.0, description="Speed multiplier (0.1–10.0)", ge=0.1, le=10.0) 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="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 (e.g. '#4CAF50'). Empty/null inherits the channel accent.", + ) class SyncClockUpdate(BaseModel): @@ -22,6 +32,16 @@ class SyncClockUpdate(BaseModel): speed: Optional[float] = Field(None, description="Speed multiplier (0.1–10.0)", ge=0.1, le=10.0) 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="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 SyncClockResponse(BaseModel): @@ -32,6 +52,16 @@ class SyncClockResponse(BaseModel): speed: float = Field(description="Speed multiplier") description: Optional[str] = Field(None, description="Description") tags: List[str] = Field(default_factory=list, description="User-defined tags") + icon: Optional[str] = Field( + None, + max_length=64, + description="Icon id from the curated icon library.", + ) + icon_color: Optional[str] = Field( + None, + max_length=32, + description="Optional CSS color override for the icon.", + ) is_running: bool = Field(True, description="Whether clock is currently running") elapsed_time: float = Field(0.0, description="Current elapsed time in seconds") created_at: datetime = Field(description="Creation timestamp") diff --git a/server/src/ledgrab/api/schemas/templates.py b/server/src/ledgrab/api/schemas/templates.py index 5b6775e..279fcda 100644 --- a/server/src/ledgrab/api/schemas/templates.py +++ b/server/src/ledgrab/api/schemas/templates.py @@ -14,6 +14,16 @@ class TemplateCreate(BaseModel): engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration") description: Optional[str] = Field(None, description="Template description", max_length=500) tags: List[str] = Field(default_factory=list, description="User-defined tags") + 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. Empty/null inherits the channel accent.", + ) class TemplateUpdate(BaseModel): @@ -24,6 +34,16 @@ class TemplateUpdate(BaseModel): engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") description: Optional[str] = Field(None, description="Template description", max_length=500) tags: Optional[List[str]] = None + 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. Empty/null inherits the channel accent.", + ) class TemplateResponse(BaseModel): @@ -37,6 +57,12 @@ class TemplateResponse(BaseModel): created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Template description") + icon: Optional[str] = Field( + None, max_length=64, description="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 TemplateListResponse(BaseModel): diff --git a/server/src/ledgrab/api/schemas/value_sources.py b/server/src/ledgrab/api/schemas/value_sources.py index 1217e17..e0db9bf 100644 --- a/server/src/ledgrab/api/schemas/value_sources.py +++ b/server/src/ledgrab/api/schemas/value_sources.py @@ -17,6 +17,16 @@ class _ValueSourceResponseBase(BaseModel): name: str = Field(description="Source name") description: Optional[str] = Field(None, description="Description") tags: List[str] = Field(default_factory=list, description="User-defined tags") + icon: Optional[str] = Field( + None, + max_length=64, + description="Icon id from the curated icon library.", + ) + icon_color: Optional[str] = Field( + None, + max_length=32, + description="Optional CSS color override for the icon.", + ) created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") @@ -171,6 +181,16 @@ class _ValueSourceCreateBase(BaseModel): name: str = Field(description="Source 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="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 (e.g. '#4CAF50'). Empty/null inherits the channel accent.", + ) class StaticValueSourceCreate(_ValueSourceCreateBase): @@ -320,6 +340,16 @@ class _ValueSourceUpdateBase(BaseModel): name: Optional[str] = Field(None, description="Source 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="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 StaticValueSourceUpdate(_ValueSourceUpdateBase): diff --git a/server/src/ledgrab/api/schemas/weather_sources.py b/server/src/ledgrab/api/schemas/weather_sources.py index 6ad9ee8..e409007 100644 --- a/server/src/ledgrab/api/schemas/weather_sources.py +++ b/server/src/ledgrab/api/schemas/weather_sources.py @@ -25,6 +25,16 @@ class WeatherSourceCreate(BaseModel): ) 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="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 (e.g. '#4CAF50'). Empty/null inherits the channel accent.", + ) class WeatherSourceUpdate(BaseModel): @@ -44,6 +54,16 @@ class WeatherSourceUpdate(BaseModel): ) 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="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 WeatherSourceResponse(BaseModel): @@ -60,6 +80,16 @@ class WeatherSourceResponse(BaseModel): update_interval: int = Field(description="API poll interval in seconds") description: Optional[str] = Field(None, description="Description") tags: List[str] = Field(default_factory=list, description="User-defined tags") + icon: Optional[str] = Field( + None, + max_length=64, + description="Icon id from the curated icon library.", + ) + icon_color: Optional[str] = Field( + None, + max_length=32, + 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/js/core/card-icon.ts b/server/src/ledgrab/static/js/core/card-icon.ts new file mode 100644 index 0000000..6f11bf4 --- /dev/null +++ b/server/src/ledgrab/static/js/core/card-icon.ts @@ -0,0 +1,47 @@ +/** + * Card icon plate helper — builds the ``iconHtml`` / ``iconColor`` / + * ``iconAttrs`` slots for a mod-card head from any entity that has + * optional ``icon`` and ``icon_color`` string fields. + * + * Usage: + * + * import { makeCardIconFields } from '../core/card-icon.ts'; + * + * const mod: ModCardOpts = { + * head: { + * badge: { text: 'WEATHER · IN' }, + * name: source.name, + * ...makeCardIconFields('weather_source', source.id, source), + * // ... + * }, + * }; + * + * The icon picker is opened by document-level click delegation on + * ``[data-icon-picker-trigger]``; each feature module registers its + * adapter via ``registerIconEntityType()`` from ``icon-picker.ts``. + */ + +import { renderDeviceIconSvg } from './device-icons.ts'; +import type { ModHeadOpts } from './mod-card.ts'; + +export interface IconableEntity { + icon?: string; + icon_color?: string; +} + +type IconHeadFields = Pick; + +export function makeCardIconFields( + entityType: string, + entityId: string, + entity: IconableEntity, + opts: { size?: number } = {}, +): IconHeadFields { + const iconId = entity.icon ?? ''; + const iconColor = entity.icon_color || undefined; + return { + iconHtml: iconId ? renderDeviceIconSvg(iconId, { size: opts.size ?? 24 }) : '', + iconColor, + iconAttrs: { 'data-icon-picker-trigger': `${entityType}:${entityId}` }, + }; +} diff --git a/server/src/ledgrab/static/js/core/state.ts b/server/src/ledgrab/static/js/core/state.ts index 3e5cd5a..e7e9cd9 100644 --- a/server/src/ledgrab/static/js/core/state.ts +++ b/server/src/ledgrab/static/js/core/state.ts @@ -404,6 +404,8 @@ export interface GradientEntity { is_builtin: boolean; description?: string; tags: string[]; + icon?: string; + icon_color?: string; } export const gradientsCache = new DataCache({ diff --git a/server/src/ledgrab/static/js/features/assets.ts b/server/src/ledgrab/static/js/features/assets.ts index 5a6f762..99c6d12 100644 --- a/server/src/ledgrab/static/js/features/assets.ts +++ b/server/src/ledgrab/static/js/features/assets.ts @@ -12,9 +12,23 @@ import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; +import { makeCardIconFields } from '../core/card-icon.ts'; +import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts'; import { loadPictureSources } from './streams.ts'; import type { Asset } from '../types.ts'; +registerIconEntityType('asset', makeSimpleIconAdapter({ + cache: assetsCache, + endpointPrefix: '/assets', + reload: async () => { + assetsCache.invalidate(); + await loadPictureSources(); + }, + typeLabelKey: 'device.icon.entity.asset', + typeLabelFallback: 'Asset', + cardSelectors: (id) => [`[data-card-section="assets"] [data-id="${CSS.escape(id)}"]`], +})); + const _icon = (d: string) => `${d}`; const ICON_PLAY_SOUND = _icon(P.play); const ICON_UPLOAD = _icon(P.fileUp); @@ -162,6 +176,7 @@ export function createAssetCard(asset: Asset): string { name: asset.name, metaHtml: escapeHtml(`${typeLabel} · ${sizeStr}`), leds: ['on'], + ...makeCardIconFields('asset', asset.id, asset), menu: { hideOnclick: `toggleCardHidden('assets','${asset.id}')`, deleteOnclick: `deleteAsset('${asset.id}')`, diff --git a/server/src/ledgrab/static/js/features/audio-processing-templates.ts b/server/src/ledgrab/static/js/features/audio-processing-templates.ts index f12d80c..0fc7aed 100644 --- a/server/src/ledgrab/static/js/features/audio-processing-templates.ts +++ b/server/src/ledgrab/static/js/features/audio-processing-templates.ts @@ -24,8 +24,22 @@ import { TagInput, renderTagChips } from '../core/tag-input.ts'; import { FilterListManager } from '../core/filter-list.ts'; import { wrapCard } from '../core/card-colors.ts'; import type { ModCardOpts } from '../core/mod-card.ts'; +import { makeCardIconFields } from '../core/card-icon.ts'; +import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts'; import { loadPictureSources } from './streams.ts'; +registerIconEntityType('audio_processing_template', makeSimpleIconAdapter({ + cache: audioProcessingTemplatesCache, + endpointPrefix: '/audio-processing-templates', + reload: async () => { + audioProcessingTemplatesCache.invalidate(); + await loadPictureSources(); + }, + typeLabelKey: 'device.icon.entity.audio_processing_template', + typeLabelFallback: 'Audio processing template', + cardSelectors: (id) => [`[data-apt-id="${CSS.escape(id)}"]`], +})); + // ── Module state ───────────────────────────────────────────── let _aptTagsInput: TagInput | null = null; @@ -286,6 +300,7 @@ export function createAudioProcessingTemplateCard(tmpl: any): string { name: tmpl.name, metaHtml: escapeHtml(`${filters.length} ${t('audio_processing.title') || 'filters'}`), leds: ['off'], + ...makeCardIconFields('audio_processing_template', tmpl.id, tmpl), menu: { duplicateOnclick: `cloneAudioProcessingTemplate('${tmpl.id}')`, hideOnclick: `toggleCardHidden('audio-processing-templates','${tmpl.id}')`, diff --git a/server/src/ledgrab/static/js/features/automations.ts b/server/src/ledgrab/static/js/features/automations.ts index 459d384..1c2c772 100644 --- a/server/src/ledgrab/static/js/features/automations.ts +++ b/server/src/ledgrab/static/js/features/automations.ts @@ -16,6 +16,8 @@ import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; import type { ModCardOpts, ModChipOpts } from '../core/mod-card.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; +import { makeCardIconFields } from '../core/card-icon.ts'; +import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts'; import { getBaseOrigin } from './settings.ts'; import { IconSelect } from '../core/icon-select.ts'; import { EntitySelect } from '../core/entity-palette.ts'; @@ -24,6 +26,20 @@ import { TreeNav } from '../core/tree-nav.ts'; import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts'; import type { Automation } from '../types.ts'; +registerIconEntityType('automation', makeSimpleIconAdapter({ + cache: automationsCacheObj, + endpointPrefix: '/automations', + reload: async () => { + automationsCacheObj.invalidate(); + if (typeof (window as any).loadAutomations === 'function') { + await (window as any).loadAutomations(); + } + }, + typeLabelKey: 'device.icon.entity.automation', + typeLabelFallback: 'Automation', + cardSelectors: (id) => [`[data-automation-id="${CSS.escape(id)}"]`], +})); + // ── HA rule entity cache ── let _haRuleEntities: any[] = []; @@ -401,6 +417,7 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) { name: automation.name, metaHtml, leds: [ledState], + ...makeCardIconFields('automation', automation.id, automation), menu: { duplicateOnclick: `cloneAutomation('${automation.id}')`, hideOnclick: `toggleCardHidden('automations','${automation.id}')`, diff --git a/server/src/ledgrab/static/js/features/color-strips/cards.ts b/server/src/ledgrab/static/js/features/color-strips/cards.ts index 67cc5fd..766b26f 100644 --- a/server/src/ledgrab/static/js/features/color-strips/cards.ts +++ b/server/src/ledgrab/static/js/features/color-strips/cards.ts @@ -23,6 +23,23 @@ import type { ColorStripSource } from '../../types.ts'; import { bindableValue, bindableColor } from '../../types.ts'; import { renderTagChips } from '../../core/tag-input.ts'; import { rgbArrayToHex } from '../css-gradient-editor.ts'; +import { makeCardIconFields } from '../../core/card-icon.ts'; +import { registerIconEntityType, makeSimpleIconAdapter } from '../icon-picker.ts'; + +registerIconEntityType('color_strip_source', makeSimpleIconAdapter({ + cache: colorStripSourcesCache, + endpointPrefix: '/color-strip-sources', + reload: async () => { + colorStripSourcesCache.invalidate(); + if (typeof (window as any).loadPictureSources === 'function') { + await (window as any).loadPictureSources(); + } + }, + typeLabelKey: 'device.icon.entity.color_strip_source', + typeLabelFallback: 'Color strip', + cardSelectors: (id) => [`[data-css-id="${CSS.escape(id)}"]`], + bodyExtras: (rec) => ({ source_type: (rec as any)?.source_type ?? 'static' }), +})); /* ── Types ────────────────────────────────────────────────────── */ @@ -332,6 +349,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap: name: source.name, metaHtml: escapeHtml(metaText), leds: ['off'], + ...makeCardIconFields('color_strip_source', source.id, source), menu: { duplicateOnclick: `cloneColorStrip('${source.id}')`, hideOnclick: `toggleCardHidden('color-strips','${source.id}')`, diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index 204b955..3e611e8 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -457,8 +457,11 @@ function renderDashboardSyncClock(clock: SyncClock): string { const btnLabel = clock.is_running ? (t('sync_clock.action.pause') || 'Pause') : (t('sync_clock.action.resume') || 'Resume'); const scStyle = cardColorStyle(clock.id); + const iconPlate = _dashboardIconPlate(clock as any); + const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head'; return `