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:
2026-03-16 18:46:38 +03:00
parent a4a0e39b9b
commit 304fa24389
47 changed files with 2594 additions and 250 deletions

View File

@@ -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 = {};

View File

@@ -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 ───────────────────────────────── */

View File

@@ -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')}">&#x2713;</button>
<button type="button" class="btn btn-icon btn-sm btn-danger"
onclick="deleteAndRefreshGradientPreset(${JSON.stringify(p.name)})"
title="${t('common.delete')}">&#x2715;</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')}">&#x2807;</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}`,
});
}

View File

@@ -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() {

View File

@@ -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(() => []),

View File

@@ -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');
}
}

View File

@@ -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',

View File

@@ -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() : '';

View File

@@ -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');
}
}

View File

@@ -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() {

View File

@@ -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>` : ''}`,

View File

@@ -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');
}

View File

@@ -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) => {

View File

@@ -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';