diff --git a/web_interface/static/v3/app.js b/web_interface/static/v3/app.js index dd17fc7d..f2739ddc 100644 --- a/web_interface/static/v3/app.js +++ b/web_interface/static/v3/app.js @@ -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, diff --git a/web_interface/static/v3/icons/apple-touch-icon.png b/web_interface/static/v3/icons/apple-touch-icon.png new file mode 100644 index 00000000..1099cf8a Binary files /dev/null and b/web_interface/static/v3/icons/apple-touch-icon.png differ diff --git a/web_interface/static/v3/icons/icon-192.png b/web_interface/static/v3/icons/icon-192.png new file mode 100644 index 00000000..144a7e79 Binary files /dev/null and b/web_interface/static/v3/icons/icon-192.png differ diff --git a/web_interface/static/v3/icons/icon-512.png b/web_interface/static/v3/icons/icon-512.png new file mode 100644 index 00000000..3bed5672 Binary files /dev/null and b/web_interface/static/v3/icons/icon-512.png differ diff --git a/web_interface/static/v3/manifest.json b/web_interface/static/v3/manifest.json new file mode 100644 index 00000000..1922cf76 --- /dev/null +++ b/web_interface/static/v3/manifest.json @@ -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" + } + ] +} diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 09000568..89e8b095 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -5,6 +5,12 @@ LED Matrix Control Panel + + + + + + @@ -396,6 +402,36 @@ + + +