diff --git a/server/CLAUDE.md b/server/CLAUDE.md
index 8fb351b..8656bbe 100644
--- a/server/CLAUDE.md
+++ b/server/CLAUDE.md
@@ -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:
diff --git a/server/src/wled_controller/api/routes/scene_presets.py b/server/src/wled_controller/api/routes/scene_presets.py
index 9767d75..8e1493d 100644
--- a/server/src/wled_controller/api/routes/scene_presets.py
+++ b/server/src/wled_controller/api/routes/scene_presets.py
@@ -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 =====
diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js
index a01c765..f031b29 100644
--- a/server/src/wled_controller/static/js/app.js
+++ b/server/src/wled_controller/static/js/app.js
@@ -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,
diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js
index 9627971..857457d 100644
--- a/server/src/wled_controller/static/js/features/automations.js
+++ b/server/src/wled_controller/static/js/features/automations.js
@@ -185,6 +185,7 @@ function createAutomationCard(automation, sceneMap = new Map()) {
${condPills}
`,
actions: `
+ ${ICON_CLONE}
${ICON_SETTINGS}
${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);
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 1a20289..7669ec4 100644
--- a/server/src/wled_controller/static/js/features/scene-presets.js
+++ b/server/src/wled_controller/static/js/features/scene-presets.js
@@ -308,21 +308,49 @@ 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');
+ 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('/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 = `${escapeHtml(tgt.name)} ✕ `;
+ targetList.appendChild(item);
+ }
+ _refreshTargetSelect();
+ }
+ } catch { /* ignore */ }
}
+
+ scenePresetModal.open();
+ scenePresetModal.snapshot();
}
// ===== Delete =====
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json
index 62b20b8..2b9b670 100644
--- a/server/src/wled_controller/static/locales/en.json
+++ b/server/src/wled_controller/static/locales/en.json
@@ -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."
}
diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json
index e515256..c3baf94 100644
--- a/server/src/wled_controller/static/locales/ru.json
+++ b/server/src/wled_controller/static/locales/ru.json
@@ -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": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах."
}
diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json
index a8a353b..4dc65d8 100644
--- a/server/src/wled_controller/static/locales/zh.json
+++ b/server/src/wled_controller/static/locales/zh.json
@@ -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": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。"
}
diff --git a/server/src/wled_controller/storage/scene_preset_store.py b/server/src/wled_controller/storage/scene_preset_store.py
index ac80868..e4ae532 100644
--- a/server/src/wled_controller/storage/scene_preset_store.py
+++ b/server/src/wled_controller/storage/scene_preset_store.py
@@ -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}")