diff --git a/server/src/wled_controller/static/css/base.css b/server/src/wled_controller/static/css/base.css index 9ff14c1..b3116ca 100644 --- a/server/src/wled_controller/static/css/base.css +++ b/server/src/wled_controller/static/css/base.css @@ -12,11 +12,47 @@ --warning-color: #ff9800; --info-color: #2196F3; --font-mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'SF Mono', 'Consolas', 'Liberation Mono', monospace; + + /* Spacing scale */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 12px; + --space-lg: 20px; + --space-xl: 40px; + + /* Border radius */ --radius: 8px; --radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px; --radius-pill: 100px; + + /* Animation timing */ + --duration-fast: 0.15s; + --duration-normal: 0.25s; + --duration-slow: 0.4s; + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + + /* Font weights */ + --weight-normal: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + /* Z-index layers */ + --z-card-elevated: 10; + --z-sticky: 100; + --z-dropdown: 200; + --z-bulk-toolbar: 1000; + --z-modal: 2000; + --z-log-overlay: 2100; + --z-confirm: 2500; + --z-command-palette: 3000; + --z-toast: 3000; + --z-overlay-spinner: 9999; + --z-lightbox: 10000; + --z-connection: 10000; } /* ── SVG icon base ── */ @@ -59,8 +95,8 @@ --card-bg: #ffffff; --text-color: #333333; --text-primary: #333333; - --text-secondary: #666; - --text-muted: #999; + --text-secondary: #595959; + --text-muted: #767676; --border-color: #e0e0e0; --display-badge-bg: rgba(255, 255, 255, 0.85); --primary-text-color: #3d8b40; @@ -186,7 +222,7 @@ body, .dashboard-target, .perf-chart-card, header { - transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; + transition: background-color var(--duration-normal) ease, color var(--duration-normal) ease, border-color var(--duration-normal) ease; } /* ── Respect reduced motion preference ── */ diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index d449694..6e16407 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -2,6 +2,59 @@ section { margin-bottom: 40px; } +/* ── Skeleton loading placeholders ── */ +@keyframes skeletonPulse { + 0%, 100% { opacity: 0.06; } + 50% { opacity: 0.12; } +} + +.skeleton-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 16px 20px 20px; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 140px; +} + +.skeleton-line { + height: 14px; + border-radius: 4px; + background: var(--text-color); + animation: skeletonPulse 1.5s ease-in-out infinite; +} + +.skeleton-line-title { + width: 60%; + height: 18px; +} + +.skeleton-line-short { + width: 40%; +} + +.skeleton-line-medium { + width: 75%; +} + +.skeleton-actions { + display: flex; + gap: 8px; + margin-top: auto; + padding-top: 12px; + border-top: 1px solid var(--border-color); +} + +.skeleton-btn { + height: 32px; + flex: 1; + border-radius: var(--radius-sm); + background: var(--text-color); + animation: skeletonPulse 1.5s ease-in-out infinite; +} + .displays-grid, .devices-grid { display: grid; @@ -54,7 +107,7 @@ section { background: var(--card-bg); border: 1px solid var(--border-color); border-radius: var(--radius-md); - padding: 12px 20px 20px; + padding: 16px 20px 20px; position: relative; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; display: flex; @@ -152,6 +205,17 @@ section { animation: rotateBorder 4s linear infinite; } +/* Fallback for browsers without mask-composite support (older Firefox) */ +@supports not (mask-composite: exclude) { + .card-running::before { + -webkit-mask: none; + mask: none; + background: none; + border: 2px solid var(--primary-color); + opacity: 0.7; + } +} + @keyframes rotateBorder { to { --border-angle: 360deg; } } @@ -1192,7 +1256,7 @@ ul.section-tip li { display: flex; align-items: center; gap: 12px; - z-index: 1000; + z-index: var(--z-bulk-toolbar); box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3); transition: transform 0.25s ease; white-space: nowrap; diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index e1281b1..1f1c50f 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -193,6 +193,21 @@ select:focus { box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15); } +/* Inline validation states */ +input.field-invalid, +select.field-invalid { + border-color: var(--danger-color); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger-color) 15%, transparent); +} + +.field-error-msg { + display: block; + color: var(--danger-color); + font-size: 0.78rem; + margin-top: 4px; + line-height: 1.3; +} + /* Remove browser autofill styling */ input:-webkit-autofill, input:-webkit-autofill:hover, @@ -260,7 +275,7 @@ input:-webkit-autofill:focus { flex-direction: column; align-items: center; justify-content: center; - z-index: 9999; + z-index: var(--z-overlay-spinner); backdrop-filter: blur(4px); } @@ -353,7 +368,7 @@ input:-webkit-autofill:focus { font-size: 15px; opacity: 0; transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); - z-index: 3000; + z-index: var(--z-toast); box-shadow: 0 4px 20px var(--shadow-color); min-width: 300px; text-align: center; @@ -384,6 +399,52 @@ input:-webkit-autofill:focus { background: var(--info-color); } +/* Toast with undo action */ +.toast-with-action { + display: flex; + align-items: center; + gap: 12px; +} + +.toast-message { + flex: 1; +} + +.toast-undo-btn { + background: rgba(255, 255, 255, 0.25); + border: 1px solid rgba(255, 255, 255, 0.4); + color: white; + padding: 4px 12px; + border-radius: var(--radius-sm); + font-weight: var(--weight-semibold, 600); + font-size: 0.85rem; + cursor: pointer; + transition: background var(--duration-fast, 0.15s); + white-space: nowrap; + flex-shrink: 0; +} + +.toast-undo-btn:hover { + background: rgba(255, 255, 255, 0.4); +} + +.toast-timer { + width: 100%; + height: 3px; + position: absolute; + bottom: 0; + left: 0; + border-radius: 0 0 var(--radius-md) var(--radius-md); + background: rgba(255, 255, 255, 0.3); + transform-origin: left; + animation: toastTimer var(--toast-duration, 5s) linear forwards; +} + +@keyframes toastTimer { + from { transform: scaleX(1); } + to { transform: scaleX(0); } +} + /* ── Card Tags ──────────────────────────────────────────── */ .card-tags { @@ -604,7 +665,7 @@ textarea:focus-visible { .icon-select-popup { position: fixed; - z-index: 10000; + z-index: var(--z-lightbox); overflow: hidden; opacity: 0; transition: opacity 0.15s ease; @@ -683,7 +744,7 @@ textarea:focus-visible { .type-picker-overlay { position: fixed; inset: 0; - z-index: 3000; + z-index: var(--z-command-palette); display: flex; justify-content: center; padding-top: 15vh; @@ -758,7 +819,7 @@ textarea:focus-visible { display: none; position: fixed; inset: 0; - z-index: 10000; + z-index: var(--z-lightbox); background: rgba(0, 0, 0, 0.5); justify-content: center; align-items: flex-start; diff --git a/server/src/wled_controller/static/css/layout.css b/server/src/wled_controller/static/css/layout.css index 3e2526d..3640993 100644 --- a/server/src/wled_controller/static/css/layout.css +++ b/server/src/wled_controller/static/css/layout.css @@ -5,7 +5,7 @@ header { padding: 8px 20px; position: sticky; top: 0; - z-index: 100; + z-index: var(--z-sticky); background: var(--bg-color); border-bottom: 2px solid var(--border-color); } @@ -133,7 +133,7 @@ h2 { .connection-overlay { position: fixed; inset: 0; - z-index: 10000; + z-index: var(--z-connection); display: flex; align-items: center; justify-content: center; @@ -177,6 +177,19 @@ h2 { animation: conn-spin 0.8s linear infinite; } +/* Visually hidden — screen readers only */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + /* WLED device health indicator */ .health-dot { display: inline-block; @@ -448,7 +461,7 @@ h2 { #command-palette { position: fixed; inset: 0; - z-index: 3000; + z-index: var(--z-command-palette); display: flex; justify-content: center; padding-top: 15vh; diff --git a/server/src/wled_controller/static/css/mobile.css b/server/src/wled_controller/static/css/mobile.css index 33600dd..f927bcd 100644 --- a/server/src/wled_controller/static/css/mobile.css +++ b/server/src/wled_controller/static/css/mobile.css @@ -154,7 +154,7 @@ bottom: 0; left: 0; right: 0; - z-index: 100; + z-index: var(--z-sticky); background: var(--card-bg); border-bottom: none; border-top: 1px solid var(--border-color); diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index ab82ca3..13eeb78 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -7,7 +7,7 @@ width: 100%; height: 100%; background: rgba(0, 0, 0, 0.8); - z-index: 2000; + z-index: var(--z-modal); align-items: center; justify-content: center; animation: fadeIn 0.2s ease-out; @@ -16,7 +16,7 @@ /* Confirm dialog must stack above all other modals */ #confirm-modal { - z-index: 2500; + z-index: var(--z-confirm); } /* Audio test spectrum canvas */ @@ -393,7 +393,7 @@ .log-overlay { position: fixed; inset: 0; - z-index: 2100; + z-index: var(--z-log-overlay); display: flex; flex-direction: column; background: var(--bg-color, #111); @@ -1007,7 +1007,7 @@ width: 100%; height: 100%; background: rgba(0, 0, 0, 0.92); - z-index: 10000; + z-index: var(--z-lightbox); justify-content: center; align-items: center; cursor: zoom-out; diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts index d446f01..7df4921 100644 --- a/server/src/wled_controller/static/js/app.ts +++ b/server/src/wled_controller/static/js/app.ts @@ -18,8 +18,9 @@ import { initTabIndicator, updateTabIndicator } from './core/tab-indicator.ts'; // Layer 2: ui import { toggleHint, lockBody, unlockBody, closeLightbox, - showToast, showConfirm, closeConfirmModal, + showToast, showUndoToast, showConfirm, closeConfirmModal, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, + setFieldError, clearFieldError, setupBlurValidation, } from './core/ui.ts'; // Layer 3: displays, tutorials @@ -86,7 +87,7 @@ import { clonePatternTemplate, } from './features/pattern-templates.ts'; import { - loadAutomations, openAutomationEditor, closeAutomationEditorModal, + loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal, saveAutomationEditor, addAutomationCondition, toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl, } from './features/automations.ts'; @@ -208,11 +209,15 @@ Object.assign(window, { unlockBody, closeLightbox, showToast, + showUndoToast, showConfirm, closeConfirmModal, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, + setFieldError, + clearFieldError, + setupBlurValidation, // core / api + i18n t, @@ -365,6 +370,7 @@ Object.assign(window, { // automations loadAutomations, + switchAutomationTab, openAutomationEditor, closeAutomationEditorModal, saveAutomationEditor, diff --git a/server/src/wled_controller/static/js/core/api.ts b/server/src/wled_controller/static/js/core/api.ts index 74cf89d..4bc1f0e 100644 --- a/server/src/wled_controller/static/js/core/api.ts +++ b/server/src/wled_controller/static/js/core/api.ts @@ -4,6 +4,7 @@ import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts'; import { t } from './i18n.ts'; +import { showToast } from './ui.ts'; export const API_BASE = '/api/v1'; @@ -68,6 +69,11 @@ export async function fetchWithAuth(url: string, options: FetchAuthOpts = {}): P await new Promise(r => setTimeout(r, 500 * 2 ** attempt)); continue; } + // Final attempt failed — show user-facing error + const errMsg = (err as Error)?.name === 'AbortError' + ? t('api.error.timeout') + : t('api.error.network'); + showToast(errMsg, 'error'); throw err; } } diff --git a/server/src/wled_controller/static/js/core/bulk-toolbar.ts b/server/src/wled_controller/static/js/core/bulk-toolbar.ts index 8b4f86d..864a477 100644 --- a/server/src/wled_controller/static/js/core/bulk-toolbar.ts +++ b/server/src/wled_controller/static/js/core/bulk-toolbar.ts @@ -106,11 +106,23 @@ async function _executeAction(actionKey) { if (!ok) return; } + // Show progress state on toolbar + const el = _toolbarEl; + const actionBtns = el?.querySelectorAll('.bulk-action-btn') as NodeListOf; + actionBtns?.forEach(btn => { btn.disabled = true; }); + const countEl = el?.querySelector('.bulk-count'); + const prevCount = countEl?.textContent || ''; + if (countEl) countEl.textContent = t('bulk.processing') || 'Processing…'; + try { await action.handler(keys); } catch (e) { console.error(`Bulk action "${actionKey}" failed:`, e); } + // Restore toolbar state (in case exit doesn't happen) + actionBtns?.forEach(btn => { btn.disabled = false; }); + if (countEl) countEl.textContent = prevCount; + section.exitSelectionMode(); } diff --git a/server/src/wled_controller/static/js/core/card-sections.ts b/server/src/wled_controller/static/js/core/card-sections.ts index e37fea1..ec0eaba 100644 --- a/server/src/wled_controller/static/js/core/card-sections.ts +++ b/server/src/wled_controller/static/js/core/card-sections.ts @@ -62,6 +62,24 @@ function _getCollapsedMap(): Record { catch { return {}; } } +/** Generate skeleton placeholder cards for loading state. */ +export function renderSkeletonCards(count = 3, gridClass = 'devices-grid') { + let html = ''; + for (let i = 0; i < count; i++) { + const delay = `animation-delay: ${i * 0.15}s`; + html += `
+
+
+
+
+
+
+
+
`; + } + return `
${html}
`; +} + export class CardSection { sectionKey: string; diff --git a/server/src/wled_controller/static/js/core/modal.ts b/server/src/wled_controller/static/js/core/modal.ts index c6e45e5..61b0199 100644 --- a/server/src/wled_controller/static/js/core/modal.ts +++ b/server/src/wled_controller/static/js/core/modal.ts @@ -6,7 +6,7 @@ */ import { t } from './i18n.ts'; -import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus } from './ui.ts'; +import { lockBody, unlockBody, setupBackdropClose, showConfirm, trapFocus, releaseFocus, isTouchDevice } from './ui.ts'; export class Modal { static _stack: Modal[] = []; @@ -40,6 +40,15 @@ export class Modal { trapFocus(this.el!); Modal._stack = Modal._stack.filter(m => m !== this); Modal._stack.push(this); + // Auto-focus first visible input (skip on touch to avoid virtual keyboard) + if (!isTouchDevice()) { + requestAnimationFrame(() => { + const input = this.el!.querySelector( + '.modal-body input:not([type="hidden"]):not([disabled]):not([style*="display:none"]):not([style*="display: none"]), .modal-body select:not([disabled]), .modal-body textarea:not([disabled])' + ) as HTMLElement | null; + if (input && input.offsetParent !== null) input.focus(); + }); + } } forceClose() { diff --git a/server/src/wled_controller/static/js/core/ui.ts b/server/src/wled_controller/static/js/core/ui.ts index e240d74..edb6bda 100644 --- a/server/src/wled_controller/static/js/core/ui.ts +++ b/server/src/wled_controller/static/js/core/ui.ts @@ -135,6 +135,43 @@ export function showToast(message: string, type = 'info') { }, 3000); } +let _undoTimer: ReturnType | null = null; + +/** + * Show a toast with an Undo button. Executes `action` after `delay` ms + * unless the user clicks Undo (which calls `undoFn`). + */ +export function showUndoToast(message: string, action: () => void, undoFn: () => void, delay = 5000) { + if (_undoTimer) { clearTimeout(_undoTimer); _undoTimer = null; } + + const toast = document.getElementById('toast')!; + toast.className = 'toast info show'; + toast.style.setProperty('--toast-duration', `${delay}ms`); + toast.innerHTML = `
+ ${message} + +
+
`; + + let cancelled = false; + + const undoBtn = toast.querySelector('.toast-undo-btn')!; + undoBtn.addEventListener('click', () => { + cancelled = true; + if (_undoTimer) { clearTimeout(_undoTimer); _undoTimer = null; } + undoFn(); + toast.className = 'toast'; + toast.innerHTML = ''; + }, { once: true }); + + _undoTimer = setTimeout(() => { + _undoTimer = null; + if (!cancelled) action(); + toast.className = 'toast'; + toast.innerHTML = ''; + }, delay); +} + export function showConfirm(message: string, title: string | null = null) { return new Promise((resolve) => { setConfirmResolve(resolve); @@ -328,6 +365,49 @@ export function updateOverlayPreview(thumbnailSrc: string, stats: any) { } } +// ── Inline field validation ── + +/** Mark a field as invalid with an error message. */ +export function setFieldError(input: HTMLInputElement | HTMLSelectElement, message: string) { + input.classList.add('field-invalid'); + clearFieldError(input); // remove existing + if (message) { + const msg = document.createElement('span'); + msg.className = 'field-error-msg'; + msg.textContent = message; + input.parentElement?.appendChild(msg); + } +} + +/** Clear field error state. */ +export function clearFieldError(input: HTMLInputElement | HTMLSelectElement) { + input.classList.remove('field-invalid'); + const existing = input.parentElement?.querySelector('.field-error-msg'); + if (existing) existing.remove(); +} + +/** Validate a required field on blur. Returns true if valid. */ +export function validateRequired(input: HTMLInputElement | HTMLSelectElement, errorMsg?: string): boolean { + const value = input.value.trim(); + if (!value) { + setFieldError(input, errorMsg || t('validation.required')); + return false; + } + clearFieldError(input); + return true; +} + +/** Set up blur validation on required fields within a container. */ +export function setupBlurValidation(container: HTMLElement) { + const fields = container.querySelectorAll('input[required], select[required]') as NodeListOf; + fields.forEach(field => { + field.addEventListener('blur', () => validateRequired(field)); + field.addEventListener('input', () => { + if (field.classList.contains('field-invalid')) clearFieldError(field); + }); + }); +} + /** Toggle the thin loading bar on a tab panel during data refresh. * Delays showing the bar by 400ms so quick loads never flash it. */ const _refreshTimers: Record> = {}; diff --git a/server/src/wled_controller/static/js/features/dashboard.ts b/server/src/wled_controller/static/js/features/dashboard.ts index 019bf67..84d2192 100644 --- a/server/src/wled_controller/static/js/features/dashboard.ts +++ b/server/src/wled_controller/static/js/features/dashboard.ts @@ -587,7 +587,8 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco let healthDot = ''; if (isLed && state.device_last_checked != null) { const cls = state.device_online ? 'health-online' : 'health-offline'; - healthDot = ``; + const statusLabel = state.device_online ? t('device.health.online') : t('device.health.offline'); + healthDot = ``; } const cStyle = cardColorStyle(target.id); diff --git a/server/src/wled_controller/static/js/features/devices.ts b/server/src/wled_controller/static/js/features/devices.ts index 317f7fb..dc9191c 100644 --- a/server/src/wled_controller/static/js/features/devices.ts +++ b/server/src/wled_controller/static/js/features/devices.ts @@ -156,7 +156,7 @@ export function createDeviceCard(device: Device & { state?: any }) { content: `
- + ${device.name || device.id} ${device.url && device.url.startsWith('http') ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}${ICON_WEB}` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('ws://') && !device.url.startsWith('openrgb://') && !device.url.startsWith('http') ? `${escapeHtml(device.url)}` : '')} ${healthLabel} diff --git a/server/src/wled_controller/static/js/features/graph-editor.ts b/server/src/wled_controller/static/js/features/graph-editor.ts index 54c04f9..10672f3 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.ts +++ b/server/src/wled_controller/static/js/features/graph-editor.ts @@ -914,7 +914,7 @@ function _graphHTML(): string { // Only set size from saved state; position is applied in _initMinimap via anchor logic const mmStyle = mmRect?.width ? `width:${mmRect.width}px;height:${mmRect.height}px;` : ''; return ` -
+
`} ${isProcessing ? ` - ` : ''} @@ -1234,7 +1232,9 @@ const _ledPreviewLastFrame = {}; * one canvas per zone with labels. Otherwise, a single canvas. */ function _buildLedPreviewHtml(targetId: any, device: any, bvsId: any, cssSource: any, colorStripSourceMap: any) { - const visible = ledPreviewWebSockets[targetId] ? '' : 'none'; + // Always render hidden — JS toggles visibility. This keeps card HTML stable + // so reconciliation doesn't replace the card when preview is toggled. + const visible = 'none'; const bvsAttr = bvsId ? ' data-has-bvs="1"' : ''; // Check for per-zone preview @@ -1356,7 +1356,13 @@ function _renderLedStrip(canvas: any, rgbBytes: any) { } function connectLedPreviewWS(targetId: any) { - disconnectLedPreviewWS(targetId); + // Close existing WS without touching DOM (caller manages panel/button state) + const oldWs = ledPreviewWebSockets[targetId]; + if (oldWs) { + oldWs.onclose = null; + oldWs.close(); + delete ledPreviewWebSockets[targetId]; + } const key = localStorage.getItem('wled_api_key'); if (!key) return; @@ -1440,6 +1446,27 @@ function connectLedPreviewWS(targetId: any) { } } +function _setPreviewButtonState(targetId: any, active: boolean) { + const btn = document.querySelector(`[data-led-preview-btn="${targetId}"]`); + if (btn) { + btn.classList.toggle('btn-warning', active); + btn.classList.toggle('btn-secondary', !active); + } +} + +/** Restore preview panel visibility, button state, and last frame after card replacement. */ +function _restoreLedPreviewState(targetId: any) { + const panel = document.getElementById(`led-preview-panel-${targetId}`); + if (panel) panel.style.display = ''; + _setPreviewButtonState(targetId, true); + // Re-render cached frame onto the new canvas + const frame = _ledPreviewLastFrame[targetId]; + if (frame) { + const canvas = panel?.querySelector('.led-preview-canvas'); + if (canvas) _renderLedStrip(canvas, frame); + } +} + function disconnectLedPreviewWS(targetId: any) { const ws = ledPreviewWebSockets[targetId]; if (ws) { @@ -1450,6 +1477,7 @@ function disconnectLedPreviewWS(targetId: any) { delete _ledPreviewLastFrame[targetId]; const panel = document.getElementById(`led-preview-panel-${targetId}`); if (panel) panel.style.display = 'none'; + _setPreviewButtonState(targetId, false); } export function disconnectAllLedPreviewWS() { @@ -1464,6 +1492,7 @@ export function toggleLedPreview(targetId: any) { disconnectLedPreviewWS(targetId); } else { panel.style.display = ''; + _setPreviewButtonState(targetId, true); connectLedPreviewWS(targetId); } } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 091ea7e..493e18f 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -436,6 +436,11 @@ "common.none_no_cspt": "None (no processing template)", "common.none_no_input": "None (no input source)", "common.none_own_speed": "None (use own speed)", + "common.undo": "Undo", + "validation.required": "This field is required", + "bulk.processing": "Processing…", + "api.error.timeout": "Request timed out — please try again", + "api.error.network": "Network error — check your connection", "palette.search": "Search…", "section.filter.placeholder": "Filter...", "section.filter.reset": "Clear filter", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index ca9d1a1..4ddba06 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -436,6 +436,11 @@ "common.none_no_cspt": "Нет (без шаблона обработки)", "common.none_no_input": "Нет (без источника)", "common.none_own_speed": "Нет (своя скорость)", + "common.undo": "Отменить", + "validation.required": "Обязательное поле", + "bulk.processing": "Обработка…", + "api.error.timeout": "Превышено время ожидания — попробуйте снова", + "api.error.network": "Ошибка сети — проверьте подключение", "palette.search": "Поиск…", "section.filter.placeholder": "Фильтр...", "section.filter.reset": "Очистить фильтр", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index ccb346d..3a064ed 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -436,6 +436,11 @@ "common.none_no_cspt": "无(无处理模板)", "common.none_no_input": "无(无输入源)", "common.none_own_speed": "无(使用自身速度)", + "common.undo": "撤销", + "validation.required": "此字段为必填项", + "bulk.processing": "处理中…", + "api.error.timeout": "请求超时 — 请重试", + "api.error.network": "网络错误 — 请检查连接", "palette.search": "搜索…", "section.filter.placeholder": "筛选...", "section.filter.reset": "清除筛选", diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 6783324..ca43ac8 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -95,13 +95,20 @@
-
+
+
+
+
+
-
-
+
+ +
+
+
@@ -159,7 +166,7 @@ -
+
{% include 'modals/calibration.html' %} {% include 'modals/advanced-calibration.html' %} diff --git a/server/src/wled_controller/templates/modals/settings.html b/server/src/wled_controller/templates/modals/settings.html index 8092d45..372cba7 100644 --- a/server/src/wled_controller/templates/modals/settings.html +++ b/server/src/wled_controller/templates/modals/settings.html @@ -181,6 +181,7 @@
+
@@ -227,8 +228,9 @@
- +
+