Add clone support for scene and automation cards, update sync clock descriptions

- Scene clone: opens capture modal with prefilled name/description/targets
  instead of server-side duplication; removed backend clone endpoint
- Automation clone: opens editor with prefilled conditions, scene, logic,
  deactivation mode (webhook tokens stripped for uniqueness)
- Updated sync clock i18n descriptions to reflect speed-only-on-clock model
- Added entity card clone pattern documentation to server/CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 22:47:11 +03:00
parent bc5d8fdc9b
commit a330a8c0f0
9 changed files with 115 additions and 65 deletions

View File

@@ -130,6 +130,31 @@ After restarting the server with new code:
## Frontend UI Patterns ## Frontend UI Patterns
### Entity Cards
All entity cards (devices, targets, CSS sources, streams, scenes, automations, etc.) **must support clone functionality**. Clone buttons use the `ICON_CLONE` (📋) icon in `.card-actions`.
**Clone pattern**: Clone must open the entity's add/create modal with fields prefilled from the cloned item. It must **never** silently create a duplicate — the user should review and confirm.
Implementation:
1. Export a `cloneMyEntity(id)` function that fetches (or finds in cache) the entity data
2. Call the add/create modal function, passing the entity data as `cloneData`
3. In the modal opener, detect clone mode (no ID + cloneData present) and prefill all fields
4. Append `' (Copy)'` to the name
5. Set the modal title to the "add" variant (not "edit")
6. The save action creates a new entity (POST), not an update (PUT)
```javascript
export async function cloneMyEntity(id) {
const entity = myCache.data.find(e => e.id === id);
if (!entity) return;
showMyEditor(null, entity); // null id = create mode, entity = cloneData
}
```
Register the clone function in `app.js` window exports so inline `onclick` handlers can call it.
### Modal Dialogs ### Modal Dialogs
**IMPORTANT**: All modal dialogs must follow these standards for consistent UX: **IMPORTANT**: All modal dialogs must follow these standards for consistent UX:

View File

@@ -164,27 +164,6 @@ 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 =====

View File

