mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
feat(web): restart-pending banner, unsaved-changes guard, installable web app
Three usability improvements from live testing feedback: - Restart-pending banner: every successful POST to /api/v3/config/main (display hardware, rotation/durations, general settings) now raises a persistent banner - "Configuration saved, restart the display to apply" - with a Restart Now button that posts restart_display_service directly. Backed by sessionStorage so it survives tab switches and reloads until restarted or dismissed. Plugin config saves are deliberately excluded: they apply live via the display process's config watcher. - Unsaved-changes guard: plugin config panels are Alpine x-if templates, so navigating away destroys the panel and revisiting re-fetches it - edits were silently discarded. Forms now mark themselves dirty on input (cleared on successful submit), a capture-phase click handler confirms before a lossy tab switch, and beforeunload guards full page unloads. System tabs (x-show, persistent DOM) are exempt - no false prompts. - Installable web app: manifest.json (standalone display, dark theme) + generated LED-grid icons (192/512 maskable + 180 apple-touch), linked from base.html. "Add to Home Screen" now yields an app-like fullscreen experience; no service worker, so zero behavioral risk. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
05aee74147
commit
69863f70cd
@@ -49,6 +49,111 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Main-config saves (display hardware, rotation/durations, general) only
|
||||
// take effect after a display-service restart — surface the reminder
|
||||
// banner. Plugin config saves apply live and are deliberately excluded.
|
||||
try {
|
||||
const cfg = event.detail.requestConfig;
|
||||
if (cfg && cfg.verb === 'post' &&
|
||||
(cfg.path || '').includes('/api/v3/config/main') &&
|
||||
response && response.status >= 200 && response.status < 300) {
|
||||
window.showRestartPending();
|
||||
}
|
||||
} catch { /* banner is best-effort */ }
|
||||
});
|
||||
|
||||
// ===== Unsaved-changes guard =====
|
||||
// Plugin config panels are Alpine x-if templates: navigating away DESTROYS
|
||||
// the panel and revisiting re-fetches it, silently discarding any edits.
|
||||
// (System tabs use x-show + data-loaded and persist, so they're exempt.)
|
||||
// Track dirty forms and confirm before a lossy navigation.
|
||||
(function() {
|
||||
function markDirty(e) {
|
||||
const form = e.target && e.target.closest ? e.target.closest('form') : null;
|
||||
if (form) form.setAttribute('data-dirty', '');
|
||||
}
|
||||
document.body.addEventListener('input', markDirty);
|
||||
document.body.addEventListener('change', markDirty);
|
||||
|
||||
// A successful submit makes the form clean again
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
const xhr = event.detail.xhr;
|
||||
const form = event.detail.elt && event.detail.elt.closest ? event.detail.elt.closest('form') : null;
|
||||
if (form && xhr && xhr.status >= 200 && xhr.status < 300) {
|
||||
form.removeAttribute('data-dirty');
|
||||
}
|
||||
});
|
||||
|
||||
// Capture phase so this runs before Alpine's bubbling @click switches tabs
|
||||
document.addEventListener('click', function(e) {
|
||||
const tabBtn = e.target && e.target.closest ? e.target.closest('.nav-tab') : null;
|
||||
if (!tabBtn) return;
|
||||
const lossy = Array.prototype.filter.call(
|
||||
document.querySelectorAll('.plugin-config-tab form[data-dirty]'),
|
||||
function(f) { return f.offsetParent !== null; }
|
||||
);
|
||||
if (lossy.length === 0) return;
|
||||
if (!window.confirm('You have unsaved plugin settings — leaving this page will discard them. Leave anyway?')) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Full page unload loses every panel's edits
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
const dirty = Array.prototype.some.call(
|
||||
document.querySelectorAll('form[data-dirty]'),
|
||||
function(f) { return f.offsetParent !== null; }
|
||||
);
|
||||
if (dirty) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// ===== Restart-pending banner =====
|
||||
// Shown after restart-requiring saves; persists across tab switches (and
|
||||
// reloads, via sessionStorage) until the display restarts or it's dismissed.
|
||||
window.showRestartPending = function() {
|
||||
try { sessionStorage.setItem('ledmatrix-restart-pending', '1'); } catch { /* private browsing */ }
|
||||
const banner = document.getElementById('restart-pending-banner');
|
||||
if (banner) banner.style.display = 'block';
|
||||
};
|
||||
|
||||
window.dismissRestartPending = function() {
|
||||
try { sessionStorage.removeItem('ledmatrix-restart-pending'); } catch { /* no-op */ }
|
||||
const banner = document.getElementById('restart-pending-banner');
|
||||
if (banner) banner.style.display = 'none';
|
||||
};
|
||||
|
||||
window.restartPendingNow = function() {
|
||||
const btn = document.getElementById('restart-pending-btn');
|
||||
if (btn) btn.disabled = true;
|
||||
fetch('/api/v3/system/action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'restart_display_service' })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showNotification(data.message || 'Display restarting…', data.status || 'success');
|
||||
window.dismissRestartPending();
|
||||
})
|
||||
.catch(err => {
|
||||
showNotification('Error restarting display: ' + err.message, 'error');
|
||||
})
|
||||
.finally(() => { if (btn) btn.disabled = false; });
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
try {
|
||||
if (sessionStorage.getItem('ledmatrix-restart-pending') === '1') {
|
||||
const banner = document.getElementById('restart-pending-banner');
|
||||
if (banner) banner.style.display = 'block';
|
||||
}
|
||||
} catch { /* no-op */ }
|
||||
});
|
||||
|
||||
// SSE reconnection helper — closes and reopens both SSE streams,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "LED Matrix Control",
|
||||
"short_name": "LEDMatrix",
|
||||
"description": "Control panel for the LEDMatrix display",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#111827",
|
||||
"theme_color": "#111827",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/v3/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/v3/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LED Matrix Control Panel</title>
|
||||
|
||||
<!-- Installable web app (Add to Home Screen) -->
|
||||
<link rel="manifest" href="{{ url_for('static', filename='v3/manifest.json') }}">
|
||||
<meta name="theme-color" content="#111827">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='v3/icons/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='v3/icons/icon-192.png') }}">
|
||||
|
||||
<!-- Debug logging gate: verbose console output is suppressed unless
|
||||
localStorage.pluginDebug === 'true' (the same switch the plugin
|
||||
scripts already use). console.error/warn are never gated. -->
|
||||
@@ -396,6 +402,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart-pending banner: shown after a main-config save (display
|
||||
hardware, rotation order, durations, general settings) — those only
|
||||
take effect after the display service restarts. Plugin config saves
|
||||
apply live and don't trigger this. Wired in app.js. -->
|
||||
<div id="restart-pending-banner" style="display:none"
|
||||
class="update-banner border-b transition-all duration-300 ease-in-out">
|
||||
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fas fa-rotate text-lg"></i>
|
||||
<span class="text-sm font-medium" aria-live="polite">
|
||||
Configuration saved — restart the display to apply the changes
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button onclick="window.restartPendingNow()" id="restart-pending-btn"
|
||||
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
|
||||
update-banner-action transition-colors duration-150">
|
||||
<i class="fas fa-power-off mr-1"></i> Restart Now
|
||||
</button>
|
||||
<button type="button" onclick="window.dismissRestartPending()"
|
||||
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
|
||||
title="Dismiss" aria-label="Dismiss restart reminder">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Under-voltage / throttling warning banner -->
|
||||
<div id="power-warning-banner" style="display:none"
|
||||
class="power-warning-banner border-b transition-all duration-300 ease-in-out">
|
||||
|
||||
Reference in New Issue
Block a user