mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Verified each finding against current code. Fixed: - api_v3: plugin_rotation_order is now strictly validated (JSON list of strings, 400 with a descriptive message otherwise) and popped from the payload before any further handling. - display_controller: _apply_plugin_rotation_order defensively ignores a non-list value (keeps the existing rotation, logs a warning) and drops non-string entries; new logs carry the [DisplayController] prefix. Unit-tested both defensive paths. - app.py: snapshot-read handler narrowed to OSError with debug logging; flask-compress ImportError now emits one structured warning with the install remedy. - htmx-config: the response-error logger prints form FIELD NAMES only - values (API keys, passwords) never reach the console. - plugin-order-list: saved order/exclusions normalized with Array.isArray (a saved "null" previously crashed .forEach); each row gained keyboard/touch-accessible move-up/move-down buttons (HTML5 drag events don't fire on most mobile browsers) that reorder and syncInputs() immediately alongside native drag. - app-shell: window.installedPlugins setter always takes the new list (same-ID metadata/enabled updates were silently dropped); tab rebuild stays gated on ID changes. LED dot renderer reads the frame with ONE getImageData call instead of one per pixel (~9,200/frame at 192x48). - plugins_manager: togglePlugin returns its request promise resolving the API outcome; the install flow now shows the "installed and enabled" toast (with Restart Now) only after enablement succeeds, and a warning without a restart offer when it fails. - a11y: hamburger aria-label flips Open/Close with drawer state; both Advanced-section toggle buttons declare aria-controls/aria-expanded and the shared toggleSection() keeps aria-expanded in sync; move buttons have per-plugin aria-labels. - Rotation/Vegas order-list bootstraps cap their retries (~5s) and show a reload hint instead of spinning forever; Alpine app-state lookups prefer [x-data="app()"] with a generic fallback. Skipped, with reasons: - executePluginAction arg order: caller (plugin_config.html) already passes (actionId, index, pluginId) matching the signature exactly. - generateFieldHtml XSS, entity-unescape blocks, dotToNested pollution, and "app.loadInstalledPlugins" in app-shell: all inside the legacy client-side config cluster whose entry points are shadowed by plugins_manager.js / replaced by server-rendered forms (zero live callers, verified) - queued for wholesale deletion in the follow-up rather than patching dead code. - custom-feeds-helpers.js findings (3): file was deleted in a prior commit. - console.error/warn override removal and afterSwap script re-execution removal: deliberate pre-existing workarounds every partial's inline init currently depends on; reworking them safely needs isolated testing (follow-up), and the error suppression is already double-gated (insertBefore AND htmx match). - "move durations bootstrap into a bundle": inline partial-scoped init is the established pattern for HTMX partials in this codebase. Validation: all 40 web tests pass; py_compile on all touched Python; all touched templates parse; rotation-order defensive paths unit-tested. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
1156 lines
61 KiB
HTML
1156 lines
61 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 sseSrc = '/static/v3/js/htmx-sse.js';
|
|
const jsonEncSrc = '/static/v3/js/htmx-json-enc.js';
|
|
const htmxCdn = 'https://unpkg.com/htmx.org@1.9.10';
|
|
const sseCdn = 'https://unpkg.com/htmx.org@1.9.10/dist/ext/sse.js';
|
|
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 extensions after core loads
|
|
loadScript(sseSrc, sseCdn);
|
|
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>
|
|
|
|
<!-- Fallback for loading plugins if HTMX fails -->
|
|
<script>
|
|
function loadPluginsDirect() {
|
|
const content = document.getElementById('plugins-content');
|
|
if (content && !content.hasAttribute('data-loaded')) {
|
|
content.setAttribute('data-loaded', 'true');
|
|
debugLog('Loading plugins directly via fetch (HTMX fallback)...');
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
fetch('/v3/partials/plugins', { signal: controller.signal })
|
|
.then(r => {
|
|
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
|
return r.text();
|
|
})
|
|
.then(html => {
|
|
clearTimeout(timeout);
|
|
content.innerHTML = html;
|
|
if (typeof htmx !== 'undefined') {
|
|
htmx.process(content);
|
|
}
|
|
// Trigger full initialization chain
|
|
if (window.pluginManager) {
|
|
window.pluginManager.initialized = false;
|
|
window.pluginManager.initializing = false;
|
|
}
|
|
if (window.initPluginsPage) {
|
|
window.initPluginsPage();
|
|
}
|
|
})
|
|
.catch(err => {
|
|
clearTimeout(timeout);
|
|
console.error('Failed to load plugins:', err);
|
|
content.removeAttribute('data-loaded');
|
|
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Plugin Manager. Please refresh the page.</p></div>';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fallback if HTMX doesn't load within 5 seconds
|
|
var _pluginsFallbackTimer = setTimeout(() => {
|
|
if (typeof htmx === 'undefined') {
|
|
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
|
|
// Load plugins tab content directly regardless of active tab,
|
|
// so it's ready when the user navigates to it
|
|
loadPluginsDirect();
|
|
}
|
|
}, 5000);
|
|
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { 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>
|
|
|
|
<!-- 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.x.x/dist/cdn.min.js';
|
|
document.head.appendChild(s);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
|
|
<link rel="preload" href="{{ url_for('static', filename='v3/vendor/codemirror/codemirror.min.css') }}" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
|
<noscript><link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/codemirror/codemirror.min.css') }}"></noscript>
|
|
<link rel="preload" href="{{ url_for('static', filename='v3/vendor/codemirror/theme/monokai.min.css') }}" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
|
<noscript><link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/codemirror/theme/monokai.min.css') }}"></noscript>
|
|
<!-- CodeMirror scripts loaded on demand when JSON editor is opened -->
|
|
<script>
|
|
// Lazy load CodeMirror when needed
|
|
window.loadCodeMirror = function() {
|
|
if (window.CodeMirror) return Promise.resolve();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
// Vendored CodeMirror 5.65.2 (note: the previously listed CDN
|
|
// mode/json/json.min.js never existed — it 404'd on every
|
|
// editor open; JSON highlighting comes from the javascript
|
|
// mode, so it's simply dropped here)
|
|
const scripts = [
|
|
'/static/v3/vendor/codemirror/codemirror.min.js',
|
|
'/static/v3/vendor/codemirror/mode/javascript/javascript.min.js',
|
|
'/static/v3/vendor/codemirror/addon/edit/closebrackets.min.js',
|
|
'/static/v3/vendor/codemirror/addon/edit/matchbrackets.min.js'
|
|
];
|
|
|
|
let loaded = 0;
|
|
scripts.forEach((src, index) => {
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.defer = true;
|
|
script.onload = () => {
|
|
loaded++;
|
|
if (loaded === scripts.length) resolve();
|
|
};
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
});
|
|
};
|
|
</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">
|
|
<!-- 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">
|
|
<!-- 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-xl font-bold text-gray-900">
|
|
<i class="fas fa-tv text-blue-600 mr-2"></i>
|
|
LED Matrix Control
|
|
</h1>
|
|
</div>
|
|
|
|
<!-- Connection status and theme toggle -->
|
|
<div id="header-widgets" class="flex items-center 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">
|
|
<i class="fas fa-microchip"></i>
|
|
<span>--%</span>
|
|
</span>
|
|
<span id="memory-stat" class="flex items-center space-x-1">
|
|
<i class="fas fa-memory"></i>
|
|
<span>--%</span>
|
|
</span>
|
|
<span id="temp-stat" class="flex items-center space-x-1">
|
|
<i class="fas fa-thermometer-half"></i>
|
|
<span>--°C</span>
|
|
</span>
|
|
<span id="power-stat" class="hidden items-center space-x-1" title="">
|
|
<i class="fas fa-bolt"></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"></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"></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"></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"></i>
|
|
<span class="text-sm font-medium" aria-live="polite">
|
|
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"></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"></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"></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"></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 -->
|
|
<nav class="mb-8">
|
|
<!-- First row - System tabs -->
|
|
<div class="border-b border-gray-200 mb-4">
|
|
<nav 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"></i>Overview
|
|
</button>
|
|
<button @click="activeTab = 'general'"
|
|
:class="activeTab === 'general' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-sliders-h"></i>General
|
|
</button>
|
|
<button @click="activeTab = 'wifi'"
|
|
:class="activeTab === 'wifi' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-wifi"></i>WiFi
|
|
</button>
|
|
<button @click="activeTab = 'schedule'"
|
|
:class="activeTab === 'schedule' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-clock"></i>Schedule
|
|
</button>
|
|
<button @click="activeTab = 'display'"
|
|
:class="activeTab === 'display' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-desktop"></i>Display
|
|
</button>
|
|
<button @click="activeTab = 'durations'"
|
|
:class="activeTab === 'durations' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-rotate"></i>Rotation
|
|
</button>
|
|
<button @click="activeTab = 'config-editor'"
|
|
:class="activeTab === 'config-editor' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-file-code"></i>Config Editor
|
|
</button>
|
|
<button @click="activeTab = 'backup-restore'"
|
|
:class="activeTab === 'backup-restore' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-save"></i>Backup & Restore
|
|
</button>
|
|
<button @click="activeTab = 'fonts'"
|
|
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-font"></i>Fonts
|
|
</button>
|
|
<button @click="activeTab = 'logs'"
|
|
:class="activeTab === 'logs' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-file-alt"></i>Logs
|
|
</button>
|
|
<button @click="activeTab = 'cache'"
|
|
:class="activeTab === 'cache' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-database"></i>Cache
|
|
</button>
|
|
<button @click="activeTab = 'operation-history'"
|
|
:class="activeTab === 'operation-history' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-history"></i>Operation History
|
|
</button>
|
|
<button @click="activeTab = 'tools'"
|
|
:class="activeTab === 'tools' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-tools"></i>Tools
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Second row - Plugin tabs (populated dynamically) -->
|
|
<div id="plugin-tabs-row" class="border-b border-gray-200">
|
|
<nav 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(() => loadPluginsDirect()); }"
|
|
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-plug"></i>Plugin Manager
|
|
</button>
|
|
<!-- Installed plugin tabs will be added here dynamically -->
|
|
</nav>
|
|
</div>
|
|
</nav>
|
|
</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"></i>Live Preview</span>
|
|
<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"></i>
|
|
</button>
|
|
</div>
|
|
<img id="floating-preview-img" alt="Live display preview"
|
|
style="image-rendering: pixelated; display: block; width: 256px; 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"></i>
|
|
</button>
|
|
|
|
|
|
<!-- Tab content -->
|
|
<div id="tab-content" class="space-y-6">
|
|
<!-- 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::htmx:response-error="loadOverviewDirect()">
|
|
<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>
|
|
|
|
<script>
|
|
// Fallback: Load overview content directly if HTMX fails
|
|
function loadOverviewDirect() {
|
|
const overviewContent = document.getElementById('overview-content');
|
|
if (overviewContent && !overviewContent.hasAttribute('data-loaded')) {
|
|
fetch('/v3/partials/overview')
|
|
.then(response => response.text())
|
|
.then(html => {
|
|
overviewContent.innerHTML = html;
|
|
overviewContent.setAttribute('data-loaded', 'true');
|
|
if (typeof htmx !== 'undefined') {
|
|
htmx.process(overviewContent);
|
|
}
|
|
// Re-initialize Alpine.js for the new content
|
|
if (window.Alpine) {
|
|
window.Alpine.initTree(overviewContent);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load overview content:', err);
|
|
overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load overview page. Please refresh the page.</p></div>';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Listen for HTMX load failure
|
|
window.addEventListener('htmx-load-failed', function() {
|
|
console.warn('HTMX failed to load, setting up direct content loading fallbacks');
|
|
// Try to load content directly after a delay
|
|
setTimeout(() => {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement.__x) {
|
|
const activeTab = appElement.__x.$data.activeTab || 'overview';
|
|
if (activeTab === 'overview') {
|
|
loadOverviewDirect();
|
|
}
|
|
}
|
|
}, 2000);
|
|
});
|
|
|
|
// Also try direct load if HTMX doesn't load within 5 seconds
|
|
var _overviewFallbackTimer = setTimeout(() => {
|
|
if (typeof htmx === 'undefined') {
|
|
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement.__x) {
|
|
const activeTab = appElement.__x.$data.activeTab || 'overview';
|
|
if (activeTab === 'overview') {
|
|
loadOverviewDirect();
|
|
}
|
|
}
|
|
}
|
|
}, 5000);
|
|
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
|
|
</script>
|
|
|
|
<!-- 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::htmx:response-error="loadWifiDirect()">
|
|
<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>
|
|
|
|
<script>
|
|
// Fallback: Load WiFi content directly if HTMX fails
|
|
function loadWifiDirect() {
|
|
const wifiContent = document.getElementById('wifi-content');
|
|
if (wifiContent && !wifiContent.hasAttribute('data-loaded')) {
|
|
fetch('/v3/partials/wifi')
|
|
.then(response => response.text())
|
|
.then(html => {
|
|
wifiContent.innerHTML = html;
|
|
wifiContent.setAttribute('data-loaded', 'true');
|
|
// Re-initialize Alpine.js for the new content
|
|
if (window.Alpine) {
|
|
window.Alpine.initTree(wifiContent);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load WiFi content:', err);
|
|
wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load WiFi setup page. Please refresh the page.</p></div>';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Also try direct load if HTMX doesn't load within 3 seconds (AP mode detection)
|
|
setTimeout(() => {
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
if (isAPMode && typeof htmx === 'undefined') {
|
|
console.warn('HTMX not loaded, using direct fetch for WiFi content');
|
|
loadWifiDirect();
|
|
}
|
|
}, 3000);
|
|
</script>
|
|
|
|
<!-- 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="loadPluginsDirect()">
|
|
<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>
|
|
|
|
<!-- SSE connection for real-time updates -->
|
|
<script src="{{ url_for('static', filename='v3/js/app-shell.js') }}"></script>
|
|
|
|
<!-- 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/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/store_manager.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/config_manager.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
|
|
|
|
<!-- Load config utilities -->
|
|
<script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script>
|
|
|
|
<!-- Widget Registry System -->
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/registry.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/base-widget.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/notification.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-order-list.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/file-upload.js') }}?v=20260307" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/checkbox-group.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/array-table.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/schedule-picker.js') }}" defer></script>
|
|
<!-- Basic input widgets -->
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/text-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/number-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/textarea.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/select-dropdown.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/font-selector.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/toggle-switch.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/radio-group.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/date-picker.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/slider.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/color-picker.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/email-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/url-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/password-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
|
|
|
|
<!-- Reusable JSON file manager widget (used by of-the-day and others via x-widget: json-file-manager) -->
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/json-file-manager.js') }}" defer></script>
|
|
|
|
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
|
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" 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">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h3>
|
|
<button id="close-on-demand-modal" class="text-gray-400 hover:text-gray-600">
|
|
<i class="fas fa-times"></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"></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) {
|
|
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;
|
|
document.getElementById('update-banner').style.display = '';
|
|
try { sessionStorage.setItem('update-sha', data.remote_sha); } catch(e) {}
|
|
} else {
|
|
document.getElementById('update-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"></i> Update Now';
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></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) {}
|
|
}
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message || 'Update complete', data.status || 'success');
|
|
}
|
|
})
|
|
.catch(function() {
|
|
btn.innerHTML = originalHTML;
|
|
btn.disabled = false;
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Update failed — check your connection', 'error');
|
|
}
|
|
});
|
|
};
|
|
|
|
// Initial check shortly after page load, then periodic
|
|
setTimeout(checkForUpdate, 2000);
|
|
setInterval(checkForUpdate, CHECK_INTERVAL);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|