mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Merge branch 'main' into fix/custom-feeds-logo-upload-contract
Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
This commit is contained in:
@@ -413,6 +413,9 @@ a, button, input, select, textarea {
|
||||
/* Responsive breakpoints */
|
||||
@media (min-width: 640px) {
|
||||
.sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||
.sm\:block { display: block; }
|
||||
.sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.sm\:text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -421,6 +424,8 @@ a, button, input, select, textarea {
|
||||
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.md\:flex { display: flex; }
|
||||
.md\:hidden { display: none; }
|
||||
.md\:block { display: block; }
|
||||
.md\:w-auto { width: auto; }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@@ -431,9 +436,14 @@ a, button, input, select, textarea {
|
||||
.lg\:px-8 { padding-left: 2rem; padding-right: 2rem; }
|
||||
.lg\:gap-x-3 { column-gap: 0.75rem; }
|
||||
.lg\:gap-x-6 { column-gap: 1.5rem; }
|
||||
.lg\:block { display: block; }
|
||||
.lg\:flex { display: flex; }
|
||||
.lg\:w-64 { width: 16rem; }
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.xl\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
@@ -446,6 +456,9 @@ a, button, input, select, textarea {
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.2xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.2xl\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.2xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.2xl\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.2xl\:grid-cols-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
.2xl\:grid-cols-7 { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
@@ -456,6 +469,129 @@ a, button, input, select, textarea {
|
||||
.2xl\:space-x-8 > * + * { margin-left: 2rem; }
|
||||
}
|
||||
|
||||
/* ===== Mobile navigation drawer =====
|
||||
Below md the #site-nav wrapper becomes an off-canvas drawer; at md and up
|
||||
none of these rules apply and the nav renders exactly as before. */
|
||||
@media (max-width: 767.98px) {
|
||||
.site-nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 60;
|
||||
width: min(85vw, 320px);
|
||||
background-color: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.site-nav.open {
|
||||
transform: translateX(0);
|
||||
box-shadow: 0 0 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
/* Tabs become full-width rows with >=44px touch targets */
|
||||
.site-nav .nav-tab {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
.site-nav nav.-mb-px {
|
||||
display: block;
|
||||
}
|
||||
.nav-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 55;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
/* Header widgets relocated into the drawer (see placeHeaderWidgets in
|
||||
app.js). The originals carry `hidden`/breakpoint classes tuned for the
|
||||
header, so re-enable them explicitly in the drawer context. */
|
||||
#drawer-widgets #settings-search-wrap {
|
||||
display: block !important;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
#drawer-widgets #settings-search-wrap input {
|
||||
width: 100%;
|
||||
}
|
||||
#drawer-widgets #settings-search-results {
|
||||
position: static;
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
#drawer-widgets #system-stats {
|
||||
display: flex !important;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* Larger touch targets inside horizontally scrolling tables */
|
||||
.overflow-x-auto table button {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.overflow-x-auto table input:not([type="checkbox"]),
|
||||
.overflow-x-auto table select {
|
||||
min-height: 40px;
|
||||
}
|
||||
.overflow-x-auto table input[type="checkbox"] {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
/* Hard guards: even if mobileNavOpen was left true when the viewport
|
||||
crossed the breakpoint, the drawer/backdrop must render as plain
|
||||
in-flow nav on desktop. */
|
||||
.site-nav {
|
||||
position: static;
|
||||
transform: none;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
border-right: none;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
overflow-y: visible;
|
||||
}
|
||||
.nav-backdrop {
|
||||
display: none !important;
|
||||
}
|
||||
#drawer-widgets {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile modal sizing: every .modal-content dialog fits the viewport with
|
||||
internal scrolling instead of overflowing it. */
|
||||
@media (max-width: 640px) {
|
||||
.modal-content {
|
||||
width: 95vw !important;
|
||||
max-width: 95vw !important;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Edge-fade hint that a container scrolls horizontally (pure CSS,
|
||||
Lea Verou scrolling-shadows technique — backgrounds sit behind content). */
|
||||
.overflow-x-auto {
|
||||
background:
|
||||
linear-gradient(90deg, var(--color-surface) 30%, rgba(255, 255, 255, 0)) left / 24px 100%,
|
||||
linear-gradient(270deg, var(--color-surface) 30%, rgba(255, 255, 255, 0)) right / 24px 100%,
|
||||
radial-gradient(farthest-side at 0 50%, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0)) left / 12px 100%,
|
||||
radial-gradient(farthest-side at 100% 50%, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0)) right / 12px 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
}
|
||||
|
||||
/* HTMX loading states */
|
||||
.htmx-request .loading {
|
||||
display: inline-block;
|
||||
@@ -1220,3 +1356,44 @@ 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;
|
||||
/* Desktop: draggable resize handle (bottom-left visually, since the
|
||||
panel is anchored to the right). Touch devices use the size button. */
|
||||
resize: both;
|
||||
min-width: 160px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
.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) {
|
||||
/* Whatever size is chosen, never wider than the phone viewport */
|
||||
.floating-preview {
|
||||
max-width: calc(100vw - 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx */
|
||||
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx, debugLog */
|
||||
// LED Matrix v3 JavaScript
|
||||
// Additional helpers for HTMX and Alpine.js integration
|
||||
|
||||
@@ -12,8 +12,8 @@ window.showNotification = function(message, type = 'info') {
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
} else {
|
||||
// Fallback notification
|
||||
console.log(`${type}: ${message}`);
|
||||
// Fallback notification — user-facing last resort, so never gated
|
||||
console.info(`${type}: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -246,13 +351,13 @@ window.performanceMonitor = {
|
||||
logMetrics: function() {
|
||||
const metrics = this.getMetrics();
|
||||
console.group('Performance Metrics');
|
||||
console.log('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms');
|
||||
console.log('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms');
|
||||
console.log('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms');
|
||||
console.log('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms');
|
||||
console.log('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB');
|
||||
debugLog('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms');
|
||||
debugLog('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms');
|
||||
debugLog('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms');
|
||||
debugLog('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms');
|
||||
debugLog('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB');
|
||||
if (Object.keys(metrics.measures || {}).length > 0) {
|
||||
console.log('Custom Measures:', metrics.measures);
|
||||
debugLog('Custom Measures:', metrics.measures);
|
||||
}
|
||||
console.groupEnd();
|
||||
}
|
||||
@@ -273,3 +378,170 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 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();
|
||||
};
|
||||
|
||||
// Preset widths the size button cycles through (px). Desktop users can also
|
||||
// drag the panel's native resize handle (CSS resize: both).
|
||||
const FLOATING_PREVIEW_SIZES = [192, 256, 384, 512];
|
||||
|
||||
window.applyFloatingPreviewSize = function() {
|
||||
const panel = document.getElementById('floating-preview');
|
||||
if (!panel) return;
|
||||
let size = 256;
|
||||
try { size = parseInt(localStorage.getItem('ledmatrix-floating-preview-size'), 10) || 256; } catch { /* no-op */ }
|
||||
panel.style.width = size + 'px';
|
||||
// Clear any manual drag-resize height so the image's aspect ratio rules
|
||||
panel.style.height = '';
|
||||
};
|
||||
|
||||
window.cycleFloatingPreviewSize = function() {
|
||||
let size = 256;
|
||||
try { size = parseInt(localStorage.getItem('ledmatrix-floating-preview-size'), 10) || 256; } catch { /* no-op */ }
|
||||
const idx = FLOATING_PREVIEW_SIZES.indexOf(size);
|
||||
const next = FLOATING_PREVIEW_SIZES[(idx + 1) % FLOATING_PREVIEW_SIZES.length];
|
||||
try { localStorage.setItem('ledmatrix-floating-preview-size', String(next)); } catch { /* no-op */ }
|
||||
window.applyFloatingPreviewSize();
|
||||
};
|
||||
|
||||
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="app()"]') || 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 */ }
|
||||
const showPanel = !onOverview && open;
|
||||
panel.style.display = showPanel ? 'block' : 'none';
|
||||
toggle.style.display = (!onOverview && !open) ? 'flex' : 'none';
|
||||
if (showPanel) {
|
||||
window.applyFloatingPreviewSize();
|
||||
// Show the last cached frame immediately — SSE only pushes on
|
||||
// display changes, so a freshly opened panel would otherwise stay
|
||||
// empty until the next change.
|
||||
const img = document.getElementById('floating-preview-img');
|
||||
if (img && !img.src && window._lastPreviewFrame) {
|
||||
img.src = 'data:image/png;base64,' + window._lastPreviewFrame;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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="app()"]') || 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
|
||||
// drawer's #drawer-widgets slot; at md and up they move back. Single-instance
|
||||
// constraint: settings-search.js and the SSE stats updater both look these
|
||||
// elements up by id, so they must never be duplicated.
|
||||
window.placeHeaderWidgets = function() {
|
||||
const drawer = document.getElementById('drawer-widgets');
|
||||
const header = document.getElementById('header-widgets');
|
||||
const search = document.getElementById('settings-search-wrap');
|
||||
const stats = document.getElementById('system-stats');
|
||||
if (!drawer || !header) return;
|
||||
|
||||
const desktop = window.matchMedia('(min-width: 768px)').matches;
|
||||
if (desktop) {
|
||||
// Restore original header order: search before the theme toggle,
|
||||
// stats as the last item.
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (search && search.parentElement !== header) {
|
||||
header.insertBefore(search, themeToggle || null);
|
||||
}
|
||||
if (stats && stats.parentElement !== header) {
|
||||
header.appendChild(stats);
|
||||
}
|
||||
} else {
|
||||
if (search && search.parentElement !== drawer) drawer.appendChild(search);
|
||||
if (stats && stats.parentElement !== drawer) drawer.appendChild(stats);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.placeHeaderWidgets();
|
||||
window.matchMedia('(min-width: 768px)').addEventListener('change', window.placeHeaderWidgets);
|
||||
});
|
||||
|
||||
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,356 @@
|
||||
/* global debugLog */
|
||||
// Early helpers and the app() stub (must run before Alpine init)
|
||||
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
|
||||
// Helper function to get installed plugins with fallback
|
||||
// Must be defined before app() function that uses it
|
||||
async function getInstalledPluginsSafe() {
|
||||
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
|
||||
try {
|
||||
const plugins = await window.PluginAPI.getInstalledPlugins();
|
||||
// Ensure plugins is always an array
|
||||
const pluginsArray = Array.isArray(plugins) ? plugins : [];
|
||||
return { status: 'success', data: { plugins: pluginsArray } };
|
||||
} catch (error) {
|
||||
console.error('Error using PluginAPI.getInstalledPlugins, falling back to direct fetch:', error);
|
||||
// Fall through to direct fetch
|
||||
}
|
||||
}
|
||||
// Fallback to direct fetch if PluginAPI not loaded
|
||||
const response = await fetch('/api/v3/plugins/installed');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Global event listener for pluginsUpdated - works even if Alpine isn't ready yet
|
||||
// This ensures tabs update when plugins_manager.js loads plugins
|
||||
document.addEventListener('pluginsUpdated', function(event) {
|
||||
debugLog('[GLOBAL] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
|
||||
const plugins = event.detail?.plugins || [];
|
||||
|
||||
// Update window.installedPlugins
|
||||
window.installedPlugins = plugins;
|
||||
|
||||
// Try to update Alpine component if it exists (only if using full implementation)
|
||||
if (window.Alpine) {
|
||||
const appElement = document.querySelector('[x-data="app()"]');
|
||||
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
||||
const appComponent = appElement._x_dataStack[0];
|
||||
appComponent.installedPlugins = plugins;
|
||||
// Only call updatePluginTabs if it's the full implementation (has _doUpdatePluginTabs)
|
||||
if (typeof appComponent.updatePluginTabs === 'function' &&
|
||||
appComponent.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
|
||||
debugLog('[GLOBAL] Updating plugin tabs via Alpine component (full implementation)');
|
||||
appComponent.updatePluginTabs();
|
||||
return; // Full implementation handles it, don't do direct update
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only do direct DOM update if full implementation isn't available yet
|
||||
const pluginTabsRow = document.getElementById('plugin-tabs-row');
|
||||
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
|
||||
if (pluginTabsRow && pluginTabsNav && plugins.length > 0) {
|
||||
// Clear existing plugin tabs (except Plugin Manager)
|
||||
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
||||
existingTabs.forEach(tab => { tab.remove(); });
|
||||
|
||||
// Add tabs for each installed plugin
|
||||
plugins.forEach(plugin => {
|
||||
const tabButton = document.createElement('button');
|
||||
tabButton.type = 'button';
|
||||
tabButton.setAttribute('data-plugin-id', plugin.id);
|
||||
tabButton.className = `plugin-tab nav-tab`;
|
||||
tabButton.onclick = function() {
|
||||
// Try to set activeTab via Alpine if available
|
||||
if (window.Alpine) {
|
||||
const appElement = document.querySelector('[x-data="app()"]');
|
||||
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
||||
appElement._x_dataStack[0].activeTab = plugin.id;
|
||||
// Only call updatePluginTabStates if it exists
|
||||
if (typeof appElement._x_dataStack[0].updatePluginTabStates === 'function') {
|
||||
appElement._x_dataStack[0].updatePluginTabStates();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// Built with DOM APIs (no innerHTML): the icon class and
|
||||
// name come from plugin manifests, which are only
|
||||
// semi-trusted input.
|
||||
const tabIcon = document.createElement('i');
|
||||
tabIcon.className = plugin.icon || 'fas fa-puzzle-piece';
|
||||
tabButton.textContent = '';
|
||||
tabButton.appendChild(tabIcon);
|
||||
tabButton.appendChild(document.createTextNode(plugin.name || plugin.id));
|
||||
pluginTabsNav.appendChild(tabButton);
|
||||
});
|
||||
debugLog('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
|
||||
}
|
||||
});
|
||||
|
||||
// Guard flag to prevent duplicate stub-to-full enhancement
|
||||
window._appEnhanced = false;
|
||||
|
||||
// Define app() function early so Alpine can find it when it initializes
|
||||
// This is a complete implementation that will work immediately
|
||||
(function() {
|
||||
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
||||
window.location.hostname.startsWith('192.168.4.');
|
||||
|
||||
// Create the app function - will be enhanced by full implementation later
|
||||
window.app = function() {
|
||||
return {
|
||||
activeTab: isAPMode ? 'wifi' : 'overview',
|
||||
mobileNavOpen: false,
|
||||
installedPlugins: [],
|
||||
|
||||
init() {
|
||||
// Try to enhance immediately with full implementation
|
||||
const tryEnhance = () => {
|
||||
if (window._appEnhanced) return true;
|
||||
if (typeof window.app === 'function') {
|
||||
const fullApp = window.app();
|
||||
// Check if this is the full implementation (has updatePluginTabs with proper implementation)
|
||||
if (fullApp && typeof fullApp.updatePluginTabs === 'function' && fullApp.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
|
||||
window._appEnhanced = true;
|
||||
// Preserve runtime state that should not be reset
|
||||
const preservedPlugins = this.installedPlugins;
|
||||
const preservedTab = this.activeTab;
|
||||
const defaultTab = isAPMode ? 'wifi' : 'overview';
|
||||
const wasInitialized = this._initialized;
|
||||
Object.assign(this, fullApp);
|
||||
// Restore runtime state if non-default
|
||||
if (preservedPlugins && preservedPlugins.length > 0) {
|
||||
this.installedPlugins = preservedPlugins;
|
||||
}
|
||||
if (preservedTab && preservedTab !== defaultTab) {
|
||||
this.activeTab = preservedTab;
|
||||
}
|
||||
if (wasInitialized) {
|
||||
this._initialized = wasInitialized;
|
||||
}
|
||||
// Only call init if not already initialized
|
||||
if (typeof this.init === 'function' && !this._initialized) {
|
||||
this.init();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Set up event listener for pluginsUpdated in stub (only if not already enhanced)
|
||||
// The full implementation will have its own listener, so we only need this for the stub
|
||||
if (!this._pluginsUpdatedListenerSet) {
|
||||
const handlePluginsUpdated = (event) => {
|
||||
debugLog('[STUB] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
|
||||
const plugins = event.detail?.plugins || [];
|
||||
// Only update if we're still in stub mode (not enhanced yet)
|
||||
if (typeof this.updatePluginTabs === 'function' && !this.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
|
||||
this.installedPlugins = plugins;
|
||||
if (this.$nextTick && typeof this.$nextTick === 'function') {
|
||||
this.$nextTick(() => {
|
||||
this.updatePluginTabs();
|
||||
});
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.updatePluginTabs();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('pluginsUpdated', handlePluginsUpdated);
|
||||
this._pluginsUpdatedListenerSet = true;
|
||||
debugLog('[STUB] init: Set up pluginsUpdated event listener');
|
||||
}
|
||||
|
||||
// Try immediately - if full implementation is already loaded, use it right away
|
||||
if (!tryEnhance()) {
|
||||
// Full implementation not ready yet, load plugins directly while waiting
|
||||
this.loadInstalledPluginsDirectly();
|
||||
// Try again very soon to enhance with full implementation
|
||||
setTimeout(tryEnhance, 10);
|
||||
|
||||
// Also set up a periodic check to update tabs if plugins get loaded by plugins_manager.js
|
||||
let retryCount = 0;
|
||||
const maxRetries = 20; // Check for 2 seconds (20 * 100ms)
|
||||
const checkAndUpdateTabs = () => {
|
||||
if (retryCount >= maxRetries) {
|
||||
// Fallback: if plugins_manager.js hasn't loaded after 2 seconds, fetch directly
|
||||
if (!window.installedPlugins || window.installedPlugins.length === 0) {
|
||||
debugLog('[STUB] checkAndUpdateTabs: Fallback - fetching plugins directly after timeout');
|
||||
this.loadInstalledPluginsDirectly();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if plugins are available (either from window or component)
|
||||
const plugins = window.installedPlugins || this.installedPlugins || [];
|
||||
if (plugins.length > 0) {
|
||||
debugLog('[STUB] checkAndUpdateTabs: Found', plugins.length, 'plugins, updating tabs');
|
||||
this.installedPlugins = plugins;
|
||||
if (typeof this.updatePluginTabs === 'function') {
|
||||
this.updatePluginTabs();
|
||||
}
|
||||
} else {
|
||||
retryCount++;
|
||||
setTimeout(checkAndUpdateTabs, 100);
|
||||
}
|
||||
};
|
||||
// Start checking after a short delay
|
||||
setTimeout(checkAndUpdateTabs, 200);
|
||||
} else {
|
||||
// Full implementation loaded, but still set up fallback timer
|
||||
setTimeout(() => {
|
||||
if (!window.installedPlugins || window.installedPlugins.length === 0) {
|
||||
debugLog('[STUB] init: Fallback timer - fetching plugins directly');
|
||||
this.loadInstalledPluginsDirectly();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
|
||||
// Direct plugin loading for stub (before full implementation loads)
|
||||
async loadInstalledPluginsDirectly() {
|
||||
try {
|
||||
debugLog('[STUB] loadInstalledPluginsDirectly: Starting...');
|
||||
// Ensure DOM is ready
|
||||
const ensureDOMReady = () => {
|
||||
return new Promise((resolve) => {
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
// Use requestAnimationFrame to ensure DOM is painted
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(resolve, 50); // Small delay to ensure rendering
|
||||
});
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
await ensureDOMReady();
|
||||
|
||||
const data = await getInstalledPluginsSafe();
|
||||
if (data.status === 'success') {
|
||||
const plugins = data.data.plugins || [];
|
||||
debugLog('[STUB] loadInstalledPluginsDirectly: Loaded', plugins.length, 'plugins');
|
||||
|
||||
// Update both component and window
|
||||
this.installedPlugins = plugins;
|
||||
window.installedPlugins = plugins;
|
||||
|
||||
// Dispatch event so global listener can update tabs
|
||||
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
|
||||
detail: { plugins: plugins }
|
||||
}));
|
||||
debugLog('[STUB] loadInstalledPluginsDirectly: Dispatched pluginsUpdated event');
|
||||
|
||||
// Update tabs if we have the method - use $nextTick if available
|
||||
if (typeof this.updatePluginTabs === 'function') {
|
||||
if (this.$nextTick && typeof this.$nextTick === 'function') {
|
||||
this.$nextTick(() => {
|
||||
this.updatePluginTabs();
|
||||
});
|
||||
} else {
|
||||
// Fallback: wait a bit for DOM
|
||||
setTimeout(() => {
|
||||
this.updatePluginTabs();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('[STUB] loadInstalledPluginsDirectly: Failed to load plugins:', data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[STUB] loadInstalledPluginsDirectly: Error loading plugins:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Stub methods that will be replaced by full implementation
|
||||
loadTabContent: function(tab) {},
|
||||
loadInstalledPlugins: async function() {
|
||||
// Try to use global function if available, otherwise use direct loading
|
||||
if (typeof window.loadInstalledPlugins === 'function') {
|
||||
await window.loadInstalledPlugins();
|
||||
// Update tabs after loading (window.installedPlugins should be set by the global function)
|
||||
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
|
||||
this.installedPlugins = window.installedPlugins;
|
||||
this.updatePluginTabs();
|
||||
}
|
||||
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
|
||||
await window.pluginManager.loadInstalledPlugins();
|
||||
// Update tabs after loading
|
||||
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
|
||||
this.installedPlugins = window.installedPlugins;
|
||||
this.updatePluginTabs();
|
||||
}
|
||||
} else {
|
||||
// Fallback to direct loading (which already calls updatePluginTabs)
|
||||
await this.loadInstalledPluginsDirectly();
|
||||
}
|
||||
},
|
||||
updatePluginTabs: function() {
|
||||
// Basic implementation for stub - will be replaced by full implementation
|
||||
// Debounce to prevent multiple rapid calls
|
||||
if (this._updatePluginTabsTimeout) {
|
||||
clearTimeout(this._updatePluginTabsTimeout);
|
||||
}
|
||||
|
||||
this._updatePluginTabsTimeout = setTimeout(() => {
|
||||
debugLog('[STUB] updatePluginTabs: Executing with', this.installedPlugins?.length || 0, 'plugins');
|
||||
const pluginTabsRow = document.getElementById('plugin-tabs-row');
|
||||
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
|
||||
if (!pluginTabsRow || !pluginTabsNav) {
|
||||
console.warn('[STUB] updatePluginTabs: Plugin tabs container not found');
|
||||
return;
|
||||
}
|
||||
if (!this.installedPlugins || this.installedPlugins.length === 0) {
|
||||
debugLog('[STUB] updatePluginTabs: No plugins to display');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if tabs are already correct by comparing plugin IDs
|
||||
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
||||
const existingIds = Array.from(existingTabs).map(tab => tab.getAttribute('data-plugin-id')).sort().join(',');
|
||||
const currentIds = this.installedPlugins.map(p => p.id).sort().join(',');
|
||||
|
||||
if (existingIds === currentIds && existingTabs.length === this.installedPlugins.length) {
|
||||
debugLog('[STUB] updatePluginTabs: Tabs already match, skipping update');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing plugin tabs (except Plugin Manager)
|
||||
existingTabs.forEach(tab => { tab.remove(); });
|
||||
debugLog('[STUB] updatePluginTabs: Cleared', existingTabs.length, 'existing tabs');
|
||||
|
||||
// Add tabs for each installed plugin
|
||||
this.installedPlugins.forEach(plugin => {
|
||||
const tabButton = document.createElement('button');
|
||||
tabButton.type = 'button';
|
||||
tabButton.setAttribute('data-plugin-id', plugin.id);
|
||||
tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`;
|
||||
tabButton.onclick = () => {
|
||||
this.activeTab = plugin.id;
|
||||
if (typeof this.updatePluginTabStates === 'function') {
|
||||
this.updatePluginTabStates();
|
||||
}
|
||||
};
|
||||
// DOM APIs instead of innerHTML: manifest
|
||||
// icon/name are semi-trusted input.
|
||||
const tabIcon = document.createElement('i');
|
||||
tabIcon.className = plugin.icon || 'fas fa-puzzle-piece';
|
||||
tabButton.textContent = '';
|
||||
tabButton.appendChild(tabIcon);
|
||||
tabButton.appendChild(document.createTextNode(plugin.name || plugin.id));
|
||||
pluginTabsNav.appendChild(tabButton);
|
||||
});
|
||||
debugLog('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
|
||||
}, 100);
|
||||
},
|
||||
showNotification: function(message, type) {},
|
||||
escapeHtml: function(text) { return String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
};
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
/* global debugLog */
|
||||
// HTMX swap/script-execution configuration and section toggle helpers
|
||||
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
|
||||
// Configure HTMX to evaluate scripts in swapped content and fix insertBefore errors
|
||||
(function() {
|
||||
function setupScriptExecution() {
|
||||
if (document.body) {
|
||||
// Fix HTMX insertBefore errors by validating targets before swap
|
||||
document.body.addEventListener('htmx:beforeSwap', function(event) {
|
||||
try {
|
||||
const target = event.detail.target;
|
||||
if (!target) {
|
||||
console.warn('[HTMX] Target is null, skipping swap');
|
||||
event.detail.shouldSwap = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if target is a valid DOM element
|
||||
if (!(target instanceof Element)) {
|
||||
console.warn('[HTMX] Target is not a valid Element, skipping swap');
|
||||
event.detail.shouldSwap = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if target has a parent node (required for insertBefore)
|
||||
if (!target.parentNode) {
|
||||
console.warn('[HTMX] Target has no parent node, skipping swap');
|
||||
event.detail.shouldSwap = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure target is in the DOM
|
||||
if (!document.body.contains(target) && !document.head.contains(target)) {
|
||||
console.warn('[HTMX] Target is not in DOM, skipping swap');
|
||||
event.detail.shouldSwap = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional check: ensure parent is also in DOM
|
||||
if (target.parentNode && !document.body.contains(target.parentNode) && !document.head.contains(target.parentNode)) {
|
||||
console.warn('[HTMX] Target parent is not in DOM, skipping swap');
|
||||
event.detail.shouldSwap = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// All checks passed, allow swap
|
||||
return true;
|
||||
} catch (e) {
|
||||
// If validation fails, cancel swap
|
||||
console.warn('[HTMX] Error validating target:', e);
|
||||
event.detail.shouldSwap = false;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Suppress HTMX insertBefore errors and other noisy errors - they're harmless but noisy
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
console.error = function(...args) {
|
||||
const errorStr = args.join(' ');
|
||||
const errorStack = args.find(arg => arg && typeof arg === 'string' && arg.includes('htmx')) || '';
|
||||
|
||||
// Suppress HTMX insertBefore errors (comprehensive check)
|
||||
// These occur when HTMX tries to swap content but the target element is null
|
||||
// Usually happens due to timing/race conditions and is harmless
|
||||
if (errorStr.includes("insertBefore") ||
|
||||
errorStr.includes("Cannot read properties of null") ||
|
||||
errorStr.includes("reading 'insertBefore'")) {
|
||||
// Check if it's from HTMX by looking at stack trace or error string
|
||||
// Also check the call stack if available
|
||||
const isHtmxError = errorStr.includes('htmx') ||
|
||||
errorStack.includes('htmx') ||
|
||||
args.some(arg => {
|
||||
if (typeof arg === 'string') {
|
||||
return arg.includes('htmx');
|
||||
}
|
||||
// Check error objects for stack traces
|
||||
if (arg && typeof arg === 'object' && arg.stack) {
|
||||
return arg.stack.includes('htmx');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (isHtmxError) {
|
||||
return; // Suppress - this is a harmless HTMX timing/race condition issue
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress script execution errors from malformed HTML
|
||||
if (errorStr.includes("Failed to execute 'appendChild' on 'Node'") ||
|
||||
errorStr.includes("Failed to execute 'insertBefore' on 'Node'")) {
|
||||
if (errorStr.includes('Unexpected token')) {
|
||||
return; // Suppress malformed HTML errors
|
||||
}
|
||||
}
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
|
||||
console.warn = function(...args) {
|
||||
const warnStr = args.join(' ');
|
||||
// Suppress Permissions-Policy warnings (harmless browser warnings)
|
||||
if (warnStr.includes('Permissions-Policy header') ||
|
||||
warnStr.includes('Unrecognized feature') ||
|
||||
warnStr.includes('Origin trial controlled feature') ||
|
||||
warnStr.includes('browsing-topics') ||
|
||||
warnStr.includes('run-ad-auction') ||
|
||||
warnStr.includes('join-ad-interest-group') ||
|
||||
warnStr.includes('private-state-token') ||
|
||||
warnStr.includes('private-aggregation') ||
|
||||
warnStr.includes('attribution-reporting')) {
|
||||
return; // Suppress - these are harmless browser feature warnings
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
// Handle HTMX errors gracefully with detailed logging
|
||||
document.body.addEventListener('htmx:responseError', function(event) {
|
||||
const detail = event.detail;
|
||||
const xhr = detail.xhr;
|
||||
const target = detail.target;
|
||||
|
||||
// Enhanced error logging
|
||||
console.error('HTMX response error:', {
|
||||
status: xhr?.status,
|
||||
statusText: xhr?.statusText,
|
||||
url: xhr?.responseURL,
|
||||
target: target?.id || target?.tagName,
|
||||
responseText: xhr?.responseText
|
||||
});
|
||||
|
||||
// For form submissions, log field names only — values
|
||||
// may contain API keys, passwords, or other secrets
|
||||
// that must never reach the console.
|
||||
if (target && target.tagName === 'FORM') {
|
||||
const formData = new FormData(target);
|
||||
const fieldNames = [];
|
||||
for (const [key] of formData.entries()) {
|
||||
fieldNames.push(key);
|
||||
}
|
||||
console.error('Form fields (values redacted):', fieldNames);
|
||||
|
||||
// Try to parse error response for validation details
|
||||
if (xhr?.responseText) {
|
||||
try {
|
||||
const errorData = JSON.parse(xhr.responseText);
|
||||
console.error('Error details:', {
|
||||
message: errorData.message,
|
||||
details: errorData.details,
|
||||
validation_errors: errorData.validation_errors,
|
||||
context: errorData.context
|
||||
});
|
||||
} catch {
|
||||
console.error('Error response (non-JSON):', xhr.responseText.substring(0, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:swapError', function(event) {
|
||||
// Log but don't break the app
|
||||
console.warn('HTMX swap error:', event.detail);
|
||||
});
|
||||
|
||||
// Execute <script> tags in swapped content ourselves, on
|
||||
// htmx:afterSwap (synchronous, right after the swap) rather
|
||||
// than relying on htmx's own script handling, which runs
|
||||
// during its later "settle" phase (~20ms after swap, per
|
||||
// htmx's defaultSettleDelay). Alpine's MutationObserver
|
||||
// processes newly-inserted x-data elements synchronously
|
||||
// as soon as the swap lands, which is BEFORE htmx's settle
|
||||
// phase - so any partial whose x-data component function
|
||||
// (e.g. wifiSetup()) is defined by an inline <script> in
|
||||
// that same partial would have that script still un-run
|
||||
// when Alpine evaluates x-data, permanently failing with
|
||||
// "wifiSetup is not defined" (Alpine does not retry).
|
||||
// Disable htmx's own native script re-execution so the
|
||||
// same script doesn't also run a second time via settle.
|
||||
if (typeof htmx !== 'undefined' && htmx.config) {
|
||||
htmx.config.allowScriptTags = false;
|
||||
}
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
const target = event.detail && event.detail.target;
|
||||
if (!target || !(target instanceof Element)) return;
|
||||
target.querySelectorAll('script').forEach(function(oldScript) {
|
||||
const newScript = document.createElement('script');
|
||||
for (const attr of oldScript.attributes) {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
newScript.textContent = oldScript.textContent;
|
||||
oldScript.replaceWith(newScript);
|
||||
});
|
||||
});
|
||||
|
||||
// Mark tab containers as loaded once their content settles, so switching
|
||||
// away and back doesn't re-fetch. Scoped to the "loadtab" trigger (tab
|
||||
// containers only) so modals and plugin config panels can still reload.
|
||||
document.body.addEventListener('htmx:afterSettle', function(event) {
|
||||
if (event.detail && event.detail.target) {
|
||||
const target = event.detail.target;
|
||||
const trigger = target.getAttribute('hx-trigger') || '';
|
||||
if (trigger.includes('loadtab')) {
|
||||
target.setAttribute('data-loaded', 'true');
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupScriptExecution);
|
||||
} else {
|
||||
setTimeout(setupScriptExecution, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
setupScriptExecution();
|
||||
|
||||
// Section toggle function - define early so it's available for HTMX-loaded content
|
||||
window.toggleSection = function(sectionId) {
|
||||
const section = document.getElementById(sectionId);
|
||||
const icon = document.getElementById(sectionId + '-icon');
|
||||
if (!section) {
|
||||
console.warn('toggleSection: Could not find section for', sectionId);
|
||||
return;
|
||||
}
|
||||
if (!icon) {
|
||||
console.warn('toggleSection: Could not find icon for', sectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if currently hidden by checking both class and computed display
|
||||
const hasHiddenClass = section.classList.contains('hidden');
|
||||
const computedDisplay = window.getComputedStyle(section).display;
|
||||
const isHidden = hasHiddenClass || computedDisplay === 'none';
|
||||
|
||||
if (isHidden) {
|
||||
// Show the section - remove hidden class and explicitly set display to block
|
||||
section.classList.remove('hidden');
|
||||
section.style.display = 'block';
|
||||
icon.classList.remove('fa-chevron-right');
|
||||
icon.classList.add('fa-chevron-down');
|
||||
} else {
|
||||
// Hide the section - add hidden class and set display to none
|
||||
section.classList.add('hidden');
|
||||
section.style.display = 'none';
|
||||
icon.classList.remove('fa-chevron-down');
|
||||
icon.classList.add('fa-chevron-right');
|
||||
}
|
||||
|
||||
// Keep assistive tech in sync: any toggle button that declares
|
||||
// aria-controls for this section mirrors the expanded state.
|
||||
const controlBtn = document.querySelector(`[aria-controls="${sectionId}"]`);
|
||||
if (controlBtn) {
|
||||
controlBtn.setAttribute('aria-expanded', String(isHidden));
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -173,7 +173,14 @@
|
||||
|
||||
function setActiveTab(tab) {
|
||||
var data = getAppData();
|
||||
if (data) { data.activeTab = tab; return true; }
|
||||
if (data) {
|
||||
data.activeTab = tab;
|
||||
// Navigating from a search result should also dismiss the mobile
|
||||
// nav drawer (harmless no-op on desktop, where the drawer CSS
|
||||
// doesn't apply).
|
||||
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||
removeButton.setAttribute('aria-label', 'Remove feed');
|
||||
removeButton.addEventListener('click', function() {
|
||||
window.removeCustomFeedRow(this);
|
||||
});
|
||||
@@ -333,6 +334,7 @@
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||
removeButton.setAttribute('aria-label', 'Remove feed');
|
||||
removeButton.addEventListener('click', function() {
|
||||
window.removeCustomFeedRow(this);
|
||||
});
|
||||
@@ -410,6 +412,9 @@
|
||||
// carries the result in a top-level "uploaded_files" key, not nested
|
||||
// under "data". file-upload-single.js's working upload flow uses this
|
||||
// same contract.
|
||||
// Backend contract (api_v3.upload_plugin_asset): field must be named
|
||||
// "files" (request.files.getlist('files')), and the response carries
|
||||
// results in a top-level "uploaded_files" key, not nested under "data".
|
||||
formData.append('files', file);
|
||||
formData.append('plugin_id', pluginId);
|
||||
|
||||
@@ -501,8 +506,6 @@
|
||||
// Append container to logoCell
|
||||
logoCell.appendChild(container);
|
||||
}
|
||||
// Allow re-uploading the same file
|
||||
event.target.value = '';
|
||||
} else {
|
||||
const notifyFn = window.showNotification || alert;
|
||||
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
|
||||
@@ -512,6 +515,12 @@
|
||||
console.error('Upload error:', error);
|
||||
const notifyFn = window.showNotification || alert;
|
||||
notifyFn('Upload failed: ' + error.message, 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
// Reset regardless of outcome, so the same file can be re-selected
|
||||
// to retry after a failure (browsers won't fire "change" again
|
||||
// for an input that still holds that exact file).
|
||||
event.target.value = '';
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
|
||||
// Track active notifications
|
||||
let activeNotifications = [];
|
||||
// onAction callbacks for notifications with an inline action button,
|
||||
// keyed by notification id (cleaned up on dismiss). A Map rather than a
|
||||
// plain object so ids can never collide with prototype properties.
|
||||
const actionCallbacks = new Map();
|
||||
let notificationCounter = 0;
|
||||
|
||||
/**
|
||||
@@ -113,6 +117,7 @@
|
||||
|
||||
// Remove from tracking array
|
||||
activeNotifications = activeNotifications.filter(id => id !== notificationId);
|
||||
actionCallbacks.delete(notificationId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +163,20 @@
|
||||
|
||||
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`;
|
||||
|
||||
// Optional inline action button (e.g. "Restart Now" on a restart nudge).
|
||||
// The callback is stored by id and invoked via triggerAction, which
|
||||
// also dismisses the notification.
|
||||
if (options.actionLabel && typeof options.onAction === 'function') {
|
||||
actionCallbacks.set(notificationId, options.onAction);
|
||||
html += `
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')"
|
||||
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150">
|
||||
${escapeHtml(options.actionLabel)}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
if (dismissible) {
|
||||
html += `
|
||||
<button type="button"
|
||||
@@ -227,6 +246,17 @@
|
||||
removeNotification(notificationId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Invoke a notification's onAction callback (see options.actionLabel /
|
||||
* options.onAction on show) and dismiss it.
|
||||
* @param {string} notificationId - Notification ID whose action to run
|
||||
*/
|
||||
triggerAction: function(notificationId) {
|
||||
const cb = actionCallbacks.get(notificationId);
|
||||
removeNotification(notificationId);
|
||||
if (typeof cb === 'function') cb();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all notifications
|
||||
*/
|
||||
@@ -262,9 +292,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Global shorthand function (backwards compatible with existing code)
|
||||
// Global shorthand function (backwards compatible with existing code).
|
||||
// Accepts either the legacy type string or a full options object
|
||||
// ({ type, duration, actionLabel, onAction, ... }).
|
||||
window.showNotification = function(message, type = 'info') {
|
||||
return showNotification(message, { type: type });
|
||||
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
|
||||
};
|
||||
|
||||
// Initialize container on load
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Plugin Order List — shared drag-and-drop reorder list of enabled plugins.
|
||||
*
|
||||
* Factored out of the Vegas Scroll section of display.html so both Vegas mode
|
||||
* and the primary rotation (Durations tab) use one implementation. Renders
|
||||
* one draggable row per enabled plugin into a container and keeps a hidden
|
||||
* input's value in sync as a JSON array of plugin ids in display order.
|
||||
*
|
||||
* Usage:
|
||||
* PluginOrderList.init({
|
||||
* containerId: 'vegas_plugin_order', // rows render here
|
||||
* orderInputId: 'vegas_plugin_order_value', // hidden input, JSON array of ids
|
||||
* excludedInputId: 'vegas_excluded_plugins_value', // optional: adds an
|
||||
* // include-checkbox per row; unchecked ids collect here (JSON array)
|
||||
* showVegasModeBadge: true // optional: Scroll/Fixed/Static badge
|
||||
* });
|
||||
*
|
||||
* The container re-renders from /api/v3/plugins/installed each init; the
|
||||
* hidden input(s) must already hold the saved order/exclusions (JSON).
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const MODE_LABELS = new Map([
|
||||
['scroll', { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }],
|
||||
['fixed', { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }],
|
||||
['static', { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' }]
|
||||
]);
|
||||
|
||||
function init(options) {
|
||||
const container = document.getElementById(options.containerId);
|
||||
const orderInput = document.getElementById(options.orderInputId);
|
||||
const excludedInput = options.excludedInputId ? document.getElementById(options.excludedInputId) : null;
|
||||
if (!container || !orderInput) return;
|
||||
|
||||
function syncInputs() {
|
||||
const order = [];
|
||||
const excluded = [];
|
||||
container.querySelectorAll('.plugin-order-item').forEach(item => {
|
||||
const pluginId = item.dataset.pluginId;
|
||||
order.push(pluginId);
|
||||
const checkbox = item.querySelector('.plugin-order-include');
|
||||
if (checkbox && !checkbox.checked) excluded.push(pluginId);
|
||||
});
|
||||
orderInput.value = JSON.stringify(order);
|
||||
if (excludedInput) excludedInput.value = JSON.stringify(excluded);
|
||||
}
|
||||
|
||||
function setupDragAndDrop() {
|
||||
let draggedItem = null;
|
||||
container.querySelectorAll('.plugin-order-item').forEach(item => {
|
||||
item.addEventListener('dragstart', function(e) {
|
||||
draggedItem = this;
|
||||
this.style.opacity = '0.5';
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
item.addEventListener('dragend', function() {
|
||||
this.style.opacity = '1';
|
||||
draggedItem = null;
|
||||
syncInputs();
|
||||
});
|
||||
item.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const rect = this.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
if (e.clientY < midY) {
|
||||
this.style.borderTop = '2px solid #3b82f6';
|
||||
this.style.borderBottom = '';
|
||||
} else {
|
||||
this.style.borderBottom = '2px solid #3b82f6';
|
||||
this.style.borderTop = '';
|
||||
}
|
||||
});
|
||||
item.addEventListener('dragleave', function() {
|
||||
this.style.borderTop = '';
|
||||
this.style.borderBottom = '';
|
||||
});
|
||||
item.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.borderTop = '';
|
||||
this.style.borderBottom = '';
|
||||
if (draggedItem && draggedItem !== this) {
|
||||
const rect = this.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
if (e.clientY < midY) {
|
||||
container.insertBefore(draggedItem, this);
|
||||
} else {
|
||||
container.insertBefore(draggedItem, this.nextSibling);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fetch('/api/v3/plugins/installed')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const allPlugins = (data.data && data.data.plugins) || data.plugins || [];
|
||||
const plugins = allPlugins.filter(p => p.enabled);
|
||||
if (plugins.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'text-sm text-gray-500 italic';
|
||||
empty.textContent = 'No enabled plugins';
|
||||
container.textContent = '';
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
let currentOrder = [];
|
||||
let excluded = [];
|
||||
try {
|
||||
currentOrder = JSON.parse(orderInput.value || '[]');
|
||||
if (excludedInput) excluded = JSON.parse(excludedInput.value || '[]');
|
||||
} catch (e) {
|
||||
console.error('Error parsing saved plugin order:', e);
|
||||
}
|
||||
// JSON.parse can succeed and still return null/objects
|
||||
// (e.g. a saved value of "null"); normalize to arrays.
|
||||
if (!Array.isArray(currentOrder)) currentOrder = [];
|
||||
if (!Array.isArray(excluded)) excluded = [];
|
||||
|
||||
// Saved order first, then any newly enabled plugins.
|
||||
const orderedPlugins = [];
|
||||
currentOrder.forEach(id => {
|
||||
const plugin = plugins.find(p => p.id === id);
|
||||
if (plugin) orderedPlugins.push(plugin);
|
||||
});
|
||||
plugins.forEach(plugin => {
|
||||
if (!orderedPlugins.find(p => p.id === plugin.id)) orderedPlugins.push(plugin);
|
||||
});
|
||||
|
||||
// Rows are built with DOM APIs rather than innerHTML — plugin
|
||||
// ids/names come from installed manifests (semi-trusted).
|
||||
container.textContent = '';
|
||||
orderedPlugins.forEach(plugin => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center p-2 bg-gray-50 rounded border border-gray-200 cursor-move plugin-order-item';
|
||||
row.dataset.pluginId = plugin.id;
|
||||
row.draggable = true;
|
||||
|
||||
const grip = document.createElement('i');
|
||||
grip.className = 'fas fa-grip-vertical text-gray-400 mr-3';
|
||||
row.appendChild(grip);
|
||||
|
||||
if (excludedInput) {
|
||||
const isExcluded = excluded.includes(plugin.id);
|
||||
const label = document.createElement('label');
|
||||
label.className = 'flex items-center flex-1';
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'plugin-order-include h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2';
|
||||
checkbox.checked = !isExcluded;
|
||||
const name = document.createElement('span');
|
||||
name.className = 'text-sm font-medium text-gray-700';
|
||||
name.textContent = plugin.name || plugin.id;
|
||||
label.appendChild(checkbox);
|
||||
label.appendChild(name);
|
||||
row.appendChild(label);
|
||||
} else {
|
||||
const name = document.createElement('span');
|
||||
name.className = 'text-sm font-medium text-gray-700 flex-1';
|
||||
name.textContent = plugin.name || plugin.id;
|
||||
row.appendChild(name);
|
||||
}
|
||||
|
||||
if (options.showVegasModeBadge) {
|
||||
const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed';
|
||||
const modeInfo = MODE_LABELS.get(vegasMode) || MODE_LABELS.get('fixed');
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `text-xs ${modeInfo.color} ml-2`;
|
||||
badge.title = `Vegas display mode: ${modeInfo.label}`;
|
||||
const badgeIcon = document.createElement('i');
|
||||
badgeIcon.className = `fas ${modeInfo.icon} mr-1`;
|
||||
badge.appendChild(badgeIcon);
|
||||
badge.appendChild(document.createTextNode(modeInfo.label));
|
||||
row.appendChild(badge);
|
||||
}
|
||||
|
||||
// Up/down buttons: touch- and keyboard-accessible
|
||||
// reordering alongside native drag-and-drop (HTML5 drag
|
||||
// events don't fire on most mobile browsers).
|
||||
const pluginLabel = plugin.name || plugin.id;
|
||||
[['up', 'fa-chevron-up', `Move ${pluginLabel} up`],
|
||||
['down', 'fa-chevron-down', `Move ${pluginLabel} down`]].forEach(([dir, iconCls, ariaLabel]) => {
|
||||
const moveBtn = document.createElement('button');
|
||||
moveBtn.type = 'button';
|
||||
moveBtn.className = 'plugin-order-move text-gray-400 hover:text-gray-700 px-2 py-1';
|
||||
moveBtn.setAttribute('aria-label', ariaLabel);
|
||||
const moveIcon = document.createElement('i');
|
||||
moveIcon.className = `fas ${iconCls} text-xs`;
|
||||
moveBtn.appendChild(moveIcon);
|
||||
moveBtn.addEventListener('click', function() {
|
||||
if (dir === 'up' && row.previousElementSibling) {
|
||||
container.insertBefore(row, row.previousElementSibling);
|
||||
} else if (dir === 'down' && row.nextElementSibling) {
|
||||
container.insertBefore(row.nextElementSibling, row);
|
||||
}
|
||||
syncInputs();
|
||||
moveBtn.focus();
|
||||
});
|
||||
row.appendChild(moveBtn);
|
||||
});
|
||||
|
||||
container.appendChild(row);
|
||||
});
|
||||
|
||||
setupDragAndDrop();
|
||||
container.querySelectorAll('.plugin-order-include').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', syncInputs);
|
||||
});
|
||||
syncInputs();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching plugins:', error);
|
||||
const err = document.createElement('p');
|
||||
err.className = 'text-sm text-red-500';
|
||||
err.textContent = 'Error loading plugins';
|
||||
container.textContent = '';
|
||||
container.appendChild(err);
|
||||
});
|
||||
}
|
||||
|
||||
window.PluginOrderList = { init: init };
|
||||
})();
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(P){var t={pairs:"()[]{}''\"\"",closeBefore:")]}'\":;>",triples:"",explode:"[]{}"},S=P.Pos;function k(e,n){return"pairs"==n&&"string"==typeof e?e:("object"==typeof e&&null!=e[n]?e:t)[n]}P.defineOption("autoCloseBrackets",!1,function(e,n,t){t&&t!=P.Init&&(e.removeKeyMap(i),e.state.closeBrackets=null),n&&(r(k(n,"pairs")),e.state.closeBrackets=n,e.addKeyMap(i))});var i={Backspace:function(e){var n=y(e);if(!n||e.getOption("disableInput"))return P.Pass;for(var t=k(n,"pairs"),r=e.listSelections(),i=0;i<r.length;i++){if(!r[i].empty())return P.Pass;var a=s(e,r[i].head);if(!a||t.indexOf(a)%2!=0)return P.Pass}for(i=r.length-1;0<=i;i--){var o=r[i].head;e.replaceRange("",S(o.line,o.ch-1),S(o.line,o.ch+1),"+delete")}},Enter:function(r){var e=y(r),n=e&&k(e,"explode");if(!n||r.getOption("disableInput"))return P.Pass;for(var i=r.listSelections(),t=0;t<i.length;t++){if(!i[t].empty())return P.Pass;var a=s(r,i[t].head);if(!a||n.indexOf(a)%2!=0)return P.Pass}r.operation(function(){var e=r.lineSeparator()||"\n";r.replaceSelection(e+e,null),O(r,-1),i=r.listSelections();for(var n=0;n<i.length;n++){var t=i[n].head.line;r.indentLine(t,null,!0),r.indentLine(t+1,null,!0)}})}};function r(e){for(var n=0;n<e.length;n++){var t=e.charAt(n),r="'"+t+"'";i[r]||(i[r]=function(n){return function(e){return function(i,e){var n=y(i);if(!n||i.getOption("disableInput"))return P.Pass;var t=k(n,"pairs"),r=t.indexOf(e);if(-1==r)return P.Pass;for(var a,o=k(n,"closeBefore"),s=k(n,"triples"),l=t.charAt(r+1)==e,c=i.listSelections(),f=r%2==0,h=0;h<c.length;h++){var u,d=c[h],p=d.head,g=i.getRange(p,S(p.line,p.ch+1));if(f&&!d.empty())u="surround";else if(!l&&f||g!=e)if(l&&1<p.ch&&0<=s.indexOf(e)&&i.getRange(S(p.line,p.ch-2),p)==e+e){if(2<p.ch&&/\bstring/.test(i.getTokenTypeAt(S(p.line,p.ch-2))))return P.Pass;u="addFour"}else if(l){d=0==p.ch?" ":i.getRange(S(p.line,p.ch-1),p);if(P.isWordChar(g)||d==e||P.isWordChar(d))return P.Pass;u="both"}else{if(!f||!(0===g.length||/\s/.test(g)||-1<o.indexOf(g)))return P.Pass;u="both"}else u=l&&function(e,n){var t=e.getTokenAt(S(n.line,n.ch+1));return/\bstring/.test(t.type)&&t.start==n.ch&&(0==n.ch||!/\bstring/.test(e.getTokenTypeAt(n)))}(i,p)?"both":0<=s.indexOf(e)&&i.getRange(p,S(p.line,p.ch+3))==e+e+e?"skipThree":"skip";if(a){if(a!=u)return P.Pass}else a=u}var v=r%2?t.charAt(r-1):e,b=r%2?e:t.charAt(r+1);i.operation(function(){if("skip"==a)O(i,1);else if("skipThree"==a)O(i,3);else if("surround"==a){for(var e=i.getSelections(),n=0;n<e.length;n++)e[n]=v+e[n]+b;i.replaceSelections(e,"around");for(e=i.listSelections().slice(),n=0;n<e.length;n++)e[n]=(t=e[n],r=void 0,r=0<P.cmpPos(t.anchor,t.head),{anchor:new S(t.anchor.line,t.anchor.ch+(r?-1:1)),head:new S(t.head.line,t.head.ch+(r?1:-1))});i.setSelections(e)}else"both"==a?(i.replaceSelection(v+b,null),i.triggerElectric(v+b),O(i,-1)):"addFour"==a&&(i.replaceSelection(v+v+v+v,"before"),O(i,1));var t,r})}(e,n)}}(t))}}function y(e){var n=e.state.closeBrackets;return n&&!n.override&&e.getModeAt(e.getCursor()).closeBrackets||n}function O(e,n){for(var t=[],r=e.listSelections(),i=0,a=0;a<r.length;a++){var o=r[a];o.head==e.getCursor()&&(i=a);o=o.head.ch||0<n?{line:o.head.line,ch:o.head.ch+n}:{line:o.head.line-1};t.push({anchor:o,head:o})}e.setSelections(t,i)}function s(e,n){n=e.getRange(S(n.line,n.ch-1),S(n.line,n.ch+1));return 2==n.length?n:null}r(t.pairs+"`")});
|
||||
@@ -0,0 +1 @@
|
||||
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){var u=/MSIE \d/.test(navigator.userAgent)&&(null==document.documentMode||document.documentMode<8),k=r.Pos,p={"(":")>",")":"(<","[":"]>","]":"[<","{":"}>","}":"{<","<":">>",">":"<<"};function y(t){return t&&t.bracketRegex||/[(){}[\]]/}function f(t,e,n){var r=t.getLineHandle(e.line),i=e.ch-1,c=n&&n.afterCursor;null==c&&(c=/(^| )cm-fat-cursor($| )/.test(t.getWrapperElement().className));var a=y(n),c=!c&&0<=i&&a.test(r.text.charAt(i))&&p[r.text.charAt(i)]||a.test(r.text.charAt(i+1))&&p[r.text.charAt(++i)];if(!c)return null;a=">"==c.charAt(1)?1:-1;if(n&&n.strict&&0<a!=(i==e.ch))return null;r=t.getTokenTypeAt(k(e.line,i+1)),n=o(t,k(e.line,i+(0<a?1:0)),a,r,n);return null==n?null:{from:k(e.line,i),to:n&&n.pos,match:n&&n.ch==c.charAt(0),forward:0<a}}function o(t,e,n,r,i){for(var c=i&&i.maxScanLineLength||1e4,a=i&&i.maxScanLines||1e3,o=[],h=y(i),l=0<n?Math.min(e.line+a,t.lastLine()+1):Math.max(t.firstLine()-1,e.line-a),s=e.line;s!=l;s+=n){var u=t.getLine(s);if(u){var f=0<n?0:u.length-1,m=0<n?u.length:-1;if(!(u.length>c))for(s==e.line&&(f=e.ch-(n<0?1:0));f!=m;f+=n){var g=u.charAt(f);if(h.test(g)&&(void 0===r||(t.getTokenTypeAt(k(s,f+1))||"")==(r||""))){var d=p[g];if(d&&">"==d.charAt(1)==0<n)o.push(g);else{if(!o.length)return{pos:k(s,f),ch:g};o.pop()}}}}}return s-n!=(0<n?t.lastLine():t.firstLine())&&null}function e(t,e,n){for(var r=t.state.matchBrackets.maxHighlightLineLength||1e3,i=n&&n.highlightNonMatching,c=[],a=t.listSelections(),o=0;o<a.length;o++){var h,l=a[o].empty()&&f(t,a[o].head,n);l&&(l.match||!1!==i)&&t.getLine(l.from.line).length<=r&&(h=l.match?"CodeMirror-matchingbracket":"CodeMirror-nonmatchingbracket",c.push(t.markText(l.from,k(l.from.line,l.from.ch+1),{className:h})),l.to&&t.getLine(l.to.line).length<=r&&c.push(t.markText(l.to,k(l.to.line,l.to.ch+1),{className:h})))}if(c.length){u&&t.state.focused&&t.focus();function s(){t.operation(function(){for(var t=0;t<c.length;t++)c[t].clear()})}if(!e)return s;setTimeout(s,800)}}function i(t){t.operation(function(){t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null),t.state.matchBrackets.currentlyHighlighted=e(t,!1,t.state.matchBrackets)})}function c(t){t.state.matchBrackets&&t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null)}r.defineOption("matchBrackets",!1,function(t,e,n){n&&n!=r.Init&&(t.off("cursorActivity",i),t.off("focus",i),t.off("blur",c),c(t)),e&&(t.state.matchBrackets="object"==typeof e?e:{},t.on("cursorActivity",i),t.on("focus",i),t.on("blur",c))}),r.defineExtension("matchBrackets",function(){e(this,!0)}),r.defineExtension("findMatchingBracket",function(t,e,n){return f(this,t,e=n||"boolean"==typeof e?n?(n.strict=e,n):e?{strict:!0}:null:e)}),r.defineExtension("scanForBracket",function(t,e,n,r){return o(this,t,e,n,r)})});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.cm-s-monokai.CodeMirror{background:#272822;color:#f8f8f2}.cm-s-monokai div.CodeMirror-selected{background:#49483e}.cm-s-monokai .CodeMirror-line::selection,.cm-s-monokai .CodeMirror-line>span::selection,.cm-s-monokai .CodeMirror-line>span>span::selection{background:rgba(73,72,62,.99)}.cm-s-monokai .CodeMirror-line::-moz-selection,.cm-s-monokai .CodeMirror-line>span::-moz-selection,.cm-s-monokai .CodeMirror-line>span>span::-moz-selection{background:rgba(73,72,62,.99)}.cm-s-monokai .CodeMirror-gutters{background:#272822;border-right:0}.cm-s-monokai .CodeMirror-guttermarker{color:#fff}.cm-s-monokai .CodeMirror-guttermarker-subtle{color:#d0d0d0}.cm-s-monokai .CodeMirror-linenumber{color:#d0d0d0}.cm-s-monokai .CodeMirror-cursor{border-left:1px solid #f8f8f0}.cm-s-monokai span.cm-comment{color:#75715e}.cm-s-monokai span.cm-atom{color:#ae81ff}.cm-s-monokai span.cm-number{color:#ae81ff}.cm-s-monokai span.cm-comment.cm-attribute{color:#97b757}.cm-s-monokai span.cm-comment.cm-def{color:#bc9262}.cm-s-monokai span.cm-comment.cm-tag{color:#bc6283}.cm-s-monokai span.cm-comment.cm-type{color:#5998a6}.cm-s-monokai span.cm-attribute,.cm-s-monokai span.cm-property{color:#a6e22e}.cm-s-monokai span.cm-keyword{color:#f92672}.cm-s-monokai span.cm-builtin{color:#66d9ef}.cm-s-monokai span.cm-string{color:#e6db74}.cm-s-monokai span.cm-variable{color:#f8f8f2}.cm-s-monokai span.cm-variable-2{color:#9effff}.cm-s-monokai span.cm-type,.cm-s-monokai span.cm-variable-3{color:#66d9ef}.cm-s-monokai span.cm-def{color:#fd971f}.cm-s-monokai span.cm-bracket{color:#f8f8f2}.cm-s-monokai span.cm-tag{color:#f92672}.cm-s-monokai span.cm-header{color:#ae81ff}.cm-s-monokai span.cm-link{color:#ae81ff}.cm-s-monokai span.cm-error{background:#f92672;color:#f8f8f0}.cm-s-monokai .CodeMirror-activeline-background{background:#373831}.cm-s-monokai .CodeMirror-matchingbracket{text-decoration:underline;color:#fff!important}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user