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>
489 lines
29 KiB
HTML
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 — all devices and data are virtual. No real hardware is used.</span>
|
|
<button class="demo-banner-dismiss" onclick="dismissDemoBanner()" aria-label="Dismiss">×</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>
|