Files
LEDMatrix/web_interface/templates/v3/base.html
T
ChuckandClaude Opus 5 4423ec33d5 feat(plugins): search, filter and sort for Installed Plugins, on a shared ListFilter helper (#540)
* feat(plugins): add search, filter and sort to Installed Plugins, on a shared helper

The Installed Plugins grid had no way to narrow it down: no search, no way to
see only what's enabled, disabled, or out of date. On a rig with a couple dozen
plugins that means scrolling the whole grid to find one.

The two sections below it already solved this, twice, independently — the
Plugin Store and Starlark Apps carried a copy-paste fork of the same ~600 lines
(filter state, apply-filters-and-sort, page renderer, pagination strip,
active-filter badge, listener wiring). Rather than add a third copy, this
extracts the shared machinery and builds the new toolbar on it.

New: web_interface/static/v3/js/plugins/list_filter.js — ListFilter.create()
owns debounced search, filter axes, sort, the active-filter count, Clear, and
optional pagination/persistence. Callers keep their own card markup via a
`render` callback. Three control types cover every axis the page uses: pills
(new), select (store category, starlark author) and cycle (the tri-state
All -> Installed -> Not Installed button).

Installed Plugins gets a compact toolbar: search box, one-click All / Enabled /
Disabled / Updates pills, and a sort dropdown (A-Z, Z-A, updates first,
recently updated, category). Filters reset on load, so you never come back to a
mysteriously short list. No new CSS — this is the first consumer of the
.filter-pill rules already sitting unused in app.css.

renderInstalledPlugins() is split so it still publishes canonical state while
renderInstalledCards() draws only the visible subset; the filtered list is
never assigned to window.installedPlugins, which the toggle handler,
isStorePluginInstalled(), runUpdateAllPlugins() and the Alpine config tabs all
read as their source of truth. Toggling a plugin while filtered pins its card
so it doesn't vanish from under the cursor.

The Store and Starlark migrations are behaviour-preserving: same element ids,
same localStorage keys (storeSort/storePerPage, starlarkSort/starlarkPerPage),
same tri-state button markup, same pagination. Verified by differential tests
that run the old and new implementations side by side against identical
fixtures and compare every observable after each interaction. The only visible
change is the pagination attribute (data-store-page/data-starlark-page ->
data-list-page), which nothing outside its own click handler referenced.

Net -156 lines in plugins_manager.js while adding a feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(plugins): keep raw search text, and stop the store search refetching

Two review findings from CodeRabbit on #540.

Do not write the trimmed search value back into the input. setSearch() trimmed
before storing, and syncControls() then copied that trimmed value back over what
the user had typed. Pausing longer than the debounce after typing a space
deleted the space (and reset the caret), making multi-word terms effectively
untypable. The raw text is now kept alongside the trimmed one: filtering and
activeCount() still use the trimmed value, while the input keeps exactly what
was typed.

Remove the legacy #plugin-search / #plugin-category listeners in
initializePlugins(). They bound searchPluginStore as the event handler, so the
DOM event arrived as its `fetchCommitInfo` argument — always truthy, which
skipped the cached-filter fast path and refetched /api/v3/plugins/store/list
with commit info on every keystroke burst and category change. The store's
ListFilter controller already filters the cached list, which is what those two
controls should do. This double-binding predates this PR (the old code guarded
with _listenerSetup and _storeFilterInit, two different flags, so both sets
stayed live); it is fixed here because the refactor owns that wiring now.

Both fixes are covered by tests that fail without them: the trailing-space
regressions in the installed-plugins DOM suite, and a new whole-file jsdom test
that counts fetches while typing (1 request at init, 0 thereafter; previously
1 -> 2 -> 3 -> 5).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(plugins): build pagination via DOM APIs, drop computed member access

Addresses the five Codacy security findings, all in list_filter.js.

Pagination no longer assembles an HTML string (3 findings: 2 critical + 1 high,
"unsafe assignment to innerHTML"). The interpolated values were only page
integers and local class constants, so there was no injection path, but
concatenating markup into innerHTML is the pattern the scanners flag and
createElement is no less clear. Each button now also owns its click listener
directly instead of the container being re-queried afterwards, and the strip is
cleared with textContent = '' rather than by assigning empty markup. No
innerHTML assignment remains in the file.

haystack() now walks Object.entries(item) and keeps the configured fields,
instead of reading item[field] per field ("generic object injection sink").
Field order no longer drives the haystack order, which is irrelevant to the
substring test. matches() iterates controls with for...of instead of an index
("variable assigned to object injection sink").

The rendered pagination is unchanged: same buttons, labels, page numbers,
disabled states and classes. The old-vs-new differential tests now compare
pagination structurally (tag, text, page, disabled, sorted class list) rather
than as an HTML string, since building nodes legitimately serialises
differently — «/» as characters rather than &laquo;/&raquo;, disabled="" rather
than a bare attribute. That comparison is stronger than the string one it
replaces, and the real-DOM suite still drives the actual page buttons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(plugins): keep configured field order when building the search haystack

The previous commit swapped item[field] for Object.entries(item) to clear a
static-analysis object-injection warning, and in doing so changed the order of
the haystack: entries follow the object's own key insertion order, not the
configured `fields` order. Since the values are concatenated, that order decides
which values end up adjacent, so a multi-word query spanning a field boundary
matched differently. For store fields [name, description, author, id, ...] and
API objects keyed {id, name, description, author, ...}, "bob plugin-01" matched
before and stopped matching after.

That contradicted the behaviour-preservation claim for the store and starlark
migrations, and the differential tests missed it because every fixture query was
a single word.

Values now come out of a Map built from Object.entries, iterated in `fields`
order: the original haystack is restored, and there is still no computed member
access for the analyser to flag.

Regression coverage for the ordering itself, at both levels:
  - unit: phrases spanning name->id and category->tags, plus the reverse
    (object-key) order asserted NOT to match
  - differential: the same class of query compared old-vs-new, with a guard that
    the phrase actually matches something so a mutual zero-result cannot pass
    vacuously

Verified both fail without this fix (3 unit, 2 differential) and pass with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* test(web): add JS suites for ListFilter and the plugin-manager grids

No JS toolchain exists in this repo, so these are plain node scripts with no
framework: each prints ok/FAIL lines and exits non-zero. `node test/js/run_all.js`
runs everything, skipping the DOM suites (rather than failing) when jsdom is
absent or nothing is listening, so it stays useful in a bare checkout.

  unit/test_list_filter.js    ListFilter search/filter/sort/count/sticky, driven
                              through the installed-plugins config eval'd
                              verbatim out of plugins_manager.js so the test
                              cannot drift from the real configuration
  unit/test_render_cards.js   renderInstalledCards markup, both empty states,
                              and escaping of hostile plugin metadata
  dom/test_installed_dom.js   the toolbar in a real DOM, including the HTMX
                              partial re-swap and a getComputedStyle check that
                              .filter-pill[data-active] matches what we emit
  dom/test_store_dom.js       store pagination, per-page, category, tri-state
                              Installed button, persistence across a re-boot
  dom/test_no_double_fetch.js loads the whole plugins_manager.js and counts
                              requests, so a keystroke cannot refetch the store

The DOM suites deliberately fetch the partial and the plugin data from a running
web interface instead of using fixtures, so a renamed element id or a changed
payload shape fails them loudly. Point them at a rig with a full plugin set when
it matters (BASE=http://host:5000); a dev box with two plugins installed passes
while exercising very little.

Several assertions exist to stop specific bugs recurring: trailing spaces
surviving the search debounce, a query spanning two adjacent search fields
(haystack field order is load-bearing), and window.installedPlugins staying at
full length while the grid is filtered. Others guard against passing vacuously —
counting only non-skeleton cards, and checking a search phrase matches something
before comparing two result sets.

The old-vs-new differential suites that verified the store and starlark
migrations are not included: they compared against the pre-refactor code, which
now exists only in git history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:04:47 -04:00

1202 lines
64 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"
id="restart-pending-text">
Configuration saved &mdash; 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 &amp; 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>
<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"></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"></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"></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>
<!-- Shared search/filter/sort controller for the plugin card grids -->
<script src="{{ url_for('static', filename='v3/js/plugins/list_filter.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/google-oauth.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/time-picker.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/file-upload-single.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-file-manager.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>
<!-- plugins_manager.js: loaded LAST on purpose — it defines the live
window.* implementations for several plugin actions (installPlugin,
uninstallPlugin, executePluginAction, ...) and intentionally wins
over same-named definitions in js/app.js and js/app-shell.js. Do not
remove or reorder without resolving that overlap first. -->
<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) {
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"></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) {}
// 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');
}
}
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>