diff --git a/TODO.md b/TODO.md index 3ed8420..050a4a5 100644 --- a/TODO.md +++ b/TODO.md @@ -59,3 +59,5 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort - Complexity: medium — new `tags: List[str]` field on all card entities; tag CRUD API; filter bar UI per section; tag badge rendering on cards; persistence migration - Impact: medium-high — essential for setups with many devices/targets; enables quick filtering (e.g. "bedroom", "desk", "gaming") - [x] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest +- [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default +- [ ] `P1` **Review protocol badge on LED target cards** — Review and improve the protocol badge display on LED target cards diff --git a/server/src/wled_controller/api/routes/scene_presets.py b/server/src/wled_controller/api/routes/scene_presets.py index 1df32ca..9767d75 100644 --- a/server/src/wled_controller/api/routes/scene_presets.py +++ b/server/src/wled_controller/api/routes/scene_presets.py @@ -164,6 +164,28 @@ async def delete_scene_preset( raise HTTPException(status_code=404, detail=str(e)) +# ===== Clone ===== + +@router.post( + "/api/v1/scene-presets/{preset_id}/clone", + response_model=ScenePresetResponse, + tags=["Scene Presets"], + status_code=201, +) +async def clone_scene_preset( + preset_id: str, + _auth: AuthRequired, + store: ScenePresetStore = Depends(get_scene_preset_store), +): + """Duplicate a scene preset with all its stored target snapshots.""" + try: + cloned = store.clone_preset(preset_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + return _preset_to_response(cloned) + + # ===== Recapture ===== @router.post( diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index f1865e4..67aaa90 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -85,7 +85,7 @@ import { } from './features/automations.js'; import { openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor, - activateScenePreset, recaptureScenePreset, deleteScenePreset, + activateScenePreset, recaptureScenePreset, cloneScenePreset, deleteScenePreset, addSceneTarget, removeSceneTarget, } from './features/scene-presets.js'; @@ -325,6 +325,7 @@ Object.assign(window, { closeScenePresetEditor, activateScenePreset, recaptureScenePreset, + cloneScenePreset, deleteScenePreset, addSceneTarget, removeSceneTarget, diff --git a/server/src/wled_controller/static/js/features/scene-presets.js b/server/src/wled_controller/static/js/features/scene-presets.js index e9a9926..1a20289 100644 --- a/server/src/wled_controller/static/js/features/scene-presets.js +++ b/server/src/wled_controller/static/js/features/scene-presets.js @@ -9,7 +9,7 @@ import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { CardSection } from '../core/card-sections.js'; import { - ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, + ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE, } from '../core/icons.js'; import { scenePresetsCache } from '../core/state.js'; import { cardColorStyle, cardColorButton } from '../core/card-colors.js'; @@ -61,6 +61,7 @@ export function createSceneCard(preset) { ${updated ? `${updated}` : ''}
+ @@ -304,6 +305,26 @@ export async function recaptureScenePreset(presetId) { } } +// ===== Clone ===== + +export async function cloneScenePreset(presetId) { + try { + const resp = await fetchWithAuth(`/scene-presets/${presetId}/clone`, { + method: 'POST', + }); + if (resp.ok) { + showToast(t('scenes.cloned'), 'success'); + _reloadScenesTab(); + } else { + const err = await resp.json().catch(() => ({})); + showToast(err.detail || t('scenes.error.clone_failed'), 'error'); + } + } catch (error) { + if (error.isAuth) return; + showToast(t('scenes.error.clone_failed'), 'error'); + } +} + // ===== Delete ===== export async function deleteScenePreset(presetId) { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 1b5dfbf..a26b307 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -681,6 +681,8 @@ "scenes.error.activate_failed": "Failed to activate scene", "scenes.error.recapture_failed": "Failed to recapture scene", "scenes.error.delete_failed": "Failed to delete scene", + "scenes.cloned": "Scene cloned", + "scenes.error.clone_failed": "Failed to clone scene", "time.hours_minutes": "{h}h {m}m", "time.minutes_seconds": "{m}m {s}s", "time.seconds": "{s}s", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index cb459cf..97d4814 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -681,6 +681,8 @@ "scenes.error.activate_failed": "Не удалось активировать сцену", "scenes.error.recapture_failed": "Не удалось перезахватить сцену", "scenes.error.delete_failed": "Не удалось удалить сцену", + "scenes.cloned": "Сцена клонирована", + "scenes.error.clone_failed": "Не удалось клонировать сцену", "time.hours_minutes": "{h}ч {m}м", "time.minutes_seconds": "{m}м {s}с", "time.seconds": "{s}с", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 9e76d21..c20f44f 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -681,6 +681,8 @@ "scenes.error.activate_failed": "激活场景失败", "scenes.error.recapture_failed": "重新捕获场景失败", "scenes.error.delete_failed": "删除场景失败", + "scenes.cloned": "场景已克隆", + "scenes.error.clone_failed": "克隆场景失败", "time.hours_minutes": "{h}时 {m}分", "time.minutes_seconds": "{m}分 {s}秒", "time.seconds": "{s}秒", diff --git a/server/src/wled_controller/storage/scene_preset_store.py b/server/src/wled_controller/storage/scene_preset_store.py index eb46d74..ac80868 100644 --- a/server/src/wled_controller/storage/scene_preset_store.py +++ b/server/src/wled_controller/storage/scene_preset_store.py @@ -6,7 +6,7 @@ from datetime import datetime from pathlib import Path from typing import Dict, List, Optional -from wled_controller.storage.scene_preset import ScenePreset +from wled_controller.storage.scene_preset import ScenePreset, TargetSnapshot from wled_controller.utils import atomic_write_json, get_logger logger = get_logger(__name__) @@ -117,6 +117,25 @@ class ScenePresetStore: logger.info(f"Recaptured scene preset: {preset_id}") return existing + def clone_preset(self, preset_id: str) -> ScenePreset: + """Duplicate an existing preset with a new ID and '(Copy)' suffix.""" + if preset_id not in self._presets: + raise ValueError(f"Scene preset not found: {preset_id}") + + source = self._presets[preset_id] + new_id = f"scene_{uuid.uuid4().hex[:8]}" + cloned = ScenePreset( + id=new_id, + name=f"{source.name} (Copy)", + description=source.description, + targets=[TargetSnapshot.from_dict(t.to_dict()) for t in source.targets], + order=source.order, + ) + self._presets[new_id] = cloned + self._save() + logger.info(f"Cloned scene preset: {preset_id} -> {new_id}") + return cloned + def delete_preset(self, preset_id: str) -> None: if preset_id not in self._presets: raise ValueError(f"Scene preset not found: {preset_id}")