Files
wled-screen-controller-mixed/server/src/wled_controller/templates/index.html
alexei.dolgolyov c0d0d839dc feat: add gradient entity modal and fix color picker clipping
Add full gradient editor modal with name, description, visual stop
editor, tags, and dirty checking. Gradient editor now supports ID
prefix to avoid DOM conflicts between CSS editor and standalone modal.

Fix color picker popover clipped by template-card overflow:hidden.
Fix gradient canvas not sizing correctly in standalone modal.
2026-03-24 13:58:51 +03:00

539 lines
32 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Grab</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>💡</text></svg>">
<!-- PWA -->
<meta name="theme-color" content="#1a1a1a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="LED Grab">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/static/dist/app.bundle.css">
</head>
<body style="visibility: hidden;">
<div class="demo-banner" id="demo-banner" style="display:none">
<span data-i18n="demo.banner">You're in demo mode &mdash; all devices and data are virtual. No real hardware is used.</span>
<button class="demo-banner-dismiss" onclick="dismissDemoBanner()" aria-label="Dismiss">&times;</button>
</div>
<canvas id="bg-anim-canvas"></canvas>
<canvas id="bg-effect-canvas"></canvas>
<div id="bg-effect-layer"></div>
<div id="connection-overlay" class="connection-overlay" style="display:none" aria-hidden="true">
<div class="connection-overlay-content">
<div class="connection-spinner-lg"></div>
<h2 data-i18n="app.connection_lost">Server unreachable</h2>
<p data-i18n="app.connection_retrying">Attempting to reconnect…</p>
</div>
</div>
<header>
<div class="header-title">
<span id="server-status" class="status-badge"></span>
<h1 data-i18n="app.title">LED Grab</h1>
<span id="server-version"><span id="version-number"></span></span>
<span class="demo-badge" id="demo-badge" style="display:none" data-i18n="demo.badge">DEMO</span>
</div>
<div class="tab-bar" role="tablist">
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')" role="tab" aria-selected="true" aria-controls="tab-dashboard" id="tab-btn-dashboard" title="Ctrl+1"><svg class="icon" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg> <span data-i18n="dashboard.title">Dashboard</span></button>
<button class="tab-btn" data-tab="automations" onclick="switchTab('automations')" role="tab" aria-selected="false" aria-controls="tab-automations" id="tab-btn-automations" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="automations.title">Automations</span><span class="tab-badge" id="tab-badge-automations" style="display:none"></span></button>
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span><span class="tab-badge" id="tab-badge-targets" style="display:none"></span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
<button class="tab-btn" data-tab="graph" onclick="switchTab('graph')" role="tab" aria-selected="false" aria-controls="tab-graph" id="tab-btn-graph" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><circle cx="5" cy="6" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M7.5 7.5 10.5 16"/><path d="M16.5 7.5 13.5 16"/></svg> <span data-i18n="graph.title">Graph</span></button>
</div>
<div class="header-toolbar">
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
<span class="header-toolbar-sep"></span>
<button class="header-btn" id="tour-restart-btn" onclick="startGettingStartedTutorial()" data-i18n-title="tour.restart" title="Restart tutorial">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
</button>
<button class="header-btn" onclick="openCommandPalette()" data-i18n-title="search.open" title="Search (Ctrl+K)">
<svg class="icon" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
</button>
<button class="header-btn" id="bg-anim-btn" onclick="toggleBgAnim()" data-i18n-title="bg.anim.toggle" title="Toggle ambient background">
<svg class="icon" viewBox="0 0 24 24"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/></svg>
</button>
<button class="header-btn" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme">
<span id="theme-icon"><svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg></span>
</button>
<span class="color-picker-wrapper" id="cp-wrap-accent">
<button class="header-btn" onclick="event.stopPropagation(); window._cpToggle('accent')" data-i18n-title="accent.title" title="Accent color">
<span id="cp-swatch-accent" class="color-picker-swatch" style="background: var(--primary-color)"></span>
</button>
<div class="color-picker-popover anchor-right" id="cp-pop-accent" style="display:none" onclick="event.stopPropagation()">
<div class="color-picker-grid">
<button class="color-picker-dot" style="background:#4CAF50" onclick="event.stopPropagation(); window._cpPick('accent','#4CAF50')"></button>
<button class="color-picker-dot" style="background:#7C4DFF" onclick="event.stopPropagation(); window._cpPick('accent','#7C4DFF')"></button>
<button class="color-picker-dot" style="background:#FF6D00" onclick="event.stopPropagation(); window._cpPick('accent','#FF6D00')"></button>
<button class="color-picker-dot" style="background:#E91E63" onclick="event.stopPropagation(); window._cpPick('accent','#E91E63')"></button>
<button class="color-picker-dot" style="background:#00BCD4" onclick="event.stopPropagation(); window._cpPick('accent','#00BCD4')"></button>
<button class="color-picker-dot" style="background:#FF5252" onclick="event.stopPropagation(); window._cpPick('accent','#FF5252')"></button>
<button class="color-picker-dot" style="background:#26A69A" onclick="event.stopPropagation(); window._cpPick('accent','#26A69A')"></button>
<button class="color-picker-dot" style="background:#2196F3" onclick="event.stopPropagation(); window._cpPick('accent','#2196F3')"></button>
<button class="color-picker-dot" style="background:#FFC107" onclick="event.stopPropagation(); window._cpPick('accent','#FFC107')"></button>
</div>
<div class="color-picker-custom" onclick="this.querySelector('input').click()">
<input type="color" id="cp-native-accent" value="#4CAF50"
oninput="event.stopPropagation(); window._cpPick('accent',this.value)" onchange="event.stopPropagation(); window._cpPick('accent',this.value)">
<span data-i18n="accent.custom">Custom</span>
</div>
</div>
</span>
<button class="header-btn" onclick="openSettingsModal()" data-i18n-title="settings.title" title="Settings">
<svg class="icon" viewBox="0 0 24 24"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<select id="locale-select" class="header-locale" onchange="changeLocale()" data-i18n-title="locale.change" title="Change language">
<option value="en">EN</option>
<option value="ru">RU</option>
<option value="zh">ZH</option>
</select>
<span class="header-toolbar-sep"></span>
<button id="login-btn" class="header-btn" onclick="showLogin()" style="display: none" data-i18n-title="auth.login" title="Login">
<svg class="icon" viewBox="0 0 24 24"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/></svg>
</button>
<button id="logout-btn" class="header-btn" onclick="logout()" style="display: none" data-i18n-title="auth.logout" title="Logout">
<svg class="icon" viewBox="0 0 24 24"><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>
</button>
</div>
</header>
<div class="container">
<div class="tabs">
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
<div id="dashboard-content">
<div class="devices-grid">
<div class="skeleton-card"><div class="skeleton-line skeleton-line-title"></div><div class="skeleton-line skeleton-line-medium"></div><div class="skeleton-line skeleton-line-short"></div><div class="skeleton-actions"><div class="skeleton-btn"></div><div class="skeleton-btn"></div></div></div>
<div class="skeleton-card"><div class="skeleton-line skeleton-line-title"></div><div class="skeleton-line skeleton-line-medium"></div><div class="skeleton-line skeleton-line-short"></div><div class="skeleton-actions"><div class="skeleton-btn"></div><div class="skeleton-btn"></div></div></div>
<div class="skeleton-card"><div class="skeleton-line skeleton-line-title"></div><div class="skeleton-line skeleton-line-medium"></div><div class="skeleton-line skeleton-line-short"></div><div class="skeleton-actions"><div class="skeleton-btn"></div><div class="skeleton-btn"></div></div></div>
</div>
</div>
</div>
<div class="tab-panel" id="tab-automations" role="tabpanel" aria-labelledby="tab-btn-automations">
<div class="tree-layout">
<nav class="tree-sidebar" id="automations-tree-nav"></nav>
<div class="tree-content" id="automations-content">
<div class="loading-spinner"></div>
</div>
</div>
</div>
<div class="tab-panel" id="tab-targets" role="tabpanel" aria-labelledby="tab-btn-targets">
<div class="tree-layout">
<nav class="tree-sidebar" id="targets-tree-nav"></nav>
<div class="tree-content" id="targets-panel-content">
<div class="loading-spinner"></div>
</div>
</div>
</div>
<div class="tab-panel" id="tab-streams" role="tabpanel" aria-labelledby="tab-btn-streams">
<div class="tree-layout">
<nav class="tree-sidebar" id="streams-tree-nav"></nav>
<div class="tree-content" id="streams-list">
<div class="loading-spinner"></div>
</div>
</div>
</div>
<div class="tab-panel" id="tab-graph" role="tabpanel" aria-labelledby="tab-btn-graph">
<div id="graph-editor-content">
<div class="loading-spinner"></div>
</div>
</div>
<script>
// Apply saved tab immediately during parse to prevent visible jump
(function() {
var hash = location.hash.replace(/^#/, '');
var saved = hash ? hash.split('/')[0] : localStorage.getItem('activeTab');
if (saved === 'devices') saved = 'targets';
if (!saved || !document.getElementById('tab-' + saved)) saved = 'dashboard';
/* graph tab is valid */
document.querySelectorAll('.tab-btn').forEach(function(btn) { btn.classList.toggle('active', btn.dataset.tab === saved); });
document.querySelectorAll('.tab-panel').forEach(function(panel) { panel.classList.toggle('active', panel.id === 'tab-' + saved); });
})();
</script>
</div>
<footer class="app-footer">
<div class="footer-content">
<p>
Created by <strong>Alexei Dolgolyov</strong>
<a href="mailto:dolgolyov.alexei@gmail.com">dolgolyov.alexei@gmail.com</a>
<a href="https://git.dolgolyov-family.by/alexei.dolgolyov/wled-screen-controller-mixed" target="_blank" rel="noopener">Source Code</a>
</p>
</div>
</footer>
</div>
<button id="scroll-to-top" class="scroll-to-top" onclick="window.scrollTo({top:0,behavior:'smooth'})" aria-label="Scroll to top">
<svg class="icon" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></svg>
</button>
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
{% include 'modals/calibration.html' %}
{% include 'modals/advanced-calibration.html' %}
{% include 'modals/device-settings.html' %}
{% include 'modals/target-editor.html' %}
{% include 'modals/css-editor.html' %}
{% include 'modals/gradient-editor.html' %}
{% include 'modals/test-css-source.html' %}
{% include 'modals/notification-history.html' %}
{% include 'modals/kc-editor.html' %}
{% include 'modals/pattern-template.html' %}
{% include 'modals/api-key.html' %}
{% include 'modals/confirm.html' %}
{% include 'modals/add-device.html' %}
{% include 'modals/capture-template.html' %}
{% include 'modals/test-template.html' %}
{% include 'modals/test-stream.html' %}
{% include 'modals/test-pp-template.html' %}
{% include 'modals/stream.html' %}
{% include 'modals/pp-template.html' %}
{% include 'modals/cspt-modal.html' %}
{% include 'modals/automation-editor.html' %}
{% include 'modals/scene-preset-editor.html' %}
{% include 'modals/audio-source-editor.html' %}
{% include 'modals/test-audio-source.html' %}
{% include 'modals/audio-template.html' %}
{% include 'modals/test-audio-template.html' %}
{% include 'modals/value-source-editor.html' %}
{% include 'modals/test-value-source.html' %}
{% include 'modals/sync-clock-editor.html' %}
{% include 'modals/settings.html' %}
{% include 'partials/tutorial-overlay.html' %}
{% include 'partials/image-lightbox.html' %}
{% include 'partials/display-picker.html' %}
<div id="command-palette" style="display:none">
<div class="cp-backdrop"></div>
<div class="cp-dialog">
<input id="cp-input" class="cp-input" placeholder="Search..." autocomplete="off">
<div id="cp-results" class="cp-results"></div>
<div class="cp-footer" data-i18n="search.footer">↑↓ navigate · Enter select · Esc close</div>
</div>
</div>
<script defer src="/static/dist/app.bundle.js"></script>
<script>
// Initialize ambient background
const savedBgAnim = localStorage.getItem('bgAnim') || 'off';
document.documentElement.setAttribute('data-bg-anim', savedBgAnim);
// All known CSS bg-effect classes (must match appearance.ts BG_EFFECT_PRESETS)
var _bgEffectClasses = ['bg-effect-grid', 'bg-effect-mesh', 'bg-effect-scanlines', 'bg-effect-particles'];
function toggleBgAnim() {
var savedEffect = localStorage.getItem('bgEffect') || 'none';
var isOn = _isBgEffectActive();
if (isOn) {
// Turn everything off
document.documentElement.setAttribute('data-bg-anim', 'off');
document.documentElement.removeAttribute('data-bg-effect');
var lyr = document.getElementById('bg-effect-layer');
if (lyr) _bgEffectClasses.forEach(function(c) { lyr.classList.remove(c); });
updateBgAnimBtn('off');
} else {
// Restore saved effect (or fallback to WebGL noise)
if (savedEffect === 'none') savedEffect = 'noise';
if (typeof window.applyBgEffect === 'function') {
window.applyBgEffect(savedEffect);
} else {
// Fallback before bundle loads: just toggle WebGL
document.documentElement.setAttribute('data-bg-anim', 'on');
}
updateBgAnimBtn('on');
}
}
function _isBgEffectActive() {
if (document.documentElement.getAttribute('data-bg-anim') === 'on') return true;
var lyr = document.getElementById('bg-effect-layer');
if (!lyr) return false;
for (var i = 0; i < _bgEffectClasses.length; i++) {
if (lyr.classList.contains(_bgEffectClasses[i])) return true;
}
return false;
}
function updateBgAnimBtn(state) {
var btn = document.getElementById('bg-anim-btn');
if (btn) btn.style.opacity = state === 'on' ? '1' : '0.5';
}
// Initialize theme
const savedTheme = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
updateThemeIcon(savedTheme);
function updateThemeIcon(theme) {
const icon = document.getElementById('theme-icon');
icon.innerHTML = theme === 'dark'
? '<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>'
: '<svg class="icon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></svg>';
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
updateThemeIcon(newTheme);
if (window._updateBgAnimTheme) window._updateBgAnimTheme(newTheme === 'dark');
// Re-derive accent text variant for the new theme
const accent = localStorage.getItem('accentColor');
if (accent) applyAccentColor(accent, true);
showToast(window.t ? t(newTheme === 'dark' ? 'theme.switched.dark' : 'theme.switched.light') : `Switched to ${newTheme} theme`, 'info');
}
// Initialize accent color
function adjustLightness(hex, amount) {
const r = parseInt(hex.slice(1,3),16)/255;
const g = parseInt(hex.slice(3,5),16)/255;
const b = parseInt(hex.slice(5,7),16)/255;
const max = Math.max(r,g,b), min = Math.min(r,g,b);
let h, s, l = (max+min)/2;
if (max===min) { h=s=0; } else {
const d = max-min;
s = l>0.5 ? d/(2-max-min) : d/(max+min);
if (max===r) h=((g-b)/d+(g<b?6:0))/6;
else if (max===g) h=((b-r)/d+2)/6;
else h=((r-g)/d+4)/6;
}
l = Math.max(0, Math.min(1, l + amount/100));
const hue2rgb = (p,q,t) => { if(t<0)t+=1; if(t>1)t-=1; if(t<1/6)return p+(q-p)*6*t; if(t<1/2)return q; if(t<2/3)return p+(q-p)*(2/3-t)*6; return p; };
let rr,gg,bb;
if (s===0) { rr=gg=bb=l; } else {
const q = l<0.5 ? l*(1+s) : l+s-l*s, p = 2*l-q;
rr=hue2rgb(p,q,h+1/3); gg=hue2rgb(p,q,h); bb=hue2rgb(p,q,h-1/3);
}
return '#'+[rr,gg,bb].map(x=>Math.round(x*255).toString(16).padStart(2,'0')).join('');
}
function contrastColor(hex) {
const r = parseInt(hex.slice(1,3),16)/255;
const g = parseInt(hex.slice(3,5),16)/255;
const b = parseInt(hex.slice(5,7),16)/255;
const lin = c => c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
const L = 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b);
return L > 0.36 ? '#1a1a1a' : '#ffffff';
}
function applyAccentColor(hex, silent) {
const root = document.documentElement;
root.style.setProperty('--primary-color', hex);
const theme = root.getAttribute('data-theme');
root.style.setProperty('--primary-text-color', adjustLightness(hex, theme === 'dark' ? 15 : -15));
root.style.setProperty('--primary-hover', adjustLightness(hex, 8));
root.style.setProperty('--primary-contrast', contrastColor(hex));
// Update canvas background blobs if module loaded
if (window._updateBgAnimAccent) window._updateBgAnimAccent(hex);
const swatch = document.getElementById('cp-swatch-accent');
if (swatch) swatch.style.background = hex;
const native = document.getElementById('cp-native-accent');
if (native) native.value = hex;
localStorage.setItem('accentColor', hex);
document.dispatchEvent(new CustomEvent('accentColorChanged', { detail: { color: hex } }));
if (!silent) showToast(window.t ? t('accent.color.updated') : 'Accent color updated', 'info');
}
// Bootstrap _cpToggle/_cpPick globals before the color-picker module loads
// (the module will overwrite them with proper versions that handle all pickers)
window._cpCallbacks = { accent: function(hex) { applyAccentColor(hex); } };
window._cpToggle = window._cpToggle || function(id) {
document.querySelectorAll('.color-picker-popover').forEach(p => {
if (p.id !== 'cp-pop-' + id) p.style.display = 'none';
});
const pop = document.getElementById('cp-pop-' + id);
if (pop) pop.style.display = pop.style.display === 'none' ? '' : 'none';
};
window._cpPick = window._cpPick || function(id, hex) {
var s = document.getElementById('cp-swatch-' + id);
if (s) s.style.background = hex;
var n = document.getElementById('cp-native-' + id);
if (n) n.value = hex;
var p = document.getElementById('cp-pop-' + id);
if (p) p.style.display = 'none';
if (window._cpCallbacks[id]) window._cpCallbacks[id](hex);
};
// Close all color pickers on outside click
document.addEventListener('click', function() {
document.querySelectorAll('.color-picker-popover').forEach(function(p) { p.style.display = 'none'; });
});
const savedAccent = localStorage.getItem('accentColor');
if (savedAccent) applyAccentColor(savedAccent, true);
// Early-apply saved background effect class on the dedicated layer element
const savedBgEffect = localStorage.getItem('bgEffect');
if (savedBgEffect && savedBgEffect !== 'none' && savedBgEffect !== 'noise') {
var effectClasses = { grid: 'bg-effect-grid', mesh: 'bg-effect-mesh', scanlines: 'bg-effect-scanlines', particles: 'bg-effect-particles' };
var layer = document.getElementById('bg-effect-layer');
if (layer && effectClasses[savedBgEffect]) {
layer.classList.add(effectClasses[savedBgEffect]);
document.documentElement.setAttribute('data-bg-effect', savedBgEffect);
}
}
// Set header toggle button state (reflects both WebGL and CSS effects)
updateBgAnimBtn(_isBgEffectActive() ? 'on' : 'off');
// Initialize auth state
function updateAuthUI() {
const apiKey = localStorage.getItem('wled_api_key');
const loginBtn = document.getElementById('login-btn');
const logoutBtn = document.getElementById('logout-btn');
const tabBar = document.querySelector('.tab-bar');
const authDisabled = window._authRequired === false;
if (authDisabled) {
// Auth disabled — hide login/logout, always show tabs
loginBtn.style.display = 'none';
logoutBtn.style.display = 'none';
if (tabBar) tabBar.style.display = '';
} else if (apiKey) {
loginBtn.style.display = 'none';
logoutBtn.style.display = 'inline-block';
if (tabBar) tabBar.style.display = '';
} else {
loginBtn.style.display = 'inline-block';
logoutBtn.style.display = 'none';
if (tabBar) tabBar.style.display = 'none';
}
}
function showLogin() {
showApiKeyModal(t('auth.message'));
document.getElementById('modal-cancel-btn').style.display = 'inline-block';
}
async function logout() {
const confirmed = await showConfirm(t('auth.logout.confirm'));
if (!confirmed) {
return;
}
localStorage.removeItem('wled_api_key');
if (window.setApiKey) window.setApiKey(null);
updateAuthUI();
showToast(t('auth.logout.success'), 'info');
// Stop background activity
if (window.stopDashboardWS) window.stopDashboardWS();
if (window.stopPerfPolling) window.stopPerfPolling();
if (window.stopUptimeTimer) window.stopUptimeTimer();
if (window.disconnectAllKCWebSockets) window.disconnectAllKCWebSockets();
// Clear all tab panels
const loginMsg = `<div class="loading">${t('auth.please_login')}</div>`;
document.getElementById('dashboard-content').innerHTML = loginMsg;
document.getElementById('automations-content').innerHTML = loginMsg;
document.getElementById('targets-panel-content').innerHTML = loginMsg;
document.getElementById('streams-list').innerHTML = loginMsg;
document.getElementById('graph-editor-content').innerHTML = loginMsg;
}
// Demo banner dismiss
function dismissDemoBanner() {
var banner = document.getElementById('demo-banner');
if (banner) banner.style.display = 'none';
localStorage.setItem('demo-banner-dismissed', 'true');
}
// Initialize on load
updateAuthUI();
// Modal functions
function togglePasswordVisibility() {
const input = document.getElementById('api-key-input');
const button = document.querySelector('.password-toggle');
if (input.type === 'password') {
input.type = 'text';
button.innerHTML = '<svg class="icon" viewBox="0 0 24 24"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>';
} else {
input.type = 'password';
button.innerHTML = '<svg class="icon" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>';
}
}
function showApiKeyModal(message, hideCancel = false) {
const modal = document.getElementById('api-key-modal');
const description = document.querySelector('.modal-description');
const input = document.getElementById('api-key-input');
const error = document.getElementById('api-key-error');
const cancelBtn = document.getElementById('modal-cancel-btn');
description.textContent = message || t('auth.message');
input.value = '';
input.placeholder = t('auth.placeholder');
error.style.display = 'none';
modal.style.display = 'flex';
lockBody();
// Hide cancel button and close X if this is required login (no existing session)
cancelBtn.style.display = hideCancel ? 'none' : 'inline-block';
const closeXBtn = document.getElementById('modal-close-x-btn');
if (closeXBtn) closeXBtn.style.display = hideCancel ? 'none' : '';
// Hide login button while modal is open
document.getElementById('login-btn').style.display = 'none';
setTimeout(() => input.focus(), 100);
}
function closeApiKeyModal() {
const modal = document.getElementById('api-key-modal');
modal.style.display = 'none';
unlockBody();
updateAuthUI();
}
function submitApiKey(event) {
if (event) {
event.preventDefault();
}
const input = document.getElementById('api-key-input');
const error = document.getElementById('api-key-error');
const key = input.value.trim();
if (!key) {
error.textContent = t('auth.error.required');
error.style.display = 'block';
return;
}
// Store the key
localStorage.setItem('wled_api_key', key);
if (window.setApiKey) window.setApiKey(key);
updateAuthUI();
closeApiKeyModal();
showToast(t('auth.success'), 'success');
// Reload data
loadServerInfo();
loadDisplays();
loadTargetsTab();
// Start auto-refresh
startAutoRefresh();
// Show getting-started tutorial on first login
if (!localStorage.getItem('tour_completed') && typeof startGettingStartedTutorial === 'function') {
setTimeout(() => startGettingStartedTutorial(), 600);
}
}
</script>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
</body>
</html>