mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
fix(web): Cache/Logs error mix-up, store errors, tab fallbacks; remove ~2.5k lines of dead JS (#639)
* fix(web): keep Cache and Logs helpers out of each other's way Both partials declared top-level showError and escapeHtml. Their scripts run at global scope after every HTMX swap, so whichever tab was opened last owned window.showError, and a Cache failure after visiting Logs rendered into the Logs panel (and the other way round). Each script is now an IIFE; Cache still exports deleteCacheFile for its row buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): make the HTMX-failure fallbacks for tab panels actually run - The "HTMX never loaded" fallback read appElement.__x.$data, which is Alpine 2. The page ships Alpine 3, so the check was always false and the Overview never loaded without HTMX. It now reads Alpine.$data(). - The Overview and WiFi panels used hx-on::htmx:response-error, which htmx expands to "htmx:htmx:response-error", an event that never fires. - loadTabContent sent requests with <body> as the source, so htmx fired its events on <body> and no panel's hx-on handler ran at all. The panel is now the source. htmx also resolves its promise on a 4xx/5xx, and the panel was stamped data-loaded anyway, leaving a skeleton that never retried; it is now stamped only when no responseError fired. loadPluginsDirect, loadOverviewDirect and loadWifiDirect are merged into one window.loadPartialDirect(id, url), which also runs the partial's inline scripts before Alpine sees the markup (as htmx-config.js does on htmx:afterSwap). The ~10 s "htmx never arrived" path in loadTabContent uses it for every tab instead of four hard-coded ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): store and registry failures no longer wipe the Plugin Manager showError replaced the whole #plugins-content with an error message, so one failed store search, custom-registry install or saved-repository call took the installed list, the store and every control with it, with no way back short of reloading the tab. Those failures are now error notifications. The full-panel message is kept only for a first load of the installed list that failed (nothing to show yet); a failed refresh of an already-rendered list is a notification too. showSuccess's fallback branch, which wrote the message into innerHTML unescaped, is gone: showNotification always exists. The "Please try refreshing your browser" hint tested for the text "Failed to Fetch", which no browser produces (Chrome says "Failed to fetch", Firefox "NetworkError..."), so it never appeared. It now keys on the failure itself: a TypeError from fetch(), or PluginAPI's NETWORK_ERROR wrapper around one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): escape plugin action and install output on every path executePluginAction escaped data.message and data.output when an action failed but put data.message straight into innerHTML when it succeeded, and set the OAuth step-2 button's innerHTML from the manifest's step2_button_text. Plugin actions run plugin code, so that is plugin- or server-controlled markup in the page. Both paths now escape, and the button label is set with textContent. The same pattern sat in the install-from-GitHub-URL status lines (plugin_id, the server's message, and error.message, which can echo a repository URL) and the custom-registry load error; those are escaped too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): file-upload widget owns the image list and schedule editor plugins_manager.js loads after the widget bundle, so its older copies of deleteUploadedFile, updateImageList, hideUploadProgress, formatDate, openImageSchedule, toggleImageScheduleEnabled, updateImageSchedule{Mode, Time,Day} and updateCheckboxGroupData replaced the widget's. They are deleted; the widget files are the only definitions. Before switching over, the two sets were diffed and fixed so nothing regresses: - The old copy labelled the schedule/delete buttons for screen readers and lazy-loaded thumbnails; the widget now does both. - The schedule button did nothing on a card rendered by plugin_config.html whenever the image id is a UUID (every upload): the template turns "-" into "_" in the editor's id, and neither JS copy did. Both now use the template's rule. - The widget's "keep the open editor open" copied the editor's innerHTML into the new list. That dropped its event listeners and showed the old values, so after the first change the editor looked live but ignored input. A schedule edit now saves to the hidden input and updates the card's summary in place without re-rendering the list; a list re-render (upload, delete) rebuilds an open editor from the data. Editor controls are routed by one delegated change listener, so there are no per-element listeners to lose. - The old deleteUploadedFile had a JSON branch that removed a #file_<id> element and skipped the re-render. No template or script renders such an element, and JSON uploads are listed through updateImageList like images, so re-rendering (the widget's behaviour) is the consistent one; the branch was not carried over. - The template always renders the summary line (".image-schedule-summary", "Always shown" when unscheduled) so an edit has a line to update. The inline-handler test evaluated plugins_manager.js's updateImageList; test_file_upload_widget.js now covers the widget's list and editor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): delete the unused handleCredentialsUpload Its last caller went when plugin_config.html switched credential uploads to the file-upload widget's handleSingleFileSelect. Nothing in the web UI, the tests or the plugin monorepo references it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): delete dead and shadowed front-end code Nothing calls any of these (checked across web_interface/, test/ and the ledmatrix-plugins monorepo, including hx-*/x-*/onclick attributes): - app-shell.js: the Alpine methods refreshPlugins (it called a nonexistent this.searchPluginStore), loadPluginConfig, savePluginConfig, getSchemaPropertyType, escapeCssSelector, formatCommitInfo and formatDateInfo, and the top-level copies of savePluginConfig, getSchemaPropertyType, escapeCssSelector, formatCommitInfo, formatDateInfo and togglePluginFromTab. Plugin config forms save through hx-post in plugin_config.html. - window.reconnectSSE (app-shell.js); window.updateArrayTableAddButtonState (array-table.js). - toggleNestedSection, defined twice (app-shell.js and plugins_manager.js) and called from nowhere. - plugins_manager.js: the window.initializePlugins wrapper around an IIFE-local origInit that was always undefined, and __pluginDomReady, which was written but never read. - display.html's fixInvalidNumberInputs fallback: app-shell.js defines it before any partial loads. - base.html's window.loadCodeMirror and the two CodeMirror stylesheet preloads, and the .CodeMirror rules in plugins.html. The raw JSON editor is a plain textarea. Also deleted: app-shell.js definitions that a later script always replaced, so they never ran: executePluginAction (plugins_manager.js assigns its own), uninstallPlugin and its pollUninstallOperation (plugins_manager.js), and updateAllPlugins (install_manager.js). vendor/codemirror stays: test/test_web_smoke.py still requests codemirror.min.js as a sample static asset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): call showNotification without checking it exists app-shell.js defines window.showNotification (a stand-in that queues until the notification widget loads) and base.html runs it, deferred, before every other script that notifies: app.js, the utilities, the widget bundle, plugins_manager.js, and all partials, which HTMX loads after the page. The 81 `typeof showNotification === 'function'` / `!== 'undefined'` checks, the `window.showNotification || console.log` and `|| alert` fallbacks, and their else branches (alert(), console output, and schedule.html's own hand-built toast) could never take the fallback path. They are removed, as is fonts.html's second copy of the queueing stand-in. The stand-in in app-shell.js keeps its guard (it must not replace the widget's implementation if load order ever changes), and BaseWidget's public notify()/getNotificationFunction() keep their shape for widgets that plugins ship. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): one HTML escaper, window.LEDEscape About 30 files each carried their own escapeHtml / escapeAttr / escHtml / _esc / escapeJs. They disagreed: several (notification.js, display.html's escapeHtml, operation_history.html, the app() stub) did not escape quotes, google-calendar-picker.js and tools.html's escHtml left ' alone, and some turned 0 into ''. Most were fine only because the quote-safe widget copies were preferred at runtime. window.LEDEscape now lives at the top of app-early.js, a blocking script in <head>, so it exists before any other script runs: html(v) & < > " ' as entities, null/undefined as '' attr(v) the same, for call sites that want to say "attribute" jsStringAttr(v) a JS string literal safe inside an inline handler Every former copy is now a one-line name for it (kept so call sites do not change), widgets included, with no fallback. plugins_manager.js loses its four escapeJs wrappers (callers use jsStringAttr), the duplicate escapeAttr and escapeHtml inside renderInstalledCards and renderCustomRegistryPlugins, and the window.escapeHtml / window.escapeAttribute exports, which nothing read. addArrayObjectItem's fallback markup (with a sixth hand-written escape chain) is gone too: window.renderArrayObjectItem is defined earlier in the same file, so the fallback could not run. The unused escapeHtml methods on the Alpine app (app-early.js stub and app-shell.js) are deleted. test_html_escaping.js now runs LEDEscape and every remaining name for it, and fails if a hand-rolled escaper reappears anywhere in web_interface/. Suites that evaluate slices of plugins_manager.js or widget files load LEDEscape from app-early.js through test/js/led_escape.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): stop htmx re-running partial scripts after every tab load htmx-config.js runs each swapped-in <script> itself on htmx:afterSwap and meant to turn htmx's own script handling off with htmx.config.allowScriptTags = false. It did that once, while setting up, but base.html loads htmx with a dynamic <script>, so htmx was not defined yet and the setting never applied. On every tab load htmx then tried to run each script again in its settle phase, found it already replaced (no parent node) and threw "Cannot read properties of null (reading 'insertBefore')" into the console, which also skipped the rest of that swap's settle tasks. The setting is now applied in the afterSwap handler, which always runs after htmx exists and before htmx settles the same swap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): show "--" for a system stat the server could not read The stats stream and /system/status now send null for a metric they cannot read (cpu_temp off a Pi, for one) instead of 0. updateSystemStats built the header and Overview text as value + unit, so a null showed as "null°C". CPU, memory and temperature, in the header and on the Overview, now render "--" plus the unit for null or a missing field -- the same placeholder the page starts with, and what tools.html already shows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): one Alpine accessor and one plugin-list signal window.getApp() (app-early.js) returns the root <body x-data="app()"> component through Alpine's public Alpine.$data, or null before Alpine has initialised it. It replaces the private el._x_dataStack[0] reads in app.js, app-early.js, app-shell.js, settings-search.js, overview.html and plugins_manager.js, the three local getAppComponent/appData/getAppData copies, and the Alpine 2 el.__x.$data fallbacks, which Alpine 3 never provides. Publishing the installed-plugin list: one load set window.installedPlugins and dispatched pluginsUpdated twice (loadInstalledPlugins, then renderInstalledPlugins), then wrote into the Alpine component through _x_dataStack[0] and called its updatePluginTabs() directly, and app-early.js's global listener set window.installedPlugins a third time and called updatePluginTabs() again. Now renderInstalledPlugins is the one publisher: it sets window.installedPlugins and dispatches pluginsUpdated once, and the full app()'s listener (app-shell.js) is the receiver. The app-early.js listener only builds the tab row while the app is not the full implementation yet. The "grid not loaded yet" case is a normal state (Plugin Manager tab not opened), so it logs through pluginLog instead of console.warn. updatePluginTabs had a "Debounce" comment and clearTimeout over a timer nothing ever set, and two identical branches; it now just calls _doUpdatePluginTabs (app-early.js detects the full implementation by that name in its source, which the new comment says). app()'s unused baseComponent lookup is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): reload the plugin list after installs and failed toggles Several callers refreshed the installed list with if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins(); else if (typeof window.loadInstalledPlugins === 'function') ... but loadInstalledPlugins is local to the plugin-manager IIFE and window.loadInstalledPlugins is never defined, so from outside that IIFE both tests were false and nothing reloaded: - A failed plugin toggle left the switch drawn in the new state while the data said the old one. It now re-renders from the reverted data. The optimistic in-place edit also has to forget the grid's last-rendered markup, or setGridHtmlIfChanged sees identical HTML and skips the revert. A successful toggle still keeps the switch (and focus) as drawn. - Installing from a GitHub URL (the early handleGitHubPluginInstall), installing or uploading a Starlark app, and toggling a Starlark app on its config tab never refreshed the list, so the new app had no tab or Installed badge until the page was reloaded. They now force a reload through window.pluginManager.loadInstalledPlugins(true), and the Starlark grid redraws when that finishes instead of after a fixed 500 ms. - The Starlark uninstall inside the IIFE reloaded from the 3 s cache, which could still hold the app; it now forces a reload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): route debug output through debugLog base.html defines window.debugLog, gated on localStorage.pluginDebug. plugins_manager.js read the same key twice more into its own flags (_PLUGIN_DEBUG_EARLY, and PLUGIN_DEBUG behind a pluginLog() wrapper), and api_client.js's RequestThrottler had a separate `debug` property with a setDebug() that nothing called. All of it now goes through debugLog. The "functions defined" dumps with their ✓ lines, and two per-plugin "enabled=" loops that ran on every render, are dropped; "[PLUGINS STUB]" labels on code that has not been a stub for a long time read "[PLUGINS]". Ungated console.log calls that announced normal events on every page load or action (settings search and tooltips registering, every toast repeated to the console, the schedule pickers initialising, widget registry unregister/clear) go through debugLog too. What remains on console.log is the widget registry's on-demand LEDMatrixWidgets.debug() dump and BaseWidget.notify's no-notifier fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): drop waits and guards that could never fire - handlePluginAction polled up to 10 x 50 ms for window.togglePlugin, configurePlugin, updatePlugin and uninstallPlugin before calling them. All four are defined when the scripts load, before any card can be clicked, so the poll always succeeded at once; it now calls them. The long thinking-aloud comment over the toggle state is replaced by two lines on why the stored state, not the checkbox, decides. - initializePlugins checked typeof on setupGitHubInstallHandlers and applyStoreFiltersAndSort, function declarations in the same IIFE, and wrapped window.checkGitHubAuthStatus(), which returns a promise with its own .catch, in try/catch. - searchPluginStore wrapped each "#store-count" update (a getElementById and an innerHTML assignment) in try/catch four times; one setStoreCount() helper does it. The store's post-render re-attach of the GitHub token handler dropped its try/catch and existence checks for the same reason. - The load-time fallback outside the IIFE tested typeof initializePluginPageWhenReady, which is IIFE-local and so always undefined there; it calls window.initPluginsPage directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): delete two unused plugin-manager helpers stopOnDemand (IIFE-local; the page's stop button calls window.stopOnDemand from app-shell.js) and debounce had no callers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): document the plugin-config handlers templates call validatePluginConfigForm, handleConfigSave, handleToggleResponse, handlePluginUpdate and refreshPluginConfig each get a JSDoc naming the attribute in partials/plugin_config.html that calls it and what the return value means (only validatePluginConfigForm's matters: false cancels the submit). - The `if (!window.__pluginConfigHandlersInitialized)` wrapper is gone: app-shell.js runs once per page, so it was never false. The block is dedented one level; `git diff -w` shows the real change. - The three handlers read xhr.responseJSON first. XMLHttpRequest has no such property (it is jQuery's), so that branch never ran; one xhrJson(xhr) helper parses responseText for all of them, with the same fallbacks as before. - runPluginOnDemand and stopOnDemand checked that plugins_manager.js's openOnDemandModal/requestOnDemandStop exist; plugins_manager.js is on every page, so they call them. - fixInvalidNumberInputs had a stray "Notification helper function" comment on top of its own; a leftover "section toggle ... duplicate definition removed" note is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): one toast per save, and a failed durations save says so app.js's global htmx:afterRequest listener showed the server's message for every htmx request, and every form and button that posts through htmx (plugin config save/toggle/update, Display, Durations, General, Schedule, Dim schedule, the Overview actions) also reports its own result from hx-on after-request. Each save showed two toasts. The global listener now stays quiet for a request whose element, or its form, has its own after-request handler. That exposed the Rotation & Durations form's handler, which read xhr.responseJSON: XMLHttpRequest has no such property, so it always said "Durations saved" in green, even when the save failed (the global toast had been the only place the error showed). display.html already had a correct version (2xx only counts as saved; the server's message wins; its status may refine success but never overturn failure). That is now window.showSaveResult(xhr, savedText, failedText) in app.js, used by the Display, Durations and General forms; General's inline copy of the same logic is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(web): file headers and comments that say what the code does now - plugins_manager.js, app-shell.js, app.js and app-early.js open with a header: what the file owns, how base.html loads it and in what order relative to the others, and the globals it defines. app-early.js's app() stub also says why it exists and that, with app-shell.js now loaded before Alpine, it does not run in practice. - base.html's note on plugins_manager.js said it must load last to win over same-named functions in app.js/app-shell.js; there are none left, so it now gives the real reason (it uses everything loaded before it). - Change-narration and "already defined at the top, no need to redefine" notes are gone or rewritten as present-tense reasons; comments that were wrong are fixed ("Toggle password visibility" over the function that opens the token panel, "Insert before the closing </nav>" over an appendChild, "(from v2)", the export note that still listed escapeHtml). About forty comments that restated the line below them are removed, and a second window.currentPluginConfig = null outside the IIFE is dropped (the IIFE sets it). - The file-upload, checkbox-group and custom-feeds widgets' render() stubs say plainly that the widget is rendered server-side, instead of "for now" / "placeholder for future client-side rendering". test_plugin_action_delegation.js sliced the source up to one of the removed notes; it now ends the slice at the next section header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): keep the escapeHtml/escapeAttribute globals for plugin pages 6da77363 removed window.escapeHtml and window.escapeAttribute because nothing in core or the plugin monorepo read them. Plugin web UIs served through serve_plugin_web_ui and third-party plugin pages may still call them, so they come back as aliases of window.LEDEscape.html and .attr, defined in app-early.js before any other script runs. test_html_escaping.js checks the aliases exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): web-frontend Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): encode the image thumbnail path; match script tags case-insensitively CodeQL flagged the upload widget building an <img> src from a stored path, and the escaper test extracting inline scripts with a case-sensitive regex. Each path segment is now URL-encoded (still a same-origin path, and correct for names with spaces or * fix(web): clear Codacy findings in the escaper, app shell and upload widget - LEDEscape looks entities up in a Map instead of indexing an object. - showNotification is declared as a global for app-shell.js. - openImageSchedule checks the index is a non-negative integer and reads the image with Array.prototype.at. - The schedule editor calls escapeHtml directly and documents why its innerHTML template is safe: every value is escaped or constrained. The remaining rule hits are suppressed on that line with the reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): build the image schedule editor with DOM calls Codacy does not honour inline suppressions, and the editor's innerHTML template kept tripping its XSS rules even though every value was escaped. The editor is now built with a small element helper (createElement and setAttribute), so no value is ever parsed as HTML, and the file's own escapeHtml goes away. Also for Codacy: - LEDEscape.attr is its own function rather than a second name for html. - The tab loader records a failed load on the panel (data-load-failed) from a named handler, instead of a closure over a local flag. The fake DOM in test_file_upload_widget.js gains append/replaceChildren, its hostile-id check now asserts the id arrives as attribute data with no innerHTML anywhere in the editor, and test_html_escaping.js drops the file-upload.js escaper it no longer has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): schedule editor helpers as plain functions Codacy's lint flags arrow functions held in local constants and a forEach callback that returns a value. The editor's pieces are now named function declarations (displayStyle, scheduleModeOption, scheduleRangeTime, scheduleDayTime, scheduleDayRow) taking what they need as arguments, and the element helper loops with for...of. htmx is declared as a global in app-shell.js. Output is unchanged; test_file_upload_widget.js passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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