@@ -80,7 +80,7 @@ import {
import { import {
loadAutomations, openAutomationEditor, closeAutomationEditorModal, loadAutomations, openAutomationEditor, closeAutomationEditorModal,
saveAutomationEditor, addAutomationCondition, saveAutomationEditor, addAutomationCondition,
toggleAutomationEnabled, deleteAutomation, copyWebhookUrl, toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
expandAllAutomationSections, collapseAllAutomationSections, expandAllAutomationSections, collapseAllAutomationSections,
} from './features/automations.js'; } from './features/automations.js';
import { import {
@@ -315,6 +315,7 @@ Object.assign(window, {
saveAutomationEditor, saveAutomationEditor,
addAutomationCondition, addAutomationCondition,
toggleAutomationEnabled, toggleAutomationEnabled,
cloneAutomation,
deleteAutomation, deleteAutomation,
copyWebhookUrl, copyWebhookUrl,
expandAllAutomationSections, expandAllAutomationSections,

View File

@@ -185,6 +185,7 @@ function createAutomationCard(automation, sceneMap = new Map()) {
</div> </div>
<div class="stream-card-props">${condPills}</div>`, <div class="stream-card-props">${condPills}</div>`,
actions: ` actions: `
<button class="btn btn-icon btn-secondary" onclick="cloneAutomation('${automation.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="openAutomationEditor('${automation.id}')" title="${t('automations.edit')}">${ICON_SETTINGS}</button> <button class="btn btn-icon btn-secondary" onclick="openAutomationEditor('${automation.id}')" title="${t('automations.edit')}">${ICON_SETTINGS}</button>
<button class="btn btn-icon ${automation.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleAutomationEnabled('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}"> <button class="btn btn-icon ${automation.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleAutomationEnabled('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">
${automation.enabled ? ICON_PAUSE : ICON_START} ${automation.enabled ? ICON_PAUSE : ICON_START}
@@ -192,7 +193,7 @@ function createAutomationCard(automation, sceneMap = new Map()) {
}); });
} }
export async function openAutomationEditor(automationId) { export async function openAutomationEditor(automationId, cloneData) {
const modal = document.getElementById('automation-editor-modal'); const modal = document.getElementById('automation-editor-modal');
const titleEl = document.getElementById('automation-editor-title'); const titleEl = document.getElementById('automation-editor-title');
const idInput = document.getElementById('automation-editor-id'); const idInput = document.getElementById('automation-editor-id');
@@ -241,6 +242,26 @@ export async function openAutomationEditor(automationId) {
showToast(e.message, 'error'); showToast(e.message, 'error');
return; return;
} }
} else if (cloneData) {
// Clone mode — create with prefilled data
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
idInput.value = '';
nameInput.value = (cloneData.name || '') + ' (Copy)';
enabledInput.checked = cloneData.enabled !== false;
logicSelect.value = cloneData.condition_logic || 'or';
// Clone conditions (strip webhook tokens — they must be unique)
for (const c of (cloneData.conditions || [])) {
const clonedCond = { ...c };
if (clonedCond.condition_type === 'webhook') delete clonedCond.token;
addAutomationConditionRow(clonedCond);
}
_initSceneSelector('automation-scene', cloneData.scene_preset_id);
document.getElementById('automation-deactivation-mode').value = cloneData.deactivation_mode || 'none';
_onDeactivationModeChange();
_initSceneSelector('automation-fallback-scene', cloneData.deactivation_scene_preset_id);
} else { } else {
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`; titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
idInput.value = ''; idInput.value = '';
@@ -729,6 +750,18 @@ export function copyWebhookUrl(btn) {
}); });
} }
export async function cloneAutomation(automationId) {
try {
const resp = await fetchWithAuth(`/automations/${automationId}`);
if (!resp.ok) throw new Error('Failed to load automation');
const automation = await resp.json();
openAutomationEditor(null, automation);
} catch (e) {
if (e.isAuth) return;
showToast(t('automations.error.clone_failed'), 'error');
}
}
export async function deleteAutomation(automationId, automationName) { export async function deleteAutomation(automationId, automationName) {
const msg = t('automations.delete.confirm').replace('{name}', automationName); const msg = t('automations.delete.confirm').replace('{name}', automationName);
const confirmed = await showConfirm(msg); const confirmed = await showConfirm(msg);

View File

@@ -308,21 +308,49 @@ export async function recaptureScenePreset(presetId) {
// ===== Clone ===== // ===== Clone =====
export async function cloneScenePreset(presetId) { export async function cloneScenePreset(presetId) {
try { const preset = scenePresetsCache.data.find(p => p.id === presetId);
const resp = await fetchWithAuth(`/scene-presets/${presetId}/clone`, { if (!preset) return;
method: 'POST',
}); // Open the capture modal in create mode, prefilled from the cloned preset
if (resp.ok) { _editingId = null;
showToast(t('scenes.cloned'), 'success'); document.getElementById('scene-preset-editor-id').value = '';
_reloadScenesTab(); document.getElementById('scene-preset-editor-name').value = (preset.name || '') + ' (Copy)';
} else { document.getElementById('scene-preset-editor-description').value = preset.description || '';
const err = await resp.json().catch(() => ({})); document.getElementById('scene-preset-editor-error').style.display = 'none';
showToast(err.detail || t('scenes.error.clone_failed'), 'error');
} const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
} catch (error) { if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.add'); titleEl.textContent = t('scenes.add'); }
if (error.isAuth) return;
showToast(t('scenes.error.clone_failed'), 'error'); // Fetch targets and populate selector, then pre-add the cloned preset's targets
const selectorGroup = document.getElementById('scene-target-selector-group');
const targetList = document.getElementById('scene-target-list');
if (selectorGroup && targetList) {
selectorGroup.style.display = '';
targetList.innerHTML = '';
try {
const resp = await fetchWithAuth('/picture-targets');
if (resp.ok) {
const data = await resp.json();
_allTargets = data.targets || [];
// Pre-add targets from the cloned preset
const clonedTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
for (const tid of clonedTargetIds) {
const tgt = _allTargets.find(t => t.id === tid);
if (!tgt) continue;
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = tid;
item.innerHTML = `<span>${escapeHtml(tgt.name)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
targetList.appendChild(item);
}
_refreshTargetSelect();
}
} catch { /* ignore */ }
} }
scenePresetModal.open();
scenePresetModal.snapshot();
} }
// ===== Delete ===== // ===== Delete =====

