fix(web): harden, polish and optimize the web UI per the Sept 2026 audit (#568)

* fix(web): harden, polish and optimize the web UI per the September 2026 audit

Works through docs/archive/WEB_UI_AUDIT_2026-09.md (health 8/20).

Implementation integrity (P0)
- app.css now defines every utility class the templates and JS use,
  including .hidden, so the ~145 JS show/hide toggles work. Button reset,
  and base component rules (.btn, .form-control) wrapped in :where() so
  utility classes on the same element win. New static-audit test fails
  when a used utility class has no rule.

Accessibility
- Focus rings render (the old ring rule referenced undefined variables);
  one :focus-visible outline everywhere; skip link; labelled nav landmarks.
- Shared dialog helper (js/utils/dialog.js): role/aria-modal, focus trap,
  Escape, focus return, applied to every modal.
- Named icon-only buttons and labelled ~70 form fields.
- Toasts announced once; errors persist >= 10s; one showNotification.
- Captive WiFi page: live region, timeouts, dark mode, 16px inputs.

Performance (Pi Zero 2 W)
- SSE streams and tab timers pause when hidden or off-tab; the display
  stream only runs while a preview is visible. app-shell.js deferred.
- Widget scripts served as one versioned bundle (/assets/widgets.js):
  52 -> 21 script tags, 66 -> 35 requests on first load.
- Stdlib gzip fallback when flask-compress is missing: first-load JS/CSS
  1358 KB -> 291 KB on the wire. SSE untouched.

Theming and responsive
- File managers, form fields and Fonts upload on theme tokens; bare
  inputs themed in dark mode; no more white surfaces.
- No horizontal overflow at 375px on any tab; 44px touch targets on
  coarse pointers; reduced-motion respected; header title truncates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): clear Codacy findings on #568

- json-file-manager: focus-trap releases kept in a Map (no dynamic
  property access or delete; no value-returning forEach callback)
- notification / schedule-picker: style and day-label lookups via Map
- app.js: move the pending-queue assignment out of the expression
- diff_viewer / error_handler: named function declarations instead of
  arrow consts

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: check the OAuth widget ships in the widget bundle

base.html no longer tags widget scripts one by one; they load through
/assets/widgets.js. Assert the page requests the bundle and the bundle
contains google-oauth.js, which is what the test was protecting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): address review feedback on #568

- widget bundle version fingerprints every file (name, mtime_ns, size)
- gzip fallback appends Accept-Encoding to an existing Vary header
- dialog helper: releasing a non-top dialog no longer moves focus out of
  the dialog the user is in
- labels: file-upload targets its file input; fallback config fields get
  label for/id pairs; native color input has a fallback name
- utility audit also reads class names inside bound :class expressions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): give the native color-picker input an accessible name

CodeRabbit flagged this on PR #568 as an outside-diff finding (never
posted inline, so it was missed in the round of fixes that addressed
the other 6 review comments). The <input type="color"> only carried a
title attribute; screen readers don't reliably announce title, and
there's no other label naming the control when showHexInput is false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): clear Codacy findings in app-shell.js

- drop the unused catch binding on the SSE JSON parse
- move the pending-notification queue assignment out of the expression

No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): contain plugin widgets/ dir and bound style-editor retries

From CodeRabbit review on #568 (code that arrived with the main merge):
- serve_plugin_widget resolves widgets/ with resolve_under before
  resolving the manifest script under it, so a symlinked widgets
  directory can't become the containment base (CWE-22). New test.
- style-editor init stops polling after ~10s when the widget never
  registers and leaves the plain fallback fields in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-14 09:42:24 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 69d408b321
