Add clone support for scene presets and update TODO
- Add clone_preset() to ScenePresetStore with deep copy of target snapshots
- Add POST /scene-presets/{id}/clone API endpoint
- Add clone button to scene preset cards in Automations tab
- Add i18n keys for clone feedback in all 3 locales
- Add TODO items for dashboard stats collapse and protocol badge review
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
TODO.md
2
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
|
- 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")
|
- 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
|
- [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
|
||||||
|
|||||||
@@ -164,6 +164,28 @@ async def delete_scene_preset(
|
|||||||
raise HTTPException(status_code=404, detail=str(e))
|
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 =====
|
# ===== Recapture =====
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ import {
|
|||||||
} from './features/automations.js';
|
} from './features/automations.js';
|
||||||
import {
|
import {
|
||||||
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
|
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
|
||||||
activateScenePreset, recaptureScenePreset, deleteScenePreset,
|
activateScenePreset, recaptureScenePreset, cloneScenePreset, deleteScenePreset,
|
||||||
addSceneTarget, removeSceneTarget,
|
addSceneTarget, removeSceneTarget,
|
||||||
} from './features/scene-presets.js';
|
} from './features/scene-presets.js';
|
||||||
|
|
||||||
@@ -325,6 +325,7 @@ Object.assign(window, {
|
|||||||
closeScenePresetEditor,
|
closeScenePresetEditor,
|
||||||
activateScenePreset,
|
activateScenePreset,
|
||||||
recaptureScenePreset,
|
recaptureScenePreset,
|
||||||
|
cloneScenePreset,
|
||||||
deleteScenePreset,
|
deleteScenePreset,
|
||||||
addSceneTarget,
|
addSceneTarget,
|
||||||
removeSceneTarget,
|
removeSceneTarget,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { showToast, showConfirm } from '../core/ui.js';
|
|||||||
import { Modal } from '../core/modal.js';
|
import { Modal } from '../core/modal.js';
|
||||||
import { CardSection } from '../core/card-sections.js';
|
import { CardSection } from '../core/card-sections.js';
|
||||||
import {
|
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';
|
} from '../core/icons.js';
|
||||||
import { scenePresetsCache } from '../core/state.js';
|
import { scenePresetsCache } from '../core/state.js';
|
||||||
import { cardColorStyle, cardColorButton } from '../core/card-colors.js';
|
import { cardColorStyle, cardColorButton } from '../core/card-colors.js';
|
||||||
@@ -61,6 +61,7 @@ export function createSceneCard(preset) {
|
|||||||
${updated ? `<span class="stream-card-prop">${updated}</span>` : ''}
|
${updated ? `<span class="stream-card-prop">${updated}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="cloneScenePreset('${preset.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||||
<button class="btn btn-icon btn-secondary" onclick="editScenePreset('${preset.id}')" title="${t('scenes.edit')}">${ICON_EDIT}</button>
|
<button class="btn btn-icon btn-secondary" onclick="editScenePreset('${preset.id}')" title="${t('scenes.edit')}">${ICON_EDIT}</button>
|
||||||
<button class="btn btn-icon btn-secondary" onclick="recaptureScenePreset('${preset.id}')" title="${t('scenes.recapture')}">${ICON_REFRESH}</button>
|
<button class="btn btn-icon btn-secondary" onclick="recaptureScenePreset('${preset.id}')" title="${t('scenes.recapture')}">${ICON_REFRESH}</button>
|
||||||
<button class="btn btn-icon btn-success" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
|
<button class="btn btn-icon btn-success" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
|
||||||
@@ -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 =====
|
// ===== Delete =====
|
||||||
|
|
||||||
export async function deleteScenePreset(presetId) {
|
export async function deleteScenePreset(presetId) {
|
||||||
|
|||||||
@@ -681,6 +681,8 @@
|
|||||||
"scenes.error.activate_failed": "Failed to activate scene",
|
"scenes.error.activate_failed": "Failed to activate scene",
|
||||||
"scenes.error.recapture_failed": "Failed to recapture scene",
|
"scenes.error.recapture_failed": "Failed to recapture scene",
|
||||||
"scenes.error.delete_failed": "Failed to delete 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.hours_minutes": "{h}h {m}m",
|
||||||
"time.minutes_seconds": "{m}m {s}s",
|
"time.minutes_seconds": "{m}m {s}s",
|
||||||
"time.seconds": "{s}s",
|
"time.seconds": "{s}s",
|
||||||
|
|||||||
@@ -681,6 +681,8 @@
|
|||||||
"scenes.error.activate_failed": "Не удалось активировать сцену",
|
"scenes.error.activate_failed": "Не удалось активировать сцену",
|
||||||
"scenes.error.recapture_failed": "Не удалось перезахватить сцену",
|
"scenes.error.recapture_failed": "Не удалось перезахватить сцену",
|
||||||
"scenes.error.delete_failed": "Не удалось удалить сцену",
|
"scenes.error.delete_failed": "Не удалось удалить сцену",
|
||||||
|
"scenes.cloned": "Сцена клонирована",
|
||||||
|
"scenes.error.clone_failed": "Не удалось клонировать сцену",
|
||||||
"time.hours_minutes": "{h}ч {m}м",
|
"time.hours_minutes": "{h}ч {m}м",
|
||||||
"time.minutes_seconds": "{m}м {s}с",
|
"time.minutes_seconds": "{m}м {s}с",
|
||||||
"time.seconds": "{s}с",
|
"time.seconds": "{s}с",
|
||||||
|
|||||||
@@ -681,6 +681,8 @@
|
|||||||
"scenes.error.activate_failed": "激活场景失败",
|
"scenes.error.activate_failed": "激活场景失败",
|
||||||
"scenes.error.recapture_failed": "重新捕获场景失败",
|
"scenes.error.recapture_failed": "重新捕获场景失败",
|
||||||
"scenes.error.delete_failed": "删除场景失败",
|
"scenes.error.delete_failed": "删除场景失败",
|
||||||
|
"scenes.cloned": "场景已克隆",
|
||||||
|
"scenes.error.clone_failed": "克隆场景失败",
|
||||||
"time.hours_minutes": "{h}时 {m}分",
|
"time.hours_minutes": "{h}时 {m}分",
|
||||||
"time.minutes_seconds": "{m}分 {s}秒",
|
"time.minutes_seconds": "{m}分 {s}秒",
|
||||||
"time.seconds": "{s}秒",
|
"time.seconds": "{s}秒",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
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
|
from wled_controller.utils import atomic_write_json, get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -117,6 +117,25 @@ class ScenePresetStore:
|
|||||||
logger.info(f"Recaptured scene preset: {preset_id}")
|
logger.info(f"Recaptured scene preset: {preset_id}")
|
||||||
return existing
|
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:
|
def delete_preset(self, preset_id: str) -> None:
|
||||||
if preset_id not in self._presets:
|
if preset_id not in self._presets:
|
||||||
raise ValueError(f"Scene preset not found: {preset_id}")
|
raise ValueError(f"Scene preset not found: {preset_id}")
|
||||||
|
|||||||
Reference in New Issue
Block a user