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:
@@ -130,6 +130,31 @@ After restarting the server with new code:
|
||||
|
||||
## 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
|
||||
|
||||
**IMPORTANT**: All modal dialogs must follow these standards for consistent UX:
|
||||
|
||||
@@ -164,27 +164,6 @@ 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 =====
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ import {
|
||||
import {
|
||||
loadAutomations, openAutomationEditor, closeAutomationEditorModal,
|
||||
saveAutomationEditor, addAutomationCondition,
|
||||
toggleAutomationEnabled, deleteAutomation, copyWebhookUrl,
|
||||
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
|
||||
expandAllAutomationSections, collapseAllAutomationSections,
|
||||
} from './features/automations.js';
|
||||
import {
|
||||
@@ -315,6 +315,7 @@ Object.assign(window, {
|
||||
saveAutomationEditor,
|
||||
addAutomationCondition,
|
||||
toggleAutomationEnabled,
|
||||
cloneAutomation,
|
||||
deleteAutomation,
|
||||
copyWebhookUrl,
|
||||
expandAllAutomationSections,
|
||||
|
||||
@@ -185,6 +185,7 @@ function createAutomationCard(automation, sceneMap = new Map()) {
|
||||
</div>
|
||||
<div class="stream-card-props">${condPills}</div>`,
|
||||
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 ${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}
|
||||
@@ -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 titleEl = document.getElementById('automation-editor-title');
|
||||
const idInput = document.getElementById('automation-editor-id');
|
||||
@@ -241,6 +242,26 @@ export async function openAutomationEditor(automationId) {
|
||||
showToast(e.message, 'error');
|
||||
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 {
|
||||
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
|
||||
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) {
|
||||
const msg = t('automations.delete.confirm').replace('{name}', automationName);
|
||||
const confirmed = await showConfirm(msg);
|
||||
|
||||
@@ -308,21 +308,49 @@ export async function recaptureScenePreset(presetId) {
|
||||
// ===== Clone =====
|
||||
|
||||
export async function cloneScenePreset(presetId) {
|
||||
const preset = scenePresetsCache.data.find(p => p.id === presetId);
|
||||
if (!preset) return;
|
||||
|
||||
// Open the capture modal in create mode, prefilled from the cloned preset
|
||||
_editingId = null;
|
||||
document.getElementById('scene-preset-editor-id').value = '';
|
||||
document.getElementById('scene-preset-editor-name').value = (preset.name || '') + ' (Copy)';
|
||||
document.getElementById('scene-preset-editor-description').value = preset.description || '';
|
||||
document.getElementById('scene-preset-editor-error').style.display = 'none';
|
||||
|
||||
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
|
||||
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.add'); titleEl.textContent = t('scenes.add'); }
|
||||
|
||||
// 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(`/scene-presets/${presetId}/clone`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const resp = await fetchWithAuth('/picture-targets');
|
||||
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');
|
||||
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">✕</button>`;
|
||||
targetList.appendChild(item);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('scenes.error.clone_failed'), 'error');
|
||||
_refreshTargetSelect();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
scenePresetModal.open();
|
||||
scenePresetModal.snapshot();
|
||||
}
|
||||
|
||||
// ===== Delete =====
|
||||
|
||||
@@ -652,6 +652,7 @@
|
||||
"automations.created": "Automation created",
|
||||
"automations.deleted": "Automation deleted",
|
||||
"automations.error.name_required": "Name is required",
|
||||
"automations.error.clone_failed": "Failed to clone automation",
|
||||
"scenes.title": "Scenes",
|
||||
"scenes.add": "Capture Scene",
|
||||
"scenes.edit": "Edit Scene",
|
||||
@@ -1172,7 +1173,7 @@
|
||||
"sync_clock.name.placeholder": "Main Animation Clock",
|
||||
"sync_clock.name.hint": "A descriptive name for this synchronization clock",
|
||||
"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.placeholder": "Optional description",
|
||||
"sync_clock.description.hint": "Optional notes about this clock's purpose",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"sync_clock.paused": "Clock paused",
|
||||
"sync_clock.resumed": "Clock resumed",
|
||||
"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.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."
|
||||
}
|
||||
|
||||
@@ -652,6 +652,7 @@
|
||||
"automations.created": "Автоматизация создана",
|
||||
"automations.deleted": "Автоматизация удалена",
|
||||
"automations.error.name_required": "Введите название",
|
||||
"automations.error.clone_failed": "Не удалось клонировать автоматизацию",
|
||||
"scenes.title": "Сцены",
|
||||
"scenes.add": "Захватить сцену",
|
||||
"scenes.edit": "Редактировать сцену",
|
||||
@@ -1172,7 +1173,7 @@
|
||||
"sync_clock.name.placeholder": "Основные часы анимации",
|
||||
"sync_clock.name.hint": "Описательное название для этих часов синхронизации",
|
||||
"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.placeholder": "Необязательное описание",
|
||||
"sync_clock.description.hint": "Необязательные заметки о назначении этих часов",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"sync_clock.paused": "Часы приостановлены",
|
||||
"sync_clock.resumed": "Часы возобновлены",
|
||||
"sync_clock.reset_done": "Часы сброшены на ноль",
|
||||
"sync_clock.delete.confirm": "Удалить эти часы синхронизации? Источники, использующие их, вернутся к собственной скорости.",
|
||||
"sync_clock.delete.confirm": "Удалить эти часы синхронизации? Привязанные источники потеряют синхронизацию и будут работать на скорости по умолчанию.",
|
||||
"color_strip.clock": "Часы синхронизации:",
|
||||
"color_strip.clock.hint": "Привязка к часам синхронизации для синхронной анимации. При установке скорость берётся из часов."
|
||||
"color_strip.clock.hint": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах."
|
||||
}
|
||||
|
||||
@@ -652,6 +652,7 @@
|
||||
"automations.created": "自动化已创建",
|
||||
"automations.deleted": "自动化已删除",
|
||||
"automations.error.name_required": "名称为必填项",
|
||||
"automations.error.clone_failed": "克隆自动化失败",
|
||||
"scenes.title": "场景",
|
||||
"scenes.add": "捕获场景",
|
||||
"scenes.edit": "编辑场景",
|
||||
@@ -1172,7 +1173,7 @@
|
||||
"sync_clock.name.placeholder": "主动画时钟",
|
||||
"sync_clock.name.hint": "此同步时钟的描述性名称",
|
||||
"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.placeholder": "可选描述",
|
||||
"sync_clock.description.hint": "关于此时钟用途的可选备注",
|
||||
@@ -1189,7 +1190,7 @@
|
||||
"sync_clock.paused": "时钟已暂停",
|
||||
"sync_clock.resumed": "时钟已恢复",
|
||||
"sync_clock.reset_done": "时钟已重置为零",
|
||||
"sync_clock.delete.confirm": "删除此同步时钟?使用它的源将恢复为各自的速度。",
|
||||
"sync_clock.delete.confirm": "删除此同步时钟?关联的源将失去同步并以默认速度运行。",
|
||||
"color_strip.clock": "同步时钟:",
|
||||
"color_strip.clock.hint": "关联同步时钟以实现同步动画。设置后,速度将来自时钟。"
|
||||
"color_strip.clock.hint": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。"
|
||||
}
|
||||
|
||||
@@ -117,25 +117,6 @@ 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}")
|
||||
|
||||
Reference in New Issue
Block a user