View File

@@ -652,6 +652,7 @@
"automations.created": "Automation created", "automations.created": "Automation created",
"automations.deleted": "Automation deleted", "automations.deleted": "Automation deleted",
"automations.error.name_required": "Name is required", "automations.error.name_required": "Name is required",
"automations.error.clone_failed": "Failed to clone automation",
"scenes.title": "Scenes", "scenes.title": "Scenes",
"scenes.add": "Capture Scene", "scenes.add": "Capture Scene",
"scenes.edit": "Edit Scene", "scenes.edit": "Edit Scene",
@@ -1172,7 +1173,7 @@
"sync_clock.name.placeholder": "Main Animation Clock", "sync_clock.name.placeholder": "Main Animation Clock",
"sync_clock.name.hint": "A descriptive name for this synchronization clock", "sync_clock.name.hint": "A descriptive name for this synchronization clock",
"sync_clock.speed": "Speed:", "sync_clock.speed": "Speed:",
"sync_clock.speed.hint": "Speed multiplier shared by all linked sources. 1.0 = normal speed.", "sync_clock.speed.hint": "Animation speed multiplier for all linked sources. 1.0 = normal, 2.0 = double speed, 0.5 = half speed.",
"sync_clock.description": "Description (optional):", "sync_clock.description": "Description (optional):",
"sync_clock.description.placeholder": "Optional description", "sync_clock.description.placeholder": "Optional description",
"sync_clock.description.hint": "Optional notes about this clock's purpose", "sync_clock.description.hint": "Optional notes about this clock's purpose",
@@ -1189,7 +1190,7 @@
"sync_clock.paused": "Clock paused", "sync_clock.paused": "Clock paused",
"sync_clock.resumed": "Clock resumed", "sync_clock.resumed": "Clock resumed",
"sync_clock.reset_done": "Clock reset to zero", "sync_clock.reset_done": "Clock reset to zero",
"sync_clock.delete.confirm": "Delete this sync clock? Sources using it will revert to their own speed.", "sync_clock.delete.confirm": "Delete this sync clock? Linked sources will lose synchronization and run at default speed.",
"color_strip.clock": "Sync Clock:", "color_strip.clock": "Sync Clock:",
"color_strip.clock.hint": "Link to a sync clock for synchronized animation. When set, speed comes from the clock." "color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock."
} }

View File

