Comprehensive WebUI review: 41 UX/feature/CSS improvements
Safety & Correctness: - Add confirmation dialogs to Stop All, turnOffDevice - i18n confirm dialog (title, yes, no buttons) - Fix duplicate tutorial-overlay ID - Define missing CSS variables (--radius, --text-primary, --hover-bg, --input-bg) - Fix toast z-index conflict with confirm dialog (2500 → 3000) UX Consistency: - Add backdrop-close to test modals - Add device clone feature (only entity without it) - Add sync clocks to command palette - Replace 20+ hardcoded accent colors with CSS vars/color-mix() - Remove dead .badge duplicate from components.css - Make calibration elements keyboard-accessible (div → button) - Add aria-labels to color picker swatches - Fix pattern canvas mobile horizontal scroll - Fix graph editor mobile bottom clipping Polish: - Add empty-state messages to all CardSection instances - Convert 21 px font-sizes to rem - Add scroll-behavior: smooth with reduced-motion override - Add @media print styles - Add :focus-visible to 4 missing interactive elements - Fix settings modal close label (Cancel → Close) - Fix api-key submit button i18n New Features: - Command palette actions: start/stop targets, activate scenes, enable/disable - Bulk start/stop API endpoints (POST /output-targets/bulk/start|stop) - OS notification history viewer modal - Scene "used by" automation reference count on cards - Clock elapsed time display on Streams tab cards - Device "last seen" relative timestamp on cards - Audio device refresh button in edit modal - Composite layer drag-to-reorder - MQTT settings panel (broker config with JSON persistence) - WebSocket log viewer with level filtering and ring buffer - Runtime log-level adjustment (GET/PUT endpoints + settings UI) - Animated value source waveform canvas preview - Gradient custom preset save/delete (localStorage) - API key read-only display in settings - Backup metadata (file size, auto/manual badges) - Server restart button with confirm + overlay - Partial config export/import per entity type - Progressive disclosure in target editor (Advanced section) CSS Architecture: - Define radius scale tokens (--radius-sm/md/lg/pill) - Scope .cs-filter selectors to remove 7 !important overrides - Consolidate duplicate toggle switch (filter-list → settings-toggle) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -102,6 +102,7 @@ import {
|
||||
import {
|
||||
onDeviceTypeChanged, updateBaudFpsHint, onSerialPortFocus,
|
||||
showAddDevice, closeAddDeviceModal, scanForDevices, handleAddDevice,
|
||||
cloneDevice,
|
||||
} from './features/device-discovery.js';
|
||||
import {
|
||||
loadTargetsTab, switchTargetSubTab,
|
||||
@@ -123,6 +124,10 @@ import {
|
||||
mappedAddZone, mappedRemoveZone,
|
||||
onAudioVizChange,
|
||||
applyGradientPreset,
|
||||
onGradientPresetChange,
|
||||
promptAndSaveGradientPreset,
|
||||
applyCustomGradientPreset,
|
||||
deleteAndRefreshGradientPreset,
|
||||
cloneColorStrip,
|
||||
toggleCSSOverlay,
|
||||
previewCSSFromEditor,
|
||||
@@ -130,6 +135,7 @@ import {
|
||||
onNotificationFilterModeChange,
|
||||
notificationAddAppColor, notificationRemoveAppColor,
|
||||
testNotification,
|
||||
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||
} from './features/color-strips.js';
|
||||
|
||||
@@ -138,6 +144,7 @@ import {
|
||||
showAudioSourceModal, closeAudioSourceModal, saveAudioSource,
|
||||
editAudioSource, cloneAudioSource, deleteAudioSource,
|
||||
testAudioSource, closeTestAudioSourceModal,
|
||||
refreshAudioDevices,
|
||||
} from './features/audio-sources.js';
|
||||
|
||||
// Layer 5: value sources
|
||||
@@ -177,6 +184,11 @@ import { openCommandPalette, closeCommandPalette, initCommandPalette } from './c
|
||||
import {
|
||||
openSettingsModal, closeSettingsModal, downloadBackup, handleRestoreFileSelected,
|
||||
saveAutoBackupSettings, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
|
||||
restartServer, saveMqttSettings,
|
||||
loadApiKeysList,
|
||||
downloadPartialExport, handlePartialImportFileSelected,
|
||||
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
|
||||
loadLogLevel, setLogLevel,
|
||||
} from './features/settings.js';
|
||||
|
||||
// ─── Register all HTML onclick / onchange / onfocus globals ───
|
||||
@@ -240,6 +252,7 @@ Object.assign(window, {
|
||||
loadDevices,
|
||||
updateSettingsBaudFpsHint,
|
||||
copyWsUrl,
|
||||
cloneDevice,
|
||||
|
||||
// dashboard
|
||||
loadDashboard,
|
||||
@@ -424,6 +437,10 @@ Object.assign(window, {
|
||||
mappedRemoveZone,
|
||||
onAudioVizChange,
|
||||
applyGradientPreset,
|
||||
onGradientPresetChange,
|
||||
promptAndSaveGradientPreset,
|
||||
applyCustomGradientPreset,
|
||||
deleteAndRefreshGradientPreset,
|
||||
cloneColorStrip,
|
||||
toggleCSSOverlay,
|
||||
previewCSSFromEditor,
|
||||
@@ -431,6 +448,7 @@ Object.assign(window, {
|
||||
onNotificationFilterModeChange,
|
||||
notificationAddAppColor, notificationRemoveAppColor,
|
||||
testNotification,
|
||||
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
|
||||
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||
|
||||
// audio sources
|
||||
@@ -442,6 +460,7 @@ Object.assign(window, {
|
||||
deleteAudioSource,
|
||||
testAudioSource,
|
||||
closeTestAudioSourceModal,
|
||||
refreshAudioDevices,
|
||||
|
||||
// value sources
|
||||
showValueSourceModal,
|
||||
@@ -504,7 +523,7 @@ Object.assign(window, {
|
||||
openCommandPalette,
|
||||
closeCommandPalette,
|
||||
|
||||
// settings (backup / restore / auto-backup)
|
||||
// settings (backup / restore / auto-backup / MQTT / partial export-import / api keys / log level)
|
||||
openSettingsModal,
|
||||
closeSettingsModal,
|
||||
downloadBackup,
|
||||
@@ -513,6 +532,17 @@ Object.assign(window, {
|
||||
restoreSavedBackup,
|
||||
downloadSavedBackup,
|
||||
deleteSavedBackup,
|
||||
restartServer,
|
||||
saveMqttSettings,
|
||||
loadApiKeysList,
|
||||
downloadPartialExport,
|
||||
handlePartialImportFileSelected,
|
||||
connectLogViewer,
|
||||
disconnectLogViewer,
|
||||
clearLogViewer,
|
||||
applyLogFilter,
|
||||
loadLogLevel,
|
||||
setLogLevel,
|
||||
});
|
||||
|
||||
// ─── Global keyboard shortcuts ───
|
||||
|
||||
@@ -45,8 +45,9 @@ export class CardSection {
|
||||
* @param {string} [opts.addCardOnclick] onclick handler string for the "+" add card
|
||||
* @param {string} [opts.keyAttr] data attribute that uniquely identifies cards (e.g. 'data-device-id')
|
||||
* @param {string} [opts.headerExtra] Extra HTML injected between count badge and filter (e.g. action buttons)
|
||||
* @param {string} [opts.emptyKey] i18n key for the empty-state message shown when there are no items
|
||||
*/
|
||||
constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible }) {
|
||||
constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey }) {
|
||||
this.sectionKey = sectionKey;
|
||||
this.titleKey = titleKey;
|
||||
this.gridClass = gridClass;
|
||||
@@ -54,6 +55,7 @@ export class CardSection {
|
||||
this.keyAttr = keyAttr || '';
|
||||
this.headerExtra = headerExtra || '';
|
||||
this.collapsible = !!collapsible;
|
||||
this.emptyKey = emptyKey || '';
|
||||
this._filterValue = '';
|
||||
this._lastItems = null;
|
||||
this._dragState = null;
|
||||
@@ -85,6 +87,10 @@ export class CardSection {
|
||||
? `<div class="template-card add-template-card cs-add-card" data-cs-add="${this.sectionKey}" onclick="${this.addCardOnclick}"><div class="add-template-icon">+</div></div>`
|
||||
: '';
|
||||
|
||||
const emptyState = (count === 0 && this.emptyKey)
|
||||
? `<div class="cs-empty-state" data-cs-empty="${this.sectionKey}"><span class="cs-empty-text text-muted">${t(this.emptyKey)}</span></div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}">
|
||||
<div class="subtab-section-header cs-header" data-cs-toggle="${this.sectionKey}">
|
||||
@@ -99,7 +105,7 @@ export class CardSection {
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-content ${this.gridClass}" data-cs-content="${this.sectionKey}"${contentDisplay}>
|
||||
${cardsHtml}
|
||||
${emptyState}${cardsHtml}
|
||||
${addCard}
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -205,6 +211,25 @@ export class CardSection {
|
||||
const countEl = document.querySelector(`[data-cs-toggle="${this.sectionKey}"] .cs-count`);
|
||||
if (countEl && !this._filterValue) countEl.textContent = items.length;
|
||||
|
||||
// Show/hide empty state
|
||||
if (this.emptyKey) {
|
||||
let emptyEl = content.querySelector(`[data-cs-empty="${this.sectionKey}"]`);
|
||||
if (items.length === 0) {
|
||||
if (!emptyEl) {
|
||||
emptyEl = document.createElement('div');
|
||||
emptyEl.className = 'cs-empty-state';
|
||||
emptyEl.setAttribute('data-cs-empty', this.sectionKey);
|
||||
emptyEl.innerHTML = `<span class="cs-empty-text text-muted">${t(this.emptyKey)}</span>`;
|
||||
const addCard = content.querySelector('.cs-add-card');
|
||||
if (addCard) content.insertBefore(emptyEl, addCard);
|
||||
else content.appendChild(emptyEl);
|
||||
}
|
||||
emptyEl.style.display = '';
|
||||
} else if (emptyEl) {
|
||||
emptyEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const newMap = new Map(items.map(i => [i.key, i.html]));
|
||||
const addCard = content.querySelector('.cs-add-card');
|
||||
const added = new Set();
|
||||
|
||||
@@ -31,7 +31,7 @@ const PRESETS = [
|
||||
export function createColorPicker({ id, currentColor, onPick, anchor = 'right', showReset = false, resetColor = '#808080' }) {
|
||||
const dots = PRESETS.map(c => {
|
||||
const active = c.toLowerCase() === currentColor.toLowerCase() ? ' active' : '';
|
||||
return `<button class="color-picker-dot${active}" style="background:${c}" onclick="event.stopPropagation(); window._cpPick('${id}','${c}')"></button>`;
|
||||
return `<button class="color-picker-dot${active}" style="background:${c}" aria-label="${c}" onclick="event.stopPropagation(); window._cpPick('${id}','${c}')"></button>`;
|
||||
}).join('');
|
||||
|
||||
const resetBtn = showReset
|
||||
|
||||
@@ -8,10 +8,11 @@ import { navigateToCard } from './navigation.js';
|
||||
import {
|
||||
getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
|
||||
ICON_DEVICE, ICON_TARGET, ICON_AUTOMATION, ICON_VALUE_SOURCE, ICON_SCENE,
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT,
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CSPT, ICON_CLOCK,
|
||||
} from './icons.js';
|
||||
import { getCardColor } from './card-colors.js';
|
||||
import { graphNavigateToNode } from '../features/graph-editor.js';
|
||||
import { showToast } from './ui.js';
|
||||
|
||||
let _isOpen = false;
|
||||
let _items = [];
|
||||
@@ -33,7 +34,7 @@ function _mapEntities(data, mapFn) {
|
||||
}
|
||||
|
||||
function _buildItems(results, states = {}) {
|
||||
const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates] = results;
|
||||
const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets, csptTemplates, syncClocks] = results;
|
||||
const items = [];
|
||||
|
||||
_mapEntities(devices, d => items.push({
|
||||
@@ -54,6 +55,26 @@ function _buildItems(results, states = {}) {
|
||||
nav: ['targets', 'led-targets', 'led-targets', 'data-target-id', tgt.id], running,
|
||||
});
|
||||
}
|
||||
// Action items: start or stop
|
||||
if (running) {
|
||||
items.push({
|
||||
name: tgt.name, detail: t('search.action.stop'), group: 'actions', icon: '■',
|
||||
action: async () => {
|
||||
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/stop`, { method: 'POST' });
|
||||
if (resp.ok) showToast(t('device.stopped'), 'success');
|
||||
else showToast(t('target.error.stop_failed'), 'error');
|
||||
},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
name: tgt.name, detail: t('search.action.start'), group: 'actions', icon: '▶',
|
||||
action: async () => {
|
||||
const resp = await fetchWithAuth(`/output-targets/${tgt.id}/start`, { method: 'POST' });
|
||||
if (resp.ok) showToast(t('device.started'), 'success');
|
||||
else showToast(t('target.error.start_failed'), 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_mapEntities(css, c => items.push({
|
||||
@@ -61,10 +82,31 @@ function _buildItems(results, states = {}) {
|
||||
nav: ['streams', 'color_strip', 'color-strips', 'data-css-id', c.id],
|
||||
}));
|
||||
|
||||
_mapEntities(automations, a => items.push({
|
||||
name: a.name, detail: a.enabled ? 'enabled' : '', group: 'automations', icon: ICON_AUTOMATION,
|
||||
nav: ['automations', null, 'automations', 'data-automation-id', a.id],
|
||||
}));
|
||||
_mapEntities(automations, a => {
|
||||
items.push({
|
||||
name: a.name, detail: a.enabled ? 'enabled' : '', group: 'automations', icon: ICON_AUTOMATION,
|
||||
nav: ['automations', null, 'automations', 'data-automation-id', a.id],
|
||||
});
|
||||
if (a.enabled) {
|
||||
items.push({
|
||||
name: a.name, detail: t('search.action.disable'), group: 'actions', icon: ICON_AUTOMATION,
|
||||
action: async () => {
|
||||
const resp = await fetchWithAuth(`/automations/${a.id}/disable`, { method: 'POST' });
|
||||
if (resp.ok) showToast(t('search.action.disable') + ': ' + a.name, 'success');
|
||||
else showToast(t('search.action.disable') + ' failed', 'error');
|
||||
},
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
name: a.name, detail: t('search.action.enable'), group: 'actions', icon: ICON_AUTOMATION,
|
||||
action: async () => {
|
||||
const resp = await fetchWithAuth(`/automations/${a.id}/enable`, { method: 'POST' });
|
||||
if (resp.ok) showToast(t('search.action.enable') + ': ' + a.name, 'success');
|
||||
else showToast(t('search.action.enable') + ' failed', 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_mapEntities(capTempl, ct => items.push({
|
||||
name: ct.name, detail: ct.engine_type, group: 'capture_templates', icon: ICON_CAPTURE_TEMPLATE,
|
||||
@@ -102,16 +144,31 @@ function _buildItems(results, states = {}) {
|
||||
});
|
||||
});
|
||||
|
||||
_mapEntities(scenePresets, sp => items.push({
|
||||
name: sp.name, detail: sp.description || '', group: 'scenes', icon: ICON_SCENE,
|
||||
nav: ['automations', null, 'scenes', 'data-scene-id', sp.id],
|
||||
}));
|
||||
_mapEntities(scenePresets, sp => {
|
||||
items.push({
|
||||
name: sp.name, detail: sp.description || '', group: 'scenes', icon: ICON_SCENE,
|
||||
nav: ['automations', null, 'scenes', 'data-scene-id', sp.id],
|
||||
});
|
||||
items.push({
|
||||
name: sp.name, detail: t('search.action.activate'), group: 'actions', icon: '⚡',
|
||||
action: async () => {
|
||||
const resp = await fetchWithAuth(`/scene-presets/${sp.id}/activate`, { method: 'POST' });
|
||||
if (resp.ok) showToast(t('scenes.activated'), 'success');
|
||||
else showToast(t('scenes.error.activate_failed'), 'error');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
_mapEntities(csptTemplates, ct => items.push({
|
||||
name: ct.name, detail: '', group: 'cspt', icon: ICON_CSPT,
|
||||
nav: ['streams', 'css_processing', 'cspt-templates', 'data-cspt-id', ct.id],
|
||||
}));
|
||||
|
||||
_mapEntities(syncClocks, sc => items.push({
|
||||
name: sc.name, detail: sc.is_running ? 'running' : '', group: 'sync_clocks', icon: ICON_CLOCK,
|
||||
nav: ['streams', 'sync', 'sync-clocks', 'data-id', sc.id],
|
||||
}));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -129,6 +186,7 @@ const _responseKeys = [
|
||||
['/picture-sources', 'streams'],
|
||||
['/scene-presets', 'presets'],
|
||||
['/color-strip-processing-templates', 'templates'],
|
||||
['/sync-clocks', 'clocks'],
|
||||
];
|
||||
|
||||
async function _fetchAllEntities() {
|
||||
@@ -149,9 +207,10 @@ async function _fetchAllEntities() {
|
||||
// ─── Group ordering ───
|
||||
|
||||
const _groupOrder = [
|
||||
'actions',
|
||||
'devices', 'targets', 'kc_targets', 'css', 'cspt', 'automations',
|
||||
'streams', 'capture_templates', 'pp_templates', 'pattern_templates',
|
||||
'audio', 'value', 'scenes',
|
||||
'audio', 'value', 'scenes', 'sync_clocks',
|
||||
];
|
||||
|
||||
const _groupRank = new Map(_groupOrder.map((g, i) => [g, i]));
|
||||
@@ -204,9 +263,11 @@ function _render() {
|
||||
html += `<div class="cp-group-header">${t('search.group.' + group)}</div>`;
|
||||
for (const item of items) {
|
||||
const active = idx === _selectedIdx ? ' cp-active' : '';
|
||||
const color = getCardColor(item.nav[4]);
|
||||
const entityId = item.nav ? item.nav[4] : null;
|
||||
const color = entityId ? getCardColor(entityId) : null;
|
||||
const colorStyle = color ? ` style="border-left:3px solid ${color}"` : '';
|
||||
html += `<div class="cp-result${active}" data-cp-idx="${idx}"${colorStyle}>` +
|
||||
const actionClass = item.action ? ' cp-action-item' : '';
|
||||
html += `<div class="cp-result${active}${actionClass}" data-cp-idx="${idx}"${colorStyle}>` +
|
||||
`<span class="cp-icon">${item.icon}</span>` +
|
||||
`<span class="cp-name">${escapeHtml(item.name)}</span>` +
|
||||
(item.running ? '<span class="cp-running"></span>' : '') +
|
||||
@@ -307,6 +368,12 @@ function _selectCurrent() {
|
||||
if (_selectedIdx < 0 || _selectedIdx >= _filtered.length) return;
|
||||
const item = _filtered[_selectedIdx];
|
||||
closeCommandPalette();
|
||||
if (item.action) {
|
||||
item.action().catch(err => {
|
||||
if (!err.isAuth) showToast(err.message || 'Action failed', 'error');
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If graph tab is active, navigate to graph node instead of card
|
||||
const graphTabActive = document.querySelector('.tab-btn[data-tab="graph"].active');
|
||||
if (graphTabActive) {
|
||||
|
||||
@@ -148,10 +148,11 @@ export class FilterListManager {
|
||||
if (opt.type === 'bool') {
|
||||
const checked = currentVal === true || currentVal === 'true';
|
||||
html += `<div class="pp-filter-option pp-filter-option-bool">
|
||||
<label for="${inputId}">
|
||||
<span>${escapeHtml(opt.label)}</span>
|
||||
<span class="pp-filter-option-label">${escapeHtml(opt.label)}</span>
|
||||
<label class="settings-toggle" for="${inputId}">
|
||||
<input type="checkbox" id="${inputId}" ${checked ? 'checked' : ''}
|
||||
onchange="${updateFn}(${index}, '${opt.key}', this.checked)">
|
||||
<span class="settings-toggle-slider"></span>
|
||||
</label>
|
||||
</div>`;
|
||||
} else if (opt.type === 'select' && Array.isArray(opt.choices)) {
|
||||
|
||||
@@ -211,6 +211,18 @@ export async function deleteAudioSource(sourceId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Refresh devices ───────────────────────────────────────────
|
||||
|
||||
export async function refreshAudioDevices() {
|
||||
const btn = document.getElementById('audio-source-refresh-devices');
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
await _loadAudioDevices();
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
let _cachedDevicesByEngine = {};
|
||||
|
||||
@@ -42,7 +42,7 @@ class AutomationEditorModal extends Modal {
|
||||
}
|
||||
|
||||
const automationModal = new AutomationEditorModal();
|
||||
const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id' });
|
||||
const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id', emptyKey: 'section.empty.automations' });
|
||||
|
||||
/* ── Condition logic IconSelect ───────────────────────────────── */
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
|
||||
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
|
||||
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST,
|
||||
ICON_SUN_DIM, ICON_WARNING,
|
||||
ICON_SUN_DIM, ICON_WARNING, ICON_AUTOMATION,
|
||||
} from '../core/icons.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
@@ -25,10 +25,12 @@ import {
|
||||
rgbArrayToHex, hexToRgbArray,
|
||||
gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset,
|
||||
getGradientStops, GRADIENT_PRESETS, gradientPresetStripHTML,
|
||||
loadCustomGradientPresets, saveCurrentAsCustomPreset, deleteCustomGradientPreset,
|
||||
} from './css-gradient-editor.js';
|
||||
|
||||
// Re-export for app.js window global bindings
|
||||
export { gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset };
|
||||
export { saveCurrentAsCustomPreset, deleteCustomGradientPreset };
|
||||
|
||||
class CSSEditorModal extends Modal {
|
||||
constructor() {
|
||||
@@ -173,7 +175,7 @@ export function onCSSTypeChange() {
|
||||
_ensureAudioPaletteIconSelect();
|
||||
onAudioVizChange();
|
||||
}
|
||||
if (type === 'gradient') _ensureGradientPresetIconSelect();
|
||||
if (type === 'gradient') { _ensureGradientPresetIconSelect(); _renderCustomPresetList(); }
|
||||
if (type === 'notification') {
|
||||
_ensureNotificationEffectIconSelect();
|
||||
_ensureNotificationFilterModeIconSelect();
|
||||
@@ -415,19 +417,64 @@ function _ensureAudioVizIconSelect() {
|
||||
_audioVizIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||
}
|
||||
|
||||
function _ensureGradientPresetIconSelect() {
|
||||
const sel = document.getElementById('css-editor-gradient-preset');
|
||||
if (!sel) return;
|
||||
const items = [
|
||||
function _buildGradientPresetItems() {
|
||||
const builtIn = [
|
||||
{ value: '', icon: _icon(P.palette), label: t('color_strip.gradient.preset.custom') },
|
||||
...Object.entries(GRADIENT_PRESETS).map(([key, stops]) => ({
|
||||
value: key, icon: gradientPresetStripHTML(stops), label: t(`color_strip.gradient.preset.${key}`),
|
||||
})),
|
||||
];
|
||||
const custom = loadCustomGradientPresets().map(p => ({
|
||||
value: `__custom__${p.name}`,
|
||||
icon: gradientPresetStripHTML(p.stops),
|
||||
label: p.name,
|
||||
isCustom: true,
|
||||
}));
|
||||
return [...builtIn, ...custom];
|
||||
}
|
||||
|
||||
function _ensureGradientPresetIconSelect() {
|
||||
const sel = document.getElementById('css-editor-gradient-preset');
|
||||
if (!sel) return;
|
||||
const items = _buildGradientPresetItems();
|
||||
if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; }
|
||||
_gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
||||
}
|
||||
|
||||
/** Rebuild the preset picker after adding/removing custom presets. */
|
||||
export function refreshGradientPresetPicker() {
|
||||
if (_gradientPresetIconSelect) {
|
||||
_gradientPresetIconSelect.updateItems(_buildGradientPresetItems());
|
||||
_gradientPresetIconSelect.setValue('');
|
||||
}
|
||||
_renderCustomPresetList();
|
||||
}
|
||||
|
||||
/** Render the custom preset list below the save button. */
|
||||
function _renderCustomPresetList() {
|
||||
const container = document.getElementById('css-editor-custom-presets-list');
|
||||
if (!container) return;
|
||||
const presets = loadCustomGradientPresets();
|
||||
if (presets.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = presets.map(p => {
|
||||
const strip = gradientPresetStripHTML(p.stops, 60, 14);
|
||||
const safeName = escapeHtml(p.name);
|
||||
return `<div class="custom-preset-row">
|
||||
${strip}
|
||||
<span class="custom-preset-name">${safeName}</span>
|
||||
<button type="button" class="btn btn-icon btn-sm btn-secondary"
|
||||
onclick="applyCustomGradientPreset(${JSON.stringify(p.name)})"
|
||||
title="${t('color_strip.gradient.preset.apply')}">✓</button>
|
||||
<button type="button" class="btn btn-icon btn-sm btn-danger"
|
||||
onclick="deleteAndRefreshGradientPreset(${JSON.stringify(p.name)})"
|
||||
title="${t('common.delete')}">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _ensureNotificationEffectIconSelect() {
|
||||
const sel = document.getElementById('css-editor-notification-effect');
|
||||
if (!sel) return;
|
||||
@@ -473,6 +520,40 @@ function _buildAnimationTypeItems(cssType) {
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Handles the gradient preset selector change — routes to built-in or custom preset. */
|
||||
export function onGradientPresetChange(value) {
|
||||
if (!value) return; // "— Custom —" selected
|
||||
if (value.startsWith('__custom__')) {
|
||||
applyCustomGradientPreset(value.slice('__custom__'.length));
|
||||
} else {
|
||||
applyGradientPreset(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from inline onclick in the HTML save button. Prompts for a name and saves. */
|
||||
export function promptAndSaveGradientPreset() {
|
||||
const name = window.prompt(t('color_strip.gradient.preset.save_prompt'), '');
|
||||
if (!name || !name.trim()) return;
|
||||
saveCurrentAsCustomPreset(name.trim());
|
||||
showToast(t('color_strip.gradient.preset.saved'), 'success');
|
||||
refreshGradientPresetPicker();
|
||||
}
|
||||
|
||||
/** Apply a custom preset by name. */
|
||||
export function applyCustomGradientPreset(name) {
|
||||
const presets = loadCustomGradientPresets();
|
||||
const preset = presets.find(p => p.name === name);
|
||||
if (!preset) return;
|
||||
gradientInit(preset.stops);
|
||||
}
|
||||
|
||||
/** Delete a custom preset and refresh the picker. */
|
||||
export function deleteAndRefreshGradientPreset(name) {
|
||||
deleteCustomGradientPreset(name);
|
||||
showToast(t('color_strip.gradient.preset.deleted'), 'success');
|
||||
refreshGradientPresetPicker();
|
||||
}
|
||||
|
||||
function _ensureAnimationTypeIconSelect(cssType) {
|
||||
const sel = document.getElementById('css-editor-animation-type');
|
||||
if (!sel) return;
|
||||
@@ -650,8 +731,9 @@ function _compositeRenderList() {
|
||||
).join('');
|
||||
const canRemove = _compositeLayers.length > 1;
|
||||
return `
|
||||
<div class="composite-layer-item">
|
||||
<div class="composite-layer-item" data-layer-index="${i}">
|
||||
<div class="composite-layer-row">
|
||||
<span class="composite-layer-drag-handle" title="${t('filters.drag_to_reorder')}">⠇</span>
|
||||
<select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
|
||||
<select class="composite-layer-blend" data-idx="${i}">
|
||||
<option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option>
|
||||
@@ -737,6 +819,8 @@ function _compositeRenderList() {
|
||||
noneLabel: t('common.none_no_cspt'),
|
||||
}));
|
||||
});
|
||||
|
||||
_initCompositeLayerDrag(list);
|
||||
}
|
||||
|
||||
export function compositeAddLayer() {
|
||||
@@ -780,6 +864,149 @@ function _compositeLayersSyncFromDom() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Composite layer drag-to-reorder ── */
|
||||
|
||||
const _COMPOSITE_DRAG_THRESHOLD = 5;
|
||||
let _compositeLayerDragState = null;
|
||||
|
||||
function _initCompositeLayerDrag(list) {
|
||||
// Guard against stacking listeners across re-renders (the list DOM node persists).
|
||||
if (list._compositeDragBound) return;
|
||||
list._compositeDragBound = true;
|
||||
|
||||
list.addEventListener('pointerdown', (e) => {
|
||||
const handle = e.target.closest('.composite-layer-drag-handle');
|
||||
if (!handle) return;
|
||||
const item = handle.closest('.composite-layer-item');
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const fromIndex = parseInt(item.dataset.layerIndex, 10);
|
||||
_compositeLayerDragState = {
|
||||
item,
|
||||
list,
|
||||
startY: e.clientY,
|
||||
started: false,
|
||||
clone: null,
|
||||
placeholder: null,
|
||||
offsetY: 0,
|
||||
fromIndex,
|
||||
scrollRaf: null,
|
||||
};
|
||||
|
||||
const onMove = (ev) => _onCompositeLayerDragMove(ev);
|
||||
const onUp = () => {
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onUp);
|
||||
_onCompositeLayerDragEnd();
|
||||
};
|
||||
document.addEventListener('pointermove', onMove);
|
||||
document.addEventListener('pointerup', onUp);
|
||||
}, { capture: false });
|
||||
}
|
||||
|
||||
function _onCompositeLayerDragMove(e) {
|
||||
const ds = _compositeLayerDragState;
|
||||
if (!ds) return;
|
||||
|
||||
if (!ds.started) {
|
||||
if (Math.abs(e.clientY - ds.startY) < _COMPOSITE_DRAG_THRESHOLD) return;
|
||||
_startCompositeLayerDrag(ds, e);
|
||||
}
|
||||
|
||||
ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';
|
||||
|
||||
const items = ds.list.querySelectorAll('.composite-layer-item');
|
||||
for (const it of items) {
|
||||
if (it.style.display === 'none') continue;
|
||||
const r = it.getBoundingClientRect();
|
||||
if (e.clientY >= r.top && e.clientY <= r.bottom) {
|
||||
const before = e.clientY < r.top + r.height / 2;
|
||||
if (it === ds.lastTarget && before === ds.lastBefore) break;
|
||||
ds.lastTarget = it;
|
||||
ds.lastBefore = before;
|
||||
if (before) {
|
||||
ds.list.insertBefore(ds.placeholder, it);
|
||||
} else {
|
||||
ds.list.insertBefore(ds.placeholder, it.nextSibling);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll near modal edges
|
||||
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
|
||||
const modal = ds.list.closest('.modal-body');
|
||||
if (modal) {
|
||||
const EDGE = 60, SPEED = 12;
|
||||
const mr = modal.getBoundingClientRect();
|
||||
let speed = 0;
|
||||
if (e.clientY < mr.top + EDGE) speed = -SPEED;
|
||||
else if (e.clientY > mr.bottom - EDGE) speed = SPEED;
|
||||
if (speed !== 0) {
|
||||
const scroll = () => { modal.scrollTop += speed; ds.scrollRaf = requestAnimationFrame(scroll); };
|
||||
ds.scrollRaf = requestAnimationFrame(scroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _startCompositeLayerDrag(ds, e) {
|
||||
ds.started = true;
|
||||
const rect = ds.item.getBoundingClientRect();
|
||||
|
||||
const clone = ds.item.cloneNode(true);
|
||||
clone.className = ds.item.className + ' composite-layer-drag-clone';
|
||||
clone.style.width = rect.width + 'px';
|
||||
clone.style.left = rect.left + 'px';
|
||||
clone.style.top = rect.top + 'px';
|
||||
document.body.appendChild(clone);
|
||||
ds.clone = clone;
|
||||
ds.offsetY = e.clientY - rect.top;
|
||||
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'composite-layer-drag-placeholder';
|
||||
placeholder.style.height = rect.height + 'px';
|
||||
ds.item.parentNode.insertBefore(placeholder, ds.item);
|
||||
ds.placeholder = placeholder;
|
||||
|
||||
ds.item.style.display = 'none';
|
||||
document.body.classList.add('composite-layer-dragging');
|
||||
}
|
||||
|
||||
function _onCompositeLayerDragEnd() {
|
||||
const ds = _compositeLayerDragState;
|
||||
_compositeLayerDragState = null;
|
||||
if (!ds || !ds.started) return;
|
||||
|
||||
if (ds.scrollRaf) cancelAnimationFrame(ds.scrollRaf);
|
||||
|
||||
// Determine new index from placeholder position
|
||||
let toIndex = 0;
|
||||
for (const child of ds.list.children) {
|
||||
if (child === ds.placeholder) break;
|
||||
if (child.classList.contains('composite-layer-item') && child.style.display !== 'none') {
|
||||
toIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup DOM
|
||||
ds.item.style.display = '';
|
||||
ds.placeholder.remove();
|
||||
ds.clone.remove();
|
||||
document.body.classList.remove('composite-layer-dragging');
|
||||
|
||||
// Sync current DOM values before reordering
|
||||
_compositeLayersSyncFromDom();
|
||||
|
||||
// Reorder array and re-render
|
||||
if (toIndex !== ds.fromIndex) {
|
||||
const [moved] = _compositeLayers.splice(ds.fromIndex, 1);
|
||||
_compositeLayers.splice(toIndex, 0, moved);
|
||||
_compositeRenderList();
|
||||
}
|
||||
}
|
||||
|
||||
function _compositeGetLayers() {
|
||||
_compositeLayersSyncFromDom();
|
||||
return _compositeLayers.map(l => {
|
||||
@@ -1074,6 +1301,79 @@ export async function testNotification(sourceId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── OS Notification History Modal ─────────────────────────────────────────
|
||||
|
||||
export function showNotificationHistory() {
|
||||
const modal = document.getElementById('notification-history-modal');
|
||||
if (!modal) return;
|
||||
modal.style.display = 'flex';
|
||||
modal.onclick = (e) => { if (e.target === modal) closeNotificationHistory(); };
|
||||
_loadNotificationHistory();
|
||||
}
|
||||
|
||||
export function closeNotificationHistory() {
|
||||
const modal = document.getElementById('notification-history-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
export async function refreshNotificationHistory() {
|
||||
await _loadNotificationHistory();
|
||||
}
|
||||
|
||||
async function _loadNotificationHistory() {
|
||||
const list = document.getElementById('notification-history-list');
|
||||
const status = document.getElementById('notification-history-status');
|
||||
if (!list) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/color-strip-sources/os-notifications/history');
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
|
||||
if (!data.available) {
|
||||
list.innerHTML = '';
|
||||
if (status) {
|
||||
status.textContent = t('color_strip.notification.history.unavailable');
|
||||
status.style.display = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (status) status.style.display = 'none';
|
||||
|
||||
const history = data.history || [];
|
||||
if (history.length === 0) {
|
||||
list.innerHTML = `<div class="notif-history-empty">${t('color_strip.notification.history.empty')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = history.map(entry => {
|
||||
const appName = entry.app || t('color_strip.notification.history.unknown_app');
|
||||
const timeStr = new Date(entry.time * 1000).toLocaleString();
|
||||
const fired = entry.fired ?? 0;
|
||||
const filtered = entry.filtered ?? 0;
|
||||
const firedBadge = fired > 0
|
||||
? `<span class="notif-history-badge notif-history-badge--fired" title="${t('color_strip.notification.history.fired')}">${fired}</span>`
|
||||
: '';
|
||||
const filteredBadge = filtered > 0
|
||||
? `<span class="notif-history-badge notif-history-badge--filtered" title="${t('color_strip.notification.history.filtered')}">${filtered}</span>`
|
||||
: '';
|
||||
return `<div class="notif-history-row">
|
||||
<div class="notif-history-app" title="${escapeHtml(appName)}">${escapeHtml(appName)}</div>
|
||||
<div class="notif-history-time">${timeStr}</div>
|
||||
<div class="notif-history-badges">${firedBadge}${filteredBadge}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to load notification history:', err);
|
||||
if (status) {
|
||||
status.textContent = t('color_strip.notification.history.error');
|
||||
status.style.display = '';
|
||||
}
|
||||
list.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function _notificationAppColorsSyncFromDom() {
|
||||
const list = document.getElementById('notification-app-colors-list');
|
||||
if (!list) return;
|
||||
@@ -1353,6 +1653,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
const testNotifyBtn = isNotification
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testNotification('${source.id}')" title="${t('color_strip.notification.test')}">${ICON_BELL}</button>`
|
||||
: '';
|
||||
const notifHistoryBtn = isNotification
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showNotificationHistory()" title="${t('color_strip.notification.history.title')}">${ICON_AUTOMATION}</button>`
|
||||
: '';
|
||||
const testPreviewBtn = !isApiInput
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`
|
||||
: '';
|
||||
@@ -1375,7 +1678,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||||
${calibrationBtn}${overlayBtn}${testNotifyBtn}${testPreviewBtn}`,
|
||||
${calibrationBtn}${overlayBtn}${testNotifyBtn}${notifHistoryBtn}${testPreviewBtn}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -378,6 +378,41 @@ function _gradientStartDrag(e, idx) {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
/* ── Custom presets (localStorage) ───────────────────────────── */
|
||||
|
||||
const _CUSTOM_PRESETS_KEY = 'custom_gradient_presets';
|
||||
|
||||
/** Load custom presets from localStorage. Returns an array of { name, stops }. */
|
||||
export function loadCustomGradientPresets() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(_CUSTOM_PRESETS_KEY) || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Save the current gradient stops as a named custom preset. */
|
||||
export function saveCurrentAsCustomPreset(name) {
|
||||
if (!name) return;
|
||||
const stops = _gradientStops.map(s => ({
|
||||
position: s.position,
|
||||
color: [...s.color],
|
||||
...(s.colorRight ? { color_right: [...s.colorRight] } : {}),
|
||||
}));
|
||||
const presets = loadCustomGradientPresets();
|
||||
// Replace if same name exists
|
||||
const idx = presets.findIndex(p => p.name === name);
|
||||
if (idx >= 0) presets[idx] = { name, stops };
|
||||
else presets.push({ name, stops });
|
||||
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
|
||||
}
|
||||
|
||||
/** Delete a custom preset by name. */
|
||||
export function deleteCustomGradientPreset(name) {
|
||||
const presets = loadCustomGradientPresets().filter(p => p.name !== name);
|
||||
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
|
||||
}
|
||||
|
||||
/* ── Track click → add stop ───────────────────────────────────── */
|
||||
|
||||
function _gradientSetupTrackClick() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, formatUptime, setTabRefreshing } from '../core/ui.js';
|
||||
import { showToast, showConfirm, formatUptime, setTabRefreshing } from '../core/ui.js';
|
||||
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js';
|
||||
import { startAutoRefresh, updateTabBadge } from './tabs.js';
|
||||
import {
|
||||
@@ -733,6 +733,8 @@ export async function dashboardStopTarget(targetId) {
|
||||
}
|
||||
|
||||
export async function dashboardStopAll() {
|
||||
const confirmed = await showConfirm(t('confirm.stop_all'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const [allTargets, statesResp] = await Promise.all([
|
||||
outputTargetsCache.fetch().catch(() => []),
|
||||
|
||||
@@ -582,7 +582,7 @@ export function onSerialPortFocus() {
|
||||
}
|
||||
}
|
||||
|
||||
export function showAddDevice(presetType = null) {
|
||||
export function showAddDevice(presetType = null, cloneData = null) {
|
||||
// When no type specified: show type picker first
|
||||
if (!presetType) {
|
||||
showTypePicker({
|
||||
@@ -623,6 +623,47 @@ export function showAddDevice(presetType = null) {
|
||||
|
||||
addDeviceModal.open();
|
||||
onDeviceTypeChanged();
|
||||
|
||||
// Prefill fields from clone data (after onDeviceTypeChanged shows/hides fields)
|
||||
if (cloneData) {
|
||||
document.getElementById('device-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
// Clear URL — devices must have unique addresses, user must enter a new one
|
||||
const urlInput = document.getElementById('device-url');
|
||||
if (urlInput) urlInput.value = '';
|
||||
// Prefill LED count
|
||||
const ledCountInput = document.getElementById('device-led-count');
|
||||
if (ledCountInput && cloneData.led_count) ledCountInput.value = cloneData.led_count;
|
||||
// Prefill baud rate for serial devices
|
||||
if (isSerialDevice(presetType)) {
|
||||
const baudSelect = document.getElementById('device-baud-rate');
|
||||
if (baudSelect && cloneData.baud_rate) baudSelect.value = String(cloneData.baud_rate);
|
||||
}
|
||||
// Prefill mock device fields
|
||||
if (isMockDevice(presetType)) {
|
||||
const ledTypeEl = document.getElementById('device-led-type');
|
||||
if (ledTypeEl) ledTypeEl.value = cloneData.rgbw ? 'rgbw' : 'rgb';
|
||||
const sendLatencyEl = document.getElementById('device-send-latency');
|
||||
if (sendLatencyEl) sendLatencyEl.value = cloneData.send_latency_ms ?? 0;
|
||||
}
|
||||
// Prefill DMX fields
|
||||
if (isDmxDevice(presetType)) {
|
||||
const dmxProto = document.getElementById('device-dmx-protocol');
|
||||
if (dmxProto && cloneData.dmx_protocol) dmxProto.value = cloneData.dmx_protocol;
|
||||
const dmxUniverse = document.getElementById('device-dmx-start-universe');
|
||||
if (dmxUniverse && cloneData.dmx_start_universe != null) dmxUniverse.value = cloneData.dmx_start_universe;
|
||||
const dmxChannel = document.getElementById('device-dmx-start-channel');
|
||||
if (dmxChannel && cloneData.dmx_start_channel != null) dmxChannel.value = cloneData.dmx_start_channel;
|
||||
}
|
||||
// Prefill CSPT template selector (after fetch completes)
|
||||
if (cloneData.default_css_processing_template_id) {
|
||||
csptCache.fetch().then(() => {
|
||||
_ensureCsptEntitySelect();
|
||||
const csptEl = document.getElementById('device-css-processing-template');
|
||||
if (csptEl) csptEl.value = cloneData.default_css_processing_template_id;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
desktopFocus(document.getElementById('device-name'));
|
||||
addDeviceModal.snapshot();
|
||||
@@ -984,3 +1025,18 @@ function _showGameSenseFields(show) {
|
||||
const el = document.getElementById('device-gamesense-device-type-group');
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
/* ── Clone device ──────────────────────────────────────────────── */
|
||||
|
||||
export async function cloneDevice(deviceId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/devices/${deviceId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load device');
|
||||
const device = await resp.json();
|
||||
showAddDevice(device.device_type || 'wled', device);
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
console.error('Failed to clone device:', error);
|
||||
showToast(t('device.error.clone_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode,
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.js';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE, ICON_CLONE } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
@@ -95,6 +95,22 @@ class DeviceSettingsModal extends Modal {
|
||||
|
||||
const settingsModal = new DeviceSettingsModal();
|
||||
|
||||
function _formatRelativeTime(isoString) {
|
||||
if (!isoString) return null;
|
||||
const then = new Date(isoString);
|
||||
const diffMs = Date.now() - then.getTime();
|
||||
if (diffMs < 0) return null;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
if (diffSec < 5) return t('device.last_seen.just_now');
|
||||
if (diffSec < 60) return t('device.last_seen.seconds').replace('%d', diffSec);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return t('device.last_seen.minutes').replace('%d', diffMin);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return t('device.last_seen.hours').replace('%d', diffHr);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
return t('device.last_seen.days').replace('%d', diffDay);
|
||||
}
|
||||
|
||||
export function createDeviceCard(device) {
|
||||
const state = device.state || {};
|
||||
|
||||
@@ -124,6 +140,7 @@ export function createDeviceCard(device) {
|
||||
}
|
||||
|
||||
const ledCount = state.device_led_count || device.led_count;
|
||||
const lastSeenLabel = devLastChecked ? _formatRelativeTime(devLastChecked) : null;
|
||||
|
||||
// Parse zone names from OpenRGB URL for badge display
|
||||
const openrgbZones = isOpenrgbDevice(device.device_type)
|
||||
@@ -152,6 +169,7 @@ export function createDeviceCard(device) {
|
||||
${state.device_led_type ? `<span class="card-meta">${ICON_PLUG} ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||||
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
||||
</div>
|
||||
${lastSeenLabel ? `<div class="stream-card-props"><span class="stream-card-prop" style="opacity:0.65;" title="${devLastChecked}">⏱ ${t('device.last_seen.label')}: ${lastSeenLabel}</span></div>` : ''}
|
||||
${(device.capabilities || []).includes('brightness_control') ? `
|
||||
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
|
||||
<input type="range" class="brightness-slider" min="0" max="255"
|
||||
@@ -166,6 +184,9 @@ export function createDeviceCard(device) {
|
||||
<button class="btn btn-icon btn-secondary card-ping-btn" onclick="event.stopPropagation(); pingDevice('${device.id}')" title="${t('device.button.ping')}">
|
||||
${ICON_REFRESH}
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneDevice('${device.id}')" title="${t('common.clone')}">
|
||||
${ICON_CLONE}
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||||
${ICON_SETTINGS}
|
||||
</button>`,
|
||||
@@ -173,6 +194,8 @@ export function createDeviceCard(device) {
|
||||
}
|
||||
|
||||
export async function turnOffDevice(deviceId) {
|
||||
const confirmed = await showConfirm(t('confirm.turn_off_device'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CardSection } from '../core/card-sections.js';
|
||||
import {
|
||||
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE,
|
||||
} from '../core/icons.js';
|
||||
import { scenePresetsCache, outputTargetsCache } from '../core/state.js';
|
||||
import { scenePresetsCache, outputTargetsCache, automationsCacheObj } from '../core/state.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { cardColorStyle, cardColorButton } from '../core/card-colors.js';
|
||||
import { EntityPalette } from '../core/entity-palette.js';
|
||||
@@ -43,13 +43,18 @@ export const csScenes = new CardSection('scenes', {
|
||||
gridClass: 'devices-grid',
|
||||
addCardOnclick: "openScenePresetCapture()",
|
||||
keyAttr: 'data-scene-id',
|
||||
emptyKey: 'section.empty.scenes',
|
||||
});
|
||||
|
||||
export function createSceneCard(preset) {
|
||||
const targetCount = (preset.targets || []).length;
|
||||
|
||||
const automations = automationsCacheObj.data || [];
|
||||
const usedByCount = automations.filter(a => a.scene_preset_id === preset.id).length;
|
||||
|
||||
const meta = [
|
||||
targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null,
|
||||
usedByCount > 0 ? `🔗 ${t('scene_preset.used_by').replace('%d', usedByCount)}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : '';
|
||||
|
||||
@@ -9,17 +9,139 @@ import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { ICON_UNDO, ICON_DOWNLOAD } from '../core/icons.js';
|
||||
|
||||
// ─── Log Viewer ────────────────────────────────────────────
|
||||
|
||||
/** @type {WebSocket|null} */
|
||||
let _logWs = null;
|
||||
|
||||
/** Level ordering for filter comparisons */
|
||||
const _LOG_LEVELS = { DEBUG: 10, INFO: 20, WARNING: 30, ERROR: 40, CRITICAL: 50 };
|
||||
|
||||
function _detectLevel(line) {
|
||||
for (const lvl of ['CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG']) {
|
||||
if (line.includes(lvl)) return lvl;
|
||||
}
|
||||
return 'DEBUG';
|
||||
}
|
||||
|
||||
function _levelClass(level) {
|
||||
if (level === 'ERROR' || level === 'CRITICAL') return 'log-line-error';
|
||||
if (level === 'WARNING') return 'log-line-warning';
|
||||
if (level === 'DEBUG') return 'log-line-debug';
|
||||
return '';
|
||||
}
|
||||
|
||||
function _filterLevel() {
|
||||
const sel = document.getElementById('log-viewer-filter');
|
||||
return sel ? sel.value : 'all';
|
||||
}
|
||||
|
||||
function _linePassesFilter(line) {
|
||||
const filter = _filterLevel();
|
||||
if (filter === 'all') return true;
|
||||
const lineLvl = _detectLevel(line);
|
||||
return (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0);
|
||||
}
|
||||
|
||||
function _appendLine(line) {
|
||||
// Skip keepalive empty pings
|
||||
if (!line) return;
|
||||
if (!_linePassesFilter(line)) return;
|
||||
|
||||
const output = document.getElementById('log-viewer-output');
|
||||
if (!output) return;
|
||||
|
||||
const level = _detectLevel(line);
|
||||
const cls = _levelClass(level);
|
||||
|
||||
const span = document.createElement('span');
|
||||
if (cls) span.className = cls;
|
||||
span.textContent = line + '\n';
|
||||
output.appendChild(span);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
export function connectLogViewer() {
|
||||
const btn = document.getElementById('log-viewer-connect-btn');
|
||||
|
||||
if (_logWs && (_logWs.readyState === WebSocket.OPEN || _logWs.readyState === WebSocket.CONNECTING)) {
|
||||
// Disconnect
|
||||
_logWs.close();
|
||||
_logWs = null;
|
||||
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
|
||||
return;
|
||||
}
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${proto}//${location.host}/api/v1/system/logs/ws?token=${encodeURIComponent(apiKey)}`;
|
||||
|
||||
_logWs = new WebSocket(url);
|
||||
|
||||
_logWs.onopen = () => {
|
||||
if (btn) { btn.textContent = t('settings.logs.disconnect'); btn.dataset.i18n = 'settings.logs.disconnect'; }
|
||||
};
|
||||
|
||||
_logWs.onmessage = (evt) => {
|
||||
_appendLine(evt.data);
|
||||
};
|
||||
|
||||
_logWs.onerror = () => {
|
||||
showToast(t('settings.logs.error'), 'error');
|
||||
};
|
||||
|
||||
_logWs.onclose = () => {
|
||||
_logWs = null;
|
||||
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
|
||||
};
|
||||
}
|
||||
|
||||
export function disconnectLogViewer() {
|
||||
if (_logWs) {
|
||||
_logWs.close();
|
||||
_logWs = null;
|
||||
}
|
||||
const btn = document.getElementById('log-viewer-connect-btn');
|
||||
if (btn) { btn.textContent = t('settings.logs.connect'); btn.dataset.i18n = 'settings.logs.connect'; }
|
||||
}
|
||||
|
||||
export function clearLogViewer() {
|
||||
const output = document.getElementById('log-viewer-output');
|
||||
if (output) output.innerHTML = '';
|
||||
}
|
||||
|
||||
/** Re-render the log output according to the current filter selection. */
|
||||
export function applyLogFilter() {
|
||||
// We don't buffer all raw lines in JS — just clear and note the filter
|
||||
// will apply to future lines. Existing lines that were already rendered
|
||||
// are re-evaluated by toggling their visibility.
|
||||
const output = document.getElementById('log-viewer-output');
|
||||
if (!output) return;
|
||||
const filter = _filterLevel();
|
||||
for (const span of output.children) {
|
||||
const line = span.textContent;
|
||||
const lineLvl = _detectLevel(line);
|
||||
const passes = filter === 'all' || (_LOG_LEVELS[lineLvl] ?? 10) >= (_LOG_LEVELS[filter] ?? 0);
|
||||
span.style.display = passes ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Simple modal (no form / no dirty check needed)
|
||||
const settingsModal = new Modal('settings-modal');
|
||||
|
||||
export function openSettingsModal() {
|
||||
document.getElementById('settings-error').style.display = 'none';
|
||||
settingsModal.open();
|
||||
loadApiKeysList();
|
||||
loadAutoBackupSettings();
|
||||
loadBackupList();
|
||||
loadMqttSettings();
|
||||
loadLogLevel();
|
||||
}
|
||||
|
||||
export function closeSettingsModal() {
|
||||
disconnectLogViewer();
|
||||
settingsModal.forceClose();
|
||||
}
|
||||
|
||||
@@ -90,9 +212,30 @@ export async function handleRestoreFileSelected(input) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Server restart ────────────────────────────────────────
|
||||
|
||||
export async function restartServer() {
|
||||
const confirmed = await showConfirm(t('settings.restart_confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/restart', { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
settingsModal.forceClose();
|
||||
showRestartOverlay(t('settings.restarting'));
|
||||
} catch (err) {
|
||||
console.error('Server restart failed:', err);
|
||||
showToast(t('settings.restore.error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Restart overlay ───────────────────────────────────────
|
||||
|
||||
function showRestartOverlay() {
|
||||
function showRestartOverlay(message) {
|
||||
const msg = message || t('settings.restore.restarting');
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'restart-overlay';
|
||||
overlay.style.cssText =
|
||||
@@ -101,7 +244,7 @@ function showRestartOverlay() {
|
||||
overlay.innerHTML =
|
||||
'<div class="spinner" style="width:48px;height:48px;border:4px solid rgba(255,255,255,0.3);' +
|
||||
'border-top-color:#fff;border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:1rem;"></div>' +
|
||||
`<div id="restart-msg">${t('settings.restore.restarting')}</div>`;
|
||||
`<div id="restart-msg">${msg}</div>`;
|
||||
|
||||
// Add spinner animation if not present
|
||||
if (!document.getElementById('restart-spinner-style')) {
|
||||
@@ -201,12 +344,20 @@ export async function loadBackupList() {
|
||||
}
|
||||
|
||||
container.innerHTML = data.backups.map(b => {
|
||||
const sizeKB = (b.size_bytes / 1024).toFixed(1);
|
||||
const sizeBytes = b.size_bytes || 0;
|
||||
const sizeStr = sizeBytes >= 1024 * 1024
|
||||
? (sizeBytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
: (sizeBytes / 1024).toFixed(1) + ' KB';
|
||||
const date = new Date(b.created_at).toLocaleString();
|
||||
const isAuto = b.filename.startsWith('ledgrab-autobackup-');
|
||||
const typeBadge = isAuto
|
||||
? `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:0.7rem;background:var(--border-color);color:var(--text-muted);white-space:nowrap;">${t('settings.saved_backups.type.auto')}</span>`
|
||||
: `<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:0.7rem;background:var(--primary-color);color:#fff;white-space:nowrap;">${t('settings.saved_backups.type.manual')}</span>`;
|
||||
return `<div style="display:flex;align-items:center;gap:0.5rem;padding:0.3rem 0;border-bottom:1px solid var(--border-color);font-size:0.82rem;">
|
||||
${typeBadge}
|
||||
<div style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${b.filename}">
|
||||
<span>${date}</span>
|
||||
<span style="color:var(--text-muted);margin-left:0.3rem;">${sizeKB} KB</span>
|
||||
<span style="color:var(--text-muted);margin-left:0.3rem;">${sizeStr}</span>
|
||||
</div>
|
||||
<button class="btn btn-icon btn-secondary" onclick="restoreSavedBackup('${b.filename}')" title="${t('settings.saved_backups.restore')}" style="padding:2px 6px;font-size:0.8rem;">${ICON_UNDO}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="downloadSavedBackup('${b.filename}')" title="${t('settings.saved_backups.download')}" style="padding:2px 6px;font-size:0.8rem;">${ICON_DOWNLOAD}</button>
|
||||
@@ -299,3 +450,192 @@ export async function deleteSavedBackup(filename) {
|
||||
showToast(t('settings.saved_backups.delete_error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API Keys (read-only display) ─────────────────────────────
|
||||
|
||||
export async function loadApiKeysList() {
|
||||
const container = document.getElementById('settings-api-keys-list');
|
||||
if (!container) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/api-keys');
|
||||
if (!resp.ok) {
|
||||
container.innerHTML = `<div style="color:var(--text-muted);">${t('settings.api_keys.load_error')}</div>`;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.count === 0) {
|
||||
container.innerHTML = `<div style="color:var(--text-muted);">${t('settings.api_keys.empty')}</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = data.keys.map(k =>
|
||||
`<div style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-bottom:1px solid var(--border-color);">
|
||||
<span style="font-weight:600;min-width:80px;">${k.label}</span>
|
||||
<code style="flex:1;color:var(--text-muted);font-size:0.8rem;">${k.masked}</code>
|
||||
</div>`
|
||||
).join('');
|
||||
} catch (err) {
|
||||
console.error('Failed to load API keys:', err);
|
||||
if (container) container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Partial Export / Import ───────────────────────────────────
|
||||
|
||||
export async function downloadPartialExport() {
|
||||
const storeKey = document.getElementById('settings-partial-store').value;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/system/export/${encodeURIComponent(storeKey)}`, { timeout: 30000 });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const disposition = resp.headers.get('Content-Disposition') || '';
|
||||
const match = disposition.match(/filename="(.+?)"/);
|
||||
const filename = match ? match[1] : `ledgrab-${storeKey}.json`;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(a.href);
|
||||
|
||||
showToast(t('settings.partial.export_success'), 'success');
|
||||
} catch (err) {
|
||||
console.error('Partial export failed:', err);
|
||||
showToast(t('settings.partial.export_error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePartialImportFileSelected(input) {
|
||||
const file = input.files[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
|
||||
const storeKey = document.getElementById('settings-partial-store').value;
|
||||
const merge = document.getElementById('settings-partial-merge').checked;
|
||||
const confirmMsg = merge
|
||||
? t('settings.partial.import_confirm_merge').replace('{store}', storeKey)
|
||||
: t('settings.partial.import_confirm_replace').replace('{store}', storeKey);
|
||||
|
||||
const confirmed = await showConfirm(confirmMsg);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const url = `${API_BASE}/system/import/${encodeURIComponent(storeKey)}?merge=${merge}`;
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${apiKey}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
showToast(data.message || t('settings.partial.import_success'), 'success');
|
||||
settingsModal.forceClose();
|
||||
|
||||
if (data.restart_scheduled) {
|
||||
showRestartOverlay();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Partial import failed:', err);
|
||||
showToast(t('settings.partial.import_error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Log Level ────────────────────────────────────────────────
|
||||
|
||||
export async function loadLogLevel() {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/log-level');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
const select = document.getElementById('settings-log-level');
|
||||
if (select) select.value = data.level;
|
||||
} catch (err) {
|
||||
console.error('Failed to load log level:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setLogLevel() {
|
||||
const select = document.getElementById('settings-log-level');
|
||||
if (!select) return;
|
||||
const level = select.value;
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/log-level', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ level }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('settings.log_level.saved'), 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to set log level:', err);
|
||||
showToast(t('settings.log_level.save_error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MQTT settings ────────────────────────────────────────────
|
||||
|
||||
export async function loadMqttSettings() {
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/mqtt/settings');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
|
||||
document.getElementById('mqtt-enabled').checked = data.enabled;
|
||||
document.getElementById('mqtt-host').value = data.broker_host;
|
||||
document.getElementById('mqtt-port').value = data.broker_port;
|
||||
document.getElementById('mqtt-username').value = data.username;
|
||||
document.getElementById('mqtt-password').value = '';
|
||||
document.getElementById('mqtt-client-id').value = data.client_id;
|
||||
document.getElementById('mqtt-base-topic').value = data.base_topic;
|
||||
|
||||
const hint = document.getElementById('mqtt-password-hint');
|
||||
if (hint) hint.style.display = data.password_set ? '' : 'none';
|
||||
} catch (err) {
|
||||
console.error('Failed to load MQTT settings:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMqttSettings() {
|
||||
const enabled = document.getElementById('mqtt-enabled').checked;
|
||||
const broker_host = document.getElementById('mqtt-host').value.trim();
|
||||
const broker_port = parseInt(document.getElementById('mqtt-port').value, 10);
|
||||
const username = document.getElementById('mqtt-username').value;
|
||||
const password = document.getElementById('mqtt-password').value;
|
||||
const client_id = document.getElementById('mqtt-client-id').value.trim();
|
||||
const base_topic = document.getElementById('mqtt-base-topic').value.trim();
|
||||
|
||||
if (!broker_host) {
|
||||
showToast(t('settings.mqtt.error_host_required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetchWithAuth('/system/mqtt/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled, broker_host, broker_port, username, password, client_id, base_topic }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('settings.mqtt.saved'), 'success');
|
||||
loadMqttSettings();
|
||||
} catch (err) {
|
||||
console.error('Failed to save MQTT settings:', err);
|
||||
showToast(t('settings.mqtt.save_error') + ': ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setTabRefreshing } from '../core/ui.js';
|
||||
import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setTabRefreshing, setupBackdropClose } from '../core/ui.js';
|
||||
import { openDisplayPicker, formatDisplayLabel } from './displays.js';
|
||||
import { CardSection } from '../core/card-sections.js';
|
||||
import { TreeNav } from '../core/tree-nav.js';
|
||||
@@ -70,19 +70,19 @@ let _audioTemplateTagsInput = null;
|
||||
let _csptTagsInput = null;
|
||||
|
||||
// ── Card section instances ──
|
||||
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
|
||||
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id' });
|
||||
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id' });
|
||||
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id' });
|
||||
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id' });
|
||||
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id' });
|
||||
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id' });
|
||||
const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id' });
|
||||
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id' });
|
||||
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id' });
|
||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' });
|
||||
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id' });
|
||||
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id' });
|
||||
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
|
||||
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates' });
|
||||
const csProcStreams = new CardSection('proc-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('processed')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
|
||||
const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postprocessing.title', gridClass: 'templates-grid', addCardOnclick: "showAddPPTemplateModal()", keyAttr: 'data-pp-template-id', emptyKey: 'section.empty.pp_templates' });
|
||||
const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources' });
|
||||
const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id', emptyKey: 'section.empty.audio_sources' });
|
||||
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
|
||||
const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources' });
|
||||
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id', emptyKey: 'section.empty.audio_templates' });
|
||||
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id', emptyKey: 'section.empty.color_strips' });
|
||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources' });
|
||||
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks' });
|
||||
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt' });
|
||||
|
||||
// Re-render picture sources when language changes
|
||||
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
|
||||
@@ -322,6 +322,7 @@ export async function showTestTemplateModal(templateId) {
|
||||
restoreCaptureDuration();
|
||||
|
||||
testTemplateModal.open();
|
||||
setupBackdropClose(testTemplateModal.el, () => closeTestTemplateModal());
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
showToast(t('templates.error.load'), 'error');
|
||||
@@ -2162,6 +2163,7 @@ export async function showTestStreamModal(streamId) {
|
||||
restoreStreamTestDuration();
|
||||
|
||||
testStreamModal.open();
|
||||
setupBackdropClose(testStreamModal.el, () => closeTestStreamModal());
|
||||
}
|
||||
|
||||
export function closeTestStreamModal() {
|
||||
@@ -2229,6 +2231,7 @@ export async function showTestPPTemplateModal(templateId) {
|
||||
});
|
||||
|
||||
testPPTemplateModal.open();
|
||||
setupBackdropClose(testPPTemplateModal.el, () => closeTestPPTemplateModal());
|
||||
}
|
||||
|
||||
export function closeTestPPTemplateModal() {
|
||||
|
||||
@@ -190,6 +190,15 @@ export async function resetSyncClock(clockId) {
|
||||
|
||||
// ── Card rendering ──
|
||||
|
||||
function _formatElapsed(seconds) {
|
||||
const s = Math.floor(seconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function createSyncClockCard(clock) {
|
||||
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
|
||||
const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
|
||||
@@ -197,6 +206,7 @@ export function createSyncClockCard(clock) {
|
||||
? `pauseSyncClock('${clock.id}')`
|
||||
: `resumeSyncClock('${clock.id}')`;
|
||||
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
|
||||
const elapsedLabel = clock.elapsed_time != null ? _formatElapsed(clock.elapsed_time) : null;
|
||||
|
||||
return wrapCard({
|
||||
type: 'template-card',
|
||||
@@ -211,6 +221,7 @@ export function createSyncClockCard(clock) {
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop">${statusIcon} ${statusLabel}</span>
|
||||
<span class="stream-card-prop">${ICON_CLOCK} ${clock.speed}x</span>
|
||||
${elapsedLabel ? `<span class="stream-card-prop" title="${t('sync_clock.elapsed')}">⏱ ${elapsedLabel}</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(clock.tags)}
|
||||
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
|
||||
|
||||
@@ -40,10 +40,10 @@ import { updateSubTabHash, updateTabBadge } from './tabs.js';
|
||||
// (pattern-templates.js calls window.loadTargetsTab)
|
||||
|
||||
// ── Card section instances ──
|
||||
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id' });
|
||||
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` });
|
||||
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` });
|
||||
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id' });
|
||||
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id', emptyKey: 'section.empty.devices' });
|
||||
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', emptyKey: 'section.empty.targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` });
|
||||
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', emptyKey: 'section.empty.kc_targets', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc" data-i18n-title="targets.stop_all.button" data-i18n-aria-label="targets.stop_all.button">${ICON_STOP}</button>` });
|
||||
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id', emptyKey: 'section.empty.pattern_templates' });
|
||||
|
||||
// Re-render targets tab when language changes (only if tab is active)
|
||||
document.addEventListener('languageChanged', () => {
|
||||
@@ -189,8 +189,12 @@ function _updateSpecificSettingsVisibility() {
|
||||
const deviceSelect = document.getElementById('target-editor-device');
|
||||
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
|
||||
const isWled = !selectedDevice || selectedDevice.device_type === 'wled';
|
||||
// Hide entire Specific Settings section for non-WLED devices (protocol + keepalive are WLED-only)
|
||||
document.getElementById('target-editor-device-settings').style.display = isWled ? '' : 'none';
|
||||
// Hide WLED-only controls (protocol + keepalive) for non-WLED devices
|
||||
const protocolGroup = document.getElementById('target-editor-protocol-group');
|
||||
if (protocolGroup) protocolGroup.style.display = isWled ? '' : 'none';
|
||||
// keepalive is controlled further by _updateKeepaliveVisibility
|
||||
const keepaliveGroup = document.getElementById('target-editor-keepalive-group');
|
||||
if (keepaliveGroup && !isWled) keepaliveGroup.style.display = 'none';
|
||||
}
|
||||
|
||||
function _updateBrightnessThresholdVisibility() {
|
||||
@@ -1069,10 +1073,14 @@ export async function stopTargetProcessing(targetId) {
|
||||
}
|
||||
|
||||
export async function stopAllLedTargets() {
|
||||
const confirmed = await showConfirm(t('confirm.stop_all'));
|
||||
if (!confirmed) return;
|
||||
await _stopAllByType('led');
|
||||
}
|
||||
|
||||
export async function stopAllKCTargets() {
|
||||
const confirmed = await showConfirm(t('confirm.stop_all'));
|
||||
if (!confirmed) return;
|
||||
await _stopAllByType('key_colors');
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export function startCalibrationTutorial() {
|
||||
if (!container) return;
|
||||
startTutorial({
|
||||
steps: calibrationTutorialSteps,
|
||||
overlayId: 'tutorial-overlay',
|
||||
overlayId: 'calibration-tutorial-overlay',
|
||||
mode: 'absolute',
|
||||
container: container,
|
||||
resolveTarget: (step) => {
|
||||
|
||||
@@ -134,6 +134,88 @@ function _ensureWaveformIconSelect() {
|
||||
_waveformIconSelect = new IconSelect({ target: sel, items, columns: 4 });
|
||||
}
|
||||
|
||||
/* ── Waveform canvas preview ──────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Draw a waveform preview on the canvas element #value-source-waveform-preview.
|
||||
* Shows one full cycle of the selected waveform shape.
|
||||
*/
|
||||
function _drawWaveformPreview(waveformType) {
|
||||
const canvas = document.getElementById('value-source-waveform-preview');
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssW = canvas.offsetWidth || 200;
|
||||
const cssH = 60;
|
||||
canvas.width = cssW * dpr;
|
||||
canvas.height = cssH * dpr;
|
||||
canvas.style.height = cssH + 'px';
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, cssW, cssH);
|
||||
|
||||
const W = cssW;
|
||||
const H = cssH;
|
||||
const padX = 8;
|
||||
const padY = 8;
|
||||
const drawW = W - padX * 2;
|
||||
const drawH = H - padY * 2;
|
||||
const midY = padY + drawH / 2;
|
||||
|
||||
// Draw zero line
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([3, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padX, midY);
|
||||
ctx.lineTo(padX + drawW, midY);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Draw waveform
|
||||
const N = 120;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const t = i / N; // 0..1 over one cycle
|
||||
let v; // -1..1
|
||||
switch (waveformType) {
|
||||
case 'triangle':
|
||||
v = t < 0.5 ? (4 * t - 1) : (3 - 4 * t);
|
||||
break;
|
||||
case 'square':
|
||||
v = t < 0.5 ? 1 : -1;
|
||||
break;
|
||||
case 'sawtooth':
|
||||
v = 2 * t - 1;
|
||||
break;
|
||||
case 'sine':
|
||||
default:
|
||||
v = Math.sin(2 * Math.PI * t);
|
||||
break;
|
||||
}
|
||||
const x = padX + t * drawW;
|
||||
const y = midY - v * (drawH / 2);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
// Glow effect: draw thick translucent line first
|
||||
ctx.strokeStyle = 'rgba(99,179,237,0.25)';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.stroke();
|
||||
|
||||
// Crisp line on top
|
||||
ctx.strokeStyle = '#63b3ed';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
export function updateWaveformPreview() {
|
||||
const wf = document.getElementById('value-source-waveform')?.value || 'sine';
|
||||
_drawWaveformPreview(wf);
|
||||
}
|
||||
|
||||
/* ── Audio mode icon-grid selector ────────────────────────────── */
|
||||
|
||||
const _AUDIO_MODE_SVG = {
|
||||
@@ -208,6 +290,7 @@ export async function showValueSourceModal(editData, presetType = null) {
|
||||
} else if (editData.source_type === 'animated') {
|
||||
document.getElementById('value-source-waveform').value = editData.waveform || 'sine';
|
||||
if (_waveformIconSelect) _waveformIconSelect.setValue(editData.waveform || 'sine');
|
||||
_drawWaveformPreview(editData.waveform || 'sine');
|
||||
_setSlider('value-source-speed', editData.speed ?? 10);
|
||||
_setSlider('value-source-min-value', editData.min_value ?? 0);
|
||||
_setSlider('value-source-max-value', editData.max_value ?? 1);
|
||||
@@ -249,6 +332,7 @@ export async function showValueSourceModal(editData, presetType = null) {
|
||||
_setSlider('value-source-min-value', 0);
|
||||
_setSlider('value-source-max-value', 1);
|
||||
document.getElementById('value-source-waveform').value = 'sine';
|
||||
_drawWaveformPreview('sine');
|
||||
_populateAudioSourceDropdown('');
|
||||
document.getElementById('value-source-mode').value = 'rms';
|
||||
if (_audioModeIconSelect) _audioModeIconSelect.setValue('rms');
|
||||
@@ -274,7 +358,7 @@ export async function showValueSourceModal(editData, presetType = null) {
|
||||
}
|
||||
|
||||
// Wire up auto-name triggers
|
||||
document.getElementById('value-source-waveform').onchange = () => _autoGenerateVSName();
|
||||
document.getElementById('value-source-waveform').onchange = () => { _autoGenerateVSName(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); };
|
||||
document.getElementById('value-source-mode').onchange = () => _autoGenerateVSName();
|
||||
document.getElementById('value-source-picture-source').onchange = () => _autoGenerateVSName();
|
||||
|
||||
@@ -296,7 +380,7 @@ export function onValueSourceTypeChange() {
|
||||
if (_vsTypeIconSelect) _vsTypeIconSelect.setValue(type);
|
||||
document.getElementById('value-source-static-section').style.display = type === 'static' ? '' : 'none';
|
||||
document.getElementById('value-source-animated-section').style.display = type === 'animated' ? '' : 'none';
|
||||
if (type === 'animated') _ensureWaveformIconSelect();
|
||||
if (type === 'animated') { _ensureWaveformIconSelect(); _drawWaveformPreview(document.getElementById('value-source-waveform').value); }
|
||||
document.getElementById('value-source-audio-section').style.display = type === 'audio' ? '' : 'none';
|
||||
if (type === 'audio') _ensureAudioModeIconSelect();
|
||||
document.getElementById('value-source-adaptive-time-section').style.display = type === 'adaptive_time' ? '' : 'none';
|
||||
|
||||
Reference in New Issue
Block a user