539e43195f
Full-app UI/UX refresh committing to a tech-instrument / studio-console aesthetic inspired by hardware synths, Eurorack panels, and DAW layouts. Design tokens and fonts: - Embed Manrope (body), JetBrains Mono (labels/metrics), Big Shoulders Display (numeric readouts) as local .woff2 variable fonts with latin + latin-ext + cyrillic + cyrillic-ext subsets via unicode-range. - New Lumenworks token layer in base.css: --lux-bg-0..3, --lux-line(-bold), --lux-ink(-dim/-mute/-faint), --ch-signal/-cyan/-magenta/-amber/-coral/ -violet channel palette, --lux-signal-glow, --lux-shadow-rack, all theme-aware for dark + light. Existing tokens untouched for compat. Shell (header + sidebar): - Header rebuilt as a 3-column CSS-grid transport bar (brand | center | toolbar) with a glowing LED brand mark rendered via pseudo-elements on .header-title. Gradient channel-color rule under the bottom border. - New sidebar.css introduces a vertical channel-strip nav. Active tab gets a glowing left stripe + radial tint + LED pip. .sidebar-foot contains a live CPU/FPS meter plate. - Sidebar collapses to a 56 px icon rail at <=1100 px and hides via display:contents at <=600 px so mobile.css's fixed bottom tab-bar flows through unchanged. Cards and dashboard: - .card gets channel stripe (data-card-type + .ch-* utilities auto-map from data-target-id / data-stream-id / data-automation-id etc.), corner bracket, gradient background, subtle rack shadow. - .card-running replaces the old @property --border-angle conic-gradient rotating border with a lightweight signalFlow linear-gradient strip on the bottom edge (cheaper paint, no GPU layer compositing per card). - Skeleton loaders rewritten: left hairline + corner bracket + gradient shimmer instead of the old text-color opacity pulse. - .dashboard-target rows pick up the same channel-stripe + signalFlow treatment. Section headers use mono micro-caps with a channel-green underline accent consistent across the app. - .perf-chart-card: channel stripe replaces old border-top; per-metric accents moved to the channel palette (CPU=coral, RAM=violet, GPU=green, temp=amber). Metric values use tabular-nums + a soft glow. Live bindings (no new endpoints): - _updateSidebarMeter: binds the sidebar Load + FPS bars to the existing /system/performance poll. - _updateTransportStatus: toggles the transport chip between "Ready" and "Armed - N live" whenever the dashboard's running-target set is recomputed. Tree-nav + sub-tabs: - tree-nav.css trigger pill gets a channel-stripe left edge that glows when open; panel has a gradient channel-accent rule across the top; group headers use silkscreened micro-caps; active leaf has a pulsing LED pip + channel tint. - .stream-tab-btn / .subtab-section-header adopt the same mono-caps + channel-underline language for consistency. - Graph editor toolbar gets gradient + hairline + rack shadow + backdrop blur. Canvas and nodes untouched. Modals (40+ modals share modal.css): - Radial-dim + 6 px blur backdrop. Content gets a gradient background, hairline border, deep rack shadow, top channel-accent rule driven by --modal-ch, bottom-right corner bracket (hidden on mobile fullscreen). - Per-modal-ID channel lanes: target editors = green, source/input editors = cyan, audio = magenta, automation/scene/game = violet, settings/auth = amber, confirm = coral. - Modal headers: vertical channel stripe left of the title + hairline divider. Modal footers: hairline top border + subtle gradient wash. Forms: - Inputs use hairline borders; number inputs switch to mono + tabular-nums for column alignment. Focus state: channel-green ring + soft glow. - Buttons use mono-uppercase type with signal-glow on primary and coral- glow on danger. Mobile (<=600 px): - Fixed bottom .tab-bar gets the full Lumenworks treatment: gradient fill, top channel-accent rule matching the transport bar, backdrop blur. Active tab has an LED pip above the icon + channel tint + icon recolor. - Fullscreen modals: corner bracket hidden, header stripe slimmed. Microcopy (en / ru / zh): - "Targets" -> "Channels" / "Каналы" / "通道" - "Sources" -> "Inputs" / "Входы" / "输入" - Internal tab keys (dashboard/automations/targets/streams/integrations/ graph) kept stable so no JS or localStorage migration is needed. - Added: sidebar.workspaces, sidebar.load, sidebar.fps, transport.status.ready, transport.status.armed. Compatibility: - All existing class hooks preserved (.tab-bar, .tab-btn, .card, .card-running, .tree-dd-*, .cs-*, .perf-chart-card, .modal-content, .dashboard-target, etc.). No JS or API changes required for the new look to take effect. - Tour selectors survive (header .header-title, #tab-btn-*, onclick markers on theme/settings/search, #cp-wrap-accent, etc.). - Mobile <=600 px bottom tab-bar keeps working via display:contents fall-through in the new sidebar. Build: tsc --noEmit clean; npm run build clean. CSS bundle grew from ~177 KB to ~201 KB for the full new visual system. Fonts loaded lazily per unicode-range subset (~98 KB critical path for English). Phased plan + deferred follow-ups (dashboard hero strip, legacy-token cleanup) recorded at the top of TODO.md. Reference mockup: server/docs/ui-redesign-mockup.html.
734 lines
41 KiB
HTML
734 lines
41 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>
|
|
<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>
|
|
<div id="conn-msg-offline">
|
|
<h2 data-i18n="app.connection_lost">Server unreachable</h2>
|
|
<p data-i18n="app.connection_retrying">Attempting to reconnect…</p>
|
|
</div>
|
|
<div id="conn-msg-restarting" style="display:none">
|
|
<h2 data-i18n="app.server_restarting">Server restarting…</h2>
|
|
<p data-i18n="app.server_restarting_sub">Please wait, the server will be back shortly.</p>
|
|
</div>
|
|
</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="transport-center">
|
|
<span class="transport-status" id="transport-status" aria-live="polite">
|
|
<span class="dot"></span>
|
|
<span data-i18n="transport.status.ready">Ready</span>
|
|
</span>
|
|
</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 id="update-banner" class="update-banner" style="display:none"></div>
|
|
<div id="donation-banner" class="donation-banner" style="display:none"></div>
|
|
<div class="app-body">
|
|
<aside class="sidebar" aria-label="Primary">
|
|
<div class="sidebar-section">
|
|
<div class="sidebar-label"><span data-i18n="sidebar.workspaces">Workspaces</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></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></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="integrations" onclick="switchTab('integrations')" role="tab" aria-selected="false" aria-controls="tab-integrations" id="tab-btn-integrations" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M15 8V2"/><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z"/><path d="M9 8V2"/></svg> <span data-i18n="integrations.title">Integrations</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+6"><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>
|
|
<div class="sidebar-foot">
|
|
<div class="cpu-meter" id="sidebar-meter" aria-hidden="true">
|
|
<div>
|
|
<div class="cpu-meter-row"><span data-i18n="sidebar.load">Load</span><b id="sidebar-meter-cpu">--%</b></div>
|
|
<div class="cpu-bar"><i id="sidebar-meter-cpu-bar" style="width:0"></i></div>
|
|
</div>
|
|
<div>
|
|
<div class="cpu-meter-row"><span data-i18n="sidebar.fps">FPS</span><b id="sidebar-meter-fps">--</b></div>
|
|
<div class="cpu-bar cpu-bar-fps"><i id="sidebar-meter-fps-bar" style="width:0"></i></div>
|
|
</div>
|
|
</div>
|
|
<div class="sidebar-version"><span id="sidebar-version-text">—</span></div>
|
|
</div>
|
|
</aside>
|
|
<main class="app-main">
|
|
<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-integrations" role="tabpanel" aria-labelledby="tab-btn-integrations">
|
|
<div class="tree-layout">
|
|
<nav class="tree-sidebar" id="integrations-tree-nav"></nav>
|
|
<div class="tree-content" id="integrations-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="#" onclick="openSettingsModal();switchSettingsTab('about');return false" data-i18n="donation.about_title">About</a>
|
|
</p>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</main>
|
|
</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/pattern-template.html' %}
|
|
{% include 'modals/api-key.html' %}
|
|
{% include 'modals/setup-required.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/weather-source-editor.html' %}
|
|
{% include 'modals/ha-source-editor.html' %}
|
|
{% include 'modals/mqtt-source-editor.html' %}
|
|
{% include 'modals/ha-light-editor.html' %}
|
|
{% include 'modals/asset-upload.html' %}
|
|
{% include 'modals/asset-editor.html' %}
|
|
{% include 'modals/game-integration-editor.html' %}
|
|
{% include 'modals/audio-processing-template.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 (preference can be 'dark', 'light', or 'system')
|
|
const _systemDarkMq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
|
|
function _resolveTheme(pref) {
|
|
if (pref === 'system') return _systemDarkMq.matches ? 'dark' : 'light';
|
|
return pref;
|
|
}
|
|
|
|
function _applyTheme(resolved) {
|
|
document.documentElement.setAttribute('data-theme', resolved);
|
|
if (window._updateBgAnimTheme) window._updateBgAnimTheme(resolved === 'dark');
|
|
const accent = localStorage.getItem('accentColor');
|
|
if (accent) applyAccentColor(accent, true);
|
|
}
|
|
|
|
const _themePref = localStorage.getItem('theme') || 'dark';
|
|
_applyTheme(_resolveTheme(_themePref));
|
|
updateThemeIcon(_themePref);
|
|
|
|
// Listen for OS preference changes when in system mode
|
|
_systemDarkMq.addEventListener('change', function() {
|
|
if (localStorage.getItem('theme') === 'system') {
|
|
_applyTheme(_resolveTheme('system'));
|
|
}
|
|
});
|
|
|
|
function updateThemeIcon(pref) {
|
|
const icon = document.getElementById('theme-icon');
|
|
if (pref === 'system') {
|
|
icon.innerHTML = '<svg class="icon" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>';
|
|
} else if (pref === 'dark') {
|
|
icon.innerHTML = '<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>';
|
|
} else {
|
|
icon.innerHTML = '<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 current = localStorage.getItem('theme') || 'dark';
|
|
const order = ['dark', 'light', 'system'];
|
|
const next = order[(order.indexOf(current) + 1) % order.length];
|
|
const resolved = _resolveTheme(next);
|
|
|
|
localStorage.setItem('theme', next);
|
|
_applyTheme(resolved);
|
|
updateThemeIcon(next);
|
|
const toastKeys = { dark: 'theme.switched.dark', light: 'theme.switched.light', system: 'theme.switched.system' };
|
|
showToast(window.t ? t(toastKeys[next]) : `Switched to ${next} 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');
|
|
|
|
// Migrate localStorage key from pre-rename installs
|
|
if (!localStorage.getItem('ledgrab_api_key') && localStorage.getItem('wled_api_key')) {
|
|
localStorage.setItem('ledgrab_api_key', localStorage.getItem('wled_api_key'));
|
|
localStorage.removeItem('wled_api_key');
|
|
}
|
|
|
|
// Initialize auth state
|
|
function updateAuthUI() {
|
|
const apiKey = localStorage.getItem('ledgrab_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('ledgrab_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();
|
|
}
|
|
|
|
// ─── Setup-required modal (shown when LAN client hits a server with
|
|
// no auth.api_keys configured — no key will ever work) ───
|
|
let _setupModalOpen = false;
|
|
|
|
function showSetupRequiredModal() {
|
|
const modal = document.getElementById('setup-required-modal');
|
|
if (!modal) return;
|
|
// Update loopback link to match the current port (default 8080)
|
|
try {
|
|
const link = document.getElementById('setup-loopback-link');
|
|
if (link) {
|
|
const port = location.port || '8080';
|
|
const href = `http://localhost:${port}`;
|
|
link.setAttribute('href', href);
|
|
link.textContent = href;
|
|
}
|
|
} catch { /* best-effort */ }
|
|
// Hide the api-key modal if it happened to be open
|
|
const apiModal = document.getElementById('api-key-modal');
|
|
if (apiModal) apiModal.style.display = 'none';
|
|
modal.style.display = 'flex';
|
|
_setupModalOpen = true;
|
|
lockBody();
|
|
// Tabs + login button make no sense while locked out
|
|
const tabBar = document.querySelector('.tab-bar');
|
|
if (tabBar) tabBar.style.display = 'none';
|
|
const loginBtn = document.getElementById('login-btn');
|
|
if (loginBtn) loginBtn.style.display = 'none';
|
|
}
|
|
|
|
function hideSetupRequiredModal() {
|
|
const modal = document.getElementById('setup-required-modal');
|
|
if (modal) modal.style.display = 'none';
|
|
_setupModalOpen = false;
|
|
unlockBody();
|
|
}
|
|
|
|
function copySetupSnippet() {
|
|
const pre = document.getElementById('setup-yaml-snippet');
|
|
if (!pre) return;
|
|
const text = pre.textContent || '';
|
|
const done = () => {
|
|
if (typeof showToast === 'function') {
|
|
showToast(t('setup.copied'), 'success');
|
|
}
|
|
};
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(done, done);
|
|
} else {
|
|
try {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
} finally {
|
|
done();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function retrySetupCheck() {
|
|
// Re-query /health. If setup_required is now false, reload so the
|
|
// normal auth flow takes over.
|
|
try {
|
|
const resp = await fetch('/health', { signal: AbortSignal.timeout(5000) });
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
if (!data.setup_required) {
|
|
hideSetupRequiredModal();
|
|
location.reload();
|
|
return;
|
|
}
|
|
}
|
|
} catch { /* ignore — will stay on the setup modal */ }
|
|
if (typeof showToast === 'function') {
|
|
showToast(t('setup.still_required'), 'info');
|
|
}
|
|
}
|
|
|
|
async function submitApiKey(event) {
|
|
if (event) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
const input = document.getElementById('api-key-input');
|
|
const error = document.getElementById('api-key-error');
|
|
const submitBtn = document.getElementById('api-key-submit');
|
|
const key = input.value.trim();
|
|
|
|
error.style.display = 'none';
|
|
|
|
if (!key) {
|
|
error.textContent = t('auth.error.required');
|
|
error.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
// Validate against server before accepting. Use a real auth-protected
|
|
// endpoint so wrong keys return 401. /api/v1/system/api-keys is
|
|
// cheap and requires AuthRequired.
|
|
if (submitBtn) submitBtn.disabled = true;
|
|
try {
|
|
const resp = await fetch('/api/v1/system/api-keys', {
|
|
headers: { 'Authorization': `Bearer ${key}` }
|
|
});
|
|
if (resp.status === 401) {
|
|
let msg = t('auth.error.invalid');
|
|
try {
|
|
const body = await resp.json();
|
|
if (body && body.detail) msg = body.detail;
|
|
} catch { /* ignore parse errors */ }
|
|
error.textContent = msg;
|
|
error.style.display = 'block';
|
|
return;
|
|
}
|
|
if (!resp.ok && resp.status !== 401) {
|
|
// Server reachable but non-auth error — accept the key anyway
|
|
}
|
|
} catch (e) {
|
|
// Network error — accept key; user may be on a slow connection
|
|
} finally {
|
|
if (submitBtn) submitBtn.disabled = false;
|
|
}
|
|
|
|
// Store the key
|
|
localStorage.setItem('ledgrab_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>
|