@@ -652,6 +652,7 @@
"automations.created": "Автоматизация создана", "automations.created": "Автоматизация создана",
"automations.deleted": "Автоматизация удалена", "automations.deleted": "Автоматизация удалена",
"automations.error.name_required": "Введите название", "automations.error.name_required": "Введите название",
"automations.error.clone_failed": "Не удалось клонировать автоматизацию",
"scenes.title": "Сцены", "scenes.title": "Сцены",
"scenes.add": "Захватить сцену", "scenes.add": "Захватить сцену",
"scenes.edit": "Редактировать сцену", "scenes.edit": "Редактировать сцену",
@@ -1172,7 +1173,7 @@
"sync_clock.name.placeholder": "Основные часы анимации", "sync_clock.name.placeholder": "Основные часы анимации",
"sync_clock.name.hint": "Описательное название для этих часов синхронизации", "sync_clock.name.hint": "Описательное название для этих часов синхронизации",
"sync_clock.speed": "Скорость:", "sync_clock.speed": "Скорость:",
"sync_clock.speed.hint": "Множитель скорости, общий для всех привязанных источников. 1.0 = нормальная скорость.", "sync_clock.speed.hint": "Множитель скорости анимации для всех привязанных источников. 1.0 = обычная, 2.0 = двойная, 0.5 = половинная.",
"sync_clock.description": "Описание (необязательно):", "sync_clock.description": "Описание (необязательно):",
"sync_clock.description.placeholder": "Необязательное описание", "sync_clock.description.placeholder": "Необязательное описание",
"sync_clock.description.hint": "Необязательные заметки о назначении этих часов", "sync_clock.description.hint": "Необязательные заметки о назначении этих часов",
@@ -1189,7 +1190,7 @@
"sync_clock.paused": "Часы приостановлены", "sync_clock.paused": "Часы приостановлены",
"sync_clock.resumed": "Часы возобновлены", "sync_clock.resumed": "Часы возобновлены",
"sync_clock.reset_done": "Часы сброшены на ноль", "sync_clock.reset_done": "Часы сброшены на ноль",
"sync_clock.delete.confirm": "Удалить эти часы синхронизации? Источники, использующие их, вернутся к собственной скорости.", "sync_clock.delete.confirm": "Удалить эти часы синхронизации? Привязанные источники потеряют синхронизацию и будут работать на скорости по умолчанию.",
"color_strip.clock": "Часы синхронизации:", "color_strip.clock": "Часы синхронизации:",
"color_strip.clock.hint": "Привязка к часам синхронизации для синхронной анимации. При установке скорость берётся из часов." "color_strip.clock.hint": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах."
} }

View File

@@ -652,6 +652,7 @@
"automations.created": "自动化已创建", "automations.created": "自动化已创建",
"automations.deleted": "自动化已删除", "automations.deleted": "自动化已删除",
"automations.error.name_required": "名称为必填项", "automations.error.name_required": "名称为必填项",
"automations.error.clone_failed": "克隆自动化失败",
"scenes.title": "场景", "scenes.title": "场景",
"scenes.add": "捕获场景", "scenes.add": "捕获场景",
"scenes.edit": "编辑场景", "scenes.edit": "编辑场景",
@@ -1172,7 +1173,7 @@
"sync_clock.name.placeholder": "主动画时钟", "sync_clock.name.placeholder": "主动画时钟",
"sync_clock.name.hint": "此同步时钟的描述性名称", "sync_clock.name.hint": "此同步时钟的描述性名称",
"sync_clock.speed": "速度:", "sync_clock.speed": "速度:",
"sync_clock.speed.hint": "所有关联源共享的速度倍率。1.0 = 正常速。", "sync_clock.speed.hint": "所有关联源的动画速度倍率。1.0 = 正常2.0 = 双倍0.5 = 半速。",
"sync_clock.description": "描述(可选):", "sync_clock.description": "描述(可选):",
"sync_clock.description.placeholder": "可选描述", "sync_clock.description.placeholder": "可选描述",
"sync_clock.description.hint": "关于此时钟用途的可选备注", "sync_clock.description.hint": "关于此时钟用途的可选备注",
@@ -1189,7 +1190,7 @@
"sync_clock.paused": "时钟已暂停", "sync_clock.paused": "时钟已暂停",
"sync_clock.resumed": "时钟已恢复", "sync_clock.resumed": "时钟已恢复",
"sync_clock.reset_done": "时钟已重置为零", "sync_clock.reset_done": "时钟已重置为零",
"sync_clock.delete.confirm": "删除此同步时钟?使用它的源将恢复为各自的速度。", "sync_clock.delete.confirm": "删除此同步时钟?关联的源将失去同步并以默认速度运行。",
"color_strip.clock": "同步时钟:", "color_strip.clock": "同步时钟:",
"color_strip.clock.hint": "关联同步时钟以实现同步动画。设置后,速度将来自时钟。" "color_strip.clock.hint": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。"
} }

View File

@@ -117,25 +117,6 @@ 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}")