mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 17:16:36 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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([['&', '&'], ['<', '<'], ['>', '>'], ['"', '"'], ["'", ''']]);
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
showNotification: function(message, type) {}
|
||||
};
|
||||
};
|
||||
})();
|
||||
|
||||
+397
-1634
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
})();
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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 = '';
|
||||
}
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// 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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
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, '"')
|
||||
.replace(/'/g, ''');
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
function escapeHtml(text) { return window.LEDEscape.html(text); }
|
||||
|
||||
function sanitizeId(id) {
|
||||
if (base) return base.sanitizeId(id);
|
||||
|
||||
@@ -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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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
@@ -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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||
});
|
||||
}
|
||||
function escapeHtml(value) { return window.LEDEscape.html(value); }
|
||||
|
||||
async function loadPreview() {
|
||||
const el = document.getElementById('export-preview');
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
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, '"').replace(/'/g, ''');
|
||||
}
|
||||
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;">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function phEscape(s) { return window.LEDEscape.html(s); }
|
||||
function phFmtSecs(v) {
|
||||
if (typeof v !== 'number' || !isFinite(v)) return '—';
|
||||
return v.toFixed(3) + 's';
|
||||
|
||||
Reference in New Issue
Block a user