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
|
## 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:
|
||||||
|
|||||||
@@ -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 =====
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">✕</button>`;
|
||||||
|
targetList.appendChild(item);
|
||||||
|
}
|
||||||
|
_refreshTargetSelect();
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scenePresetModal.open();
|
||||||
|
scenePresetModal.snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Delete =====
|
// ===== Delete =====
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user