mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
feat(web): floating live preview + per-plugin "Preview on display", drawer a11y
Preview-while-configuring: - Floating mini preview (fixed, bottom-right) available on every tab except Overview, fed from the same SSE display stream by updateDisplayPreview - no new connections. Collapses to a round toggle button; open/closed state persists in localStorage; hides on Overview where the full preview lives. - "Preview on display" button on every plugin config page header: runs that plugin on the real display for 60 seconds via the existing /display/on-demand/start API and opens the floating preview, closing the configure -> see-the-result loop. Drawer/nav accessibility: - aria-current="page" tracks the active tab (system + dynamic plugin tabs, matched via their Alpine @click expression), updated from the activeTab watcher so search deep-links and checklist navigation are covered too. - Escape closes the mobile drawer and returns focus to the hamburger; opening the drawer moves focus to its first tab. Validation: all 40 web tests pass; Jinja parse + div balance on both touched templates. 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
23dcdc2d49
commit
e1e26e8eab
@@ -1356,3 +1356,38 @@ button.bg-white {
|
||||
[data-theme="dark"] .power-warning-banner-dismiss {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ===== Floating live preview (all tabs except Overview) ===== */
|
||||
.floating-preview {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 70;
|
||||
background-color: #111827;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
.floating-preview img {
|
||||
background-color: #000;
|
||||
}
|
||||
.floating-preview-toggle {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 70;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--color-primary);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.floating-preview img {
|
||||
width: 192px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +379,104 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Floating live preview =====
|
||||
// A mini preview of the display, available on every tab except Overview
|
||||
// (which has the full-size one). Open/closed state persists per browser;
|
||||
// frames arrive via the existing SSE stream (updateDisplayPreview in
|
||||
// app-shell.js feeds #floating-preview-img).
|
||||
window.toggleFloatingPreview = function(open) {
|
||||
try { localStorage.setItem('ledmatrix-floating-preview', open ? '1' : '0'); } catch { /* no-op */ }
|
||||
window.updateFloatingPreviewVisibility();
|
||||
};
|
||||
|
||||
window.updateFloatingPreviewVisibility = function(tab) {
|
||||
const panel = document.getElementById('floating-preview');
|
||||
const toggle = document.getElementById('floating-preview-toggle');
|
||||
if (!panel || !toggle) return;
|
||||
let active = tab;
|
||||
if (!active) {
|
||||
const el = document.querySelector('[x-data]');
|
||||
const data = el && el._x_dataStack && el._x_dataStack[0];
|
||||
active = data && data.activeTab;
|
||||
}
|
||||
const onOverview = active === 'overview';
|
||||
let open = false;
|
||||
try { open = localStorage.getItem('ledmatrix-floating-preview') === '1'; } catch { /* no-op */ }
|
||||
panel.style.display = (!onOverview && open) ? 'block' : 'none';
|
||||
toggle.style.display = (!onOverview && !open) ? 'flex' : 'none';
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.updateFloatingPreviewVisibility();
|
||||
});
|
||||
|
||||
// Run a plugin on the real display for 60s via the existing on-demand API
|
||||
// and open the floating preview so the effect is visible while configuring.
|
||||
window.previewPluginNow = function(pluginId) {
|
||||
fetch('/api/v3/display/on-demand/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plugin_id: pluginId, duration: 60 })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
showNotification(data.message || ('Previewing ' + pluginId + ' for 60 seconds'),
|
||||
data.status || 'success');
|
||||
if (data.status === 'success') window.toggleFloatingPreview(true);
|
||||
})
|
||||
.catch(err => {
|
||||
showNotification('Preview failed: ' + err.message, 'error');
|
||||
});
|
||||
};
|
||||
|
||||
// ===== Nav accessibility =====
|
||||
// aria-current tracks the active tab. Buttons are matched by their Alpine
|
||||
// @click expression ("activeTab = '<tab>'"), which works for both the static
|
||||
// system tabs and the dynamically injected plugin tabs.
|
||||
window.updateNavAriaCurrent = function(tab) {
|
||||
document.querySelectorAll('.nav-tab').forEach(function(btn) {
|
||||
const expr = btn.getAttribute('@click') || btn.getAttribute('x-on:click') || '';
|
||||
const isCurrent = expr.indexOf("activeTab = '" + tab + "'") !== -1;
|
||||
if (isCurrent) {
|
||||
btn.setAttribute('aria-current', 'page');
|
||||
} else {
|
||||
btn.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Escape closes the mobile nav drawer and returns focus to the hamburger;
|
||||
// opening the drawer moves focus to its first tab.
|
||||
(function() {
|
||||
function appData() {
|
||||
const el = document.querySelector('[x-data]');
|
||||
return el && el._x_dataStack && el._x_dataStack[0];
|
||||
}
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key !== 'Escape') return;
|
||||
const data = appData();
|
||||
if (data && data.mobileNavOpen) {
|
||||
data.mobileNavOpen = false;
|
||||
const burger = document.querySelector('[aria-controls="site-nav"]');
|
||||
if (burger) burger.focus();
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', function(e) {
|
||||
const burger = e.target && e.target.closest
|
||||
? e.target.closest('[aria-controls="site-nav"]') : null;
|
||||
if (!burger) return;
|
||||
// The click handler toggles mobileNavOpen; focus the first tab once
|
||||
// the drawer has slid in (matches the CSS transition timing).
|
||||
setTimeout(function() {
|
||||
const data = appData();
|
||||
if (data && data.mobileNavOpen) {
|
||||
const first = document.querySelector('#site-nav .nav-tab');
|
||||
if (first) first.focus();
|
||||
}
|
||||
}, 120);
|
||||
});
|
||||
})();
|
||||
|
||||
// ===== Mobile nav: header-widget relocation =====
|
||||
// Below the md breakpoint the settings-search box and system-stats block are
|
||||
// MOVED (same DOM nodes, listeners intact) from the header into the nav
|
||||
|
||||
@@ -312,6 +312,17 @@
|
||||
if (typeof this.updatePluginTabStates === 'function') {
|
||||
this.updatePluginTabStates();
|
||||
}
|
||||
// Screen readers announce the current tab (covers every
|
||||
// path that changes tabs: clicks, search deep links,
|
||||
// the getting-started checklist)
|
||||
if (typeof window.updateNavAriaCurrent === 'function') {
|
||||
window.updateNavAriaCurrent(newTab);
|
||||
}
|
||||
// Floating preview hides on Overview (full preview
|
||||
// there), reappears per its saved state elsewhere
|
||||
if (typeof window.updateFloatingPreviewVisibility === 'function') {
|
||||
window.updateFloatingPreviewVisibility(newTab);
|
||||
}
|
||||
// Trigger content load when tab changes
|
||||
this.$nextTick(() => {
|
||||
this.loadTabContent(newTab);
|
||||
@@ -321,6 +332,9 @@
|
||||
// Load initial tab content
|
||||
this.$nextTick(() => {
|
||||
this.loadTabContent(this.activeTab);
|
||||
if (typeof window.updateNavAriaCurrent === 'function') {
|
||||
window.updateNavAriaCurrent(this.activeTab);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for plugin updates from pluginManager
|
||||
@@ -1861,6 +1875,14 @@
|
||||
const ledCanvas = document.getElementById('ledCanvas');
|
||||
const placeholder = document.getElementById('displayPlaceholder');
|
||||
|
||||
// Feed the floating mini preview (lives in base.html, present on
|
||||
// every tab) before the overview-only guard below.
|
||||
const floatImg = document.getElementById('floating-preview-img');
|
||||
const floatPanel = document.getElementById('floating-preview');
|
||||
if (floatImg && floatPanel && floatPanel.style.display !== 'none' && data.image) {
|
||||
floatImg.src = `data:image/png;base64,${data.image}`;
|
||||
}
|
||||
|
||||
if (!stage || !img || !placeholder) return; // Not on overview page
|
||||
|
||||
if (data.image) {
|
||||
|
||||
@@ -555,6 +555,27 @@
|
||||
</nav>
|
||||
</div> <!-- /#site-nav -->
|
||||
|
||||
<!-- Floating mini display preview: available on every tab except
|
||||
Overview (which has the full preview). Fed from the same SSE
|
||||
stream by updateDisplayPreview in app-shell.js; open/closed state
|
||||
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>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
<img id="floating-preview-img" alt="Live display preview"
|
||||
style="image-rendering: pixelated; display: block; width: 256px; height: auto;">
|
||||
</div>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
|
||||
<!-- Tab content -->
|
||||
<div id="tab-content" class="space-y-6">
|
||||
|
||||
@@ -893,6 +893,12 @@
|
||||
<p class="mt-1 text-sm text-gray-600">{{ plugin.description or 'Plugin configuration' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button type="button"
|
||||
onclick="window.previewPluginNow('{{ plugin.id }}')"
|
||||
class="btn bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 text-sm rounded-md"
|
||||
title="Run this plugin on the display for 60 seconds and open the live preview">
|
||||
<i class="fas fa-play mr-1"></i>Preview on display
|
||||
</button>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<input type="checkbox"
|
||||
id="plugin-enabled-{{ plugin.id }}"
|
||||
|
||||
Reference in New Issue
Block a user