Files
ledgrab/server/src/ledgrab/templates/index.html
T
alexei.dolgolyov 539e43195f feat(ui): Lumenworks studio-console WebUI redesign
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.
2026-04-24 15:46:47 +03:00

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 &mdash; all devices and data are virtual. No real hardware is used.</span>
<button class="demo-banner-dismiss" onclick="dismissDemoBanner()" aria-label="Dismiss">&times;</button>
</div>
<canvas id="bg-anim-canvas"></canvas>
<canvas id="bg-effect-canvas"></canvas>
<div id="bg-effect-layer"></div>
<div id="connection-overlay" class="connection-overlay" style="display:none" aria-hidden="true">
<div class="connection-overlay-content">
<div class="connection-spinner-lg"></div>
<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>