mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
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:
@@ -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 — 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 & Restore
|
||||
<i class="fas fa-save" aria-hidden="true"></i>Backup & Restore
|
||||
</button>
|
||||
<button @click="activeTab = 'fonts'"
|
||||
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
|
||||
class="nav-tab">
|
||||
<i class="fas fa-font"></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>
|
||||
|
||||
@@ -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 — Choose Network</div>
|
||||
<div class="step" aria-hidden="true">Step 1 — 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 — Password</div>
|
||||
<div class="step" aria-hidden="true">Step 2 — 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">✓</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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("'", "'") }}'>
|
||||
</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("'", "'") }}'>
|
||||
</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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user