mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 09:18:06 +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 {
|
[data-theme="dark"] .power-warning-banner-dismiss {
|
||||||
color: #fca5a5;
|
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 =====
|
// ===== Mobile nav: header-widget relocation =====
|
||||||
// Below the md breakpoint the settings-search box and system-stats block are
|
// 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
|
// MOVED (same DOM nodes, listeners intact) from the header into the nav
|
||||||
|
|||||||
@@ -312,6 +312,17 @@
|
|||||||
if (typeof this.updatePluginTabStates === 'function') {
|
if (typeof this.updatePluginTabStates === 'function') {
|
||||||
this.updatePluginTabStates();
|
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
|
// Trigger content load when tab changes
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.loadTabContent(newTab);
|
this.loadTabContent(newTab);
|
||||||
@@ -321,6 +332,9 @@
|
|||||||
// Load initial tab content
|
// Load initial tab content
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.loadTabContent(this.activeTab);
|
this.loadTabContent(this.activeTab);
|
||||||
|
if (typeof window.updateNavAriaCurrent === 'function') {
|
||||||
|
window.updateNavAriaCurrent(this.activeTab);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for plugin updates from pluginManager
|
// Listen for plugin updates from pluginManager
|
||||||
@@ -1861,6 +1875,14 @@
|
|||||||
const ledCanvas = document.getElementById('ledCanvas');
|
const ledCanvas = document.getElementById('ledCanvas');
|
||||||
const placeholder = document.getElementById('displayPlaceholder');
|
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 (!stage || !img || !placeholder) return; // Not on overview page
|
||||||
|
|
||||||
if (data.image) {
|
if (data.image) {
|
||||||
|
|||||||
@@ -555,6 +555,27 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div> <!-- /#site-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 -->
|
<!-- Tab content -->
|
||||||
<div id="tab-content" class="space-y-6">
|
<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>
|
<p class="mt-1 text-sm text-gray-600">{{ plugin.description or 'Plugin configuration' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-4">
|
<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">
|
<label class="flex items-center cursor-pointer">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
id="plugin-enabled-{{ plugin.id }}"
|
id="plugin-enabled-{{ plugin.id }}"
|
||||||
|
|||||||
Reference in New Issue
Block a user