commit d1e821c625
45 changed files with 2869 additions and 870 deletions
+84 -99
View File
@@ -204,7 +204,7 @@
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>';
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Plugin Manager. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadPluginsDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
});
}
}
@@ -229,6 +229,11 @@
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. -->
<!-- App shell (full app(), Alpine stores, SSE streams). Deferred so it
doesn't block rendering, and placed before Alpine: deferred scripts run
in document order, and its alpine:init listener must be registered
before Alpine starts. -->
<script defer src="{{ url_for('static', filename='v3/js/app-shell.js') }}"></script>
<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
@@ -238,7 +243,7 @@
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';
s.src = 'https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js';
document.head.appendChild(s);
}
});
@@ -291,11 +296,12 @@
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
</head>
<body x-data="app()" class="bg-gray-50 min-h-screen">
<a href="#tab-content" class="skip-link">Skip to main content</a>
<!-- Header -->
<header class="bg-white shadow-md border-b border-gray-200">
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16" style="max-width: 100%;">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<div class="flex items-center min-w-0">
<!-- Mobile nav toggle (hamburger) — hidden at md and up -->
<button type="button"
class="theme-toggle-btn p-2 rounded-md mr-2 md:hidden"
@@ -307,14 +313,13 @@
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 class="text-lg sm:text-xl font-bold text-gray-900 truncate">
<i class="fas fa-tv text-blue-600 mr-2" aria-hidden="true"></i>LED Matrix Control
</h1>
</div>
<!-- Connection status and theme toggle -->
<div id="header-widgets" class="flex items-center space-x-4">
<div id="header-widgets" class="flex items-center flex-shrink-0 ml-2 space-x-4">
<!-- Global settings search -->
<div class="relative hidden sm:block" id="settings-search-wrap">
<input id="settings-search"
@@ -353,20 +358,20 @@
<!-- 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 id="cpu-stat" class="flex items-center space-x-1" title="CPU usage">
<i class="fas fa-microchip" aria-hidden="true"></i>
<span>--%</span>
</span>
<span id="memory-stat" class="flex items-center space-x-1">
<i class="fas fa-memory"></i>
<span id="memory-stat" class="flex items-center space-x-1" title="Memory usage">
<i class="fas fa-memory" aria-hidden="true"></i>
<span>--%</span>
</span>
<span id="temp-stat" class="flex items-center space-x-1">
<i class="fas fa-thermometer-half"></i>
<span id="temp-stat" class="flex items-center space-x-1" title="CPU temperature">
<i class="fas fa-thermometer-half" aria-hidden="true"></i>
<span>--°C</span>
</span>
<span id="power-stat" class="hidden items-center space-x-1" title="">
<i class="fas fa-bolt"></i>
<i class="fas fa-bolt" aria-hidden="true"></i>
<span>Power</span>
</span>
</div>
@@ -381,7 +386,7 @@
<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>
<i class="fas fa-arrow-circle-up text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium" id="update-banner-text"
aria-live="polite" aria-atomic="true">
A new LEDMatrix update is available
@@ -391,12 +396,12 @@
<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
<i class="fas fa-download mr-1" aria-hidden="true"></i> Update Now
</button>
<button type="button" onclick="dismissUpdateBanner()"
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
title="Dismiss" aria-label="Dismiss update">
<i class="fas fa-times text-sm"></i>
<i class="fas fa-times text-sm" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -412,7 +417,7 @@
<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>
<i class="fas fa-rotate text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium" aria-live="polite"
id="restart-pending-text">
Configuration saved &mdash; restart the display to apply the changes
@@ -422,12 +427,12 @@
<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
<i class="fas fa-power-off mr-1" aria-hidden="true"></i> Restart Now
</button>
<button type="button" onclick="window.dismissRestartPending()"
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
title="Dismiss" aria-label="Dismiss restart reminder">
<i class="fas fa-times text-sm"></i>
<i class="fas fa-times text-sm" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -440,7 +445,7 @@
<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>
<i class="fas fa-exclamation-triangle text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium" id="power-warning-banner-text"
aria-live="polite" aria-atomic="true">
A power/thermal issue detected right now — the display may flicker or degrade. Check your power supply and cooling.
@@ -449,7 +454,7 @@
<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>
<i class="fas fa-times text-sm" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -471,90 +476,90 @@
<div id="drawer-widgets"></div>
<!-- Navigation tabs -->
<nav class="mb-8">
<div 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">
<nav aria-label="System settings" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'overview'"
:class="activeTab === 'overview' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-tachometer-alt"></i>Overview
<i class="fas fa-tachometer-alt" aria-hidden="true"></i>Overview
</button>
<button @click="activeTab = 'general'"
:class="activeTab === 'general' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-sliders-h"></i>General
<i class="fas fa-sliders-h" aria-hidden="true"></i>General
</button>
<button @click="activeTab = 'wifi'"
:class="activeTab === 'wifi' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-wifi"></i>WiFi
<i class="fas fa-wifi" aria-hidden="true"></i>WiFi
</button>
<button @click="activeTab = 'schedule'"
:class="activeTab === 'schedule' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-clock"></i>Schedule
<i class="fas fa-clock" aria-hidden="true"></i>Schedule
</button>
<button @click="activeTab = 'display'"
:class="activeTab === 'display' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-desktop"></i>Display
<i class="fas fa-desktop" aria-hidden="true"></i>Display
</button>
<button @click="activeTab = 'durations'"
:class="activeTab === 'durations' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-rotate"></i>Rotation
<i class="fas fa-rotate" aria-hidden="true"></i>Rotation
</button>
<button @click="activeTab = 'config-editor'"
:class="activeTab === 'config-editor' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-file-code"></i>Config Editor
<i class="fas fa-file-code" aria-hidden="true"></i>Config Editor
</button>
<button @click="activeTab = 'backup-restore'"
:class="activeTab === 'backup-restore' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-save"></i>Backup &amp; Restore
<i class="fas fa-save" aria-hidden="true"></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
<i class="fas fa-font" aria-hidden="true"></i>Fonts
</button>
<button @click="activeTab = 'logs'"
:class="activeTab === 'logs' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-file-alt"></i>Logs
<i class="fas fa-file-alt" aria-hidden="true"></i>Logs
</button>
<button @click="activeTab = 'cache'"
:class="activeTab === 'cache' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-database"></i>Cache
<i class="fas fa-database" aria-hidden="true"></i>Cache
</button>
<button @click="activeTab = 'operation-history'"
:class="activeTab === 'operation-history' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-history"></i>Operation History
<i class="fas fa-history" aria-hidden="true"></i>Operation History
</button>
<button @click="activeTab = 'tools'"
:class="activeTab === 'tools' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-tools"></i>Tools
<i class="fas fa-tools" aria-hidden="true"></i>Tools
</button>
</nav>
</div>
<!-- Second row - Plugin tabs (populated dynamically) -->
<div id="plugin-tabs-row" class="border-b border-gray-200">
<nav class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<nav aria-label="Plugins" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPluginsDirect()); }"
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-plug"></i>Plugin Manager
<i class="fas fa-plug" aria-hidden="true"></i>Plugin Manager
</button>
<!-- Installed plugin tabs will be added here dynamically -->
</nav>
</div>
</nav>
</div>
</div> <!-- /#site-nav -->
<!-- Floating mini display preview: available on every tab except
@@ -563,16 +568,16 @@
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="text-xs text-gray-300"><i class="fas fa-tv mr-1" aria-hidden="true"></i>Live Preview</span>
<span class="flex items-center">
<button type="button" onclick="window.cycleFloatingPreviewSize()"
class="text-gray-400 hover:text-white ml-2" aria-label="Cycle preview size"
title="Cycle preview size (drag the corner to resize on desktop)">
<i class="fas fa-up-right-and-down-left-from-center text-xs"></i>
<i class="fas fa-up-right-and-down-left-from-center text-xs" aria-hidden="true"></i>
</button>
<button type="button" onclick="window.toggleFloatingPreview(false)"
class="text-gray-400 hover:text-white ml-2" aria-label="Close live preview">
<i class="fas fa-times text-xs"></i>
<i class="fas fa-times text-xs" aria-hidden="true"></i>
</button>
</span>
</div>
@@ -582,12 +587,12 @@
<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>
<i class="fas fa-tv" aria-hidden="true"></i>
</button>
<!-- Tab content -->
<div id="tab-content" class="space-y-6">
<div id="tab-content" class="space-y-6" tabindex="-1">
<!-- Overview tab -->
<div x-show="activeTab === 'overview'" x-transition>
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
@@ -606,7 +611,10 @@
const overviewContent = document.getElementById('overview-content');
if (overviewContent && !overviewContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/overview')
.then(response => response.text())
.then(response => {
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
return response.text();
})
.then(html => {
overviewContent.innerHTML = html;
overviewContent.setAttribute('data-loaded', 'true');
@@ -620,7 +628,7 @@
})
.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>';
overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Overview. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadOverviewDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
});
}
}
@@ -698,7 +706,10 @@
const wifiContent = document.getElementById('wifi-content');
if (wifiContent && !wifiContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/wifi')
.then(response => response.text())
.then(response => {
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
return response.text();
})
.then(html => {
wifiContent.innerHTML = html;
wifiContent.setAttribute('data-loaded', 'true');
@@ -709,7 +720,7 @@
})
.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>';
wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load WiFi setup. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadWifiDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
});
}
}
@@ -952,9 +963,6 @@
<!-- 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>
@@ -964,6 +972,7 @@
<!-- Modular Plugin Management JavaScript -->
<!-- Load utilities first -->
<script src="{{ url_for('static', filename='v3/js/utils/dialog.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script>
<!-- Load core API client first (used by other modules) -->
@@ -979,43 +988,10 @@
<!-- 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/style-editor.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>
<!-- Widget Registry System: all widget scripts in one request (see
web_interface/widget_bundle.py). The individual files stay served at
their own URLs for plugin-loader.js and debugging. -->
<script src="{{ widgets_bundle_url() }}" defer></script>
<!-- plugins_manager.js: loaded LAST on purpose — it defines the live
window.* implementations for several plugin actions (installPlugin,
@@ -1030,17 +1006,17 @@
<!-- 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="modal-content p-6 w-full max-w-md bg-white rounded-lg shadow-lg" role="dialog" aria-modal="true" aria-labelledby="on-demand-modal-title">
<div class="flex justify-between items-center mb-4">
<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>
<h2 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h2>
<button type="button" id="close-on-demand-modal" class="text-gray-500 hover:text-gray-700 p-2 -m-2 rounded-md" aria-label="Close">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<!-- Service Status Alert -->
<div id="on-demand-service-warning" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2"></i>
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2" aria-hidden="true"></i>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-800">Display service is not running</p>
<p class="text-xs text-yellow-700 mt-1">
@@ -1148,8 +1124,8 @@
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...';
var originalHTML = '<i class="fas fa-download mr-1" aria-hidden="true"></i> Update Now';
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Updating...';
btn.disabled = true;
fetch('/api/v3/system/action', {
method: 'POST',
@@ -1191,9 +1167,18 @@
});
};
// Initial check shortly after page load, then periodic
setTimeout(checkForUpdate, 2000);
setInterval(checkForUpdate, CHECK_INTERVAL);
// Initial check shortly after page load, then every CHECK_INTERVAL while
// the page is visible. A hidden tab makes no requests and catches up as
// soon as it is shown again.
var lastCheck = 0;
function checkIfDue() {
if (document.hidden || Date.now() - lastCheck < CHECK_INTERVAL) return;
lastCheck = Date.now();
checkForUpdate();
}
setTimeout(checkIfDue, 2000);
setInterval(checkIfDue, 60 * 1000);
document.addEventListener('visibilitychange', checkIfDue);
})();
</script>
</body>
+155 -75
View File
@@ -3,39 +3,48 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>LEDMatrix WiFi Setup</title>
<style>
:root{--bg:#f3f4f6;--surface:#fff;--text:#1f2937;--label:#374151;--muted:#4b5563;--faint:#6b7280;--line:#e5e7eb;--field:#d1d5db;--accent:#2563eb;--accent-hover:#1d4ed8;--scan:#e5e7eb;--scan-hover:#d1d5db;--ok-bg:#d1fae5;--ok:#065f46;--err-bg:#fee2e2;--err:#991b1b;--info-bg:#dbeafe;--info:#1e40af}
@media (prefers-color-scheme:dark){:root{--bg:#111827;--surface:#1f2937;--text:#f9fafb;--label:#d1d5db;--muted:#d1d5db;--faint:#9ca3af;--line:#374151;--field:#4b5563;--accent:#3b82f6;--accent-hover:#60a5fa;--scan:#374151;--scan-hover:#4b5563;--ok-bg:#064e3b;--ok:#6ee7b7;--err-bg:#7f1d1d;--err:#fecaca;--info-bg:#1e3a5f;--info:#bfdbfe}}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f3f4f6;color:#1f2937;padding:16px;max-width:480px;margin:0 auto}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);padding:16px;max-width:480px;margin:0 auto;line-height:1.45}
h1{font-size:20px;margin-bottom:4px}
.subtitle{color:#6b7280;font-size:13px;margin-bottom:20px}
.card{background:#fff;border-radius:12px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.1);margin-bottom:16px}
label{display:block;font-size:13px;font-weight:600;color:#374151;margin-bottom:6px}
select,input[type=text],input[type=password]{width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:15px;background:#fff;-webkit-appearance:none;appearance:none}
select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b7280' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15)}
.btn{display:block;width:100%;padding:12px;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;text-align:center;transition:background .15s}
.btn-primary{background:#2563eb;color:#fff}.btn-primary:hover{background:#1d4ed8}
.btn-scan{background:#e5e7eb;color:#374151}.btn-scan:hover{background:#d1d5db}
.btn:disabled{background:#d1d5db;color:#9ca3af;cursor:not-allowed}
.subtitle{color:var(--muted);font-size:14px;margin-bottom:20px}
.card{background:var(--surface);border-radius:12px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.1);margin-bottom:16px}
label{display:block;font-size:14px;font-weight:600;color:var(--label);margin-bottom:6px}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
/* 16px inputs: iOS Safari zooms any focused field smaller than that */
select,input[type=text],input[type=password]{width:100%;min-width:0;min-height:44px;padding:10px 12px;border:1px solid var(--field);border-radius:8px;font-size:16px;background-color:var(--surface);color:var(--text);-webkit-appearance:none;appearance:none}
select{padding-right:32px;text-overflow:ellipsis;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b7280' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
select:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:1px;border-color:var(--accent)}
.btn{display:block;width:100%;min-height:44px;padding:12px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;text-align:center;transition:background-color .15s}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover)}
.btn-scan{background:var(--scan);color:var(--label);width:auto;padding:10px 16px;white-space:nowrap}.btn-scan:hover{background:var(--scan-hover)}
.btn:disabled{background:var(--scan);color:var(--muted);cursor:not-allowed;opacity:.75}
.row{display:flex;gap:8px;margin-bottom:16px}
.row>*:first-child{flex:1}
.msg{padding:12px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none}
.msg-ok{background:#d1fae5;color:#065f46;display:block}
.msg-err{background:#fee2e2;color:#991b1b;display:block}
.msg-info{background:#dbeafe;color:#1e40af;display:block}
.step{font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
.sep{margin:16px 0;border:none;border-top:1px solid #e5e7eb}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:6px}
.row>*:first-child{flex:1;min-width:0}
.msg{padding:12px;border-radius:8px;font-size:14px;margin-bottom:12px;display:none;overflow-wrap:anywhere}
.msg-ok{background:var(--ok-bg);color:var(--ok);display:block}
.msg-err{background:var(--err-bg);color:var(--err);display:block}
.msg-info{background:var(--info-bg);color:var(--info);display:block}
.step{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .6s linear infinite;vertical-align:-2px;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}}
.footer{text-align:center;margin-top:20px;font-size:12px;color:#9ca3af}
.footer a{color:#3b82f6;text-decoration:none}
@media (prefers-reduced-motion:reduce){.spinner{animation-duration:1.8s}.btn{transition:none}}
.footer{text-align:center;margin-top:20px;font-size:14px}
.footer a{color:var(--accent);text-underline-offset:.15em;display:inline-block;padding:10px}
.success-box{text-align:center;padding:24px}
.success-box .icon{font-size:48px;margin-bottom:12px}
.success-box .ip{font-size:18px;font-weight:700;color:#2563eb;word-break:break-all}
.success-box .icon{width:48px;height:48px;margin:0 auto 12px;color:#059669}
.success-box .title{font-size:16px;font-weight:600;margin-bottom:8px}
.success-box .hint{font-size:14px;color:var(--muted);margin-bottom:12px}
.success-box .note{font-size:13px;color:var(--faint);margin-top:12px}
.success-box .ip{font-size:18px;font-weight:700;color:var(--accent);word-break:break-all}
.hidden{display:none}
.or-divider{text-align:center;color:#9ca3af;font-size:12px;margin:12px 0;position:relative}
.or-divider::before,.or-divider::after{content:'';position:absolute;top:50%;width:40%;height:1px;background:#e5e7eb}
.or-divider{text-align:center;color:var(--faint);font-size:13px;margin:12px 0;position:relative}
.or-divider::before,.or-divider::after{content:'';position:absolute;top:50%;width:30%;height:1px;background:var(--line)}
.or-divider::before{left:0}
.or-divider::after{right:0}
</style>
@@ -45,44 +54,48 @@ select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px
<h1>LEDMatrix WiFi Setup</h1>
<p class="subtitle">Connect your device to a WiFi network</p>
<div id="msg" class="msg"></div>
<div id="msg" class="msg" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="setup-form">
<div class="card">
<div class="step">Step 1 &mdash; Choose Network</div>
<div class="step" aria-hidden="true">Step 1 &mdash; Choose Network</div>
<label for="net-select">Available Networks</label>
<div class="row">
<select id="net-select" onchange="onSelectNetwork()">
<option value="">-- Scan to find networks --</option>
</select>
<button class="btn btn-scan" id="btn-scan" onclick="doScan()" style="width:auto;padding:10px 16px">
<button type="button" class="btn btn-scan" id="btn-scan" onclick="doScan()">
Scan
</button>
</div>
<div class="or-divider">or enter manually</div>
<input type="text" id="manual-ssid" placeholder="Network name (SSID)" oninput="onManualInput()">
<div class="or-divider" aria-hidden="true">or enter manually</div>
<label for="manual-ssid" class="sr-only">Network name (SSID), if it isn't listed</label>
<input type="text" id="manual-ssid" placeholder="Network name (SSID)" maxlength="32"
autocomplete="off" autocapitalize="off" spellcheck="false" oninput="onManualInput()">
</div>
<div class="card">
<div class="step">Step 2 &mdash; Password</div>
<div class="step" aria-hidden="true">Step 2 &mdash; Password</div>
<label for="password">WiFi Password</label>
<input type="password" id="password" placeholder="Leave empty for open networks">
<input type="password" id="password" placeholder="Leave empty for open networks" maxlength="63"
autocomplete="current-password" aria-describedby="password-hint">
<p id="password-hint" class="sr-only">Leave empty for open networks.</p>
</div>
<div class="card">
<button class="btn btn-primary" id="btn-connect" onclick="doConnect()" disabled>
<button type="button" class="btn btn-primary" id="btn-connect" onclick="doConnect()" disabled>
Connect
</button>
</div>
</div>
<div id="success-view" class="card hidden">
<div id="success-view" class="card hidden" tabindex="-1" aria-labelledby="success-title">
<div class="success-box">
<div class="icon">&#10003;</div>
<p style="font-size:16px;font-weight:600;margin-bottom:8px">Connected!</p>
<p style="font-size:13px;color:#6b7280;margin-bottom:12px">Your device is now on the network. Access the full interface at:</p>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M7.5 12.5l3 3 6-6"/></svg>
<p class="title" id="success-title">Connected!</p>
<p class="hint">Your device is now on the network. Access the full interface at:</p>
<p class="ip" id="new-ip"></p>
<p style="font-size:12px;color:#9ca3af;margin-top:12px">You may need to reconnect your phone to the same WiFi network.</p>
<p class="note">You may need to reconnect your phone to the same WiFi network.</p>
</div>
</div>
@@ -94,17 +107,53 @@ select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px
var selectedSSID = '';
var scanning = false;
var connecting = false;
var msgTimer = null;
function $(id) { return document.getElementById(id); }
function showMsg(text, type) {
var el = $('msg');
el.textContent = text;
el.className = 'msg msg-' + (type || 'info');
if (type === 'ok') setTimeout(function() { el.style.display = 'none'; }, 8000);
// fetch() with a deadline: on a flaky AP link a request can otherwise hang
// forever and leave the buttons stuck in their busy state.
function fetchWithTimeout(url, options, ms) {
options = options || {};
if (typeof AbortController === 'undefined') return fetch(url, options);
var controller = new AbortController();
var timer = setTimeout(function() { controller.abort(); }, ms);
options.signal = controller.signal;
return fetch(url, options).finally(function() { clearTimeout(timer); });
}
function clearMsg() { $('msg').className = 'msg'; }
function readJson(r) {
return r.json().catch(function() {
throw new Error(r.ok ? 'bad-response' : 'http-' + r.status);
});
}
function setBusy(btn, label) {
btn.textContent = '';
var spin = document.createElement('span');
spin.className = 'spinner';
spin.setAttribute('aria-hidden', 'true');
btn.appendChild(spin);
btn.appendChild(document.createTextNode(label));
}
function showMsg(text, type) {
var el = $('msg');
if (msgTimer) { clearTimeout(msgTimer); msgTimer = null; }
el.style.display = '';
// Errors interrupt; progress and success wait their turn.
el.setAttribute('role', type === 'err' ? 'alert' : 'status');
el.setAttribute('aria-live', type === 'err' ? 'assertive' : 'polite');
el.textContent = text;
el.className = 'msg msg-' + (type || 'info');
if (type === 'ok') msgTimer = setTimeout(function() { el.style.display = 'none'; }, 8000);
}
function clearMsg() {
if (msgTimer) { clearTimeout(msgTimer); msgTimer = null; }
$('msg').className = 'msg';
$('msg').textContent = '';
}
function updateConnectBtn() {
var ssid = $('net-select').value || $('manual-ssid').value.trim();
@@ -128,43 +177,58 @@ function doScan() {
scanning = true;
var btn = $('btn-scan');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Scanning';
btn.setAttribute('aria-busy', 'true');
setBusy(btn, 'Scanning');
clearMsg();
fetch('/api/v3/wifi/scan')
.then(function(r) { return r.json(); })
fetchWithTimeout('/api/v3/wifi/scan', {}, 30000)
.then(readJson)
.then(function(data) {
var sel = $('net-select');
var previous = sel.value;
sel.innerHTML = '<option value="">-- Select a network --</option>';
if (data.status === 'success' && Array.isArray(data.data)) {
var nets = data.data;
var seen = {};
var nets = data.data.filter(function(n) {
if (!n || !n.ssid || seen[n.ssid]) return false;
seen[n.ssid] = true;
return true;
});
for (var i = 0; i < nets.length; i++) {
var n = nets[i];
var opt = document.createElement('option');
opt.value = n.ssid;
opt.textContent = n.ssid + ' (' + n.signal + '% - ' + n.security + ')';
var details = [];
if (n.signal !== undefined && n.signal !== null) details.push(n.signal + '%');
if (n.security) details.push(n.security);
opt.textContent = n.ssid + (details.length ? ' (' + details.join(' - ') + ')' : '');
sel.appendChild(opt);
}
if (previous && seen[previous]) sel.value = previous;
if (nets.length > 0) {
var msg = 'Found ' + nets.length + ' network' + (nets.length > 1 ? 's' : '');
var msg = 'Found ' + nets.length + (nets.length === 1 ? ' network' : ' networks');
if (data.cached) {
msg += ' \u2014 Showing cached networks. Connect to see the latest.';
msg += ' — showing cached networks. Connect to see the latest.';
}
showMsg(msg, data.cached ? 'info' : 'ok');
} else {
showMsg('No networks found. ' + (data.cached ? 'Enter your network name manually.' : 'Try scanning again.'), 'info');
showMsg('No networks found. ' + (data.cached ? 'Enter your network name manually.' : 'Try scanning again, or enter your network name manually.'), 'info');
}
} else {
showMsg(data.message || 'Scan failed', 'err');
showMsg(data.message || 'Scan failed. Try again, or enter your network name manually.', 'err');
}
})
.catch(function(e) {
showMsg('Scan failed: ' + e.message, 'err');
var why = e && e.name === 'AbortError'
? 'The scan took too long.'
: 'Couldn’t reach the device to scan.';
showMsg(why + ' Stay close to the device and try again, or enter your network name manually.', 'err');
})
.finally(function() {
scanning = false;
btn.disabled = false;
btn.innerHTML = 'Scan';
btn.removeAttribute('aria-busy');
btn.textContent = 'Scan';
updateConnectBtn();
});
}
@@ -175,16 +239,25 @@ function doConnect() {
connecting = true;
var btn = $('btn-connect');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Connecting...';
btn.setAttribute('aria-busy', 'true');
setBusy(btn, 'Connecting…');
clearMsg();
showMsg('Connecting to ' + ssid + '... This may take 15-30 seconds.', 'info');
showMsg('Connecting to ' + ssid + '… This may take 15–30 seconds.', 'info');
fetch('/api/v3/wifi/connect', {
function resetConnect() {
connecting = false;
btn.disabled = false;
btn.removeAttribute('aria-busy');
btn.textContent = 'Connect';
updateConnectBtn();
}
fetchWithTimeout('/api/v3/wifi/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: ssid, password: $('password').value || '' })
})
.then(function(r) { return r.json(); })
}, 60000)
.then(readJson)
.then(function(data) {
if (data.status === 'success') {
clearMsg();
@@ -192,15 +265,19 @@ function doConnect() {
setTimeout(function() { checkNewIP(ssid); }, 3000);
} else {
var msg = data.error_type === 'wrong_password'
? 'Incorrect password — please try again'
: (data.message || 'Connection failed');
? 'Incorrect password — please try again.'
: (data.message || 'Couldn’t connect to ' + ssid + '. Check the network name and password, then try again.');
showMsg(msg, 'err');
connecting = false;
btn.disabled = false;
btn.innerHTML = 'Connect';
resetConnect();
if (data.error_type === 'wrong_password') $('password').focus();
}
})
.catch(function(e) {
if (e && /^http-|^bad-response$/.test(e.message)) {
showMsg('The device couldn’t start the connection. Try again in a moment.', 'err');
resetConnect();
return;
}
// Connection may drop if AP mode was disabled — that's expected
clearMsg();
showMsg('Connection attempt sent. If the page stops responding, the device is connecting to ' + ssid + '.', 'info');
@@ -216,8 +293,8 @@ function checkNewIP(ssid, retriesLeft) {
showSuccessFallback(ssid);
return;
}
fetch('/api/v3/wifi/status')
.then(function(r) { return r.json(); })
fetchWithTimeout('/api/v3/wifi/status', {}, 8000)
.then(readJson)
.then(function(data) {
if (data.status === 'success' && data.data && data.data.connected && data.data.ip_address) {
showSuccess(data.data.ip_address);
@@ -231,18 +308,21 @@ function checkNewIP(ssid, retriesLeft) {
});
}
function showSuccess(ip) {
function revealSuccess(text) {
$('setup-form').classList.add('hidden');
$('success-view').classList.remove('hidden');
$('new-ip').textContent = 'http://' + ip + ':5000';
$('msg').className = 'msg';
var view = $('success-view');
view.classList.remove('hidden');
$('new-ip').textContent = text;
clearMsg();
view.focus();
}
function showSuccess(ip) {
revealSuccess('http://' + ip + ':5000');
}
function showSuccessFallback(ssid) {
$('setup-form').classList.add('hidden');
$('success-view').classList.remove('hidden');
$('new-ip').textContent = 'Check your router for the device IP';
$('msg').className = 'msg';
revealSuccess('Check your router for the device IP');
}
// Auto-scan on load
@@ -52,7 +52,7 @@
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Backup file</label>
<label for="restore-file-input" class="block text-sm font-medium text-gray-700 mb-2">Backup file</label>
<input type="file" id="restore-file-input" accept=".zip"
class="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
</div>
@@ -1039,10 +1039,25 @@ window.showDisplaySaveResult = function(xhr) {
content.appendChild(textSpan);
}
// Initial UI state and polling — guard against duplicate intervals on re-run
// Initial UI state, then the 5s sync-status poll — only while the Display
// tab is active and the page is visible. LEDVisibility keys the
// registration, so a re-run of this partial replaces the previous timer.
function stopSyncTimer() {
if (window.syncStatusInterval) {
clearInterval(window.syncStatusInterval);
window.syncStatusInterval = null;
}
}
function startSyncTimer() {
stopSyncTimer();
pollSyncStatus();
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
function startSyncPolling() {
updateSyncUI();
if (!window.syncStatusInterval) {
if (window.LEDVisibility) {
window.LEDVisibility.onActive('display', startSyncTimer, stopSyncTimer, 'display-sync-status');
} else if (!window.syncStatusInterval) {
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
}
+42 -30
View File
@@ -39,7 +39,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family Name</label>
<label for="upload-font-family" class="block text-sm font-medium text-gray-700 mb-1">Font Family Name</label>
<input type="text" id="upload-font-family" class="form-control" placeholder="e.g., my-custom-font">
<p class="text-sm text-gray-600 mt-1">Custom name for this font (letters, numbers, underscores, hyphens)</p>
</div>
@@ -75,24 +75,24 @@
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6">
<div>
<div id="font-preview-container" class="border border-gray-300 bg-black rounded p-4 min-h-[100px] flex items-center justify-center">
<img id="font-preview-image" src="" alt="Font preview" class="max-w-full" style="display: none;">
<img id="font-preview-image" alt="Font preview" class="max-w-full" style="display: none;">
<span id="font-preview-loading" class="text-gray-400">Select a font to preview</span>
</div>
</div>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Preview Text</label>
<label for="preview-text" class="block text-sm font-medium text-gray-700 mb-1">Preview Text</label>
<input type="text" id="preview-text" value="Sample Text 123" class="form-control text-sm">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
<label for="preview-family" class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
<select id="preview-family" class="form-control text-sm">
<!-- Dynamically populated from font catalog -->
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Size</label>
<label for="preview-size" class="block text-sm font-medium text-gray-700 mb-1">Font Size</label>
<select id="preview-size" class="form-control text-sm">
<option value="xs">XS (6px)</option>
<option value="sm">SM (8px)</option>
@@ -163,11 +163,18 @@ function initializeFontsTab() {
// Reset retry counter on successful init
initRetryCount = 0;
// showNotification is provided by the notification widget (notification.js)
// Fallback only if widget hasn't loaded yet
// showNotification is implemented by the notification widget
// (widgets/notification.js). If it hasn't loaded, queue the message; the
// widget shows queued messages as soon as it registers.
if (typeof window.showNotification !== 'function') {
window.showNotification = function(message, type = 'info') {
console.log(`[${type.toUpperCase()}]`, message);
const registry = window.LEDMatrixWidgets;
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
if (widget && typeof widget.show === 'function') {
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
}
(window.__pendingNotifications = window.__pendingNotifications || []).push([message, type]);
console.log(`[${String((type && type.type) || type).toUpperCase()}]`, message);
};
}
@@ -725,9 +732,8 @@ function updateUploadProgress(percent) {
<style>
.drag-over {
border-color: #3b82f6 !important;
background-color: #eff6ff !important;
transform: scale(1.02);
border-color: var(--color-primary, #3b82f6) !important;
background-color: var(--color-info-bg, #eff6ff) !important;
}
.font-upload-area {
@@ -735,45 +741,51 @@ function updateUploadProgress(percent) {
}
.upload-dropzone {
border: 3px dashed #d1d5db;
border-radius: 10px;
padding: 40px;
border: 2px dashed var(--color-field-border, #d1d5db);
border-radius: 0.75rem;
padding: 2rem 1.5rem;
text-align: center;
background: #fafafa;
transition: all 0.3s ease;
background: var(--color-background, #f9fafb);
transition: border-color 0.15s ease, background-color 0.15s ease;
cursor: pointer;
}
@media (min-width: 640px) {
.upload-dropzone {
padding: 2.5rem;
}
}
.upload-dropzone:hover {
border-color: #3b82f6;
background: #f0f8ff;
border-color: var(--color-primary, #3b82f6);
background: var(--color-info-bg, #eff6ff);
}
.upload-dropzone i {
font-size: 3rem;
color: #3b82f6;
margin-bottom: 15px;
color: var(--color-primary, #3b82f6);
margin-bottom: 0.75rem;
display: block;
}
.upload-dropzone p {
margin: 5px 0;
font-size: 1.1rem;
color: #374151;
margin: 0.25rem 0;
font-size: 1rem;
color: var(--color-text-secondary, #374151);
}
.upload-hint {
font-size: 0.9rem !important;
color: #6b7280 !important;
font-size: 0.875rem !important;
color: var(--color-text-tertiary, #4b5563) !important;
font-style: italic;
}
.upload-form {
margin-top: 20px;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #d1d5db;
margin-top: 1.25rem;
padding: 1.25rem;
background: var(--color-background, #f9fafb);
border-radius: 0.5rem;
border: 1px solid var(--color-border, #d1d5db);
}
#font-preview-container {
+51 -17
View File
@@ -17,9 +17,9 @@
<!-- Controls -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4">
<div class="flex flex-wrap items-center gap-3 sm:gap-4">
<!-- Log Mode Toggle -->
<div class="flex items-center space-x-2">
<div class="flex items-center gap-2">
<label class="flex items-center">
<input type="checkbox" id="log-realtime-toggle" class="form-control h-4 w-4" checked>
<span class="ml-2 text-sm font-medium">Real-time</span>
@@ -31,7 +31,7 @@
</div>
<!-- Log Level Filter -->
<select id="log-level-filter" class="form-control text-sm">
<select id="log-level-filter" aria-label="Filter logs by level" class="form-control text-sm w-auto">
<option value="">All Levels</option>
<option value="ERROR">Errors Only</option>
<option value="WARNING">Warnings & Errors</option>
@@ -39,18 +39,18 @@
</select>
<!-- Plugin Filter -->
<select id="log-plugin-filter" class="form-control text-sm">
<select id="log-plugin-filter" aria-label="Filter logs by plugin" class="form-control text-sm w-auto">
<option value="">All Plugins</option>
</select>
<!-- Search -->
<div class="relative">
<input type="text" id="log-search" placeholder="Search logs..." class="form-control text-sm pl-8 pr-4 py-1 w-48">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
<input type="text" id="log-search" placeholder="Search logs..." aria-label="Search logs" class="form-control text-sm pl-8 pr-4 py-1 w-48">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs pointer-events-none" aria-hidden="true"></i>
</div>
</div>
<div class="flex items-center space-x-2">
<div class="flex flex-wrap items-center gap-2">
<!-- Auto-scroll toggle -->
<label class="flex items-center">
<input type="checkbox" id="log-autoscroll" class="form-control h-4 w-4" checked>
@@ -72,7 +72,7 @@
<!-- Log Display -->
<div class="relative">
<div id="logs-container" class="bg-gray-900 text-gray-100 font-mono text-sm rounded-lg p-3 border border-gray-700 shadow-inner relative" style="height: 500px; min-height: 400px; max-height: 70vh;">
<div id="logs-loading" class="absolute inset-0 flex items-center justify-center text-gray-400 bg-gray-900">
<div id="logs-loading" class="absolute inset-0 flex items-center justify-center text-gray-300 bg-gray-900">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-2xl mb-2"></i>
<p>Loading logs...</p>
@@ -83,7 +83,7 @@
<!-- Logs will be inserted here -->
</div>
</div>
<div id="logs-empty" class="hidden absolute inset-0 flex items-center justify-center text-gray-400 bg-gray-900">
<div id="logs-empty" class="hidden absolute inset-0 flex items-center justify-center text-gray-300 bg-gray-900">
<div class="text-center">
<i class="fas fa-file-alt text-4xl mb-2"></i>
<p>No logs available</p>
@@ -215,13 +215,32 @@ window._filteredLogs = [];
});
}
refreshCurrentPluginStatus();
if (window._currentPluginPollTimer) {
clearInterval(window._currentPluginPollTimer);
// Current-plugin poll and the realtime log stream run only while the Logs
// tab is active and the page is visible (both restart with an immediate
// refresh). Re-registering on partial reload replaces the old one.
function startLogsActivity() {
refreshCurrentPluginStatus();
if (window._currentPluginPollTimer) clearInterval(window._currentPluginPollTimer);
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
if (window._isRealtime && !window._logsEventSource) setupRealtimeLogs();
}
function stopLogsActivity() {
if (window._currentPluginPollTimer) {
clearInterval(window._currentPluginPollTimer);
window._currentPluginPollTimer = null;
}
stopRealtimeLogs();
}
if (window.LEDVisibility) {
window.LEDVisibility.onActive('logs', startLogsActivity, stopLogsActivity);
} else {
startLogsActivity();
}
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
// Handle window resize for responsive height
// Handle window resize for responsive height. Added once: this script
// re-runs on every partial load, and the handler reads window globals.
if (window._logsResizeListenerAdded) return;
window._logsResizeListenerAdded = true;
window.addEventListener('resize', function() {
if (window._logContainer) {
// Force a reflow to update sizing
@@ -270,10 +289,24 @@ function loadLogs() {
});
}
function setupRealtimeLogs() {
// Closes the log stream and cancels any pending reconnect, so a paused
// (inactive tab / hidden page) stream stays closed.
function stopRealtimeLogs() {
if (window._logsReconnectTimer) {
clearTimeout(window._logsReconnectTimer);
window._logsReconnectTimer = null;
}
if (window._logsEventSource) {
window._logsEventSource.close();
window._logsEventSource = null;
}
}
function setupRealtimeLogs() {
stopRealtimeLogs();
// Don't open (or reconnect) while the Logs tab isn't on screen;
// LEDVisibility restarts the stream when it is.
if (window.LEDVisibility && !window.LEDVisibility.isActive('logs')) return;
window._logsEventSource = new EventSource('/api/v3/stream/logs');
@@ -302,8 +335,9 @@ function setupRealtimeLogs() {
<span>Disconnected from log stream</span>
`;
// Attempt to reconnect after 5 seconds
setTimeout(setupRealtimeLogs, 5000);
// Attempt to reconnect after 5 seconds (cancelled by stopRealtimeLogs)
if (window._logsReconnectTimer) clearTimeout(window._logsReconnectTimer);
window._logsReconnectTimer = setTimeout(setupRealtimeLogs, 5000);
};
}
@@ -8,12 +8,12 @@
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4">
<!-- Filter by Plugin -->
<select id="history-plugin-filter" class="form-control text-sm">
<select id="history-plugin-filter" class="form-control text-sm" aria-label="Filter by plugin">
<option value="">All Plugins</option>
</select>
<!-- Filter by Operation Type -->
<select id="history-type-filter" class="form-control text-sm">
<select id="history-type-filter" class="form-control text-sm" aria-label="Filter by operation type">
<option value="">All Operations</option>
<option value="install">Install</option>
<option value="update">Update</option>
@@ -24,7 +24,7 @@
</select>
<!-- Filter by Status -->
<select id="history-status-filter" class="form-control text-sm">
<select id="history-status-filter" class="form-control text-sm" aria-label="Filter by status">
<option value="">All Statuses</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
@@ -32,8 +32,8 @@
<!-- Search -->
<div class="relative">
<input type="text" id="history-search" placeholder="Search operations..." class="form-control text-sm pl-8 pr-4 py-1 w-48">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
<input type="text" id="history-search" placeholder="Search operations..." aria-label="Search operations" class="form-control text-sm pl-8 pr-4 py-1 w-48">
<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>
</div>
@@ -98,7 +98,7 @@
<div class="flex-1">
<p class="text-sm font-semibold text-blue-900"><i class="fas fa-rocket mr-1"></i>Getting Started</p>
<p class="text-xs text-blue-700 mt-0.5 mb-2">A few steps to get your display up and running. Click a step to jump there, or click its checkbox to mark it done yourself. The card hides once everything is checked.</p>
<ul class="space-y-1 text-sm" id="getting-started-items">
<ul class="list-none pl-0 space-y-1 text-sm" id="getting-started-items">
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _hw_done else '0' }}" data-tab="display">
<i class="far fa-square mr-2"></i>Set your panel size (Display tab)</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="timezone" data-tz="{{ _tz }}" data-tab="general">
@@ -21,8 +21,8 @@
{# Schedule picker widget - renders enable/mode/times UI #}
{% set obj_value = value if value is not none else {} %}
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="schedule-picker-container mt-1"></div>
<label id="{{ field_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="schedule-picker-container mt-1" role="group" aria-labelledby="{{ field_id }}-label"></div>
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'>
</div>
<script>
@@ -48,8 +48,8 @@
{# Time range widget - renders start/end time inputs #}
{% set obj_value = value if value is not none else {} %}
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="time-range-container mt-1"></div>
<label id="{{ field_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="time-range-container mt-1" role="group" aria-labelledby="{{ field_id }}-label"></div>
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'>
</div>
<script>
@@ -88,6 +88,15 @@
</div>
<script>
(function() {
// ~10s at 50ms: enough for the widget bundle to arrive over a
// slow link to a Pi, bounded so a bundle that never registers
// style-editor doesn't poll for the life of the page.
var retries = 0;
var MAX_RETRIES = 200;
function giveUp() {
var container = document.getElementById('{{ field_id }}_container');
if (container) { container.remove(); }
}
function fallbackIsDirty(root) {
if (!root) { return false; }
var controls = root.querySelectorAll('input, select, textarea');
@@ -106,9 +115,12 @@
return false;
}
function initWidget() {
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
var widget = window.LEDMatrixWidgets.get('style-editor');
if (!widget) { setTimeout(initWidget, 50); return; }
var widget = window.LEDMatrixWidgets && window.LEDMatrixWidgets.get('style-editor');
if (!widget) {
// The plain fallback fields stay in place either way.
if (++retries < MAX_RETRIES) { setTimeout(initWidget, 50); } else { giveUp(); }
return;
}
var container = document.getElementById('{{ field_id }}_container');
var fallback = document.getElementById('{{ field_id }}_fallback');
if (!container) { return; }
@@ -145,8 +157,27 @@
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
{% endif %}
{% else %}
{# Point the label at the control that actually takes focus. Widgets
render their own element (e.g. <id>_input), so a plain for="<id>"
would label nothing. Mirrors the branch order below; a miss just
falls back to the old for="<id>". #}
{% set _lw = prop.get('x-widget') or prop.get('x_widget') %}
{% set label_target = field_id %}
{% if field_type == 'boolean' %}
{% if _lw == 'toggle-switch' %}{% set label_target = field_id ~ '_button' %}{% endif %}
{% elif prop.enum %}
{% elif field_type in ['number', 'integer'] %}
{% if _lw in ['slider', 'number-input'] %}{% set label_target = field_id ~ '_input' %}{% endif %}
{% elif field_type == 'array' %}
{% if _lw == 'color-picker' %}{% set label_target = field_id ~ '_hex' %}
{% elif _lw == 'file-upload' %}{% set label_target = field_id ~ '_file_input' %}{% endif %}
{% elif _lw in ['text-input', 'textarea', 'select-dropdown', 'date-picker', 'time-picker', 'slider', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
{% set label_target = field_id ~ '_input' %}
{% elif _lw == 'color-picker' %}
{% set label_target = field_id ~ '_color' %}
{% endif %}
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
<label for="{{ field_id }}" class="block text-sm font-medium text-gray-700 mb-1">
<label for="{{ label_target }}" id="{{ field_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}{{ ui.help_tip(description, label) }}
</label>
@@ -277,7 +308,11 @@
data-max-files="{{ max_files }}"
data-max-size-mb="{{ max_size_mb }}"
data-allowed-types="{{ allowed_types|join(',') }}"
onclick="document.getElementById('{{ field_id }}_file_input').click()">
role="button"
tabindex="0"
aria-label="Upload files: drop them here or press Enter to choose"
onclick="document.getElementById('{{ field_id }}_file_input').click()"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('{{ field_id }}_file_input').click();}">
<input type="file"
id="{{ field_id }}_file_input"
multiple
@@ -310,8 +345,9 @@
<div id="img_{{ img_id|string|replace('.', '_')|replace('-', '_') }}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-3 flex-1">
<img src="/{{ img.get('path', '') }}"
alt="{{ img.get('filename', '') }}"
<img src="/{{ img.get('path', '') }}"
alt="{{ img.get('filename', '') }}"
loading="lazy" decoding="async"
class="w-16 h-16 object-cover rounded"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
<div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
@@ -336,9 +372,10 @@
data-image-id="{{ img_id }}"
data-image-idx="{{ loop.index0 }}"
onclick="window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId || null, parseInt(this.dataset.imageIdx))"
class="text-blue-600 hover:text-blue-800 p-2"
title="Schedule this image">
<i class="fas fa-calendar-alt"></i>
class="text-blue-600 hover:text-blue-800 p-2"
title="Schedule this image"
aria-label="Schedule image {{ img.get('original_filename') or img.get('filename', '') }}">
<i class="fas fa-calendar-alt" aria-hidden="true"></i>
</button>
<button type="button"
data-field-id="{{ field_id }}"
@@ -346,8 +383,9 @@
data-plugin-id="{{ plugin_id_from_config }}"
onclick="window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId)"
class="text-red-600 hover:text-red-800 p-2"
title="Delete image">
<i class="fas fa-trash"></i>
title="Delete image"
aria-label="Delete image {{ img.get('original_filename') or img.get('filename', '') }}">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -387,7 +425,7 @@
</div>
{% endif %}
<div class="mt-1 space-y-2">
<div class="mt-1 space-y-2" role="group" aria-labelledby="{{ field_id }}-label">
{% for option in enum_items %}
{% set is_checked = option in array_value %}
{% set option_label = labels.get(option, option|replace('_', ' ')|title) %}
@@ -508,6 +546,7 @@
name="{{ full_key }}.{{ item_index }}.name"
value="{{ item.get('name', '') }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
aria-label="Feed name, row {{ item_index + 1 }}"
placeholder="Feed Name"
required>
</td>
@@ -516,6 +555,7 @@
name="{{ full_key }}.{{ item_index }}.url"
value="{{ item.get('url', '') }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
aria-label="Feed URL, row {{ item_index + 1 }}"
placeholder="https://example.com/feed"
required>
</td>
@@ -534,7 +574,7 @@
<i class="fas fa-upload mr-1"></i> Upload
</button>
{% if logo_path %}
<img src="/{{ logo_path }}" alt="Logo" class="w-8 h-8 object-cover rounded border" id="{{ field_id }}_logo_preview_{{ item_index }}">
<img src="/{{ logo_path }}" alt="Logo" loading="lazy" decoding="async" class="w-8 h-8 object-cover rounded border" id="{{ field_id }}_logo_preview_{{ item_index }}">
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.path" value="{{ logo_path }}">
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.id" value="{{ logo_value.get('id', '') }}">
{% else %}
@@ -549,13 +589,15 @@
{% if item.get('enabled', true) %}checked{% endif %}
value="true"
onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'"
aria-label="Feed enabled, row {{ item_index + 1 }}"
class="h-4 w-4 text-blue-600">
</td>
<td class="px-4 py-3 whitespace-nowrap text-center">
<button type="button"
onclick="removeCustomFeedRow(this)"
aria-label="Remove feed, row {{ item_index + 1 }}"
class="text-red-600 hover:text-red-800 px-2 py-1">
<i class="fas fa-trash"></i>
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</td>
</tr>
@@ -624,6 +666,8 @@
<tr class="array-table-row" data-index="{{ item_index }}">
{% for col_name in display_columns %}
{% set col_def = item_properties.get(col_name, {}) %}
{# Accessible name for the cell's control: column title + row. #}
{% set cell_label = col_def.get('title', col_name|replace('_', ' ')|title) ~ ', row ' ~ (item_index + 1) %}
{# Normalize nullable types e.g. ["null","integer"] → "integer" #}
{% set _raw_type = col_def.get('type', 'string') %}
{% if _raw_type is iterable and _raw_type is not string %}
@@ -652,6 +696,7 @@
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="{{ 'true' if col_value else 'false' }}">
<input type="checkbox"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
{% if col_value %}checked{% endif %}
value="true"
onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'"
@@ -659,6 +704,7 @@
{% elif col_type == 'integer' or col_type == 'number' %}
<input type="number"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '' }}"
{% if col_def.get('minimum') is not none %}min="{{ col_def.get('minimum') }}"{% endif %}
{% if col_def.get('maximum') is not none %}max="{{ col_def.get('maximum') }}"{% endif %}
@@ -671,6 +717,7 @@
which must not be title-cased behind the user's back. #}
{% set col_labels = (col_def.get('x-options') or col_def.get('x_options') or {}).get('labels') or {} %}
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
{% for opt in col_enum %}{% if opt is not none %}
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ col_labels.get(opt, opt) }}</option>
@@ -679,28 +726,32 @@
{% elif col_xwidget == 'date-picker' %}
<input type="date"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
{% elif col_xwidget == 'time-picker' %}
<input type="time"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '00:00' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
{% elif col_xwidget == 'file-upload-single' %}
{% set cell_input_id = field_id ~ '_' ~ item_index ~ '_' ~ col_name %}
<div class="flex items-center gap-1">
{% if col_value %}<img src="/{{ col_value }}" class="w-6 h-6 object-cover rounded flex-shrink-0" onerror="this.style.display='none'">{% endif %}
{% if col_value %}<img src="/{{ col_value }}" alt="" loading="lazy" decoding="async" class="w-6 h-6 object-cover rounded flex-shrink-0" onerror="this.style.display='none'">{% endif %}
<input type="text"
id="{{ cell_input_id }}"
aria-label="{{ cell_label }}"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
value="{{ col_value if col_value is not none else '' }}"
class="block w-20 px-1 py-1 border border-gray-300 rounded text-xs"
placeholder="path…">
<label class="cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100" title="Upload image">
<i class="fas fa-upload"></i>
<label class="file-trigger cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100" title="Upload image">
<i class="fas fa-upload" aria-hidden="true"></i>
<input type="file"
class="sr-only"
aria-label="Upload image: {{ cell_label }}"
accept="image/png,image/jpeg,image/bmp,image/gif"
style="display:none"
data-plugin-id="{{ plugin_id }}"
data-target-input="{{ cell_input_id }}"
onchange="(function(e){ const t=document.getElementById('{{ cell_input_id }}'); const p=t.previousElementSibling && t.previousElementSibling.tagName==='IMG' ? t.previousElementSibling : null; window.handleArrayTableImageUpload(e,t,p,'{{ plugin_id }}'); })(event)">
@@ -709,6 +760,7 @@
{% else %}
<input type="text"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
{% if col_def.get('description') %}placeholder="{{ col_def.get('description') }}"{% endif %}
@@ -725,15 +777,17 @@
<td class="px-3 py-3 whitespace-nowrap text-center" style="min-width:90px;vertical-align:middle">
<button type="button"
onclick="removeArrayTableRow(this)"
aria-label="Remove row {{ item_index + 1 }}"
class="text-red-600 hover:text-red-800 px-2 py-1">
<i class="fas fa-trash"></i>
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
{% if has_advanced.value %}
<button type="button"
onclick="openArrayTableRowEditor(this)"
class="text-blue-500 hover:text-blue-700 px-2 py-1 ml-1"
aria-label="Edit advanced settings for row {{ item_index + 1 }}"
title="Edit layout, style and other advanced properties">
<i class="fas fa-sliders-h"></i>
<i class="fas fa-sliders-h" aria-hidden="true"></i>
</button>
{% endif %}
</td>
@@ -804,9 +858,10 @@
value="{{ hex_val }}"
class="h-9 w-12 cursor-pointer rounded border border-gray-300"
title="Color picker"
aria-label="{{ label }} color picker"
oninput="(function(h){var r=parseInt(h.slice(1,3),16),g=parseInt(h.slice(3,5),16),b=parseInt(h.slice(5,7),16);document.getElementById('{{ field_id }}_r').value=r;document.getElementById('{{ field_id }}_g').value=g;document.getElementById('{{ field_id }}_b').value=b;})(this.value)">
<div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">R</label>
<label for="{{ field_id }}_r" class="text-xs text-gray-500 font-medium"><span aria-hidden="true">R</span><span class="sr-only">Red</span></label>
<input type="number" min="0" max="255" step="1"
id="{{ field_id }}_r"
name="{{ full_key }}.0"
@@ -815,7 +870,7 @@
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
</div>
<div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">G</label>
<label for="{{ field_id }}_g" class="text-xs text-gray-500 font-medium"><span aria-hidden="true">G</span><span class="sr-only">Green</span></label>
<input type="number" min="0" max="255" step="1"
id="{{ field_id }}_g"
name="{{ full_key }}.1"
@@ -824,7 +879,7 @@
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
</div>
<div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">B</label>
<label for="{{ field_id }}_b" class="text-xs text-gray-500 font-medium"><span aria-hidden="true">B</span><span class="sr-only">Blue</span></label>
<input type="number" min="0" max="255" step="1"
id="{{ field_id }}_b"
name="{{ full_key }}.2"
@@ -1235,20 +1290,21 @@
{% for key, value in config.items() %}
{% if key not in ['enabled'] %}
<div class="form-group mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">
{% set fallback_id = 'fallback-field-' ~ (key|string|replace(' ', '-')) %}
<label for="{{ fallback_id }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ key|replace('_', ' ')|title }}
</label>
{% if value is sameas true or value is sameas false %}
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="{{ key }}" {% if value %}checked{% endif %}
<input type="checkbox" id="{{ fallback_id }}" name="{{ key }}" {% if value %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm text-gray-600">Enabled</span>
</label>
{% elif value is number %}
<input type="number" name="{{ key }}" value="{{ value }}"
<input type="number" id="{{ fallback_id }}" name="{{ key }}" value="{{ value }}"
class="form-input w-full rounded-md border-gray-300 bg-gray-900 text-white placeholder:text-gray-400">
{% else %}
<input type="text" name="{{ key }}" value="{{ value }}"
<input type="text" id="{{ fallback_id }}" name="{{ key }}" value="{{ value }}"
class="form-input w-full rounded-md border-gray-300 bg-gray-900 text-white placeholder:text-gray-400">
{% endif %}
</div>
@@ -6,7 +6,7 @@
<!-- Plugin Controls -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4">
<div class="flex flex-wrap items-center gap-2 sm:gap-4">
<button type="button" id="refresh-plugins-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
<i class="fas fa-sync-alt mr-2"></i>Refresh Plugins
</button>
@@ -34,7 +34,7 @@
<div id="installed-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Search -->
<div class="relative flex-1 min-w-[12rem]">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none" aria-hidden="true"></i>
<input type="text"
id="installed-search"
class="form-control text-sm pl-9 pr-8 py-2"
@@ -44,7 +44,7 @@
<button type="button" id="installed-search-clear"
class="hidden absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Clear search">
<i class="fas fa-times-circle"></i>
<i class="fas fa-times-circle" aria-hidden="true"></i>
</button>
</div>
@@ -149,7 +149,7 @@
<!-- GitHub Token Configuration (Combined Warning + Settings) -->
<div id="github-token-container" class="mb-5">
<!-- Warning Banner (shown when no token configured) -->
<div id="github-auth-warning" class="hidden bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-r-lg">
<div id="github-auth-warning" class="hidden bg-yellow-50 border border-yellow-300 p-4 rounded-lg">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-yellow-400"></i>
@@ -170,8 +170,8 @@
</p>
</div>
<div class="flex-shrink-0">
<button onclick="dismissGithubWarning()" class="text-yellow-700 hover:text-yellow-900">
<i class="fas fa-times"></i>
<button type="button" onclick="dismissGithubWarning()" class="text-yellow-700 hover:text-yellow-900" aria-label="Dismiss GitHub token warning">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
</div>
@@ -207,9 +207,10 @@
<input type="password" id="github-token-input"
class="w-full px-3 py-2 pr-20 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx">
<button type="button" onclick="toggleGithubTokenVisibility()"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700">
<i id="github-token-icon" class="fas fa-eye"></i>
<button type="button" onclick="toggleGithubTokenVisibility()"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700"
aria-label="Show token" aria-controls="github-token-input">
<i id="github-token-icon" class="fas fa-eye" aria-hidden="true"></i>
</button>
</div>
<p class="text-xs text-gray-500 mt-1">
@@ -246,8 +247,8 @@
</div>
<!-- Search Row -->
<div class="flex gap-3 mb-4">
<input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="plugin-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." aria-label="Search the Plugin Store" class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="plugin-category" aria-label="Filter the Plugin Store by category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<option value="">All Categories</option>
<option value="sports">Sports</option>
<option value="content">Content</option>
@@ -262,7 +263,7 @@
<!-- Sort & Filter Bar -->
<div id="store-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Sort -->
<select id="store-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<select id="store-sort" aria-label="Sort the Plugin Store" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="a-z">A → Z</option>
<option value="z-a">Z → A</option>
<option value="category">Category</option>
@@ -289,13 +290,13 @@
<!-- Results Bar (top pagination) -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<span id="store-results-info" class="text-sm text-gray-600"></span>
<div class="flex items-center gap-3">
<select id="store-per-page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<div class="flex flex-wrap items-center gap-3">
<select id="store-per-page" aria-label="Plugins per page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<option value="12">12 per page</option>
<option value="24">24 per page</option>
<option value="48">48 per page</option>
</select>
<div id="store-pagination-top" class="flex items-center gap-1"></div>
<div id="store-pagination-top" class="flex flex-wrap items-center gap-1"></div>
</div>
</div>
@@ -355,8 +356,8 @@
<!-- Search Row -->
<div class="flex gap-3 mb-4">
<input type="text" id="starlark-search" placeholder="Search by name, description, author..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="starlark-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<input type="text" id="starlark-search" placeholder="Search by name, description, author..." aria-label="Search Starlark apps" class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="starlark-category" aria-label="Filter Starlark apps by category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<option value="">All Categories</option>
</select>
</div>
@@ -364,7 +365,7 @@
<!-- Sort & Filter Bar -->
<div id="starlark-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Sort -->
<select id="starlark-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<select id="starlark-sort" aria-label="Sort Starlark apps" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="a-z">A → Z</option>
<option value="z-a">Z → A</option>
<option value="category">Category</option>
@@ -379,7 +380,7 @@
</button>
<!-- Author filter -->
<select id="starlark-filter-author" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<select id="starlark-filter-author" aria-label="Filter Starlark apps by author" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="">All Authors</option>
</select>
@@ -395,13 +396,13 @@
<!-- Results Bar (top pagination) -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<span id="starlark-results-info" class="text-sm text-gray-600"></span>
<div class="flex items-center gap-3">
<select id="starlark-per-page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<div class="flex flex-wrap items-center gap-3">
<select id="starlark-per-page" aria-label="Starlark apps per page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<option value="24">24 per page</option>
<option value="48">48 per page</option>
<option value="96">96 per page</option>
</select>
<div id="starlark-pagination-top" class="flex items-center gap-1"></div>
<div id="starlark-pagination-top" class="flex flex-wrap items-center gap-1"></div>
</div>
</div>
@@ -463,7 +464,7 @@
<p class="text-xs text-gray-600 mb-3">Install a plugin directly from its GitHub repository URL</p>
<div class="space-y-2">
<div class="flex gap-2">
<input type="text" id="github-plugin-url"
<input type="text" id="github-plugin-url" aria-label="Plugin GitHub repository URL"
placeholder="https://github.com/user/ledmatrix-plugin-name"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<button type="button" id="install-plugin-from-url"
@@ -491,7 +492,7 @@
</h4>
<p class="text-xs text-gray-600 mb-3">Load a registry-style monorepo (like the official ledmatrix-plugins repo) to browse and install plugins</p>
<div class="flex gap-2 mb-2">
<input type="text" id="github-registry-url"
<input type="text" id="github-registry-url" aria-label="Plugin registry GitHub URL"
placeholder="https://github.com/user/ledmatrix-plugins"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<button id="load-registry-from-url" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm rounded-md whitespace-nowrap">
@@ -4,7 +4,7 @@
<div class="border-b border-gray-200 pb-4 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">config.json Editor</h2>
<h2 id="main-config-editor-title" class="text-lg font-semibold text-gray-900">config.json Editor</h2>
<p class="mt-1 text-sm text-gray-600">{{ main_config_path }}</p>
</div>
<div class="flex gap-2">
@@ -28,7 +28,7 @@
</div>
<div class="relative">
<textarea id="main-config-editor"
<textarea id="main-config-editor" aria-labelledby="main-config-editor-title"
class="w-full h-96 font-mono text-sm p-4 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
spellcheck="false">{{ main_config_json }}</textarea>
<div id="main-config-validation" class="mt-2 text-sm"></div>
@@ -55,7 +55,7 @@
<div class="border-b border-gray-200 pb-4 mb-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">config_secrets.json Editor</h2>
<h2 id="secrets-config-editor-title" class="text-lg font-semibold text-gray-900">config_secrets.json Editor</h2>
<p class="mt-1 text-sm text-gray-600">{{ secrets_config_path }}</p>
</div>
<div class="flex gap-2">
@@ -79,7 +79,7 @@
</div>
<div class="relative">
<textarea id="secrets-config-editor"
<textarea id="secrets-config-editor" aria-labelledby="secrets-config-editor-title"
class="w-full h-96 font-mono text-sm p-4 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
spellcheck="false">{{ secrets_config_json }}</textarea>
<div id="secrets-config-validation" class="mt-2 text-sm"></div>
@@ -1,3 +1,6 @@
{# Prefix for label/input id pairs; app ids and field ids are sanitised so
the ids stay valid and unique if several app configs are on the page. #}
{% set sl_prefix = ('starlark-' ~ app_id)|replace(':', '-')|replace('.', '-')|replace(' ', '-')|replace('/', '-') %}
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between pb-4 border-b border-gray-200">
@@ -59,16 +62,18 @@
<div id="starlark-config-form" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">Render Interval (seconds)</label>
<label for="{{ sl_prefix }}-render-interval" class="block text-sm font-medium text-gray-700 mb-1">Render Interval (seconds)</label>
<input type="number" min="10" max="86400" step="1"
id="{{ sl_prefix }}-render-interval"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ render_interval }}"
data-starlark-config="render_interval">
<p class="text-xs text-gray-400 mt-1">How often the app re-renders (fetches new data)</p>
</div>
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
<label for="{{ sl_prefix }}-display-duration" class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
<input type="number" min="1" max="3600" step="1"
id="{{ sl_prefix }}-display-duration"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ display_duration }}"
data-starlark-config="display_duration">
@@ -98,12 +103,14 @@
{% set field_desc = field.desc or '' %}
{% set field_default = field.default if field.default is defined else '' %}
{% set current_val = config.get(field_id, field_default) if config else field_default %}
{% set input_id = (sl_prefix ~ '-f-' ~ field_id)|replace(' ', '-')|replace('.', '-')|replace(':', '-')|replace('/', '-') %}
{# ── text ── #}
{% if field_type == 'text' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
placeholder="{{ field_desc }}"
@@ -116,8 +123,8 @@
{# ── dropdown ── #}
{% elif field_type == 'dropdown' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<select class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white"
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<select id="{{ input_id }}" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white"
data-starlark-config="{{ field_id }}">
{% for opt in (field.options or []) %}
<option value="{{ opt.value }}" {{ 'selected' if current_val|string == opt.value|string else '' }}>
@@ -149,14 +156,16 @@
{# ── color ── #}
{% elif field_type == 'color' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="flex items-center gap-2">
<input type="color"
aria-label="{{ field_name }} color picker"
class="w-10 h-10 rounded border border-gray-300 cursor-pointer p-0.5"
value="{{ current_val or '#FFFFFF' }}"
data-starlark-color-picker="{{ field_id }}"
oninput="this.closest('.space-y-6').querySelector('[data-starlark-config={{ field_id }}]').value = this.value">
<input type="text"
id="{{ input_id }}"
class="form-control flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm font-mono"
value="{{ current_val or '#FFFFFF' }}"
placeholder="#RRGGBB"
@@ -171,8 +180,9 @@
{# ── datetime ── #}
{% elif field_type == 'datetime' %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="datetime-local"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
data-starlark-config="{{ field_id }}">
@@ -184,10 +194,11 @@
{# ── location (mini-form) ── #}
{% elif field_type == 'location' %}
<div class="form-group" data-starlark-location-group="{{ field_id }}" data-starlark-location-value="{{ current_val }}">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<label id="{{ input_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2" role="group" aria-labelledby="{{ input_id }}-label">
<div>
<input type="number" step="any" min="-90" max="90"
aria-label="{{ field_name }} latitude"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Latitude"
data-starlark-location-field="{{ field_id }}"
@@ -195,6 +206,7 @@
</div>
<div>
<input type="number" step="any" min="-180" max="180"
aria-label="{{ field_name }} longitude"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Longitude"
data-starlark-location-field="{{ field_id }}"
@@ -204,6 +216,7 @@
<input type="text"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Timezone (e.g. America/New_York)"
aria-label="{{ field_name }} timezone"
data-starlark-location-field="{{ field_id }}"
data-starlark-location-key="timezone">
</div>
@@ -243,8 +256,9 @@
{# ── typeahead / location_based (text fallback with note) ── #}
{% elif field_type in ('typeahead', 'location_based') %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
placeholder="{{ field_desc }}"
@@ -261,8 +275,9 @@
{# ── unknown type (text fallback) ── #}
{% else %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}"
placeholder="{{ field_desc }}"
@@ -287,8 +302,9 @@
{% for key, value in config.items() %}
{% if key not in ('render_interval', 'display_duration') and key not in schema_ids %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }} <span class="text-xs text-gray-400">(custom)</span></label>
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
{% set custom_id = (sl_prefix ~ '-c-' ~ key)|replace(' ', '-')|replace('.', '-')|replace(':', '-')|replace('/', '-') %}
<label for="{{ custom_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ key }} <span class="text-xs text-gray-400">(custom)</span></label>
<input type="text" id="{{ custom_id }}" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ value }}"
data-starlark-config="{{ key }}">
</div>
@@ -303,8 +319,9 @@
{% for key, value in config.items() %}
{% if key not in ('render_interval', 'display_duration') %}
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }}</label>
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
{% set raw_id = (sl_prefix ~ '-r-' ~ key)|replace(' ', '-')|replace('.', '-')|replace(':', '-')|replace('/', '-') %}
<label for="{{ raw_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ key }}</label>
<input type="text" id="{{ raw_id }}" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ value }}"
data-starlark-config="{{ key }}">
</div>
+37 -31
View File
@@ -32,7 +32,7 @@
<div class="space-y-4">
<!-- Switch branch -->
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">Branch</p>
<p class="text-xs text-gray-500 mt-0.5">Choose which branch this pi follows. Switching attaches tracking, so Pull Latest works afterwards.</p>
@@ -50,7 +50,7 @@
<div id="result-checkout-branch" class="hidden"></div>
<!-- Pull latest -->
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Pull latest (rebase)</p>
<p class="text-xs text-gray-500 mt-0.5">Stashes any local changes, then runs <code class="bg-gray-100 px-1 rounded">git pull --rebase</code>. The stash is preserved but not re-applied.</p>
@@ -63,7 +63,7 @@
<div id="result-git-pull" class="hidden"></div>
<!-- Force reset -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Force reset to <code class="bg-gray-100 px-1 rounded">origin/main</code></p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">git fetch origin</code> then <code class="bg-gray-100 px-1 rounded">git reset --hard origin/main</code>. Discards all local changes.</p>
@@ -99,7 +99,7 @@
<div class="space-y-4">
<!-- Base requirements -->
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Reinstall base requirements</p>
<p class="text-xs text-gray-500 mt-0.5">Installs from <code class="bg-gray-100 px-1 rounded">requirements.txt</code> in the project root.</p>
@@ -112,7 +112,7 @@
<div id="result-base-reqs" class="hidden"></div>
<!-- Plugin requirements -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Reinstall plugin requirements</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">pip install</code> for every installed plugin that has a <code class="bg-gray-100 px-1 rounded">requirements.txt</code>.</p>
@@ -134,7 +134,7 @@
</div>
<div class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Clear Python cache</p>
<p class="text-xs text-gray-500 mt-0.5">Deletes all <code class="bg-gray-100 px-1 rounded">__pycache__</code> directories in the project. Useful after switching branches or debugging import issues.</p>
@@ -166,14 +166,15 @@
<p class="mt-1 text-sm text-gray-600">Turn the WiFi radio on or off. Full WiFi setup (scan, connect, hotspot) lives on the <span class="font-medium">WiFi</span> tab.</p>
</div>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">WiFi radio</p>
<p id="wifi-radio-label" class="text-sm font-medium text-gray-900">WiFi radio</p>
<p id="wifi-radio-note" class="text-xs text-gray-500 mt-0.5">Checking current state…</p>
</div>
<div class="shrink-0 flex flex-col items-end gap-2">
<!-- Toggle switch -->
<button id="wifi-radio-toggle" type="button" role="switch" aria-checked="false"
aria-labelledby="wifi-radio-label" aria-describedby="wifi-radio-note"
onclick="onWifiRadioToggleClick()" disabled
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-gray-200 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 opacity-60">
<span id="wifi-radio-knob" aria-hidden="true"
@@ -207,7 +208,7 @@
</div>
<div class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Restart display service</p>
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix.service</code>.</p>
@@ -219,7 +220,7 @@
</div>
<div id="result-restart-display" class="hidden"></div>
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Restart web interface</p>
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix-web.service</code>. The page will go offline briefly.</p>
@@ -282,7 +283,7 @@
<div class="space-y-4">
<!-- Reboot -->
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900">Reboot</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo reboot</code>. The Pi will restart and come back online in a minute or two.</p>
@@ -308,7 +309,7 @@
<div id="result-reboot" class="hidden"></div>
<!-- Shutdown -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div>
<p class="text-sm font-medium text-gray-900">Shut down</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo poweroff</code>. The Pi will power off and must be unplugged/replugged (or power-cycled) to turn back on.</p>
@@ -979,26 +980,31 @@
loadGitInfo();
loadBranches();
// Plugin health: initial load + periodic refresh. Guard against duplicate
// timers if this partial is re-swapped in by HTMX; the handler re-resolves
// DOM nodes by id each tick.
refreshPluginHealth(false);
if (!window._pluginHealthTimer) {
window._pluginHealthTimer = setInterval(function () { refreshPluginHealth(true); }, 15000);
// Plugin health (15s) and system diagnostics (10s): the partial stays in
// the DOM (hidden via x-show) when another tab is active, so both polls
// run only while the Tools tab is active and the page is visible — each
// restarts with an immediate refresh. LEDVisibility keys the registration,
// so a partial re-swapped in by HTMX replaces (not stacks) the timers.
function stopToolsPolling() {
if (window._pluginHealthTimer) { clearInterval(window._pluginHealthTimer); window._pluginHealthTimer = null; }
if (window._diagPollInterval) { clearInterval(window._diagPollInterval); window._diagPollInterval = null; }
}
// System diagnostics: load now, then refresh every 10s. Clear any prior
// interval so re-swapping the partial doesn't stack. The recurring poll is
// gated on visibility — the partial stays in the DOM (hidden via x-show)
// when another tab is active, so without this it would keep hitting
// /api/v3/system/status every 10s and churn the Pi while off-screen.
if (window._diagPollInterval) clearInterval(window._diagPollInterval);
window.loadSystemDiagnostics();
window._diagPollInterval = setInterval(function () {
const panel = document.getElementById('diag-panel');
if (!panel || document.hidden || panel.offsetParent === null) return;
function startToolsPolling() {
stopToolsPolling();
refreshPluginHealth(false);
window._pluginHealthTimer = setInterval(function () { refreshPluginHealth(true); }, 15000);
window.loadSystemDiagnostics();
}, 10000);
window._diagPollInterval = setInterval(function () {
const panel = document.getElementById('diag-panel');
if (!panel || document.hidden || panel.offsetParent === null) return;
window.loadSystemDiagnostics();
}, 10000);
}
if (window.LEDVisibility) {
window.LEDVisibility.onActive('tools', startToolsPolling, stopToolsPolling);
} else {
startToolsPolling();
}
// WiFi radio current state.
window.loadWifiRadio();
@@ -1223,7 +1229,7 @@
const active = status.running ? `
<div class="rounded-md border border-amber-300 bg-amber-50 p-3">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-amber-900">
<i class="fas fa-circle-notch fa-spin mr-1"></i>
@@ -159,6 +159,9 @@
</div>
<div class="flex-shrink-0">
<button type="button"
role="switch"
aria-label="Auto-Enable AP Mode"
:aria-checked="status.auto_enable_ap_mode ? 'true' : 'false'"
@click="toggleAutoEnable(!status.auto_enable_ap_mode)"
:class="status.auto_enable_ap_mode
? 'bg-blue-600 hover:bg-blue-700'