Merge commit 'b9416ef803028ec37e0f2f756c051e52d52622d7' into claude/hdpi-scroll-performance-antialiasing-4ae609

# Conflicts:
#	src/vegas_mode/config.py
#	src/vegas_mode/coordinator.py
This commit is contained in:
Chuck
2026-09-24 17:38:08 -04:00
89 changed files with 2881 additions and 5493 deletions
+69 -13
View File
@@ -1,6 +1,37 @@
/* global showNotification */
// LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration
/*
* app.js -- page-wide behaviour that is not the Alpine app itself.
*
* Deferred, first of the scripts at the end of <body>; after app-shell.js
* and Alpine.
*
* Load order (templates/v3/base.html):
* <head>, blocking: debugLog and theme inline scripts; the htmx loader
* (injects htmx.min.js with a dynamic <script>);
* js/htmx-config.js; the loadPartialDirect fallback;
* js/app-early.js
* <head>, defer: js/app-shell.js, then js/alpinejs.min.js (Alpine
* starts as soon as it runs, so app-shell.js's app()
* is the one Alpine uses)
* end of <body>, defer, in this order: app.js, js/tooltips.js,
* js/settings-search.js, js/utils/dialog.js,
* js/utils/error_handler.js, js/plugins/api_client.js,
* state_manager.js, install_manager.js, list_filter.js,
* the widget bundle (web_interface/widget_bundle.py),
* plugins_manager.js
* Tab partials arrive later through htmx; their inline scripts run on
* htmx:afterSwap (js/htmx-config.js).
*
* Owns: button loading states and the fallback result toast for htmx
* requests; the unsaved-changes guard for plugin config forms; the "restart
* the display" banner; the floating live preview; aria-current on the nav;
* the mobile nav drawer's keyboard handling; header widget placement.
*
* Globals: showSaveResult, showRestartPending, dismissRestartPending,
* restartPendingNow, toggleFloatingPreview, applyFloatingPreviewSize,
* cycleFloatingPreviewSize, updateFloatingPreviewVisibility,
* previewPluginNow, updateNavAriaCurrent, placeHeaderWidgets.
*/
// HTMX response handlers
document.body.addEventListener('htmx:beforeRequest', function(event) {
@@ -22,9 +53,15 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
if (textEl) textEl.style.opacity = '1';
}
// Handle response notifications
// Show the server's message, unless the element that made the request
// (or its form) has its own after-request handler: every such handler in
// the templates reports the result itself, and this used to repeat it,
// so each save showed two toasts.
const response = event.detail.xhr;
if (response && response.responseText) {
const elt = event.detail.elt;
const reportsItself = elt && elt.closest &&
elt.closest('[hx-on\\:\\:after-request], [hx-on\\:htmx\\:after-request]');
if (!reportsItself && response && response.responseText) {
try {
const data = JSON.parse(response.responseText);
if (data.message) {
@@ -48,6 +85,30 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
} catch { /* banner is best-effort */ }
});
/**
* Shows the outcome of a settings form save as one notification. Used by the
* hx-on:htmx:after-request of the Display, Rotation & Durations and General
* forms. Only a 2xx response counts as saved (a network failure is status 0);
* the server's message is shown when there is one, and its status can refine
* a success but never overturn a failure.
* @param {XMLHttpRequest} xhr - event.detail.xhr
* @param {string} savedText - message for a success without one
* @param {string} failedText - message for a failure without one
*/
window.showSaveResult = function(xhr, savedText, failedText) {
const httpSuccess = xhr.status >= 200 && xhr.status < 300;
let message = httpSuccess ? savedText : failedText;
let status = httpSuccess ? 'success' : 'error';
try {
const data = JSON.parse(xhr.responseText);
if (data.message) message = data.message;
if (httpSuccess && data.status) status = data.status;
} catch {
// Non-JSON body: keep the status-code verdict.
}
showNotification(message, status);
};
// ===== 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.
@@ -242,9 +303,8 @@ window.updateFloatingPreviewVisibility = function(tab) {
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 app = window.getApp();
active = app && app.activeTab;
}
const onOverview = active === 'overview';
let open = false;
@@ -308,13 +368,9 @@ window.updateNavAriaCurrent = function(tab) {
// 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();
const data = window.getApp();
if (data && data.mobileNavOpen) {
data.mobileNavOpen = false;
const burger = document.querySelector('[aria-controls="site-nav"]');
@@ -328,7 +384,7 @@ window.updateNavAriaCurrent = function(tab) {
// 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();
const data = window.getApp();
if (data && data.mobileNavOpen) {
const first = document.querySelector('#site-nav .nav-tab');
if (first) first.focus();
+107 -39
View File
@@ -1,8 +1,89 @@
/* 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
/*
* app-early.js -- helpers every other script relies on, and the app() stub.
*
* A blocking <script> in <head>, so everything here exists before any other
* script, widget or partial runs.
*
* Load order (templates/v3/base.html):
* <head>, blocking: debugLog and theme inline scripts; the htmx loader
* (injects htmx.min.js with a dynamic <script>);
* js/htmx-config.js; the loadPartialDirect fallback;
* js/app-early.js
* <head>, defer: js/app-shell.js, then js/alpinejs.min.js (Alpine
* starts as soon as it runs, so app-shell.js's app()
* is the one Alpine uses)
* end of <body>, defer, in this order: app.js, js/tooltips.js,
* js/settings-search.js, js/utils/dialog.js,
* js/utils/error_handler.js, js/plugins/api_client.js,
* state_manager.js, install_manager.js, list_filter.js,
* the widget bundle (web_interface/widget_bundle.py),
* plugins_manager.js
* Tab partials arrive later through htmx; their inline scripts run on
* htmx:afterSwap (js/htmx-config.js).
*
* Globals:
* window.LEDEscape html / attr / jsStringAttr, the only HTML escaper
* window.escapeHtml / window.escapeAttribute aliases of LEDEscape.html /
* .attr, kept for plugin pages
* window.getApp() the root Alpine component (<body x-data="app()">)
* window.app a stub app() so Alpine can start before app-shell.js
* has run; app-shell.js replaces it with the full one
* (in the normal load order Alpine only ever sees the
* full one, see the note on the stub below)
* getInstalledPluginsSafe() installed list via PluginAPI or fetch
* a pluginsUpdated listener that draws the plugin tab row while the app is
* not the full implementation yet
*/
// ===== window.LEDEscape: the web UI's HTML escaping =====
// This file is a blocking <script> in <head>, so every later script,
// widget and partial can call these directly.
// html(v) text for element content or a quoted attribute
// value: & < > " ' become entities, null and
// undefined become ''. Escaping quotes is what makes
// it attribute-safe; a textContent/innerHTML round
// trip only escapes & < and >.
// attr(v) the same function, for call sites that want the
// attribute context to read explicitly.
// jsStringAttr(v) a quoted JS string literal for an inline handler
// attribute: onclick='f(${jsStringAttr(id)})'.
// JSON.stringify alone leaves ' and & untouched, so a
// value containing ' could close a single-quoted
// attribute. The browser decodes the entities before
// it parses the handler, so the JS sees the literal.
window.LEDEscape = (function() {
const ENTITIES = new Map([['&', '&amp;'], ['<', '&lt;'], ['>', '&gt;'], ['"', '&quot;'], ["'", '&#39;']]);
function html(value) {
return value == null ? '' : String(value).replace(/[&<>"']/g, c => ENTITIES.get(c));
}
function jsStringAttr(value) {
return html(JSON.stringify(value == null ? '' : String(value)));
}
// An attribute value needs no more than html(): it escapes both quote characters.
function attr(value) {
return html(value);
}
return Object.freeze({ html: html, attr: attr, jsStringAttr: jsStringAttr });
})();
// Kept for plugin web UIs and third-party plugin pages that call them.
window.escapeHtml = window.LEDEscape.html;
window.escapeAttribute = window.LEDEscape.attr;
// ===== window.getApp(): the root Alpine component =====
// The data of <body x-data="app()"> (activeTab, installedPlugins, ...),
// read through Alpine's public Alpine.$data rather than the private
// el._x_dataStack. Null until Alpine has initialised the app.
window.getApp = function() {
const el = document.querySelector('[x-data="app()"]');
if (!el || !window.Alpine) return null;
const data = window.Alpine.$data(el);
return data && 'activeTab' in data ? data : null;
};
// The installed-plugin list through PluginAPI, or a plain fetch if
// PluginAPI (loaded later, deferred) is not there yet.
async function getInstalledPluginsSafe() {
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
try {
@@ -20,32 +101,19 @@
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
// Builds the plugin tab row from a pluginsUpdated event while the app is
// not app-shell.js's full implementation (Alpine not started yet, or
// still on the stub below). The full app() has its own pluginsUpdated
// listener, registered in its init().
document.addEventListener('pluginsUpdated', function(event) {
const appComponent = window.getApp();
if (appComponent && typeof appComponent._doUpdatePluginTabs === 'function') return;
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
}
}
if (appComponent) {
appComponent.installedPlugins = plugins;
}
// 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) {
@@ -60,15 +128,11 @@
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();
}
const app = window.getApp();
if (app) {
app.activeTab = plugin.id;
if (typeof app.updatePluginTabStates === 'function') {
app.updatePluginTabStates();
}
}
};
@@ -89,8 +153,13 @@
// 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
// The stub app(). If Alpine initialises before app-shell.js has run,
// this object is what it gets; its init() copies the full
// implementation in as soon as app-shell.js has replaced window.app
// (the "enhancement", guarded by window._appEnhanced), and draws the
// plugin tabs itself until then. base.html now loads app-shell.js
// before Alpine, so in practice Alpine calls the full app() directly
// and this stub never runs.
(function() {
const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.');
@@ -349,8 +418,7 @@
debugLog('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
}, 100);
},
showNotification: function(message, type) {},
escapeHtml: function(text) { return String(text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
showNotification: function(message, type) {}
};
};
})();
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -113,12 +113,15 @@
// 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;
}
// htmx's own script handling is switched off here, in the
// handler, rather than once at setup: base.html injects
// htmx with a dynamic <script>, so at setup htmx is usually
// not defined yet and the switch never took effect. htmx
// then tried to run each script again in its settle phase,
// found it already replaced (no parent) and threw, which
// also skipped the rest of that swap's settle tasks.
document.body.addEventListener('htmx:afterSwap', function(event) {
htmx.config.allowScriptTags = false;
const target = event.detail && event.detail.target;
if (!target || !(target instanceof Element)) return;
target.querySelectorAll('script').forEach(function(oldScript) {
@@ -1,3 +1,4 @@
/* global debugLog */
/**
* API client for plugin operations.
*
@@ -10,7 +11,6 @@ const RequestThrottler = {
pending: new Map(),
cache: new Map(),
cacheTTL: 5000, // 5 seconds cache for GET requests
debug: false, // Set to true to enable logging
/**
* Throttle a request to prevent rapid-fire calls
@@ -19,23 +19,17 @@ const RequestThrottler = {
// Check cache first
const cached = this.cache.get(key);
if (cached && (Date.now() - cached.timestamp) < this.cacheTTL) {
if (this.debug) {
console.log('[RequestThrottler] Cache hit for:', key);
}
debugLog('[RequestThrottler] Cache hit for:', key);
return cached.data;
}
// Check if request is already pending
if (this.pending.has(key)) {
if (this.debug) {
console.log('[RequestThrottler] Reusing pending request for:', key);
}
debugLog('[RequestThrottler] Reusing pending request for:', key);
return this.pending.get(key);
}
if (this.debug) {
console.log('[RequestThrottler] Creating new request for:', key);
}
debugLog('[RequestThrottler] Creating new request for:', key);
// Create throttled request with abort support
let abortController = null;
@@ -49,16 +43,12 @@ const RequestThrottler = {
data: result,
timestamp: Date.now()
});
if (this.debug) {
console.log('[RequestThrottler] Cached response for:', key);
}
debugLog('[RequestThrottler] Cached response for:', key);
}
resolve(result);
} catch (error) {
// Don't cache errors
if (this.debug) {
console.error('[RequestThrottler] Request failed for:', key, error);
}
debugLog('[RequestThrottler] Request failed for:', key, error);
reject(error);
} finally {
this.pending.delete(key);
@@ -74,9 +64,7 @@ const RequestThrottler = {
// Add abort method if available
if (abortController) {
promise.abort = () => {
if (this.debug) {
console.log('[RequestThrottler] Aborting request for:', key);
}
debugLog('[RequestThrottler] Aborting request for:', key);
abortController.abort();
this.pending.delete(key);
};
@@ -97,13 +85,6 @@ const RequestThrottler = {
}
},
/**
* Enable or disable debug logging
*/
setDebug(enabled) {
this.debug = enabled;
},
/**
* Get statistics about pending requests and cache
*/
@@ -1,3 +1,4 @@
/* global debugLog */
/*
* settings-search.js — global settings search + per-tab filter for the v3 UI.
*
@@ -162,11 +163,7 @@
// --- Navigation to a setting ---------------------------------------------
function getAppData() {
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
if (!appEl) return null;
if (appEl._x_dataStack && appEl._x_dataStack[0]) return appEl._x_dataStack[0];
if (appEl.__x && appEl.__x.$data) return appEl.__x.$data;
return null;
return window.getApp();
}
function setActiveTab(tab) {
@@ -478,5 +475,5 @@
navigateToSetting: navigateToSetting
};
console.log('[SettingsSearch] registered');
debugLog('[SettingsSearch] registered');
})();
+2 -1
View File
@@ -1,3 +1,4 @@
/* global debugLog */
/*
* tooltips.js — accessible, delegated tooltip controller for the v3 web UI.
*
@@ -157,5 +158,5 @@
if (currentTrigger && !document.body.contains(currentTrigger)) hide();
});
console.log('[Tooltips] controller registered');
debugLog('[Tooltips] controller registered');
})();
@@ -139,12 +139,7 @@ function displayError(error, context = null, options = {}) {
showErrorModal(error, context, message, suggestions, docLink);
} else {
// Simple notification
if (typeof showNotification === 'function') {
showNotification(fullMessage, 'error');
} else {
console.error('Error:', fullMessage);
alert(fullMessage);
}
showNotification(fullMessage, 'error');
}
}
@@ -289,22 +284,7 @@ function closeErrorModal() {
}
}
/**
* Escape HTML to prevent XSS.
*
* Quotes are escaped as well so the result is safe inside a quoted attribute
* value -- the textContent/innerHTML round-trip alone only escapes &, < and >.
*/
function escapeHtml(text) {
if (typeof text !== 'string') {
text = String(text);
}
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
/**
* Copy error details to clipboard.
@@ -315,16 +295,12 @@ function copyErrorDetails(error) {
const errorText = JSON.stringify(error, null, 2);
function copyFailed(err) {
console.error('Failed to copy error details:', err);
if (typeof showNotification === 'function') {
showNotification("Couldn't copy to the clipboard. Open Technical details and copy the text by hand.", 'warning');
}
showNotification("Couldn't copy to the clipboard. Open Technical details and copy the text by hand.", 'warning');
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(errorText).then(() => {
if (typeof showNotification === 'function') {
showNotification('Error details copied to clipboard', 'success');
}
showNotification('Error details copied to clipboard', 'success');
}).catch(copyFailed);
} else {
// Fallback for older browsers
@@ -340,9 +316,7 @@ function copyErrorDetails(error) {
textArea.select();
try {
document.execCommand('copy');
if (typeof showNotification === 'function') {
showNotification('Error details copied to clipboard', 'success');
}
showNotification('Error details copied to clipboard', 'success');
} catch (err) {
copyFailed(err);
}
+4 -5
View File
@@ -227,11 +227,10 @@ directory is the only place the core serves plugin widgets from.
}
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
// The page's shared escaper covers HTML content and quoted attribute
// values. A textContent/innerHTML round trip leaves quotes alone, so it
// is not safe inside value="...".
const escapeHtml = (text) => window.LEDEscape.html(text);
window.LEDMatrixWidgets.register('my-custom-widget', {
name: 'My Custom Widget',
@@ -806,14 +806,13 @@
const file = event.target.files && event.target.files[0];
if (!file) return;
const notifyFn = window.showNotification || console.log;
const allowed = ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
if (!allowed.includes(file.type)) {
notifyFn(`File type "${file.type}" not allowed`, 'error');
window.showNotification(`File type "${file.type}" not allowed`, 'error');
return;
}
if (file.size > 5 * 1024 * 1024) {
notifyFn('File exceeds 5MB limit', 'error');
window.showNotification('File exceeds 5MB limit', 'error');
return;
}
@@ -829,12 +828,12 @@
const path = data.uploaded_files[0].path;
pathInput.value = path;
if (previewImg) { previewImg.src = '/' + path; previewImg.style.display = 'inline'; }
notifyFn('Image uploaded', 'success');
window.showNotification('Image uploaded', 'success');
} else {
throw new Error(data.message || 'Upload failed');
}
} catch (err) {
notifyFn('Upload error: ' + err.message, 'error');
window.showNotification('Upload error: ' + err.message, 'error');
} finally {
event.target.value = '';
}
@@ -853,7 +852,6 @@
addButton.style.opacity = isAtMax ? '0.5' : '';
}
window.updateArrayTableAddButtonState = updateAddButtonState;
window.addArrayTableRow = function(button) {
const fieldId = button.getAttribute('data-field-id');
@@ -874,7 +872,7 @@
const currentRows = tbody.querySelectorAll('.array-table-row').length;
if (currentRows >= maxItems) {
(window.showNotification || alert)(`Maximum ${maxItems} items allowed`, 'error');
window.showNotification(`Maximum ${maxItems} items allowed`, 'error');
return;
}
@@ -116,29 +116,14 @@
}
/**
* Escape HTML to prevent XSS
* Always escapes the input, even for non-strings, by coercing to string first
*
* The result is safe in text content AND inside quoted attribute values.
* The textContent/innerHTML round-trip only escapes `&`, `<` and `>` --
* the HTML serializer leaves quotes alone because they are harmless in a
* text node. Every widget here interpolates the result into attributes
* (`value="${escapeHtml(v)}"`), where an unescaped `"` closes the
* attribute and lets the value inject its own, so the quotes have to go
* too. Each widget's standalone fallback already did this; the shared
* implementation they all prefer did not.
*
* @param {*} text - Text to escape (will be coerced to string)
* Escape text for HTML content or a quoted attribute value.
* Kept for widgets that call it as a method; it is window.LEDEscape.html
* (app-early.js).
* @param {*} text - Text to escape (null/undefined become '')
* @returns {string} Escaped text, safe for text and attribute contexts
*/
escapeHtml(text) {
// Always coerce to string first, then escape
const textStr = String(text);
const div = document.createElement('div');
div.textContent = textStr;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
return window.LEDEscape.html(text);
}
/**
@@ -173,9 +158,7 @@
* @returns {Function|null} Notification function or null
*/
getNotificationFunction() {
if (typeof window.showNotification === 'function') {
return window.showNotification;
}
return window.showNotification;
return null;
}
@@ -24,15 +24,10 @@
version: '1.0.0',
/**
* Render the checkbox group widget
* Note: This widget is currently server-side rendered via Jinja2 template.
* This registration ensures the handlers are available globally.
* No-op: plugin_config.html renders this widget server-side. The
* registration exists for getValue/setValue and updateCheckboxGroupData.
*/
render: function(container, config, value, options) {
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
if (window.debugLog) window.debugLog('[CheckboxGroupWidget] Render called (server-side rendered)');
},
render: function() {},
/**
* Get current value from widget
@@ -25,12 +25,7 @@
const base = window.BaseWidget ? new window.BaseWidget('ColorPicker', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -24,15 +24,10 @@
version: '1.0.0',
/**
* Render the custom feeds widget
* Note: This widget is currently server-side rendered via Jinja2 template.
* This registration ensures the handlers are available globally.
* No-op: plugin_config.html renders this widget server-side. The
* registration exists for getValue/setValue and the window.* feed-table handlers.
*/
render: function(container, config, value, options) {
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
if (window.debugLog) window.debugLog('[CustomFeedsWidget] Render called (server-side rendered)');
},
render: function() {},
/**
* Get current value from widget
@@ -246,8 +241,7 @@
const currentRows = tbody.querySelectorAll('.custom-feed-row');
if (currentRows.length >= maxItems) {
const notifyFn = window.showNotification || alert;
notifyFn(`Maximum ${maxItems} feeds allowed`, 'error');
window.showNotification(`Maximum ${maxItems} feeds allowed`, 'error');
return;
}
@@ -507,14 +501,12 @@
logoCell.appendChild(container);
}
} else {
const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
window.showNotification('Upload failed: ' + (data.message || 'Unknown error'), 'error');
}
})
.catch(error => {
console.error('Upload error:', error);
const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + error.message, 'error');
window.showNotification('Upload failed: ' + error.message, 'error');
})
.finally(() => {
// Reset regardless of outcome, so the same file can be re-selected
@@ -26,12 +26,7 @@
const base = window.BaseWidget ? new window.BaseWidget('DatePicker', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -50,12 +50,7 @@
// Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('DaySelector', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -24,12 +24,7 @@
const base = window.BaseWidget ? new window.BaseWidget('EmailInput', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -33,12 +33,7 @@
const base = window.BaseWidget ? new window.BaseWidget('FileUploadSingle', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -199,7 +194,6 @@
const safeId = sanitizeId(fieldId);
const fileInput = document.getElementById(`${safeId}_file_input`);
const statusDiv = document.getElementById(`${safeId}_status`);
const notifyFn = window.showNotification || console.log;
// Read config from the file input data attributes
const pluginId = (fileInput && fileInput.dataset.pluginId) || '';
@@ -208,19 +202,19 @@
.split(',').map(t => t.trim());
if (!pluginId) {
notifyFn('Plugin ID not set — cannot upload', 'error');
window.showNotification('Plugin ID not set — cannot upload', 'error');
return;
}
// Validate type
if (!allowedTypes.includes(file.type)) {
notifyFn(`File type "${file.type}" not allowed`, 'error');
window.showNotification(`File type "${file.type}" not allowed`, 'error');
return;
}
// Validate size
if (file.size > maxSizeMb * 1024 * 1024) {
notifyFn(`File exceeds ${maxSizeMb}MB limit`, 'error');
window.showNotification(`File exceeds ${maxSizeMb}MB limit`, 'error');
return;
}
@@ -266,7 +260,7 @@
statusDiv.appendChild(document.createTextNode('Uploaded successfully'));
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.textContent = ''; }, 3000);
}
notifyFn('Image uploaded successfully', 'success');
window.showNotification('Image uploaded successfully', 'success');
} else {
throw new Error(data.message || 'Upload failed');
}
@@ -279,7 +273,7 @@
statusDiv.appendChild(errIcon);
statusDiv.appendChild(document.createTextNode(error.message || 'Upload failed'));
}
notifyFn(`Upload error: ${error.message}`, 'error');
window.showNotification(`Upload error: ${error.message}`, 'error');
} finally {
if (fileInput) fileInput.value = '';
}
+277 -413
View File
@@ -24,16 +24,11 @@
version: '1.0.0',
/**
* Render the file upload widget
* Note: This widget is currently server-side rendered via Jinja2 template.
* This registration ensures the handlers are available globally.
* Future enhancement: Full client-side rendering support.
* No-op: plugin_config.html renders this widget server-side. The
* registration exists for getValue/setValue and the window.* handlers
* below.
*/
render: function(container, config, value, options) {
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
if (window.debugLog) window.debugLog('[FileUploadWidget] Render called (server-side rendered)');
},
render: function() {},
/**
* Get current value from widget
@@ -142,24 +137,23 @@
.split(',').map(e => e.trim().toLowerCase());
const statusDiv = document.getElementById(`${fieldId}_upload_status`);
const notifyFn = window.showNotification || console.log;
// Guard: endpoint must be configured
if (!uploadEndpoint) {
notifyFn('No upload endpoint configured for this field', 'error');
window.showNotification('No upload endpoint configured for this field', 'error');
return;
}
// Validate extension
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
if (!allowedExtensions.includes(fileExt)) {
notifyFn(`File must be one of: ${allowedExtensions.join(', ')}`, 'error');
window.showNotification(`File must be one of: ${allowedExtensions.join(', ')}`, 'error');
return;
}
// Validate size
if (file.size > maxSizeMB * 1024 * 1024) {
notifyFn(`File exceeds ${maxSizeMB}MB limit`, 'error');
window.showNotification(`File exceeds ${maxSizeMB}MB limit`, 'error');
return;
}
@@ -198,7 +192,7 @@
// Update hidden input with the target filename
const hiddenInput = document.getElementById(fieldId);
if (hiddenInput) hiddenInput.value = targetFilename;
notifyFn(`${targetFilename} uploaded successfully`, 'success');
window.showNotification(`${targetFilename} uploaded successfully`, 'success');
} else {
if (statusDiv) {
statusDiv.className = 'mt-2 text-xs text-red-600';
@@ -208,7 +202,7 @@
statusDiv.appendChild(icon);
statusDiv.appendChild(document.createTextNode(`Upload failed: ${data.message}`));
}
notifyFn(`Upload failed: ${data.message}`, 'error');
window.showNotification(`Upload failed: ${data.message}`, 'error');
}
} catch (error) {
if (statusDiv) {
@@ -219,7 +213,7 @@
statusDiv.appendChild(icon);
statusDiv.appendChild(document.createTextNode(`Upload error: ${error.message}`));
}
notifyFn(`Upload error: ${error.message}`, 'error');
window.showNotification(`Upload error: ${error.message}`, 'error');
} finally {
const fileInput = document.getElementById(`${fieldId}_file_input`);
if (fileInput) fileInput.value = '';
@@ -249,23 +243,20 @@
const validFiles = [];
for (const file of files) {
if (file.size > maxSizeMB * 1024 * 1024) {
const notifyFn = window.showNotification || console.error;
notifyFn(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
window.showNotification(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
continue;
}
if (fileType === 'json') {
// Validate JSON files
if (!file.name.toLowerCase().endsWith('.json')) {
const notifyFn = window.showNotification || console.error;
notifyFn(`File ${file.name} must be a JSON file (.json)`, 'error');
window.showNotification(`File ${file.name} must be a JSON file (.json)`, 'error');
continue;
}
} else {
// Validate image files using allowedTypes from config
if (!allowedTypes.includes(file.type)) {
const notifyFn = window.showNotification || console.error;
notifyFn(`File ${file.name} is not a valid image type`, 'error');
window.showNotification(`File ${file.name} is not a valid image type`, 'error');
continue;
}
}
@@ -275,8 +266,7 @@
// Check max files AFTER building validFiles
if (currentFiles.length + validFiles.length > maxFiles) {
const notifyFn = window.showNotification || console.error;
notifyFn(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${validFiles.length}.`, 'error');
window.showNotification(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${validFiles.length}.`, 'error');
return;
}
@@ -317,16 +307,13 @@
window.updateImageList(fieldId, newFiles);
}
const notifyFn = window.showNotification || console.log;
notifyFn(`Successfully uploaded ${data.uploaded_files?.length || data.data?.files?.length || 0} ${fileType === 'json' ? 'file(s)' : 'image(s)'}`, 'success');
window.showNotification(`Successfully uploaded ${data.uploaded_files?.length || data.data?.files?.length || 0} ${fileType === 'json' ? 'file(s)' : 'image(s)'}`, 'success');
} else {
const notifyFn = window.showNotification || console.error;
notifyFn(`Upload failed: ${data.message}`, 'error');
window.showNotification(`Upload failed: ${data.message}`, 'error');
}
} catch (error) {
console.error('Upload error:', error);
const notifyFn = window.showNotification || console.error;
notifyFn(`Upload error: ${error.message}`, 'error');
window.showNotification(`Upload error: ${error.message}`, 'error');
} finally {
if (window.hideUploadProgress) {
window.hideUploadProgress(fieldId);
@@ -394,16 +381,13 @@
window.updateImageList(fieldId, newFiles);
}
const notifyFn = window.showNotification || console.log;
notifyFn(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success');
window.showNotification(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success');
} else {
const notifyFn = window.showNotification || console.error;
notifyFn(`Delete failed: ${data.message}`, 'error');
window.showNotification(`Delete failed: ${data.message}`, 'error');
}
} catch (error) {
console.error('Delete error:', error);
const notifyFn = window.showNotification || console.error;
notifyFn(`Delete error: ${error.message}`, 'error');
window.showNotification(`Delete error: ${error.message}`, 'error');
}
};
@@ -492,9 +476,18 @@
return [];
};
// DOM id suffix for an image's card and schedule editor. Matches the
// template's img_id|replace('.', '_')|replace('-', '_') in
// plugin_config.html, so server-rendered cards (UUID ids contain '-') and
// cards rendered here are found by the same lookup.
function imageDomId(imageId) {
return String(imageId).replace(/[^a-zA-Z0-9_]/g, '_');
}
/**
* Update image list display and hidden input
* Uses DOM creation to prevent XSS and preserves open schedule editors
* Replace the image list: writes the hidden input the form saves and
* re-renders the cards. A schedule editor that was open stays open,
* rebuilt from the new data.
* @param {string} fieldId - Field ID
* @param {Array} images - Array of image objects
*/
@@ -503,64 +496,41 @@
if (hiddenInput) {
hiddenInput.value = JSON.stringify(images);
}
// Update the display
const imageList = document.getElementById(`${fieldId}_image_list`);
if (!imageList) return;
const uploadConfig = window.getUploadConfig(fieldId);
const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image';
// Detect which schedule is currently open (if any)
const openScheduleId = (() => {
const existingItems = imageList.querySelectorAll('[id^="img_"]');
for (const item of existingItems) {
const scheduleDiv = item.querySelector('[id^="schedule_"]');
if (scheduleDiv && !scheduleDiv.classList.contains('hidden')) {
// Extract the ID from schedule_<id>
const match = scheduleDiv.id.match(/^schedule_(.+)$/);
if (match) {
return match[1];
}
}
}
return null;
})();
// Preserve open schedule content if it exists
const preservedScheduleContent = openScheduleId ? (() => {
const scheduleDiv = document.getElementById(`schedule_${openScheduleId}`);
return scheduleDiv ? scheduleDiv.innerHTML : null;
})() : null;
// Clear and rebuild using DOM creation
const openEditor = imageList.querySelector('[id^="schedule_"]:not(.hidden)');
const openScheduleId = openEditor ? openEditor.id.slice('schedule_'.length) : null;
imageList.innerHTML = '';
images.forEach((img, idx) => {
const imgId = img.id || idx;
const sanitizedId = String(imgId).replace(/[^a-zA-Z0-9_-]/g, '_');
const imgSchedule = img.schedule || {};
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown';
// Create container div
const domId = imageDomId(imgId);
const label = img.original_filename || img.filename || '';
const container = document.createElement('div');
container.id = `img_${sanitizedId}`;
container.id = `img_${domId}`;
container.className = 'bg-gray-50 p-3 rounded-lg border border-gray-200';
// Create main content div
const mainDiv = document.createElement('div');
mainDiv.className = 'flex items-center justify-between mb-2';
// Create left section with image and info
const leftSection = document.createElement('div');
leftSection.className = 'flex items-center space-x-3 flex-1';
// Create image element
const imgEl = document.createElement('img');
const imgPath = String(img.path || '').replace(/^\/+/, '');
imgEl.src = '/' + imgPath;
// A stored path names a file under the project root. Encoding each
// segment keeps it a same-origin path whatever characters it holds.
imgEl.src = '/' + String(img.path || '').replace(/^\/+/, '')
.split('/').map(encodeURIComponent).join('/');
imgEl.alt = String(img.filename || '');
imgEl.loading = 'lazy';
imgEl.decoding = 'async';
imgEl.className = 'w-16 h-16 object-cover rounded';
imgEl.addEventListener('error', function() {
this.style.display = 'none';
@@ -568,104 +538,120 @@
this.nextElementSibling.style.display = 'block';
}
});
// Create placeholder div for broken images
// Shown in place of a thumbnail that fails to load
const placeholderDiv = document.createElement('div');
placeholderDiv.style.display = 'none';
placeholderDiv.className = 'w-16 h-16 bg-gray-200 rounded flex items-center justify-center';
const placeholderIcon = document.createElement('i');
placeholderIcon.className = 'fas fa-image text-gray-400';
placeholderDiv.appendChild(placeholderIcon);
// Create info div
const infoDiv = document.createElement('div');
infoDiv.className = 'flex-1 min-w-0';
// Filename
const filenameP = document.createElement('p');
filenameP.className = 'text-sm font-medium text-gray-900 truncate';
filenameP.textContent = img.original_filename || img.filename || 'Image';
// Size and date
filenameP.textContent = label || 'Image';
const sizeDateP = document.createElement('p');
sizeDateP.className = 'text-xs text-gray-500';
const fileSize = window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB');
const uploadedDate = window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || '');
sizeDateP.textContent = `${fileSize} • ${uploadedDate}`;
// Schedule summary
sizeDateP.textContent = `${window.formatFileSize(img.size || 0)} • ${window.formatDate(img.uploaded_at)}`;
const scheduleP = document.createElement('p');
scheduleP.className = 'text-xs text-blue-600 mt-1';
const clockIcon = document.createElement('i');
clockIcon.className = 'fas fa-clock mr-1';
scheduleP.appendChild(clockIcon);
scheduleP.appendChild(document.createTextNode(scheduleSummary));
scheduleP.className = 'text-xs text-blue-600 mt-1 image-schedule-summary';
renderScheduleSummary(scheduleP, img.schedule);
infoDiv.appendChild(filenameP);
infoDiv.appendChild(sizeDateP);
infoDiv.appendChild(scheduleP);
leftSection.appendChild(imgEl);
leftSection.appendChild(placeholderDiv);
leftSection.appendChild(infoDiv);
// Create right section with buttons
const rightSection = document.createElement('div');
rightSection.className = 'flex items-center space-x-2 ml-4';
// Schedule button
const scheduleBtn = document.createElement('button');
scheduleBtn.type = 'button';
scheduleBtn.className = 'text-blue-600 hover:text-blue-800 p-2';
scheduleBtn.title = 'Schedule this image';
scheduleBtn.setAttribute('aria-label', `Schedule image ${label}`);
scheduleBtn.dataset.fieldId = fieldId;
scheduleBtn.dataset.imageId = String(imgId);
scheduleBtn.dataset.imageIdx = String(idx);
scheduleBtn.addEventListener('click', function() {
window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId, parseInt(this.dataset.imageIdx, 10));
});
const scheduleIcon = document.createElement('i');
scheduleIcon.className = 'fas fa-calendar-alt';
scheduleBtn.appendChild(scheduleIcon);
// Delete button
scheduleBtn.appendChild(iconEl('fas fa-calendar-alt'));
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'text-red-600 hover:text-red-800 p-2';
deleteBtn.title = 'Delete image';
deleteBtn.setAttribute('aria-label', `Delete image ${label}`);
deleteBtn.dataset.fieldId = fieldId;
deleteBtn.dataset.imageId = String(imgId);
deleteBtn.dataset.pluginId = pluginId;
deleteBtn.addEventListener('click', function() {
window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId);
});
const deleteIcon = document.createElement('i');
deleteIcon.className = 'fas fa-trash';
deleteBtn.appendChild(deleteIcon);
deleteBtn.appendChild(iconEl('fas fa-trash'));
rightSection.appendChild(scheduleBtn);
rightSection.appendChild(deleteBtn);
mainDiv.appendChild(leftSection);
mainDiv.appendChild(rightSection);
// Create schedule container
const scheduleContainer = document.createElement('div');
scheduleContainer.id = `schedule_${sanitizedId}`;
scheduleContainer.id = `schedule_${domId}`;
scheduleContainer.className = 'hidden mt-3 pt-3 border-t border-gray-300';
// Restore preserved schedule content if this is the open one
if (openScheduleId === sanitizedId && preservedScheduleContent) {
scheduleContainer.innerHTML = preservedScheduleContent;
scheduleContainer.classList.remove('hidden');
}
container.appendChild(mainDiv);
container.appendChild(scheduleContainer);
imageList.appendChild(container);
if (openScheduleId === domId) {
renderScheduleEditor(scheduleContainer, fieldId, imgId, idx, img.schedule);
scheduleContainer.classList.remove('hidden');
}
});
};
// Decorative icon, hidden from screen readers (the button has a label)
function iconEl(className) {
const i = document.createElement('i');
i.className = className;
i.setAttribute('aria-hidden', 'true');
return i;
}
function renderScheduleSummary(el, schedule) {
el.textContent = '';
const clock = document.createElement('i');
clock.className = 'fas fa-clock mr-1';
el.appendChild(clock);
el.appendChild(document.createTextNode(window.getScheduleSummary(schedule || {})));
}
// Saves a schedule edit: writes the hidden input and refreshes that
// card's summary line in place. The list is not re-rendered, so the open
// editor keeps its state and the control being edited keeps focus.
function commitScheduleEdit(fieldId, images, imageId) {
const hiddenInput = document.getElementById(`${fieldId}_images_data`);
if (hiddenInput) {
hiddenInput.value = JSON.stringify(images);
}
const card = document.getElementById(`img_${imageDomId(imageId)}`);
const image = images.find((img, idx) => String(img.id || idx) === String(imageId));
const summary = card && card.querySelector('.image-schedule-summary');
if (summary && image) {
renderScheduleSummary(summary, image.schedule);
}
}
/**
* Show upload progress
* @param {string} fieldId - Field ID
@@ -776,364 +762,242 @@
};
/**
* Open image schedule editor
* Show or hide an image's schedule editor.
* @param {string} fieldId - Field ID
* @param {string|number} imageId - Image ID
* @param {string|number} imageId - Image ID (the index when the image has none)
* @param {number} imageIdx - Image index
*/
window.openImageSchedule = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
const idx = Number(imageIdx);
const image = Number.isInteger(idx) && idx >= 0 ? window.getCurrentImages(fieldId).at(idx) : undefined;
if (!image) return;
// Sanitize imageId to match updateImageList's sanitization
const sanitizedId = (imageId || imageIdx).toString().replace(/[^a-zA-Z0-9_-]/g, '_');
const scheduleContainer = document.getElementById(`schedule_${sanitizedId}`);
const scheduleContainer = document.getElementById(`schedule_${imageDomId(imageId || imageIdx)}`);
if (!scheduleContainer) return;
// Toggle visibility
const isVisible = !scheduleContainer.classList.contains('hidden');
if (isVisible) {
if (!scheduleContainer.classList.contains('hidden')) {
scheduleContainer.classList.add('hidden');
return;
}
renderScheduleEditor(scheduleContainer, fieldId, imageId || imageIdx, imageIdx, image.schedule);
scheduleContainer.classList.remove('hidden');
const schedule = image.schedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
// Escape HTML helper
// Quotes too: the result lands in quoted attribute values below, and the
// textContent/innerHTML round-trip only escapes &, < and >.
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = String(text ?? '');
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
// Use sanitizedId for all ID references in the schedule HTML
// Use data attributes instead of inline handlers to prevent JS injection
scheduleContainer.innerHTML = `
<div class="bg-white rounded-lg border border-blue-200 p-4">
<h4 class="text-sm font-semibold text-gray-900 mb-3">
<i class="fas fa-clock mr-2"></i>Schedule Settings
</h4>
<!-- Enable Schedule -->
<div class="mb-4">
<label class="flex items-center">
<input type="checkbox"
id="schedule_enabled_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
${schedule.enabled ? 'checked' : ''}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-700">Enable schedule for this image</span>
</label>
<p class="ml-6 text-xs text-gray-500 mt-1">When enabled, this image will only display during scheduled times</p>
</div>
<!-- Schedule Mode -->
<div id="schedule_options_${sanitizedId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
<div>
<label for="schedule_mode_${sanitizedId}" class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
<select id="schedule_mode_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
<option value="always" ${schedule.mode === 'always' ? 'selected' : ''}>Always Show (No Schedule)</option>
<option value="time_range" ${schedule.mode === 'time_range' ? 'selected' : ''}>Same Time Every Day</option>
<option value="per_day" ${schedule.mode === 'per_day' ? 'selected' : ''}>Different Times Per Day</option>
</select>
</div>
<!-- Time Range Mode -->
<div id="time_range_${sanitizedId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
<div>
<label for="schedule_start_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
<input type="time"
id="schedule_start_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
value="${escapeHtml(schedule.start_time || '08:00')}"
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
</div>
<div>
<label for="schedule_end_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
<input type="time"
id="schedule_end_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
value="${escapeHtml(schedule.end_time || '18:00')}"
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
</div>
</div>
<!-- Per-Day Mode -->
<div id="per_day_${sanitizedId}" style="display: ${schedule.mode === 'per_day' ? 'block' : 'none'};">
<label class="block text-xs font-medium text-gray-700 mb-2">Day-Specific Times</label>
<div class="bg-gray-50 rounded p-3 space-y-2 max-h-64 overflow-y-auto">
${['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].map(day => {
const dayConfig = (schedule.days && schedule.days[day]) || { enabled: true, start_time: '08:00', end_time: '18:00' };
return `
<div class="bg-white rounded p-2 border border-gray-200">
<div class="flex items-center justify-between mb-2">
<label class="flex items-center">
<input type="checkbox"
id="day_${day}_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
data-day="${day}"
${dayConfig.enabled ? 'checked' : ''}
class="h-3 w-3 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-xs font-medium text-gray-700 capitalize">${day}</span>
</label>
</div>
<div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${sanitizedId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
<input type="time"
id="day_${day}_start_${sanitizedId}"
aria-label="${day} start time"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
data-day="${day}"
value="${escapeHtml(dayConfig.start_time || '08:00')}"
class="text-xs px-2 py-1 border border-gray-300 rounded"
${!dayConfig.enabled ? 'disabled' : ''}>
<input type="time"
id="day_${day}_end_${sanitizedId}"
aria-label="${day} end time"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
data-day="${day}"
value="${escapeHtml(dayConfig.end_time || '18:00')}"
class="text-xs px-2 py-1 border border-gray-300 rounded"
${!dayConfig.enabled ? 'disabled' : ''}>
</div>
</div>
`;
}).join('')}
</div>
</div>
</div>
</div>
`;
// Attach event listeners using data attributes (prevents JS injection)
const enabledCheckbox = document.getElementById(`schedule_enabled_${sanitizedId}`);
if (enabledCheckbox) {
enabledCheckbox.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
window.toggleImageScheduleEnabled(fieldId, imageId, imageIdx);
});
}
const modeSelect = document.getElementById(`schedule_mode_${sanitizedId}`);
if (modeSelect) {
modeSelect.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
window.updateImageScheduleMode(fieldId, imageId, imageIdx);
});
}
const startInput = document.getElementById(`schedule_start_${sanitizedId}`);
if (startInput) {
startInput.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
window.updateImageScheduleTime(fieldId, imageId, imageIdx);
});
}
const endInput = document.getElementById(`schedule_end_${sanitizedId}`);
if (endInput) {
endInput.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
window.updateImageScheduleTime(fieldId, imageId, imageIdx);
});
}
// Attach listeners for per-day inputs
['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].forEach(day => {
const dayCheckbox = document.getElementById(`day_${day}_${sanitizedId}`);
if (dayCheckbox) {
dayCheckbox.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
const day = this.dataset.day;
window.updateImageScheduleDay(fieldId, imageId, imageIdx, day);
});
}
const dayStartInput = document.getElementById(`day_${day}_start_${sanitizedId}`);
if (dayStartInput) {
dayStartInput.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
const day = this.dataset.day;
window.updateImageScheduleDay(fieldId, imageId, imageIdx, day);
});
}
const dayEndInput = document.getElementById(`day_${day}_end_${sanitizedId}`);
if (dayEndInput) {
dayEndInput.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
const day = this.dataset.day;
window.updateImageScheduleDay(fieldId, imageId, imageIdx, day);
});
}
});
};
/**
* Toggle image schedule enabled state
*/
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
// Builds a DOM element. Attribute values and text children go through the
// DOM APIs, so nothing passed in is ever parsed as HTML. `true` sets an
// empty (boolean) attribute; `false`, null and undefined leave it off.
function h(tag, attrs, children) {
const node = document.createElement(tag);
for (const [name, value] of Object.entries(attrs || {})) {
if (value === false || value == null) continue;
node.setAttribute(name, value === true ? '' : String(value));
}
for (const child of children || []) node.append(child);
return node;
}
function displayStyle(visible, shown) {
return `display: ${visible ? shown : 'none'};`;
}
function scheduleModeOption(schedule, value, label) {
return h('option', { value: value, selected: schedule.mode === value }, [label]);
}
function scheduleRangeTime(which, label, value, domId, ids) {
return h('div', {}, [
h('label', { for: `schedule_${which}_${domId}`, class: 'block text-xs font-medium text-gray-700 mb-1' }, [label]),
h('input', {
type: 'time', id: `schedule_${which}_${domId}`, 'data-schedule-control': 'time', ...ids,
value: value, class: 'block w-full px-2 py-1 text-sm border border-gray-300 rounded-md',
}),
]);
}
function scheduleDayTime(day, which, value, enabled, domId, dayIds) {
return h('input', {
type: 'time', id: `day_${day}_${which}_${domId}`, 'aria-label': `${day} ${which} time`, ...dayIds,
value: value, class: 'text-xs px-2 py-1 border border-gray-300 rounded', disabled: !enabled,
});
}
function scheduleDayRow(schedule, day, domId, ids) {
const dayConfig = (schedule.days && schedule.days[day]) || { enabled: true, start_time: '08:00', end_time: '18:00' };
const dayIds = { 'data-schedule-control': 'day', 'data-day': day, ...ids };
return h('div', { class: 'bg-white rounded p-2 border border-gray-200' }, [
h('div', { class: 'flex items-center justify-between mb-2' }, [
h('label', { class: 'flex items-center' }, [
h('input', {
type: 'checkbox', id: `day_${day}_${domId}`, ...dayIds, checked: !!dayConfig.enabled,
class: 'h-3 w-3 text-blue-600 focus:ring-blue-500 border-gray-300 rounded',
}),
h('span', { class: 'ml-2 text-xs font-medium text-gray-700 capitalize' }, [day]),
]),
]),
h('div', { class: 'grid grid-cols-2 gap-2 ml-5', id: `day_times_${day}_${domId}`, style: displayStyle(dayConfig.enabled, 'grid') }, [
scheduleDayTime(day, 'start', dayConfig.start_time || '08:00', dayConfig.enabled, domId, dayIds),
scheduleDayTime(day, 'end', dayConfig.end_time || '18:00', dayConfig.enabled, domId, dayIds),
]),
]);
}
// Builds the schedule editor. Controls carry data-schedule-control and
// their ids as data attributes; one delegated change listener (below)
// routes them, so the editor needs no per-element listeners.
function renderScheduleEditor(container, fieldId, imageId, imageIdx, savedSchedule) {
const schedule = savedSchedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
const domId = imageDomId(imageId);
const ids = { 'data-field-id': fieldId, 'data-image-id': imageId, 'data-image-idx': Number(imageIdx) };
const dayRows = [];
for (const day of DAYS) dayRows.push(scheduleDayRow(schedule, day, domId, ids));
container.replaceChildren(h('div', { class: 'bg-white rounded-lg border border-blue-200 p-4' }, [
h('h4', { class: 'text-sm font-semibold text-gray-900 mb-3' }, [
h('i', { class: 'fas fa-clock mr-2' }), 'Schedule Settings',
]),
h('div', { class: 'mb-4' }, [
h('label', { class: 'flex items-center' }, [
h('input', {
type: 'checkbox', id: `schedule_enabled_${domId}`, 'data-schedule-control': 'enabled', ...ids,
checked: !!schedule.enabled, class: 'h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded',
}),
h('span', { class: 'ml-2 text-sm font-medium text-gray-700' }, ['Enable schedule for this image']),
]),
h('p', { class: 'ml-6 text-xs text-gray-500 mt-1' }, ['When enabled, this image will only display during scheduled times']),
]),
h('div', { id: `schedule_options_${domId}`, class: 'space-y-4', style: displayStyle(schedule.enabled, 'block') }, [
h('div', {}, [
h('label', { for: `schedule_mode_${domId}`, class: 'block text-sm font-medium text-gray-700 mb-2' }, ['Schedule Type']),
h('select', {
id: `schedule_mode_${domId}`, 'data-schedule-control': 'mode', ...ids,
class: 'block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm',
}, [
scheduleModeOption(schedule, 'always', 'Always Show (No Schedule)'),
scheduleModeOption(schedule, 'time_range', 'Same Time Every Day'),
scheduleModeOption(schedule, 'per_day', 'Different Times Per Day'),
]),
]),
h('div', { id: `time_range_${domId}`, class: 'grid grid-cols-2 gap-4', style: displayStyle(schedule.mode === 'time_range', 'grid') }, [
scheduleRangeTime('start', 'Start Time', schedule.start_time || '08:00', domId, ids),
scheduleRangeTime('end', 'End Time', schedule.end_time || '18:00', domId, ids),
]),
h('div', { id: `per_day_${domId}`, style: displayStyle(schedule.mode === 'per_day', 'block') }, [
h('label', { class: 'block text-xs font-medium text-gray-700 mb-2' }, ['Day-Specific Times']),
h('div', { class: 'bg-gray-50 rounded p-3 space-y-2 max-h-64 overflow-y-auto' }, dayRows),
]),
]),
]));
}
document.addEventListener('change', function(event) {
const el = event.target && event.target.closest && event.target.closest('[data-schedule-control]');
if (!el) return;
const { fieldId, imageId, day } = el.dataset;
const imageIdx = parseInt(el.dataset.imageIdx, 10);
switch (el.dataset.scheduleControl) {
case 'enabled': window.toggleImageScheduleEnabled(fieldId, imageId, imageIdx); break;
case 'mode': window.updateImageScheduleMode(fieldId, imageId, imageIdx); break;
case 'time': window.updateImageScheduleTime(fieldId, imageId, imageIdx); break;
case 'day': window.updateImageScheduleDay(fieldId, imageId, imageIdx, day); break;
}
});
window.toggleImageScheduleEnabled = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
if (!image) return;
// Sanitize imageId for DOM lookup
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
const checkbox = document.getElementById(`schedule_enabled_${sanitizedId}`);
const domId = imageDomId(imageId);
const checkbox = document.getElementById(`schedule_enabled_${domId}`);
const enabled = checkbox ? checkbox.checked : false;
if (!image.schedule) {
image.schedule = { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
}
image.schedule.enabled = enabled;
const optionsDiv = document.getElementById(`schedule_options_${sanitizedId}`);
const optionsDiv = document.getElementById(`schedule_options_${domId}`);
if (optionsDiv) {
optionsDiv.style.display = enabled ? 'block' : 'none';
}
if (window.updateImageList) {
window.updateImageList(fieldId, currentImages);
}
commitScheduleEdit(fieldId, currentImages, imageId);
};
/**
* Update image schedule mode
*/
window.updateImageScheduleMode = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
if (!image) return;
// Sanitize imageId for DOM lookup
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
const domId = imageDomId(imageId);
if (!image.schedule) {
image.schedule = { enabled: true, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
}
const modeSelect = document.getElementById(`schedule_mode_${sanitizedId}`);
const modeSelect = document.getElementById(`schedule_mode_${domId}`);
const mode = modeSelect ? modeSelect.value : 'always';
image.schedule.mode = mode;
const timeRangeDiv = document.getElementById(`time_range_${sanitizedId}`);
const perDayDiv = document.getElementById(`per_day_${sanitizedId}`);
const timeRangeDiv = document.getElementById(`time_range_${domId}`);
const perDayDiv = document.getElementById(`per_day_${domId}`);
if (timeRangeDiv) timeRangeDiv.style.display = mode === 'time_range' ? 'grid' : 'none';
if (perDayDiv) perDayDiv.style.display = mode === 'per_day' ? 'block' : 'none';
if (window.updateImageList) {
window.updateImageList(fieldId, currentImages);
}
commitScheduleEdit(fieldId, currentImages, imageId);
};
/**
* Update image schedule time
*/
window.updateImageScheduleTime = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
if (!image) return;
// Sanitize imageId for DOM lookup
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
const domId = imageDomId(imageId);
if (!image.schedule) {
image.schedule = { enabled: true, mode: 'time_range', start_time: '08:00', end_time: '18:00' };
}
const startInput = document.getElementById(`schedule_start_${sanitizedId}`);
const endInput = document.getElementById(`schedule_end_${sanitizedId}`);
const startInput = document.getElementById(`schedule_start_${domId}`);
const endInput = document.getElementById(`schedule_end_${domId}`);
if (startInput) image.schedule.start_time = startInput.value || '08:00';
if (endInput) image.schedule.end_time = endInput.value || '18:00';
if (window.updateImageList) {
window.updateImageList(fieldId, currentImages);
}
commitScheduleEdit(fieldId, currentImages, imageId);
};
/**
* Update image schedule day
*/
window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
if (!image) return;
// Sanitize imageId for DOM lookup
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
const domId = imageDomId(imageId);
if (!image.schedule) {
image.schedule = { enabled: true, mode: 'per_day', days: {} };
}
if (!image.schedule.days) {
image.schedule.days = {};
}
const checkbox = document.getElementById(`day_${day}_${sanitizedId}`);
const startInput = document.getElementById(`day_${day}_start_${sanitizedId}`);
const endInput = document.getElementById(`day_${day}_end_${sanitizedId}`);
const checkbox = document.getElementById(`day_${day}_${domId}`);
const startInput = document.getElementById(`day_${day}_start_${domId}`);
const endInput = document.getElementById(`day_${day}_end_${domId}`);
const enabled = checkbox ? checkbox.checked : true;
if (!image.schedule.days[day]) {
image.schedule.days[day] = { enabled: true, start_time: '08:00', end_time: '18:00' };
}
image.schedule.days[day].enabled = enabled;
if (startInput) image.schedule.days[day].start_time = startInput.value || '08:00';
if (endInput) image.schedule.days[day].end_time = endInput.value || '18:00';
const dayTimesDiv = document.getElementById(`day_times_${day}_${sanitizedId}`);
const dayTimesDiv = document.getElementById(`day_times_${day}_${domId}`);
if (dayTimesDiv) {
dayTimesDiv.style.display = enabled ? 'grid' : 'none';
}
if (startInput) startInput.disabled = !enabled;
if (endInput) endInput.disabled = !enabled;
if (window.updateImageList) {
window.updateImageList(fieldId, currentImages);
}
commitScheduleEdit(fieldId, currentImages, imageId);
};
})();
@@ -31,12 +31,7 @@
let fontCatalogCache = null;
let fontCatalogPromise = null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -152,9 +152,7 @@
if (selectedIds.length === 0) {
checkbox.checked = true;
selectedIds.push(cal.id);
if (window.showNotification) {
window.showNotification('At least one calendar must be selected.', 'warning');
}
window.showNotification('At least one calendar must be selected.', 'warning');
}
syncHiddenAndSummary();
});
@@ -185,11 +183,5 @@
container.appendChild(p);
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeHtml(str) { return window.LEDEscape.html(str); }
})();
@@ -760,11 +760,7 @@
}
_notify(msg, type) {
if (typeof window.showNotification === 'function') {
window.showNotification(msg, type || 'info');
} else {
console.info(`[JsonFileManager] ${type || 'info'}: ${msg}`);
}
window.showNotification(msg, type || 'info');
}
_busy(btn, label) {
@@ -785,15 +781,8 @@
delete btn._jfmOrigText;
}
// Quotes too: the result lands in quoted attribute values (title=,
// data-cat=, pattern=, ...), and the textContent/innerHTML round-trip
// only escapes &, < and >.
_esc(str) {
const d = document.createElement('div');
d.textContent = String(str ?? '');
return d.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
return window.LEDEscape.html(str);
}
_fmtSize(bytes) {
@@ -1,10 +1,13 @@
/* global debugLog */
/**
* LEDMatrix Notification Widget
*
* Global notification/toast system for displaying messages to users.
* This is the single implementation: the early fallbacks in app-shell.js,
* app.js and partials/fonts.html only queue messages until this widget has
* loaded (see window.__pendingNotifications) and then delegate to it.
* This is the single implementation. app-shell.js defines a stand-in first
* (it runs before every other script that notifies), which queues messages
* in window.__pendingNotifications until this widget loads, shows the queue
* and replaces it. So window.showNotification always exists: call it
* directly, without a typeof check or a fallback.
*
* Usage:
* window.showNotification('Message here', 'success');
@@ -158,16 +161,7 @@
setTimeout(() => { region.textContent = text; }, 50);
}
/**
* Escape HTML to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function clearTimer(notificationId) {
const t = timers.get(notificationId);
@@ -360,8 +354,7 @@
});
}
// Log for debugging
console.log(`[${type.toUpperCase()}]`, message);
debugLog(`[${type.toUpperCase()}]`, message);
return notificationId;
}
@@ -28,12 +28,7 @@
const base = window.BaseWidget ? new window.BaseWidget('NumberInput', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -28,12 +28,7 @@
const base = window.BaseWidget ? new window.BaseWidget('PasswordInput', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -235,20 +235,10 @@
}
function notify(msg, type) {
if (window.showNotification) window.showNotification(msg, type);
else console.log(`[PFM][${type}] ${msg}`);
window.showNotification(msg, type);
}
// Quotes too: the result lands in quoted attribute values (id=, value=,
// data-col=), and the textContent/innerHTML round-trip only escapes
// &, < and >.
function escHtml(s) {
const d = document.createElement('div');
d.textContent = String(s ?? '');
return d.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escHtml(s) { return window.LEDEscape.html(s); }
function formatSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
@@ -745,13 +735,12 @@
window._pfmUpload = async function (fieldId, file) {
const st = getState(fieldId);
const notifyFn = window.showNotification || console.log;
if (!file.name.toLowerCase().endsWith('.json')) {
notifyFn('Only .json files can be uploaded', 'error'); return;
window.showNotification('Only .json files can be uploaded', 'error'); return;
}
let content;
try { content = await file.text(); JSON.parse(content); }
catch { notifyFn('File contains invalid JSON', 'error'); return; }
catch { window.showNotification('File contains invalid JSON', 'error'); return; }
const result = await callAction(st.pluginId, st.actions.upload, {
filename: file.name, content
@@ -33,12 +33,7 @@
const base = window.BaseWidget ? new window.BaseWidget('RadioGroup', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -1,3 +1,4 @@
/* global debugLog */
/**
* LEDMatrix Widget Registry
*
@@ -183,7 +184,7 @@
const removed = this._widgets.delete(widgetName);
this._handlers.delete(widgetName);
if (removed) {
console.log(`[WidgetRegistry] Unregistered widget: ${widgetName}`);
debugLog(`[WidgetRegistry] Unregistered widget: ${widgetName}`);
}
return removed;
},
@@ -194,7 +195,7 @@
clear: function() {
this._widgets.clear();
this._handlers.clear();
console.log('[WidgetRegistry] Cleared all widgets');
debugLog('[WidgetRegistry] Cleared all widgets');
}
};
@@ -54,12 +54,7 @@
// Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -28,12 +28,7 @@
const base = window.BaseWidget ? new window.BaseWidget('SelectDropdown', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
+2 -10
View File
@@ -28,17 +28,9 @@
const base = window.BaseWidget ? new window.BaseWidget('Slider', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
// Escape for use in HTML attributes (also escapes quotes)
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(text) { return window.LEDEscape.attr(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -29,12 +29,7 @@
const base = window.BaseWidget ? new window.BaseWidget('TextInput', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -26,12 +26,7 @@
const base = window.BaseWidget ? new window.BaseWidget('Textarea', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -25,12 +25,7 @@
const base = window.BaseWidget ? new window.BaseWidget('TimePicker', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -32,12 +32,7 @@
// Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('TimeRange', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -23,12 +23,7 @@
const base = window.BaseWidget ? new window.BaseWidget('TimezoneSelector', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -26,12 +26,7 @@
const base = window.BaseWidget ? new window.BaseWidget('ToggleSwitch', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
@@ -26,12 +26,7 @@
const base = window.BaseWidget ? new window.BaseWidget('UrlInput', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
File diff suppressed because it is too large Load Diff
+97 -181
View File
@@ -168,54 +168,109 @@
</script>
<script src="{{ url_for('static', filename='v3/js/htmx-config.js') }}"></script>
<!-- Fallback for loading plugins if HTMX fails -->
<!-- Direct-fetch fallback for tab panels. Used when HTMX never loads, and
by the hx-on::response-error handlers on the Overview, WiFi and
Plugin Manager panels when HTMX's own request fails. -->
<script>
function loadPluginsDirect() {
const content = document.getElementById('plugins-content');
if (content && !content.hasAttribute('data-loaded')) {
content.setAttribute('data-loaded', 'true');
debugLog('Loading plugins directly via fetch (HTMX fallback)...');
(function() {
const PANEL_LABELS = {
'overview-content': 'the Overview',
'wifi-content': 'WiFi setup',
'plugins-content': 'the Plugin Manager'
};
// Fetches `url` into the element `id`, runs the partial's inline
// scripts, and lets HTMX and Alpine process the new markup. The
// scripts run before Alpine sees the content for the same reason
// htmx-config.js runs them on htmx:afterSwap: partials define their
// x-data component functions in those scripts.
window.loadPartialDirect = function(id, url) {
const el = document.getElementById(id);
if (!el || !url || el.hasAttribute('data-loaded') || el.hasAttribute('data-direct-loading')) return;
el.setAttribute('data-direct-loading', 'true');
debugLog('Loading ' + url + ' directly via fetch (HTMX fallback)');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
fetch('/v3/partials/plugins', { signal: controller.signal })
fetch(url, { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
return r.text();
})
.then(html => {
clearTimeout(timeout);
content.innerHTML = html;
if (typeof htmx !== 'undefined') {
htmx.process(content);
}
// Trigger full initialization chain
if (window.pluginManager) {
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
}
if (window.initPluginsPage) {
el.innerHTML = html;
el.querySelectorAll('script').forEach(oldScript => {
const script = document.createElement('script');
for (const attr of oldScript.attributes) script.setAttribute(attr.name, attr.value);
script.textContent = oldScript.textContent;
oldScript.replaceWith(script);
});
el.setAttribute('data-loaded', 'true');
if (typeof htmx !== 'undefined') htmx.process(el);
if (window.Alpine) window.Alpine.initTree(el);
if (id === 'plugins-content' && window.initPluginsPage) {
if (window.pluginManager) {
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
}
window.initPluginsPage();
}
})
.catch(err => {
console.error('Failed to load ' + url + ':', err);
const label = PANEL_LABELS[id] || 'this page';
el.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load ' + label + '. Check that the device is still reachable, then try again.</p><button type="button" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
el.querySelector('button').addEventListener('click', () => window.loadPartialDirect(id, url));
})
.finally(() => {
clearTimeout(timeout);
console.error('Failed to load plugins:', err);
content.removeAttribute('data-loaded');
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Plugin Manager. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadPluginsDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
el.removeAttribute('data-direct-loading');
});
}
}
};
// Fallback if HTMX doesn't load within 5 seconds
var _pluginsFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
// Load plugins tab content directly regardless of active tab,
// so it's ready when the user navigates to it
loadPluginsDirect();
function activeTab() {
const appEl = document.querySelector('[x-data="app()"]');
try {
return (window.Alpine && appEl) ? window.Alpine.$data(appEl).activeTab : null;
} catch (e) {
return null; // Alpine has not initialised the app yet
}
}
}, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_pluginsFallbackTimer); }, { once: true });
function loadActiveTabDirect() {
const el = document.getElementById((activeTab() || 'overview') + '-content');
if (el) window.loadPartialDirect(el.id, el.getAttribute('hx-get'));
}
// HTMX loaded but never initialised, even from the CDN rescue.
window.addEventListener('htmx-load-failed', function() {
console.warn('HTMX failed to load, loading the active tab directly');
setTimeout(loadActiveTabDirect, 2000);
});
// HTMX still missing after 5 seconds: load the active tab, and the
// Plugin Manager regardless of tab so the plugin tabs work.
const fallbackTimer = setTimeout(function() {
if (typeof htmx !== 'undefined') return;
console.warn('HTMX not loaded after 5 seconds, loading tabs directly');
window.loadPartialDirect('plugins-content', '/v3/partials/plugins');
loadActiveTabDirect();
}, 5000);
// AP mode (setting up WiFi) opens on the WiFi tab and can least
// afford to wait, so it gets an earlier check.
const apTimer = setTimeout(function() {
const host = window.location.hostname;
if (typeof htmx === 'undefined' && host.startsWith('192.168.4.')) {
console.warn('HTMX not loaded, loading WiFi setup directly');
window.loadPartialDirect('wifi-content', '/v3/partials/wifi');
}
}, 3000);
window.addEventListener('htmx:ready', function() {
clearTimeout(fallbackTimer);
clearTimeout(apTimer);
}, { once: true });
})();
</script>
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script src="{{ url_for('static', filename='v3/js/app-early.js') }}"></script>
@@ -246,45 +301,6 @@
});
</script>
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
<link rel="preload" href="{{ url_for('static', filename='v3/vendor/codemirror/codemirror.min.css') }}" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/codemirror/codemirror.min.css') }}"></noscript>
<link rel="preload" href="{{ url_for('static', filename='v3/vendor/codemirror/theme/monokai.min.css') }}" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/codemirror/theme/monokai.min.css') }}"></noscript>
<!-- CodeMirror scripts loaded on demand when JSON editor is opened -->
<script>
// Lazy load CodeMirror when needed
window.loadCodeMirror = function() {
if (window.CodeMirror) return Promise.resolve();
return new Promise((resolve, reject) => {
// Vendored CodeMirror 5.65.2 (note: the previously listed CDN
// mode/json/json.min.js never existed — it 404'd on every
// editor open; JSON highlighting comes from the javascript
// mode, so it's simply dropped here)
const scripts = [
'/static/v3/vendor/codemirror/codemirror.min.js',
'/static/v3/vendor/codemirror/mode/javascript/javascript.min.js',
'/static/v3/vendor/codemirror/addon/edit/closebrackets.min.js',
'/static/v3/vendor/codemirror/addon/edit/matchbrackets.min.js'
];
let loaded = 0;
scripts.forEach((src, index) => {
const script = document.createElement('script');
script.src = src;
script.defer = true;
script.onload = () => {
loaded++;
if (loaded === scripts.length) resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
});
};
</script>
<!-- Font Awesome icons (vendored 6.0.0 — css/all.min.css references
../webfonts/ relatively, so both live under v3/vendor/fontawesome/) -->
<link rel="stylesheet" href="{{ url_for('static', filename='v3/vendor/fontawesome/css/all.min.css') }}">
@@ -548,7 +564,7 @@
<!-- Second row - Plugin tabs (populated dynamically) -->
<div id="plugin-tabs-row" class="border-b border-gray-200">
<nav aria-label="Plugins" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPluginsDirect()); }"
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPartialDirect('plugins-content', '/v3/partials/plugins')); }"
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-plug" aria-hidden="true"></i>Plugin Manager
@@ -592,7 +608,7 @@
<div id="tab-content" class="space-y-6" tabindex="-1">
<!-- Overview tab -->
<div x-show="activeTab === 'overview'" x-transition>
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -601,65 +617,6 @@
</div>
</div>
</div>
<script>
// Fallback: Load overview content directly if HTMX fails
function loadOverviewDirect() {
const overviewContent = document.getElementById('overview-content');
if (overviewContent && !overviewContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/overview')
.then(response => {
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
return response.text();
})
.then(html => {
overviewContent.innerHTML = html;
overviewContent.setAttribute('data-loaded', 'true');
if (typeof htmx !== 'undefined') {
htmx.process(overviewContent);
}
// Re-initialize Alpine.js for the new content
if (window.Alpine) {
window.Alpine.initTree(overviewContent);
}
})
.catch(err => {
console.error('Failed to load overview content:', err);
overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Overview. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadOverviewDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
});
}
}
// Listen for HTMX load failure
window.addEventListener('htmx-load-failed', function() {
console.warn('HTMX failed to load, setting up direct content loading fallbacks');
// Try to load content directly after a delay
setTimeout(() => {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement.__x) {
const activeTab = appElement.__x.$data.activeTab || 'overview';
if (activeTab === 'overview') {
loadOverviewDirect();
}
}
}, 2000);
});
// Also try direct load if HTMX doesn't load within 5 seconds
var _overviewFallbackTimer = setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement.__x) {
const activeTab = appElement.__x.$data.activeTab || 'overview';
if (activeTab === 'overview') {
loadOverviewDirect();
}
}
}
}, 5000);
window.addEventListener('htmx:ready', function() { clearTimeout(_overviewFallbackTimer); }, { once: true });
</script>
<!-- General tab -->
<div x-show="activeTab === 'general'" x-transition>
@@ -683,7 +640,7 @@
hx-get="/v3/partials/wifi"
hx-trigger="loadtab"
hx-swap="innerHTML"
hx-on::htmx:response-error="loadWifiDirect()">
hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -696,42 +653,6 @@
</div>
</div>
</div>
<script>
// Fallback: Load WiFi content directly if HTMX fails
function loadWifiDirect() {
const wifiContent = document.getElementById('wifi-content');
if (wifiContent && !wifiContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/wifi')
.then(response => {
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
return response.text();
})
.then(html => {
wifiContent.innerHTML = html;
wifiContent.setAttribute('data-loaded', 'true');
// Re-initialize Alpine.js for the new content
if (window.Alpine) {
window.Alpine.initTree(wifiContent);
}
})
.catch(err => {
console.error('Failed to load WiFi content:', err);
wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load WiFi setup. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadWifiDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
});
}
}
// Also try direct load if HTMX doesn't load within 3 seconds (AP mode detection)
setTimeout(() => {
const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.');
if (isAPMode && typeof htmx === 'undefined') {
console.warn('HTMX not loaded, using direct fetch for WiFi content');
loadWifiDirect();
}
}, 3000);
</script>
<!-- Schedule tab -->
<div x-show="activeTab === 'schedule'" x-transition>
@@ -806,7 +727,7 @@
<!-- Plugins tab -->
<div x-show="activeTab === 'plugins'" x-transition>
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="loadtab" hx-swap="innerHTML"
hx-on::response-error="loadPluginsDirect()">
hx-on::response-error="loadPartialDirect(this.id, this.getAttribute('hx-get'))">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
@@ -986,11 +907,10 @@
their own URLs for plugin-loader.js and debugging. -->
<script src="{{ widgets_bundle_url() }}" defer></script>
<!-- plugins_manager.js: loaded LAST on purpose — it defines the live
window.* implementations for several plugin actions (installPlugin,
uninstallPlugin, executePluginAction, ...) and intentionally wins
over same-named definitions in js/app.js and js/app-shell.js. Do not
remove or reorder without resolving that overlap first. -->
<!-- plugins_manager.js: loaded last. It uses PluginAPI, ListFilter,
LEDDialog, the widgets and app-shell.js's updatePlugin at load time
or when a card is clicked, so it must follow all of them. No other
script defines the same globals. -->
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
<!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the
@@ -1147,16 +1067,12 @@
'services to run the new code');
}
}
if (typeof showNotification === 'function') {
showNotification(data.message || 'Update complete', data.status || 'success');
}
showNotification(data.message || 'Update complete', data.status || 'success');
})
.catch(function() {
btn.innerHTML = originalHTML;
btn.disabled = false;
if (typeof showNotification === 'function') {
showNotification('Update failed — check your connection', 'error');
}
showNotification('Update failed — check your connection', 'error');
});
};
@@ -121,11 +121,7 @@
let inspectedFile = null;
function notify(message, kind) {
if (typeof showNotification === 'function') {
showNotification(message, kind || 'info');
} else {
console.log('[backup]', kind || 'info', message);
}
showNotification(message, kind || 'info');
}
function formatSize(bytes) {
@@ -136,11 +132,7 @@
return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i];
}
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
function escapeHtml(value) { return window.LEDEscape.html(value); }
async function loadPreview() {
const el = document.getElementById('export-preview');
+10 -26
View File
@@ -55,13 +55,12 @@
</div>
<script>
// Initialize cache management when partial loads
// Scoped: this script runs at global scope after every HTMX swap, and the Logs
// partial has helpers with the same names (showError, escapeHtml). Only
// deleteCacheFile, which the row buttons call from onclick, is exported.
(function() {
loadCacheFiles();
// Event listeners
document.getElementById('refresh-cache-btn').addEventListener('click', loadCacheFiles);
})();
loadCacheFiles();
document.getElementById('refresh-cache-btn').addEventListener('click', loadCacheFiles);
function loadCacheFiles() {
const tbody = document.getElementById('cache-files-tbody');
@@ -190,21 +189,15 @@ function deleteCacheFile(key) {
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
if (typeof showNotification !== 'undefined') {
showNotification(data.message || 'Cache file deleted successfully', 'success');
}
showNotification(data.message || 'Cache file deleted successfully', 'success');
// Reload cache files list
loadCacheFiles();
} else {
if (typeof showNotification !== 'undefined') {
showNotification(data.message || 'Failed to delete cache file', 'error');
}
showNotification(data.message || 'Failed to delete cache file', 'error');
}
})
.catch(error => {
if (typeof showNotification !== 'undefined') {
showNotification('Error deleting cache file: ' + error.message, 'error');
}
showNotification('Error deleting cache file: ' + error.message, 'error');
});
}
@@ -220,17 +213,8 @@ function showError(message) {
errorMessage.textContent = message;
}
// Utility function to escape HTML
// Quotes too: the result is interpolated into quoted attribute values, and the
// textContent/innerHTML round-trip only escapes &, < and >.
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
// Make deleteCacheFile available globally for onclick handlers
window.deleteCacheFile = deleteCacheFile;
})();
</script>
@@ -35,7 +35,7 @@
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
hx-on:htmx:after-request="showDisplaySaveResult(event.detail.xhr)"
hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Display settings saved', 'Display settings were not saved. Check your connection and try again.')"
class="space-y-6"
novalidate
onsubmit="fixInvalidNumberInputs(this); return true;">
@@ -863,62 +863,12 @@ document.getElementById('brightness').addEventListener('input', function() {
document.getElementById('brightness-display').textContent = this.value;
});
// Fix invalid number inputs function (if not already defined globally)
if (typeof window.fixInvalidNumberInputs !== 'function') {
window.fixInvalidNumberInputs = function(form) {
if (!form) return;
const allInputs = form.querySelectorAll('input[type="number"]');
allInputs.forEach(input => {
const min = parseFloat(input.getAttribute('min'));
const max = parseFloat(input.getAttribute('max'));
const value = parseFloat(input.value);
if (!isNaN(value)) {
if (!isNaN(min) && value < min) {
input.value = min;
} else if (!isNaN(max) && value > max) {
input.value = max;
}
}
});
};
}
// Report the outcome of a display-settings save. XMLHttpRequest has no
// `responseJSON` (that's a jQuery property) — read `responseText` and the real
// status code, otherwise a failed save reports success.
window.showDisplaySaveResult = function(xhr) {
// Only 2xx counts as saved. A network failure reports status 0, which any
// `>= 400` test would wave through as success.
const httpSuccess = xhr.status >= 200 && xhr.status < 300;
let message = httpSuccess
? 'Display settings saved'
: 'Display settings were not saved. Check your connection and try again.';
let status = httpSuccess ? 'success' : 'error';
try {
const data = JSON.parse(xhr.responseText);
if (data.message) message = data.message;
// A body can refine a successful verdict but never overturn a failed one.
if (httpSuccess && data.status) status = data.status;
} catch {
// Non-JSON body — fall back to the status-code verdict above.
}
showNotification(message, status);
};
// Vegas Scroll Mode Settings
(function() {
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = String(text || '');
return div.innerHTML;
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
// Escape for use in HTML attributes
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(text) { return window.LEDEscape.attr(text); }
// Toggle settings visibility
const vegasEnabledCheckbox = document.getElementById('vegas_scroll_enabled');
@@ -11,7 +11,7 @@
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
hx-on:htmx:after-request="showNotification(event.detail.xhr.responseJSON?.message || 'Durations saved', event.detail.xhr.responseJSON?.status || 'success')"
hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Durations saved', 'Durations were not saved. Check your connection and try again.')"
class="space-y-6"
novalidate
onsubmit="fixInvalidNumberInputs(this); return true;">
+1 -23
View File
@@ -164,21 +164,6 @@ function initializeFontsTab() {
// Reset retry counter on successful init
initRetryCount = 0;
// showNotification is implemented by the notification widget
// (widgets/notification.js). If it hasn't loaded, queue the message; the
// widget shows queued messages as soon as it registers.
if (typeof window.showNotification !== 'function') {
window.showNotification = function(message, type = 'info') {
const registry = window.LEDMatrixWidgets;
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
if (widget && typeof widget.show === 'function') {
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
}
(window.__pendingNotifications = window.__pendingNotifications || []).push([message, type]);
console.log(`[${String((type && type.type) || type).toUpperCase()}]`, message);
};
}
debugLog('Initializing font management...');
initializeFontManagement();
@@ -349,14 +334,7 @@ async function loadFontData() {
// Show error states
availableContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
// Only show notification if showNotification is available
if (typeof showNotification === 'function') {
showNotification('Error loading font configuration', 'error');
} else if (typeof window.showNotification === 'function') {
window.showNotification('Error loading font configuration', 'error');
} else {
console.error('Error loading font configuration:', error.message);
}
showNotification('Error loading font configuration', 'error');
}
}
@@ -11,24 +11,7 @@
hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}'
hx-swap="none"
hx-on:htmx:after-request="
var xhr = event.detail.xhr;
var isSuccess = xhr.status >= 200 && xhr.status < 300;
var message = '';
var status = 'success';
try {
var data = JSON.parse(xhr.responseText);
message = data.message || '';
status = data.status || status;
} catch (e) {}
if (isSuccess) {
message = message || 'Settings saved';
} else {
message = message || 'Failed to save settings';
status = 'error';
}
showNotification(message, status);
"
hx-on:htmx:after-request="showSaveResult(event.detail.xhr, 'Settings saved', 'Failed to save settings')"
class="space-y-6">
{# Marks this post as the whole form, so an unchecked box saves as
false. Without it the save endpoint treats absent keys as unchanged
+14 -30
View File
@@ -126,8 +126,10 @@
</div>
<script>
// Global variables - use window properties to avoid redeclaration errors with HTMX reloads
// Initialize only if not already defined
// Scoped: this script runs at global scope after every HTMX swap, and the
// Cache partial has helpers with the same names (showError, escapeHtml). State
// that must survive a reload of this partial lives on window._logs* instead.
(function() {
if (typeof window._logsEventSource === 'undefined') {
window._logsEventSource = null;
}
@@ -624,9 +626,7 @@ function toggleRealtime() {
function refreshLogs() {
loadLogs();
if (typeof showNotification !== 'undefined') {
showNotification('Logs refreshed', 'success');
}
showNotification('Logs refreshed', 'success');
}
function clearLogs() {
@@ -637,16 +637,12 @@ function clearLogs() {
}
showEmptyState();
updateLogStats();
if (typeof showNotification !== 'undefined') {
showNotification('Logs cleared', 'info');
}
showNotification('Logs cleared', 'info');
}
function downloadLogs() {
if (window._filteredLogs.length === 0) {
if (typeof showNotification !== 'undefined') {
showNotification('No logs to download', 'warning');
}
showNotification('No logs to download', 'warning');
return;
}
@@ -662,9 +658,7 @@ function downloadLogs() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (typeof showNotification !== 'undefined') {
showNotification('Logs downloaded', 'success');
}
showNotification('Logs downloaded', 'success');
}
// Track if user is near bottom for smart auto-scroll
@@ -776,16 +770,7 @@ function showError(message) {
}
}
// Utility function to escape HTML. Quotes too: the result is interpolated into
// quoted attribute values (option value=, title=), and the
// textContent/innerHTML round-trip only escapes &, < and >.
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function refreshCurrentPluginStatus() {
fetch('/api/v3/display/current-status')
@@ -920,15 +905,13 @@ function clearPluginErrors() {
})
.then(response => response.json())
.then(data => {
if (typeof showNotification !== 'undefined') {
showNotification(data.status === 'success' ? 'Plugin errors cleared'
: (data.message || 'Could not clear plugin errors'),
data.status === 'success' ? 'success' : 'error');
}
showNotification(data.status === 'success' ? 'Plugin errors cleared'
: (data.message || 'Could not clear plugin errors'),
data.status === 'success' ? 'success' : 'error');
refreshPluginErrors();
})
.catch(() => {
if (typeof showNotification !== 'undefined') showNotification('Could not clear plugin errors', 'error');
showNotification('Could not clear plugin errors', 'error');
});
}
@@ -946,4 +929,5 @@ window.addEventListener('beforeunload', function() {
window._pluginErrorsPollTimer = null;
}
});
})();
</script>
@@ -260,18 +260,10 @@
return 'bg-gray-100 text-gray-800';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function showError(message) {
if (typeof showNotification === 'function') {
showNotification(message, 'error');
} else {
alert(message);
}
showNotification(message, 'error');
}
// Populate plugin filter
@@ -216,11 +216,7 @@
if (nowDone) maybeAutoHide();
return;
}
var appEl = document.querySelector('[x-data="app()"]') || document.querySelector('[x-data]');
// Same two-tier resolution as settings-search.js's getAppData():
// _x_dataStack on current Alpine, __x.$data as an older-API fallback.
var data = appEl && ((appEl._x_dataStack && appEl._x_dataStack[0]) ||
(appEl.__x && appEl.__x.$data));
var data = window.getApp();
if (data) {
data.activeTab = btn.dataset.tab;
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
@@ -443,7 +439,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "start_display"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
hx-on:htmx:after-request="var m='Display started',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-green-600 hover:bg-green-700">
<i class="fas fa-play mr-2"></i>
Start Display
@@ -452,7 +448,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "stop_display"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
hx-on:htmx:after-request="var m='Display stopped',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-600 hover:bg-red-700">
<i class="fas fa-stop mr-2"></i>
Stop Display
@@ -462,7 +458,7 @@
hx-vals='{"action": "git_pull"}'
hx-confirm="This will stash any local changes and update the code. Continue?"
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
hx-on:htmx:after-request="var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-2"></i>
Update Code
@@ -472,7 +468,7 @@
hx-vals='{"action": "reboot_system"}'
hx-confirm="Are you sure you want to reboot the system?"
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
hx-on:htmx:after-request="var m='System rebooting...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-yellow-600 hover:bg-yellow-700">
<i class="fas fa-power-off mr-2"></i>
Reboot System
@@ -482,7 +478,7 @@
hx-vals='{"action": "shutdown_system"}'
hx-confirm="Are you sure you want to shut down the system? This will power off the Raspberry Pi."
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
hx-on:htmx:after-request="var m='System shutting down...',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-semibold rounded-md text-white bg-red-800 hover:bg-red-900">
<i class="fas fa-power-off mr-2"></i>
Shutdown System
@@ -491,7 +487,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_display_service"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
hx-on:htmx:after-request="var m='Display service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i>
Restart Display Service
@@ -500,7 +496,7 @@
<button hx-post="/api/v3/system/action"
hx-vals='{"action": "restart_web_service"}'
hx-swap="none"
hx-on:htmx:after-request="if (typeof showNotification !== 'undefined') { var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s); }"
hx-on:htmx:after-request="var m='Web service restarted',s='success'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
<i class="fas fa-redo mr-2"></i>
Restart Web Service
@@ -423,11 +423,10 @@
{% if img.get('size') %}{{ (img.get('size') / 1024)|round }} KB{% endif %}
{% if img.get('uploaded_at') %} • {{ img.get('uploaded_at') }}{% endif %}
</p>
{% if has_schedule %}
<p class="text-xs text-blue-600 mt-1">
<i class="fas fa-clock mr-1"></i>Scheduled
{# file-upload.js rewrites .image-schedule-summary in place when the schedule is edited #}
<p class="text-xs text-blue-600 mt-1 image-schedule-summary">
<i class="fas fa-clock mr-1"></i>{{ 'Scheduled' if has_schedule else 'Always shown' }}
</p>
{% endif %}
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
@@ -540,18 +540,6 @@
background-color: #e5e7eb;
}
/* CodeMirror editor styles */
.CodeMirror {
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 14px;
height: auto;
min-height: 400px;
}
.CodeMirror.cm-error {
border-color: #ef4444;
}
/* Plugin config view styles */
.plugin-config-view {
@@ -148,7 +148,7 @@
fieldId: 'schedule'
});
console.log('[Schedule] Schedule picker widget initialized');
debugLog('[Schedule] Schedule picker widget initialized');
}
// Handle form submission response
@@ -165,28 +165,7 @@
const type = response.status || 'info';
// Use global notification function if available
if (typeof window.showNotification === 'function') {
window.showNotification(message, type);
} else {
// Fallback notification
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
};
const notification = document.createElement('div');
notification.className = 'fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ' + (colors[type] || colors.info) + ' text-white';
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(function() {
notification.style.transition = 'opacity 0.5s';
notification.style.opacity = '0';
setTimeout(function() { notification.remove(); }, 500);
}, 3000);
}
window.showNotification(message, type);
};
// Initialize when DOM is ready or if already loaded
@@ -265,7 +244,7 @@
fieldId: 'dim_schedule'
});
console.log('[DimSchedule] Dim schedule picker widget initialized');
debugLog('[DimSchedule] Dim schedule picker widget initialized');
}
// Handle dim schedule form submission response
@@ -282,28 +261,7 @@
const type = response.status || 'info';
// Use global notification function if available
if (typeof window.showNotification === 'function') {
window.showNotification(message, type);
} else {
// Fallback notification
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
};
const notification = document.createElement('div');
notification.className = 'fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ' + (colors[type] || colors.info) + ' text-white';
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(function() {
notification.style.transition = 'opacity 0.5s';
notification.style.opacity = '0';
setTimeout(function() { notification.remove(); }, 500);
}, 3000);
}
window.showNotification(message, type);
};
// Initialize when DOM is ready
@@ -344,21 +344,15 @@ function forceRenderStarlarkApp(appId) {
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success') {
if (typeof showNotification === 'function') {
showNotification('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)', 'success');
} else {
alert('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)');
}
showNotification('Rendered successfully! ' + (data.frame_count || 0) + ' frame(s)', 'success');
} else {
var msg = 'Render failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
}
})
.catch(function(err) {
var msg = 'Render failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
});
}
@@ -371,22 +365,20 @@ function toggleStarlarkApp(appId, enabled) {
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success') {
if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins();
// A failed reload has already been reported by loadInstalledPlugins.
window.pluginManager.loadInstalledPlugins(true).catch(function() {});
var container = document.getElementById('plugin-config-starlark:' + appId);
if (container && window.htmx) {
htmx.ajax('GET', '/v3/partials/plugin-config/starlark:' + encodeURIComponent(appId), {target: container, swap: 'innerHTML'});
}
} else {
var msg = 'Toggle failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
}
})
.catch(function(err) {
var msg = 'Toggle failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
});
}
@@ -436,8 +428,7 @@ function saveStarlarkConfig(appId) {
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'success') {
if (typeof showNotification === 'function') showNotification('Configuration saved!', 'success');
else alert('Configuration saved!');
showNotification('Configuration saved!', 'success');
// Reload partial to reflect updated status
var container = document.getElementById('plugin-config-starlark:' + appId);
if (container && window.htmx) {
@@ -445,14 +436,12 @@ function saveStarlarkConfig(appId) {
}
} else {
var msg = 'Save failed: ' + (data.message || 'Unknown error');
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
}
})
.catch(function(err) {
var msg = 'Save failed: ' + err.message;
if (typeof showNotification === 'function') showNotification(msg, 'error');
else alert(msg);
showNotification(msg, 'error');
});
}
@@ -435,9 +435,7 @@
el.innerHTML = html;
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escHtml(s) { return window.LEDEscape.html(s); }
// ── main action dispatcher ────────────────────────────────────────────────
@@ -910,11 +908,7 @@
};
// ── plugin health panel ──────────────────────────────────────────────────
function phEscape(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function phEscape(s) { return window.LEDEscape.html(s); }
function phFmtSecs(v) {
if (typeof v !== 'number' || !isFinite(v)) return '—';
return v.toFixed(3) + 's';