Files
wled-screen-controller-mixed/server/src/wled_controller/templates/index.html
alexei.dolgolyov 2240471b67 Add demo mode: virtual hardware sandbox for testing without real devices
Demo mode provides a complete sandbox environment with:
- Virtual capture engine (radial rainbow test pattern on 3 displays)
- Virtual audio engine (synthetic music-like audio on 2 devices)
- Virtual LED device provider (strip/60, matrix/256, ring/24 LEDs)
- Isolated data directory (data/demo/) with auto-seeded sample entities
- Dedicated config (config/demo_config.yaml) with pre-configured API key
- Frontend indicator (DEMO badge + dismissible banner)
- Engine filtering (only demo engines visible in demo mode)
- Separate entry point: python -m wled_controller.demo (port 8081)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:17:14 +03:00

489 lines
29 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>
<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/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);
updateBgAnimBtn(savedBgAnim);
function toggleBgAnim() {
const cur = document.documentElement.getAttribute('data-bg-anim');
const next = cur === 'on' ? 'off' : 'on';
document.documentElement.setAttribute('data-bg-anim', next);
localStorage.setItem('bgAnim', next);
updateBgAnimBtn(next);
}
function updateBgAnimBtn(state) {
const 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);
// 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');
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>