mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 01:08: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
|
// 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,
|
// 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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>LED Matrix Control Panel</title>
|
<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
|
<!-- Debug logging gate: verbose console output is suppressed unless
|
||||||
localStorage.pluginDebug === 'true' (the same switch the plugin
|
localStorage.pluginDebug === 'true' (the same switch the plugin
|
||||||
scripts already use). console.error/warn are never gated. -->
|
scripts already use). console.error/warn are never gated. -->
|
||||||
@@ -396,6 +402,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Restart-pending banner: shown after a main-config save (display
|
||||||
|
hardware, rotation order, durations, general settings) — those only
|
||||||
|
take effect after the display service restarts. Plugin config saves
|
||||||
|
apply live and don't trigger this. Wired in app.js. -->
|
||||||
|
<div id="restart-pending-banner" style="display:none"
|
||||||
|
class="update-banner border-b transition-all duration-300 ease-in-out">
|
||||||
|
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<i class="fas fa-rotate text-lg"></i>
|
||||||
|
<span class="text-sm font-medium" aria-live="polite">
|
||||||
|
Configuration saved — restart the display to apply the changes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<button onclick="window.restartPendingNow()" id="restart-pending-btn"
|
||||||
|
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
|
||||||
|
update-banner-action transition-colors duration-150">
|
||||||
|
<i class="fas fa-power-off mr-1"></i> Restart Now
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="window.dismissRestartPending()"
|
||||||
|
class="update-banner-dismiss rounded p-1 transition-colors duration-150"
|
||||||
|
title="Dismiss" aria-label="Dismiss restart reminder">
|
||||||
|
<i class="fas fa-times text-sm"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Under-voltage / throttling warning banner -->
|
<!-- Under-voltage / throttling warning banner -->
|
||||||
<div id="power-warning-banner" style="display:none"
|
<div id="power-warning-banner" style="display:none"
|
||||||
class="power-warning-banner border-b transition-all duration-300 ease-in-out">
|
class="power-warning-banner border-b transition-all duration-300 ease-in-out">
|
||||||
|
|||||||
Reference in New Issue
Block a user