- Add `startup` automation condition type that activates on server boot, replacing the per-target `auto_start` flag - Remove `auto_start` field from targets, scene snapshots, and all API layers - Remove auto-start UI section and star buttons from dashboard and target cards - Remove `color` field from scene presets (backend, API, modal, frontend) - Add card color support to scene preset cards (color picker + border style) - Show localStorage-backed card colors on all dashboard cards (targets, automations, sync clocks, scene presets) - Fix card color picker updating wrong card when duplicate data attributes exist by using closest() from picker wrapper instead of global querySelector - Add sync clocks step to Sources tab tutorial - Bump SW cache v9 → v10 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
341 lines
13 KiB
JavaScript
341 lines
13 KiB
JavaScript
/**
|
|
* Scene Presets — capture, activate, edit, delete system state snapshots.
|
|
* Rendered as a CardSection inside the Automations tab, plus dashboard compact cards.
|
|
*/
|
|
|
|
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
|
import { t } from '../core/i18n.js';
|
|
import { showToast, showConfirm } from '../core/ui.js';
|
|
import { Modal } from '../core/modal.js';
|
|
import { CardSection } from '../core/card-sections.js';
|
|
import {
|
|
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET,
|
|
} from '../core/icons.js';
|
|
import { scenePresetsCache } from '../core/state.js';
|
|
import { cardColorStyle, cardColorButton } from '../core/card-colors.js';
|
|
|
|
let _editingId = null;
|
|
let _allTargets = []; // fetched on capture open
|
|
|
|
class ScenePresetEditorModal extends Modal {
|
|
constructor() { super('scene-preset-editor-modal'); }
|
|
snapshotValues() {
|
|
const items = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
|
.map(el => el.dataset.targetId).sort().join(',');
|
|
return {
|
|
name: document.getElementById('scene-preset-editor-name').value,
|
|
description: document.getElementById('scene-preset-editor-description').value,
|
|
targets: items,
|
|
};
|
|
}
|
|
}
|
|
const scenePresetModal = new ScenePresetEditorModal();
|
|
|
|
export const csScenes = new CardSection('scenes', {
|
|
titleKey: 'scenes.title',
|
|
gridClass: 'devices-grid',
|
|
addCardOnclick: "openScenePresetCapture()",
|
|
keyAttr: 'data-scene-id',
|
|
});
|
|
|
|
export function createSceneCard(preset) {
|
|
const targetCount = (preset.targets || []).length;
|
|
|
|
const meta = [
|
|
targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null,
|
|
].filter(Boolean);
|
|
|
|
const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : '';
|
|
|
|
const colorStyle = cardColorStyle(preset.id);
|
|
return `<div class="card" data-scene-id="${preset.id}"${colorStyle ? ` style="${colorStyle}"` : ''}>
|
|
<div class="card-top-actions">
|
|
<button class="card-remove-btn" onclick="deleteScenePreset('${preset.id}', '${escapeHtml(preset.name)}')" title="${t('scenes.delete')}">✕</button>
|
|
</div>
|
|
<div class="card-header">
|
|
<div class="card-title">${escapeHtml(preset.name)}</div>
|
|
</div>
|
|
${preset.description ? `<div class="card-subtitle"><span class="card-meta">${escapeHtml(preset.description)}</span></div>` : ''}
|
|
<div class="stream-card-props">
|
|
${meta.map(m => `<span class="stream-card-prop">${m}</span>`).join('')}
|
|
${updated ? `<span class="stream-card-prop">${updated}</span>` : ''}
|
|
</div>
|
|
<div class="card-actions">
|
|
<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-success" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
|
|
${cardColorButton(preset.id, 'data-scene-id')}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ===== Dashboard section (compact cards) =====
|
|
|
|
export async function loadScenePresets() {
|
|
return scenePresetsCache.fetch();
|
|
}
|
|
|
|
export function renderScenePresetsSection(presets) {
|
|
if (!presets || presets.length === 0) return '';
|
|
|
|
const captureBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" onclick="event.stopPropagation(); openScenePresetCapture()" title="${t('scenes.capture')}">${ICON_CAPTURE} ${t('scenes.capture')}</button>`;
|
|
const cards = presets.map(p => _renderDashboardPresetCard(p)).join('');
|
|
|
|
return { headerExtra: captureBtn, content: `<div class="dashboard-autostart-grid">${cards}</div>` };
|
|
}
|
|
|
|
function _renderDashboardPresetCard(preset) {
|
|
const targetCount = (preset.targets || []).length;
|
|
|
|
const subtitle = [
|
|
targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null,
|
|
].filter(Boolean).join(' \u00b7 ');
|
|
|
|
const pStyle = cardColorStyle(preset.id);
|
|
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'scenes','data-scene-id','${preset.id}')}"${pStyle ? ` style="${pStyle}"` : ''}>
|
|
<div class="dashboard-target-info">
|
|
<span class="dashboard-target-icon">${ICON_SCENE}</span>
|
|
<div>
|
|
<div class="dashboard-target-name">${escapeHtml(preset.name)}</div>
|
|
${preset.description ? `<div class="dashboard-target-subtitle">${escapeHtml(preset.description)}</div>` : ''}
|
|
<div class="dashboard-target-subtitle">${subtitle}</div>
|
|
</div>
|
|
</div>
|
|
<div class="dashboard-target-actions">
|
|
<button class="dashboard-action-btn start" onclick="activateScenePreset('${preset.id}')" title="${t('scenes.activate')}">${ICON_START}</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ===== Capture (create) =====
|
|
|
|
export async function openScenePresetCapture() {
|
|
_editingId = null;
|
|
document.getElementById('scene-preset-editor-id').value = '';
|
|
document.getElementById('scene-preset-editor-name').value = '';
|
|
document.getElementById('scene-preset-editor-description').value = '';
|
|
|
|
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
|
|
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 || [];
|
|
_refreshTargetSelect();
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
scenePresetModal.open();
|
|
scenePresetModal.snapshot();
|
|
}
|
|
|
|
// ===== Edit metadata =====
|
|
|
|
export async function editScenePreset(presetId) {
|
|
const preset = scenePresetsCache.data.find(p => p.id === presetId);
|
|
if (!preset) return;
|
|
|
|
_editingId = presetId;
|
|
document.getElementById('scene-preset-editor-id').value = presetId;
|
|
document.getElementById('scene-preset-editor-name').value = preset.name;
|
|
document.getElementById('scene-preset-editor-description').value = preset.description || '';
|
|
document.getElementById('scene-preset-editor-error').style.display = 'none';
|
|
|
|
// Hide target selector in edit mode (metadata only)
|
|
const selectorGroup = document.getElementById('scene-target-selector-group');
|
|
if (selectorGroup) selectorGroup.style.display = 'none';
|
|
|
|
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
|
|
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.edit'); titleEl.textContent = t('scenes.edit'); }
|
|
|
|
scenePresetModal.open();
|
|
scenePresetModal.snapshot();
|
|
}
|
|
|
|
// ===== Save (create or update) =====
|
|
|
|
export async function saveScenePreset() {
|
|
const name = document.getElementById('scene-preset-editor-name').value.trim();
|
|
const description = document.getElementById('scene-preset-editor-description').value.trim();
|
|
const errorEl = document.getElementById('scene-preset-editor-error');
|
|
|
|
if (!name) {
|
|
errorEl.textContent = t('scenes.error.name_required');
|
|
errorEl.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let resp;
|
|
if (_editingId) {
|
|
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ name, description }),
|
|
});
|
|
} else {
|
|
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
|
.map(el => el.dataset.targetId);
|
|
resp = await fetchWithAuth('/scene-presets', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, description, target_ids }),
|
|
});
|
|
}
|
|
|
|
if (!resp.ok) {
|
|
const err = await resp.json();
|
|
errorEl.textContent = err.detail || t('scenes.error.save_failed');
|
|
errorEl.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
scenePresetModal.forceClose();
|
|
showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success');
|
|
_reloadScenesTab();
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
errorEl.textContent = t('scenes.error.save_failed');
|
|
errorEl.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
export async function closeScenePresetEditor() {
|
|
await scenePresetModal.close();
|
|
}
|
|
|
|
// ===== Target selector helpers =====
|
|
|
|
function _refreshTargetSelect() {
|
|
const select = document.getElementById('scene-target-select');
|
|
if (!select) return;
|
|
const added = new Set(
|
|
[...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
|
.map(el => el.dataset.targetId)
|
|
);
|
|
select.innerHTML = '';
|
|
for (const tgt of _allTargets) {
|
|
if (added.has(tgt.id)) continue;
|
|
const opt = document.createElement('option');
|
|
opt.value = tgt.id;
|
|
opt.textContent = tgt.name;
|
|
select.appendChild(opt);
|
|
}
|
|
// Disable add button when no targets available
|
|
const addBtn = select.parentElement?.querySelector('button');
|
|
if (addBtn) addBtn.disabled = select.options.length === 0;
|
|
}
|
|
|
|
export function addSceneTarget() {
|
|
const select = document.getElementById('scene-target-select');
|
|
const list = document.getElementById('scene-target-list');
|
|
if (!select || !list || !select.value) return;
|
|
|
|
const targetId = select.value;
|
|
const targetName = select.options[select.selectedIndex].text;
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'scene-target-item';
|
|
item.dataset.targetId = targetId;
|
|
item.innerHTML = `<span>${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">✕</button>`;
|
|
list.appendChild(item);
|
|
_refreshTargetSelect();
|
|
}
|
|
|
|
export function removeSceneTarget(btn) {
|
|
btn.closest('.scene-target-item').remove();
|
|
_refreshTargetSelect();
|
|
}
|
|
|
|
// ===== Activate =====
|
|
|
|
export async function activateScenePreset(presetId) {
|
|
try {
|
|
const resp = await fetchWithAuth(`/scene-presets/${presetId}/activate`, {
|
|
method: 'POST',
|
|
});
|
|
if (!resp.ok) {
|
|
showToast(t('scenes.error.activate_failed'), 'error');
|
|
return;
|
|
}
|
|
const result = await resp.json();
|
|
if (result.status === 'activated') {
|
|
showToast(t('scenes.activated'), 'success');
|
|
} else {
|
|
showToast(`${t('scenes.activated_partial')}: ${result.errors.length} ${t('scenes.errors')}`, 'warning');
|
|
}
|
|
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast(t('scenes.error.activate_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
// ===== Recapture =====
|
|
|
|
export async function recaptureScenePreset(presetId) {
|
|
const preset = scenePresetsCache.data.find(p => p.id === presetId);
|
|
const name = preset ? preset.name : presetId;
|
|
const confirmed = await showConfirm(t('scenes.recapture_confirm', { name }));
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
const resp = await fetchWithAuth(`/scene-presets/${presetId}/recapture`, {
|
|
method: 'POST',
|
|
});
|
|
if (resp.ok) {
|
|
showToast(t('scenes.recaptured'), 'success');
|
|
_reloadScenesTab();
|
|
} else {
|
|
showToast(t('scenes.error.recapture_failed'), 'error');
|
|
}
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast(t('scenes.error.recapture_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
// ===== Delete =====
|
|
|
|
export async function deleteScenePreset(presetId) {
|
|
const preset = scenePresetsCache.data.find(p => p.id === presetId);
|
|
const name = preset ? preset.name : presetId;
|
|
const confirmed = await showConfirm(t('scenes.delete_confirm', { name }));
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
const resp = await fetchWithAuth(`/scene-presets/${presetId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (resp.ok) {
|
|
showToast(t('scenes.deleted'), 'success');
|
|
_reloadScenesTab();
|
|
} else {
|
|
showToast(t('scenes.error.delete_failed'), 'error');
|
|
}
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast(t('scenes.error.delete_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
// ===== Helpers =====
|
|
|
|
function _reloadScenesTab() {
|
|
// Reload automations tab (which includes scenes section)
|
|
if ((localStorage.getItem('activeTab') || 'dashboard') === 'automations') {
|
|
if (typeof window.loadAutomations === 'function') window.loadAutomations();
|
|
}
|
|
// Also refresh dashboard (scene presets section)
|
|
if (typeof window.loadDashboard === 'function') window.loadDashboard(true);
|
|
}
|