mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
- Plugin-supplied widgets load as /static/plugin-widgets/...js?v=<plugin version>, so an update isn't hidden behind the year-long immutable cache. - Fire-and-forget loadInstalledPlugins() calls catch the rejection it has already reported, so the global handler no longer adds a second toast. - Timezone picker renders again when the General partial is re-injected. - Remove dead code: executePluginAction's six plugin-id fallbacks and [DEBUG] logging, window.currentPluginConfig and every read of it, the file-upload JSON delete branch, unused PluginAPI / PluginInstallManager / PluginStateManager helpers, loadPluginWidgetsFromManifest, the stale install_manager.js and LEDVisibility fallbacks, error_handler.js's global escapeHtml, 13 unused CSS rules, and stale comments/no-op returns. - pytz < 2027, psutil < 7 in requirements-test.txt, pytest-cov < 8. - Pin anthropics/claude-code-action to the commit v1 resolves to. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1096 lines
58 KiB
HTML
1096 lines
58 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 Matrix Control Panel</title>
|
||
|
||
<!-- Installable web app (Add to Home Screen) -->
|
||
<link rel="manifest" href="{{ url_for('static', filename='v3/manifest.json') }}">
|
||
<meta name="theme-color" content="#111827">
|
||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='v3/icons/apple-touch-icon.png') }}">
|
||
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='v3/icons/icon-192.png') }}">
|
||
|
||
<!-- Debug logging gate: verbose console output is suppressed unless
|
||
localStorage.pluginDebug === 'true' (the same switch the plugin
|
||
scripts already use). console.error/warn are never gated. -->
|
||
<script>
|
||
window.LEDMATRIX_DEBUG = (function() {
|
||
try { return localStorage.getItem('pluginDebug') === 'true'; } catch (e) { return false; }
|
||
})();
|
||
window.debugLog = function() {
|
||
if (window.LEDMATRIX_DEBUG) console.log.apply(console, arguments);
|
||
};
|
||
</script>
|
||
|
||
<!-- Theme initialization (must run before CSS to prevent flash) -->
|
||
<script>
|
||
(function() {
|
||
// Safely read from localStorage (may throw in private browsing / restricted contexts)
|
||
function getStorage(key) {
|
||
try { return localStorage.getItem(key); } catch (e) { return null; }
|
||
}
|
||
function setStorage(key, value) {
|
||
try { localStorage.setItem(key, value); } catch (e) { /* no-op */ }
|
||
}
|
||
|
||
// Safely query prefers-color-scheme (matchMedia may be unavailable)
|
||
function prefersDark() {
|
||
try {
|
||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||
} catch (e) { return false; }
|
||
}
|
||
|
||
var saved = getStorage('theme');
|
||
var theme = saved || (prefersDark() ? 'dark' : 'light');
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
|
||
// Theme toggle function
|
||
window.toggleTheme = function() {
|
||
try {
|
||
var current = document.documentElement.getAttribute('data-theme');
|
||
var next = current === 'dark' ? 'light' : 'dark';
|
||
document.documentElement.setAttribute('data-theme', next);
|
||
setStorage('theme', next);
|
||
window.updateThemeIcon(next);
|
||
} catch (e) { /* no-op */ }
|
||
};
|
||
|
||
// Update icon visibility and ARIA state based on current theme
|
||
window.updateThemeIcon = function(theme) {
|
||
var darkIcon = document.getElementById('theme-icon-dark');
|
||
var lightIcon = document.getElementById('theme-icon-light');
|
||
var btn = document.getElementById('theme-toggle');
|
||
if (darkIcon && lightIcon) {
|
||
if (theme === 'dark') {
|
||
darkIcon.classList.add('hidden');
|
||
lightIcon.classList.remove('hidden');
|
||
} else {
|
||
darkIcon.classList.remove('hidden');
|
||
lightIcon.classList.add('hidden');
|
||
}
|
||
}
|
||
if (btn) {
|
||
var isDark = theme === 'dark';
|
||
btn.setAttribute('aria-pressed', String(isDark));
|
||
var label = isDark ? 'Switch to light mode' : 'Switch to dark mode';
|
||
btn.setAttribute('aria-label', label);
|
||
btn.setAttribute('title', label);
|
||
}
|
||
};
|
||
|
||
// Initialize icon state once DOM is ready
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
window.updateThemeIcon(document.documentElement.getAttribute('data-theme') || 'light');
|
||
});
|
||
|
||
// Listen for OS theme changes (only when no explicit user preference)
|
||
try {
|
||
var mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||
var handler = function(e) {
|
||
if (!getStorage('theme')) {
|
||
var t = e.matches ? 'dark' : 'light';
|
||
document.documentElement.setAttribute('data-theme', t);
|
||
window.updateThemeIcon(t);
|
||
}
|
||
};
|
||
if (mql.addEventListener) {
|
||
mql.addEventListener('change', handler);
|
||
} else if (mql.addListener) {
|
||
mql.addListener(handler);
|
||
}
|
||
} catch (e) { /* matchMedia unavailable */ }
|
||
})();
|
||
</script>
|
||
|
||
<!-- HTMX for dynamic content loading.
|
||
Local (vendored) files are the primary source for every network —
|
||
LAN-speed loads, fully offline-capable; the pinned CDN copies remain
|
||
only as a last-ditch rescue if a local file somehow fails. -->
|
||
<script>
|
||
(function() {
|
||
const htmxSrc = '/static/v3/js/htmx.min.js';
|
||
const jsonEncSrc = '/static/v3/js/htmx-json-enc.js';
|
||
const htmxCdn = 'https://unpkg.com/htmx.org@1.9.10';
|
||
const jsonEncCdn = 'https://unpkg.com/htmx.org@1.9.10/dist/ext/json-enc.js';
|
||
|
||
// Load a script with an optional one-shot fallback source
|
||
function loadScript(src, fallback, onLoad) {
|
||
const script = document.createElement('script');
|
||
script.src = src;
|
||
script.onload = onLoad || (() => {});
|
||
script.onerror = function() {
|
||
if (fallback && src !== fallback) {
|
||
console.warn(`Failed to load ${src}, trying fallback ${fallback}`);
|
||
const fallbackScript = document.createElement('script');
|
||
fallbackScript.src = fallback;
|
||
fallbackScript.onload = onLoad || (() => {});
|
||
document.head.appendChild(fallbackScript);
|
||
} else {
|
||
console.error(`Failed to load script: ${src}`);
|
||
}
|
||
};
|
||
document.head.appendChild(script);
|
||
}
|
||
|
||
function htmxReady() {
|
||
debugLog('HTMX loaded successfully');
|
||
window.dispatchEvent(new Event('htmx:ready'));
|
||
// Load the json-enc extension after core loads
|
||
loadScript(jsonEncSrc, jsonEncCdn);
|
||
}
|
||
|
||
// Load HTMX core (local first, CDN rescue), then the extensions
|
||
loadScript(htmxSrc, htmxCdn, function() {
|
||
// Wait a moment for HTMX to initialize, then verify
|
||
setTimeout(function() {
|
||
if (typeof htmx !== 'undefined') {
|
||
htmxReady();
|
||
return;
|
||
}
|
||
// Loaded but not initialized (e.g. corrupt cached copy):
|
||
// one CDN rescue attempt, mirroring the Alpine fallback.
|
||
console.error('HTMX not initialized after load, trying CDN rescue...');
|
||
loadScript(htmxCdn, null, function() {
|
||
setTimeout(function() {
|
||
if (typeof htmx !== 'undefined') {
|
||
htmxReady();
|
||
} else {
|
||
console.error('HTMX failed to load from both local and CDN sources');
|
||
// Trigger fallback content loading
|
||
window.dispatchEvent(new Event('htmx-load-failed'));
|
||
}
|
||
}, 100);
|
||
});
|
||
}, 100);
|
||
});
|
||
})();
|
||
</script>
|
||
<script src="{{ url_for('static', filename='v3/js/htmx-config.js') }}"></script>
|
||
|
||
<!-- Direct-fetch fallback for tab panels. Used when HTMX never loads, and
|
||
by the hx-on::response-error handlers on the Overview, WiFi and
|
||
Plugin Manager panels when HTMX's own request fails. -->
|
||
<script>
|
||
(function() {
|
||
const PANEL_LABELS = {
|
||
'overview-content': 'the Overview',
|
||
'wifi-content': 'WiFi setup',
|
||
'plugins-content': 'the Plugin Manager'
|
||
};
|
||
|
||
// Fetches `url` into the element `id`, runs the partial's inline
|
||
// scripts, and lets HTMX and Alpine process the new markup. The
|
||
// scripts run before Alpine sees the content for the same reason
|
||
// htmx-config.js runs them on htmx:afterSwap: partials define their
|
||
// x-data component functions in those scripts.
|
||
window.loadPartialDirect = function(id, url) {
|
||
const el = document.getElementById(id);
|
||
if (!el || !url || el.hasAttribute('data-loaded') || el.hasAttribute('data-direct-loading')) return;
|
||
el.setAttribute('data-direct-loading', 'true');
|
||
debugLog('Loading ' + url + ' directly via fetch (HTMX fallback)');
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||
fetch(url, { signal: controller.signal })
|
||
.then(r => {
|
||
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
||
return r.text();
|
||
})
|
||
.then(html => {
|
||
el.innerHTML = html;
|
||
el.querySelectorAll('script').forEach(oldScript => {
|
||
const script = document.createElement('script');
|
||
for (const attr of oldScript.attributes) script.setAttribute(attr.name, attr.value);
|
||
script.textContent = oldScript.textContent;
|
||
oldScript.replaceWith(script);
|
||
});
|
||
el.setAttribute('data-loaded', 'true');
|
||
if (typeof htmx !== 'undefined') htmx.process(el);
|
||
if (window.Alpine) window.Alpine.initTree(el);
|
||
if (id === 'plugins-content' && window.initPluginsPage) {
|
||
if (window.pluginManager) {
|
||
window.pluginManager.initialized = false;
|
||
window.pluginManager.initializing = false;
|
||
}
|
||
window.initPluginsPage();
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('Failed to load ' + url + ':', err);
|
||
const label = PANEL_LABELS[id] || 'this page';
|
||
el.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load ' + label + '. Check that the device is still reachable, then try again.</p><button type="button" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
|
||
el.querySelector('button').addEventListener('click', () => window.loadPartialDirect(id, url));
|
||
})
|
||
.finally(() => {
|
||
clearTimeout(timeout);
|
||
el.removeAttribute('data-direct-loading');
|
||
});
|
||
};
|
||
|
||
function activeTab() {
|
||
const appEl = document.querySelector('[x-data="app()"]');
|
||
try {
|
||
return (window.Alpine && appEl) ? window.Alpine.$data(appEl).activeTab : null;
|
||
} catch (e) {
|
||
return null; // Alpine has not initialised the app yet
|
||
}
|
||
}
|
||
|
||
function loadActiveTabDirect() {
|
||
const el = document.getElementById((activeTab() || 'overview') + '-content');
|
||
if (el) window.loadPartialDirect(el.id, el.getAttribute('hx-get'));
|
||
}
|
||
|
||
// HTMX loaded but never initialised, even from the CDN rescue.
|
||
window.addEventListener('htmx-load-failed', function() {
|
||
console.warn('HTMX failed to load, loading the active tab directly');
|
||
setTimeout(loadActiveTabDirect, 2000);
|
||
});
|
||
|
||
// HTMX still missing after 5 seconds: load the active tab, and the
|
||
// Plugin Manager regardless of tab so the plugin tabs work.
|
||
const fallbackTimer = setTimeout(function() {
|
||
if (typeof htmx !== 'undefined') return;
|
||
console.warn('HTMX not loaded after 5 seconds, loading tabs directly');
|
||
window.loadPartialDirect('plugins-content', '/v3/partials/plugins');
|
||
loadActiveTabDirect();
|
||
}, 5000);
|
||
|
||
// AP mode (setting up WiFi) opens on the WiFi tab and can least
|
||
// afford to wait, so it gets an earlier check.
|
||
const apTimer = setTimeout(function() {
|
||
const host = window.location.hostname;
|
||
if (typeof htmx === 'undefined' && host.startsWith('192.168.4.')) {
|
||
console.warn('HTMX not loaded, loading WiFi setup directly');
|
||
window.loadPartialDirect('wifi-content', '/v3/partials/wifi');
|
||
}
|
||
}, 3000);
|
||
|
||
window.addEventListener('htmx:ready', function() {
|
||
clearTimeout(fallbackTimer);
|
||
clearTimeout(apTimer);
|
||
}, { once: true });
|
||
})();
|
||
</script>
|
||
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
|
||
<script src="{{ url_for('static', filename='v3/js/app-early.js') }}"></script>
|
||
|
||
<!-- App shell (full app(), Alpine stores, SSE streams). Deferred so it
|
||
doesn't block rendering, and placed before Alpine: deferred scripts run
|
||
in document order, and its alpine:init listener must be registered
|
||
before Alpine starts. -->
|
||
<script defer src="{{ url_for('static', filename='v3/js/app-shell.js') }}"></script>
|
||
<!-- Alpine.js for reactive components.
|
||
Load the local copy first (always works, no CDN round-trip, no AP-mode
|
||
branch needed). `defer` on an HTML-parsed <script> is honored and runs
|
||
after DOM parse but before DOMContentLoaded, which is exactly what
|
||
Alpine wants — so no deferLoadingAlpine gymnastics are needed.
|
||
The inline rescue below only fires if the local file is missing. -->
|
||
<script defer src="{{ url_for('static', filename='v3/js/alpinejs.min.js') }}"></script>
|
||
<script>
|
||
// Rescue: if the local Alpine didn't load for any reason, pull the CDN
|
||
// copy once on window load. This is a last-ditch fallback, not the
|
||
// primary path.
|
||
window.addEventListener('load', function() {
|
||
if (typeof window.Alpine === 'undefined') {
|
||
console.warn('[Alpine] Local file failed to load, falling back to CDN');
|
||
const s = document.createElement('script');
|
||
s.src = 'https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js';
|
||
document.head.appendChild(s);
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<!-- Font Awesome icons (vendored 6.0.0 — css/all.min.css references
|
||
../webfonts/ relatively, so both live under v3/vendor/fontawesome/) -->
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/fontawesome/css/all.min.css') }}">
|
||
|
||
<!-- Custom v3 styles -->
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
|
||
</head>
|
||
<body x-data="app()" class="bg-gray-50 min-h-screen">
|
||
<a href="#tab-content" class="skip-link">Skip to main content</a>
|
||
<!-- Header -->
|
||
<header class="bg-white shadow-md border-b border-gray-200">
|
||
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16" style="max-width: 100%;">
|
||
<div class="flex justify-between items-center h-16">
|
||
<div class="flex items-center min-w-0">
|
||
<!-- Mobile nav toggle (hamburger) — hidden at md and up -->
|
||
<button type="button"
|
||
class="theme-toggle-btn p-2 rounded-md mr-2 md:hidden"
|
||
@click="mobileNavOpen = !mobileNavOpen"
|
||
:aria-expanded="mobileNavOpen ? 'true' : 'false'"
|
||
aria-controls="site-nav"
|
||
:aria-label="mobileNavOpen ? 'Close navigation menu' : 'Open navigation menu'"
|
||
aria-label="Open navigation menu"
|
||
title="Menu">
|
||
<i class="fas fa-bars" aria-hidden="true"></i>
|
||
</button>
|
||
<h1 class="text-lg sm:text-xl font-bold text-gray-900 truncate">
|
||
<i class="fas fa-tv text-blue-600 mr-2" aria-hidden="true"></i>LED Matrix Control
|
||
</h1>
|
||
</div>
|
||
|
||
<!-- Connection status and theme toggle -->
|
||
<div id="header-widgets" class="flex items-center flex-shrink-0 ml-2 space-x-4">
|
||
<!-- Global settings search -->
|
||
<div class="relative hidden sm:block" id="settings-search-wrap">
|
||
<input id="settings-search"
|
||
type="text"
|
||
role="combobox"
|
||
aria-expanded="false"
|
||
aria-autocomplete="list"
|
||
aria-controls="settings-search-results"
|
||
aria-label="Search settings"
|
||
placeholder="Search settings…"
|
||
autocomplete="off"
|
||
class="form-control text-sm pl-8 pr-4 py-1.5 w-48 lg:w-64">
|
||
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs" aria-hidden="true"></i>
|
||
<div id="settings-search-results"
|
||
role="listbox"
|
||
aria-label="Settings search results"
|
||
class="hidden absolute right-0 mt-1 w-80 max-h-96 overflow-y-auto"></div>
|
||
</div>
|
||
|
||
<!-- Theme toggle -->
|
||
<button id="theme-toggle"
|
||
type="button"
|
||
onclick="toggleTheme()"
|
||
class="theme-toggle-btn p-2 rounded-md"
|
||
title="Switch to dark mode"
|
||
aria-label="Switch to dark mode"
|
||
aria-pressed="false">
|
||
<i class="fas fa-moon" id="theme-icon-dark" aria-hidden="true"></i>
|
||
<i class="fas fa-sun hidden" id="theme-icon-light" aria-hidden="true"></i>
|
||
</button>
|
||
|
||
<div id="connection-status" class="flex items-center space-x-2 text-sm">
|
||
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
||
<span class="text-gray-600">Disconnected</span>
|
||
</div>
|
||
|
||
<!-- System stats (populated via SSE) -->
|
||
<div id="system-stats" class="hidden lg:flex items-center space-x-4 text-sm text-gray-600 xl:space-x-6 2xl:space-x-8">
|
||
<span id="cpu-stat" class="flex items-center space-x-1" title="CPU usage">
|
||
<i class="fas fa-microchip" aria-hidden="true"></i>
|
||
<span>--%</span>
|
||
</span>
|
||
<span id="memory-stat" class="flex items-center space-x-1" title="Memory usage">
|
||
<i class="fas fa-memory" aria-hidden="true"></i>
|
||
<span>--%</span>
|
||
</span>
|
||
<span id="temp-stat" class="flex items-center space-x-1" title="CPU temperature">
|
||
<i class="fas fa-thermometer-half" aria-hidden="true"></i>
|
||
<span>--°C</span>
|
||
</span>
|
||
<span id="power-stat" class="hidden items-center space-x-1" title="">
|
||
<i class="fas fa-bolt" aria-hidden="true"></i>
|
||
<span>Power</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Update available banner -->
|
||
<div id="update-banner" style="display:none"
|
||
class="update-banner border-b transition-all duration-300 ease-in-out">
|
||
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center space-x-3">
|
||
<i class="fas fa-arrow-circle-up text-lg" aria-hidden="true"></i>
|
||
<span class="text-sm font-medium" id="update-banner-text"
|
||
aria-live="polite" aria-atomic="true">
|
||
A new LEDMatrix update is available
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center space-x-3">
|
||
<button onclick="applyUpdate()" id="update-banner-btn"
|
||
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
|
||
update-banner-action transition-colors duration-150">
|
||
<i class="fas fa-download mr-1" aria-hidden="true"></i> Update Now
|
||
</button>
|
||
<button type="button" onclick="dismissUpdateBanner()"
|
||
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
|
||
title="Dismiss" aria-label="Dismiss update">
|
||
<i class="fas fa-times text-sm" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Restart-pending banner: shown after a main-config save (display
|
||
hardware, rotation order, durations, general settings) — those only
|
||
take effect after the display service restarts. Plugin config saves
|
||
apply live and don't trigger this. Wired in app.js. -->
|
||
<div id="restart-pending-banner" style="display:none"
|
||
class="update-banner border-b transition-all duration-300 ease-in-out">
|
||
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center space-x-3">
|
||
<i class="fas fa-rotate text-lg" aria-hidden="true"></i>
|
||
<span class="text-sm font-medium" aria-live="polite"
|
||
id="restart-pending-text">
|
||
Configuration saved — restart the display to apply the changes
|
||
</span>
|
||
</div>
|
||
<div class="flex items-center space-x-3">
|
||
<button onclick="window.restartPendingNow()" id="restart-pending-btn"
|
||
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
|
||
update-banner-action transition-colors duration-150">
|
||
<i class="fas fa-power-off mr-1" aria-hidden="true"></i> Restart Now
|
||
</button>
|
||
<button type="button" onclick="window.dismissRestartPending()"
|
||
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
|
||
title="Dismiss" aria-label="Dismiss restart reminder">
|
||
<i class="fas fa-times text-sm" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Under-voltage / throttling warning banner -->
|
||
<div id="power-warning-banner" style="display:none"
|
||
class="power-warning-banner border-b transition-all duration-300 ease-in-out">
|
||
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center space-x-3">
|
||
<i class="fas fa-exclamation-triangle text-lg" aria-hidden="true"></i>
|
||
<span class="text-sm font-medium" id="power-warning-banner-text"
|
||
aria-live="polite" aria-atomic="true">
|
||
A power/thermal issue detected right now — the display may flicker or degrade. Check your power supply and cooling.
|
||
</span>
|
||
</div>
|
||
<button type="button" onclick="dismissPowerWarningBanner()"
|
||
class="power-warning-banner-dismiss rounded p-1 transition-colors duration-150"
|
||
title="Dismiss" aria-label="Dismiss power warning">
|
||
<i class="fas fa-times text-sm" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main content -->
|
||
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
|
||
<!-- Backdrop shown behind the mobile nav drawer (no-op at md and up) -->
|
||
<div class="nav-backdrop" x-show="mobileNavOpen" x-transition.opacity
|
||
@click="mobileNavOpen = false" aria-hidden="true" style="display: none;"></div>
|
||
|
||
<!-- Navigation: static tab rows at md and up; off-canvas drawer below md.
|
||
The drawer reuses the exact same tab buttons/handlers — CSS repositions
|
||
this container, nothing is duplicated. Clicking any tab closes the drawer. -->
|
||
<div id="site-nav" class="site-nav" :class="mobileNavOpen ? 'open' : ''"
|
||
@click="if ($event.target.closest('.nav-tab')) mobileNavOpen = false">
|
||
<!-- Below md, the header's settings-search and system-stats widgets are
|
||
moved (not copied) into this slot by placeHeaderWidgets() in app.js -->
|
||
<div id="drawer-widgets"></div>
|
||
|
||
<!-- Navigation tabs -->
|
||
<div class="mb-8">
|
||
<!-- First row - System tabs -->
|
||
<div class="border-b border-gray-200 mb-4">
|
||
<nav aria-label="System settings" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
|
||
<button @click="activeTab = 'overview'"
|
||
:class="activeTab === 'overview' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-tachometer-alt" aria-hidden="true"></i>Overview
|
||
</button>
|
||
<button @click="activeTab = 'general'"
|
||
:class="activeTab === 'general' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-sliders-h" aria-hidden="true"></i>General
|
||
</button>
|
||
<button @click="activeTab = 'wifi'"
|
||
:class="activeTab === 'wifi' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-wifi" aria-hidden="true"></i>WiFi
|
||
</button>
|
||
<button @click="activeTab = 'schedule'"
|
||
:class="activeTab === 'schedule' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-clock" aria-hidden="true"></i>Schedule
|
||
</button>
|
||
<button @click="activeTab = 'display'"
|
||
:class="activeTab === 'display' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-desktop" aria-hidden="true"></i>Display
|
||
</button>
|
||
<button @click="activeTab = 'durations'"
|
||
:class="activeTab === 'durations' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-rotate" aria-hidden="true"></i>Rotation
|
||
</button>
|
||
<button @click="activeTab = 'config-editor'"
|
||
:class="activeTab === 'config-editor' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-file-code" aria-hidden="true"></i>Config Editor
|
||
</button>
|
||
<button @click="activeTab = 'backup-restore'"
|
||
:class="activeTab === 'backup-restore' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-save" aria-hidden="true"></i>Backup & Restore
|
||
</button>
|
||
<button @click="activeTab = 'fonts'"
|
||
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-font" aria-hidden="true"></i>Fonts
|
||
</button>
|
||
<button @click="activeTab = 'logs'"
|
||
:class="activeTab === 'logs' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-file-alt" aria-hidden="true"></i>Logs
|
||
</button>
|
||
<button @click="activeTab = 'cache'"
|
||
:class="activeTab === 'cache' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-database" aria-hidden="true"></i>Cache
|
||
</button>
|
||
<button @click="activeTab = 'operation-history'"
|
||
:class="activeTab === 'operation-history' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-history" aria-hidden="true"></i>Operation History
|
||
</button>
|
||
<button @click="activeTab = 'tools'"
|
||
:class="activeTab === 'tools' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-tools" aria-hidden="true"></i>Tools
|
||
</button>
|
||
</nav>
|
||
</div>
|
||
|
||
<!-- Second row - Plugin tabs (populated dynamically) -->
|
||
<div id="plugin-tabs-row" class="border-b border-gray-200">
|
||
<nav aria-label="Plugins" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
|
||
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPartialDirect('plugins-content', '/v3/partials/plugins')); }"
|
||
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
|
||
class="nav-tab">
|
||
<i class="fas fa-plug" aria-hidden="true"></i>Plugin Manager
|
||
</button>
|
||
<!-- Installed plugin tabs will be added here dynamically -->
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
</div> <!-- /#site-nav -->
|
||
|
||
<!-- Floating mini display preview: available on every tab except
|
||
Overview (which has the full preview). Fed from the same SSE
|
||
stream by updateDisplayPreview in app-shell.js; open/closed state
|
||
persists in localStorage. -->
|
||
<div id="floating-preview" class="floating-preview" style="display:none">
|
||
<div class="flex items-center justify-between px-2 py-1">
|
||
<span class="text-xs text-gray-300"><i class="fas fa-tv mr-1" aria-hidden="true"></i>Live Preview</span>
|
||
<span class="flex items-center">
|
||
<button type="button" onclick="window.cycleFloatingPreviewSize()"
|
||
class="text-gray-400 hover:text-white ml-2" aria-label="Cycle preview size"
|
||
title="Cycle preview size (drag the corner to resize on desktop)">
|
||
<i class="fas fa-up-right-and-down-left-from-center text-xs" aria-hidden="true"></i>
|
||
</button>
|
||
<button type="button" onclick="window.toggleFloatingPreview(false)"
|
||
class="text-gray-400 hover:text-white ml-2" aria-label="Close live preview">
|
||
<i class="fas fa-times text-xs" aria-hidden="true"></i>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<img id="floating-preview-img" alt="Live display preview"
|
||
style="image-rendering: pixelated; display: block; width: 100%; height: auto;">
|
||
</div>
|
||
<button id="floating-preview-toggle" type="button" onclick="window.toggleFloatingPreview(true)"
|
||
class="floating-preview-toggle" style="display:none"
|
||
title="Show live display preview" aria-label="Show live display preview">
|
||
<i class="fas fa-tv" aria-hidden="true"></i>
|
||
</button>
|
||
|
||
|
||
<!-- Tab content -->
|
||
<div id="tab-content" class="space-y-6" tabindex="-1">
|
||
<!-- Overview tab -->
|
||
<div x-show="activeTab === 'overview'" x-transition>
|
||
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="h-32 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- General tab -->
|
||
<div x-show="activeTab === 'general'" x-transition>
|
||
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="loadtab" hx-swap="innerHTML">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="space-y-4">
|
||
<div class="h-10 bg-gray-200 rounded"></div>
|
||
<div class="h-10 bg-gray-200 rounded"></div>
|
||
<div class="h-10 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WiFi tab -->
|
||
<div x-show="activeTab === 'wifi'" x-transition>
|
||
<div id="wifi-content"
|
||
hx-get="/v3/partials/wifi"
|
||
hx-trigger="loadtab"
|
||
hx-swap="innerHTML"
|
||
hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="space-y-4">
|
||
<div class="h-10 bg-gray-200 rounded"></div>
|
||
<div class="h-10 bg-gray-200 rounded"></div>
|
||
<div class="h-10 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Schedule tab -->
|
||
<div x-show="activeTab === 'schedule'" x-transition>
|
||
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="loadtab" hx-swap="innerHTML">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="space-y-4">
|
||
<div class="h-10 bg-gray-200 rounded"></div>
|
||
<div class="h-10 bg-gray-200 rounded"></div>
|
||
<div class="h-10 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Display tab -->
|
||
<div x-show="activeTab === 'display'" x-transition>
|
||
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="loadtab" hx-swap="innerHTML">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div class="h-32 bg-gray-200 rounded"></div>
|
||
<div class="h-32 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Rotation & Durations tab -->
|
||
<div x-show="activeTab === 'durations'" x-transition>
|
||
<div id="durations-content" hx-get="/v3/partials/durations" hx-trigger="loadtab" hx-swap="innerHTML">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="h-32 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Backup & Restore tab -->
|
||
<div x-show="activeTab === 'backup-restore'" x-transition>
|
||
<div id="backup-restore-content" hx-get="/v3/partials/backup-restore" hx-trigger="loadtab" hx-swap="innerHTML">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="h-32 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Config Editor tab -->
|
||
<div x-show="activeTab === 'config-editor'" x-transition>
|
||
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="loadtab" hx-swap="innerHTML">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="space-y-4">
|
||
<div class="h-64 bg-gray-200 rounded"></div>
|
||
<div class="h-64 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Plugins tab -->
|
||
<div x-show="activeTab === 'plugins'" x-transition>
|
||
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="loadtab" hx-swap="innerHTML"
|
||
hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
<div class="h-24 bg-gray-200 rounded"></div>
|
||
<div class="h-24 bg-gray-200 rounded"></div>
|
||
<div class="h-24 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fonts tab -->
|
||
<div x-show="activeTab === 'fonts'" x-transition>
|
||
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="loadtab" hx-swap="innerHTML">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="space-y-4">
|
||
<div class="h-20 bg-gray-200 rounded"></div>
|
||
<div class="h-20 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Logs tab -->
|
||
<div x-show="activeTab === 'logs'" x-transition>
|
||
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="loadtab" hx-swap="innerHTML">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="h-96 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cache tab -->
|
||
<div x-show="activeTab === 'cache'" x-transition>
|
||
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="loadtab" hx-swap="innerHTML">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="h-64 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Operation History tab -->
|
||
<div x-show="activeTab === 'operation-history'" x-transition>
|
||
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="loadtab" hx-swap="innerHTML">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="h-64 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tools tab -->
|
||
<div x-show="activeTab === 'tools'" x-transition>
|
||
<div id="tools-content" hx-get="/v3/partials/tools" hx-trigger="loadtab" hx-swap="innerHTML">
|
||
<div class="animate-pulse">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||
<div class="h-32 bg-gray-200 rounded"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Dynamic Plugin Tabs - HTMX Lazy Loading -->
|
||
<!--
|
||
Architecture: Server-side rendered plugin configuration forms
|
||
- Each plugin tab loads its config via HTMX when first viewed
|
||
- Forms are generated server-side using Jinja2 macros
|
||
- Reduces client-side complexity and improves performance
|
||
- Uses x-init to trigger HTMX after Alpine renders the element
|
||
-->
|
||
<template x-for="plugin in installedPlugins" :key="plugin.id">
|
||
<div x-show="activeTab === plugin.id" x-transition>
|
||
<!-- Only load content when tab is active (lazy loading) -->
|
||
<template x-if="activeTab === plugin.id">
|
||
<div class="bg-white rounded-lg shadow p-6 plugin-config-tab"
|
||
:id="'plugin-config-' + plugin.id"
|
||
x-init="$nextTick(() => {
|
||
const el = $el;
|
||
const pid = plugin.id;
|
||
const loadContent = (retries) => {
|
||
if (window.htmx && !el.dataset.htmxLoaded) {
|
||
el.dataset.htmxLoaded = 'true';
|
||
htmx.ajax('GET', '/v3/partials/plugin-config/' + pid, {target: el, swap: 'innerHTML'});
|
||
} else if (!window.htmx && retries < 15) {
|
||
setTimeout(() => loadContent(retries + 1), 200);
|
||
} else if (!window.htmx) {
|
||
fetch('/v3/partials/plugin-config/' + pid)
|
||
.then(r => r.text())
|
||
.then(html => {
|
||
el.innerHTML = html;
|
||
if (window.Alpine) {
|
||
window.Alpine.initTree(el);
|
||
}
|
||
});
|
||
}
|
||
};
|
||
loadContent(0);
|
||
})">
|
||
<!-- Loading skeleton shown until HTMX loads server-rendered content -->
|
||
<div class="animate-pulse space-y-6">
|
||
<div class="border-b border-gray-200 pb-4">
|
||
<div class="flex items-center justify-between">
|
||
<div class="space-y-2">
|
||
<div class="h-6 bg-gray-200 rounded w-48"></div>
|
||
<div class="h-4 bg-gray-200 rounded w-96"></div>
|
||
</div>
|
||
<div class="h-6 bg-gray-200 rounded w-24"></div>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div class="bg-gray-50 rounded-lg p-4 space-y-4">
|
||
<div class="h-5 bg-gray-200 rounded w-32"></div>
|
||
<div class="space-y-3">
|
||
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
|
||
</div>
|
||
</div>
|
||
<div class="bg-gray-50 rounded-lg p-4 space-y-4">
|
||
<div class="h-5 bg-gray-200 rounded w-32"></div>
|
||
<div class="space-y-3">
|
||
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||
<div class="h-10 bg-gray-200 rounded w-full"></div>
|
||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||
<div class="h-10 bg-gray-200 rounded w-full"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- Notifications -->
|
||
<div id="notifications" class="fixed top-4 right-4 z-50 space-y-2"></div>
|
||
|
||
<!-- Custom v3 JavaScript -->
|
||
<script src="{{ url_for('static', filename='v3/app.js') }}" defer></script>
|
||
|
||
<!-- Settings tooltips + settings search -->
|
||
<script src="{{ url_for('static', filename='v3/js/tooltips.js') }}" defer></script>
|
||
<script src="{{ url_for('static', filename='v3/js/settings-search.js') }}" defer></script>
|
||
|
||
<!-- Modular Plugin Management JavaScript -->
|
||
<!-- Load utilities first -->
|
||
<script src="{{ url_for('static', filename='v3/js/utils/dialog.js') }}" defer></script>
|
||
<script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script>
|
||
|
||
<!-- Load core API client first (used by other modules) -->
|
||
<script src="{{ url_for('static', filename='v3/js/plugins/api_client.js') }}" defer></script>
|
||
|
||
<!-- Load plugin management modules -->
|
||
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
|
||
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
|
||
<!-- Shared search/filter/sort controller for the plugin card grids -->
|
||
<script src="{{ url_for('static', filename='v3/js/plugins/list_filter.js') }}" defer></script>
|
||
|
||
<!-- Widget Registry System: all widget scripts in one request (see
|
||
web_interface/widget_bundle.py). The individual files stay served at
|
||
their own URLs for plugin-loader.js and debugging. -->
|
||
<script src="{{ widgets_bundle_url() }}" defer></script>
|
||
|
||
<!-- plugins_manager.js: loaded last. It uses PluginAPI, ListFilter,
|
||
LEDDialog, the widgets and app-shell.js's updatePlugin at load time
|
||
or when a card is clicked, so it must follow all of them. No other
|
||
script defines the same globals. -->
|
||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}" defer></script>
|
||
|
||
<!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the
|
||
deferred widget's window assignments always shadowed the inline
|
||
copies that used to sit here, so the duplicates were removed) -->
|
||
|
||
<!-- On-Demand Modal (moved here from plugins.html so it's always available) -->
|
||
<div id="on-demand-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;">
|
||
<div class="modal-content p-6 w-full max-w-md bg-white rounded-lg shadow-lg" role="dialog" aria-modal="true" aria-labelledby="on-demand-modal-title">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h2 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h2>
|
||
<button type="button" id="close-on-demand-modal" class="text-gray-500 hover:text-gray-700 p-2 -m-2 rounded-md" aria-label="Close">
|
||
<i class="fas fa-times" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
<!-- Service Status Alert -->
|
||
<div id="on-demand-service-warning" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||
<div class="flex items-start">
|
||
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2" aria-hidden="true"></i>
|
||
<div class="flex-1">
|
||
<p class="text-sm font-medium text-yellow-800">Display service is not running</p>
|
||
<p class="text-xs text-yellow-700 mt-1">
|
||
The on-demand request will be queued but won't display until the service starts.
|
||
Enable "Start display service" below to automatically start it.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<form id="on-demand-form" class="space-y-4">
|
||
<div>
|
||
<label for="on-demand-mode" class="block text-sm font-medium text-gray-700 mb-1">Display Mode</label>
|
||
<select id="on-demand-mode" name="mode"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||
</select>
|
||
<p id="on-demand-mode-hint" class="text-xs text-gray-500 mt-1"></p>
|
||
</div>
|
||
<div>
|
||
<label for="on-demand-duration" class="block text-sm font-medium text-gray-700 mb-1">
|
||
Duration (seconds, optional)
|
||
</label>
|
||
<input type="number" min="0" id="on-demand-duration" name="duration"
|
||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||
placeholder="Leave blank to use plugin default">
|
||
<p class="text-xs text-gray-500 mt-1">
|
||
Use 0 or leave empty to keep the plugin running until stopped manually.
|
||
</p>
|
||
</div>
|
||
<div class="flex items-center">
|
||
<input id="on-demand-pinned" name="pinned" type="checkbox"
|
||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||
<label for="on-demand-pinned" class="ml-2 block text-sm text-gray-700">
|
||
Pin plugin to prevent rotation until stopped
|
||
</label>
|
||
</div>
|
||
<div class="flex items-center">
|
||
<input id="on-demand-start-service" name="start_service" type="checkbox" checked
|
||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||
<label for="on-demand-start-service" class="ml-2 block text-sm text-gray-700">
|
||
Start display service if it is not running
|
||
</label>
|
||
</div>
|
||
<div class="flex justify-end gap-3 pt-3">
|
||
<button type="button" id="cancel-on-demand"
|
||
class="px-4 py-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md">
|
||
Cancel
|
||
</button>
|
||
<button type="submit"
|
||
class="px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md font-semibold">
|
||
Start On-Demand
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<!-- Update banner logic -->
|
||
<script>
|
||
(function() {
|
||
var CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
|
||
|
||
function getDismissedSha() {
|
||
try { return sessionStorage.getItem('update-sha-dismissed'); } catch(e) { return null; }
|
||
}
|
||
|
||
function checkForUpdate() {
|
||
fetch('/api/v3/system/check-update')
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
var banner = document.getElementById('update-banner');
|
||
var btn = document.getElementById('update-banner-btn');
|
||
if (data.check_failed) {
|
||
// A check that could not run is not the same as being up
|
||
// to date. Hiding the banner here made a checkout git
|
||
// refuses to touch look permanently current, with no
|
||
// route to the update button and nothing to act on.
|
||
document.getElementById('update-banner-text').textContent =
|
||
data.error || 'Could not check for updates.';
|
||
if (btn) btn.style.display = 'none';
|
||
banner.style.display = '';
|
||
return;
|
||
}
|
||
if (btn) btn.style.display = '';
|
||
if (data.update_available && getDismissedSha() !== data.remote_sha) {
|
||
var n = data.commits_behind || 0;
|
||
var msg = 'A new LEDMatrix update is available';
|
||
if (n > 0) msg += ' (' + n + ' commit' + (n > 1 ? 's' : '') + ')';
|
||
document.getElementById('update-banner-text').textContent = msg;
|
||
banner.style.display = '';
|
||
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
|
||
} else {
|
||
banner.style.display = 'none';
|
||
}
|
||
})
|
||
.catch(function() {});
|
||
}
|
||
|
||
window.dismissUpdateBanner = function() {
|
||
document.getElementById('update-banner').style.display = 'none';
|
||
try {
|
||
var sha = sessionStorage.getItem('update-sha');
|
||
if (sha) sessionStorage.setItem('update-sha-dismissed', sha);
|
||
} catch(e) {}
|
||
};
|
||
|
||
window.applyUpdate = function() {
|
||
var btn = document.getElementById('update-banner-btn');
|
||
var originalHTML = '<i class="fas fa-download mr-1" aria-hidden="true"></i> Update Now';
|
||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Updating...';
|
||
btn.disabled = true;
|
||
fetch('/api/v3/system/action', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action: 'git_pull' })
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
btn.innerHTML = originalHTML;
|
||
btn.disabled = false;
|
||
if (data.status === 'success') {
|
||
document.getElementById('update-banner').style.display = 'none';
|
||
try { sessionStorage.removeItem('update-sha-dismissed'); } catch(e) {}
|
||
// The pull replaced files on disk; the running services still
|
||
// hold the code they loaded at boot. Ask for the restart that
|
||
// makes the update actually take effect.
|
||
//
|
||
// Both services, not just the display. The plugin store's
|
||
// compatibility gate runs in the web process, so a web service
|
||
// still holding the previous version refuses every plugin that
|
||
// floors on the release just installed -- blaming a core
|
||
// version that is already correct on disk.
|
||
if (data.restart_required && typeof window.showRestartPending === 'function') {
|
||
window.showRestartPending(
|
||
'Update installed \u2014 restart the display and web ' +
|
||
'services to run the new code');
|
||
}
|
||
}
|
||
showNotification(data.message || 'Update complete', data.status || 'success');
|
||
})
|
||
.catch(function() {
|
||
btn.innerHTML = originalHTML;
|
||
btn.disabled = false;
|
||
showNotification('Update failed — check your connection', 'error');
|
||
});
|
||
};
|
||
|
||
// Initial check shortly after page load, then every CHECK_INTERVAL while
|
||
// the page is visible. A hidden tab makes no requests and catches up as
|
||
// soon as it is shown again.
|
||
var lastCheck = 0;
|
||
function checkIfDue() {
|
||
if (document.hidden || Date.now() - lastCheck < CHECK_INTERVAL) return;
|
||
lastCheck = Date.now();
|
||
checkForUpdate();
|
||
}
|
||
setTimeout(checkIfDue, 2000);
|
||
setInterval(checkIfDue, 60 * 1000);
|
||
document.addEventListener('visibilitychange', checkIfDue);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
|