mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Merge branch 'main' into fix/custom-feeds-logo-upload-contract
Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
/* global debugLog */
|
||||
// Early helpers and the app() stub (must run before Alpine init)
|
||||
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
|
||||
// Helper function to get installed plugins with fallback
|
||||
// Must be defined before app() function that uses it
|
||||
async function getInstalledPluginsSafe() {
|
||||
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
|
||||
try {
|
||||
const plugins = await window.PluginAPI.getInstalledPlugins();
|
||||
// Ensure plugins is always an array
|
||||
const pluginsArray = Array.isArray(plugins) ? plugins : [];
|
||||
return { status: 'success', data: { plugins: pluginsArray } };
|
||||
} catch (error) {
|
||||
console.error('Error using PluginAPI.getInstalledPlugins, falling back to direct fetch:', error);
|
||||
// Fall through to direct fetch
|
||||
}
|
||||
}
|
||||
// Fallback to direct fetch if PluginAPI not loaded
|
||||
const response = await fetch('/api/v3/plugins/installed');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Global event listener for pluginsUpdated - works even if Alpine isn't ready yet
|
||||
// This ensures tabs update when plugins_manager.js loads plugins
|
||||
document.addEventListener('pluginsUpdated', function(event) {
|
||||
debugLog('[GLOBAL] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
|
||||
const plugins = event.detail?.plugins || [];
|
||||
|
||||
// Update window.installedPlugins
|
||||
window.installedPlugins = plugins;
|
||||
|
||||
// Try to update Alpine component if it exists (only if using full implementation)
|
||||
if (window.Alpine) {
|
||||
const appElement = document.querySelector('[x-data="app()"]');
|
||||
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
||||
const appComponent = appElement._x_dataStack[0];
|
||||
appComponent.installedPlugins = plugins;
|
||||
// Only call updatePluginTabs if it's the full implementation (has _doUpdatePluginTabs)
|
||||
if (typeof appComponent.updatePluginTabs === 'function' &&
|
||||
appComponent.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
|
||||
debugLog('[GLOBAL] Updating plugin tabs via Alpine component (full implementation)');
|
||||
appComponent.updatePluginTabs();
|
||||
return; // Full implementation handles it, don't do direct update
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only do direct DOM update if full implementation isn't available yet
|
||||
const pluginTabsRow = document.getElementById('plugin-tabs-row');
|
||||
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
|
||||
if (pluginTabsRow && pluginTabsNav && plugins.length > 0) {
|
||||
// Clear existing plugin tabs (except Plugin Manager)
|
||||
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
||||
existingTabs.forEach(tab => { tab.remove(); });
|
||||
|
||||
// Add tabs for each installed plugin
|
||||
plugins.forEach(plugin => {
|
||||
const tabButton = document.createElement('button');
|
||||
tabButton.type = 'button';
|
||||
tabButton.setAttribute('data-plugin-id', plugin.id);
|
||||
tabButton.className = `plugin-tab nav-tab`;
|
||||
tabButton.onclick = function() {
|
||||
// Try to set activeTab via Alpine if available
|
||||
if (window.Alpine) {
|
||||
const appElement = document.querySelector('[x-data="app()"]');
|
||||
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
||||
appElement._x_dataStack[0].activeTab = plugin.id;
|
||||
// Only call updatePluginTabStates if it exists
|
||||
if (typeof appElement._x_dataStack[0].updatePluginTabStates === 'function') {
|
||||
appElement._x_dataStack[0].updatePluginTabStates();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// Built with DOM APIs (no innerHTML): the icon class and
|
||||
// name come from plugin manifests, which are only
|
||||
// semi-trusted input.
|
||||
const tabIcon = document.createElement('i');
|
||||
tabIcon.className = plugin.icon || 'fas fa-puzzle-piece';
|
||||
tabButton.textContent = '';
|
||||
tabButton.appendChild(tabIcon);
|
||||
tabButton.appendChild(document.createTextNode(plugin.name || plugin.id));
|
||||
pluginTabsNav.appendChild(tabButton);
|
||||
});
|
||||
debugLog('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
|
||||
}
|
||||
});
|
||||
|
||||
// Guard flag to prevent duplicate stub-to-full enhancement
|
||||
window._appEnhanced = false;
|
||||
|
||||
// Define app() function early so Alpine can find it when it initializes
|
||||
// This is a complete implementation that will work immediately
|
||||
(function() {
|
||||
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
||||
window.location.hostname.startsWith('192.168.4.');
|
||||
|
||||
// Create the app function - will be enhanced by full implementation later
|
||||
window.app = function() {
|
||||
return {
|
||||
activeTab: isAPMode ? 'wifi' : 'overview',
|
||||
mobileNavOpen: false,
|
||||
installedPlugins: [],
|
||||
|
||||
init() {
|
||||
// Try to enhance immediately with full implementation
|
||||
const tryEnhance = () => {
|
||||
if (window._appEnhanced) return true;
|
||||
if (typeof window.app === 'function') {
|
||||
const fullApp = window.app();
|
||||
// Check if this is the full implementation (has updatePluginTabs with proper implementation)
|
||||
if (fullApp && typeof fullApp.updatePluginTabs === 'function' && fullApp.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
|
||||
window._appEnhanced = true;
|
||||
// Preserve runtime state that should not be reset
|
||||
const preservedPlugins = this.installedPlugins;
|
||||
const preservedTab = this.activeTab;
|
||||
const defaultTab = isAPMode ? 'wifi' : 'overview';
|
||||
const wasInitialized = this._initialized;
|
||||
Object.assign(this, fullApp);
|
||||
// Restore runtime state if non-default
|
||||
if (preservedPlugins && preservedPlugins.length > 0) {
|
||||
this.installedPlugins = preservedPlugins;
|
||||
}
|
||||
if (preservedTab && preservedTab !== defaultTab) {
|
||||
this.activeTab = preservedTab;
|
||||
}
|
||||
if (wasInitialized) {
|
||||
this._initialized = wasInitialized;
|
||||
}
|
||||
// Only call init if not already initialized
|
||||
if (typeof this.init === 'function' && !this._initialized) {
|
||||
this.init();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Set up event listener for pluginsUpdated in stub (only if not already enhanced)
|
||||
// The full implementation will have its own listener, so we only need this for the stub
|
||||
if (!this._pluginsUpdatedListenerSet) {
|
||||
const handlePluginsUpdated = (event) => {
|
||||
debugLog('[STUB] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
|
||||
const plugins = event.detail?.plugins || [];
|
||||
// Only update if we're still in stub mode (not enhanced yet)
|
||||
if (typeof this.updatePluginTabs === 'function' && !this.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
|
||||
this.installedPlugins = plugins;
|
||||
if (this.$nextTick && typeof this.$nextTick === 'function') {
|
||||
this.$nextTick(() => {
|
||||
this.updatePluginTabs();
|
||||
});
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.updatePluginTabs();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('pluginsUpdated', handlePluginsUpdated);
|
||||
this._pluginsUpdatedListenerSet = true;
|
||||
debugLog('[STUB] init: Set up pluginsUpdated event listener');
|
||||
}
|
||||
|
||||
// Try immediately - if full implementation is already loaded, use it right away
|
||||
if (!tryEnhance()) {
|
||||
// Full implementation not ready yet, load plugins directly while waiting
|
||||
this.loadInstalledPluginsDirectly();
|
||||
// Try again very soon to enhance with full implementation
|
||||
setTimeout(tryEnhance, 10);
|
||||
|
||||
// Also set up a periodic check to update tabs if plugins get loaded by plugins_manager.js
|
||||
let retryCount = 0;
|
||||
const maxRetries = 20; // Check for 2 seconds (20 * 100ms)
|
||||
const checkAndUpdateTabs = () => {
|
||||
if (retryCount >= maxRetries) {
|
||||
// Fallback: if plugins_manager.js hasn't loaded after 2 seconds, fetch directly
|
||||
if (!window.installedPlugins || window.installedPlugins.length === 0) {
|
||||
debugLog('[STUB] checkAndUpdateTabs: Fallback - fetching plugins directly after timeout');
|
||||
this.loadInstalledPluginsDirectly();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if plugins are available (either from window or component)
|
||||
const plugins = window.installedPlugins || this.installedPlugins || [];
|
||||
if (plugins.length > 0) {
|
||||
debugLog('[STUB] checkAndUpdateTabs: Found', plugins.length, 'plugins, updating tabs');
|
||||
this.installedPlugins = plugins;
|
||||
if (typeof this.updatePluginTabs === 'function') {
|
||||
this.updatePluginTabs();
|
||||
}
|
||||
} else {
|
||||
retryCount++;
|
||||
setTimeout(checkAndUpdateTabs, 100);
|
||||
}
|
||||
};
|
||||
// Start checking after a short delay
|
||||
setTimeout(checkAndUpdateTabs, 200);
|
||||
} else {
|
||||
// Full implementation loaded, but still set up fallback timer
|
||||
setTimeout(() => {
|
||||
if (!window.installedPlugins || window.installedPlugins.length === 0) {
|
||||
debugLog('[STUB] init: Fallback timer - fetching plugins directly');
|
||||
this.loadInstalledPluginsDirectly();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
|
||||
// Direct plugin loading for stub (before full implementation loads)
|
||||
async loadInstalledPluginsDirectly() {
|
||||
try {
|
||||
debugLog('[STUB] loadInstalledPluginsDirectly: Starting...');
|
||||
// Ensure DOM is ready
|
||||
const ensureDOMReady = () => {
|
||||
return new Promise((resolve) => {
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
// Use requestAnimationFrame to ensure DOM is painted
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(resolve, 50); // Small delay to ensure rendering
|
||||
});
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
await ensureDOMReady();
|
||||
|
||||
const data = await getInstalledPluginsSafe();
|
||||
if (data.status === 'success') {
|
||||
const plugins = data.data.plugins || [];
|
||||
debugLog('[STUB] loadInstalledPluginsDirectly: Loaded', plugins.length, 'plugins');
|
||||
|
||||
// Update both component and window
|
||||
this.installedPlugins = plugins;
|
||||
window.installedPlugins = plugins;
|
||||
|
||||
// Dispatch event so global listener can update tabs
|
||||
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
|
||||
detail: { plugins: plugins }
|
||||
}));
|
||||
debugLog('[STUB] loadInstalledPluginsDirectly: Dispatched pluginsUpdated event');
|
||||
|
||||
// Update tabs if we have the method - use $nextTick if available
|
||||
if (typeof this.updatePluginTabs === 'function') {
|
||||
if (this.$nextTick && typeof this.$nextTick === 'function') {
|
||||
this.$nextTick(() => {
|
||||
this.updatePluginTabs();
|
||||
});
|
||||
} else {
|
||||
// Fallback: wait a bit for DOM
|
||||
setTimeout(() => {
|
||||
this.updatePluginTabs();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('[STUB] loadInstalledPluginsDirectly: Failed to load plugins:', data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[STUB] loadInstalledPluginsDirectly: Error loading plugins:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Stub methods that will be replaced by full implementation
|
||||
loadTabContent: function(tab) {},
|
||||
loadInstalledPlugins: async function() {
|
||||
// Try to use global function if available, otherwise use direct loading
|
||||
if (typeof window.loadInstalledPlugins === 'function') {
|
||||
await window.loadInstalledPlugins();
|
||||
// Update tabs after loading (window.installedPlugins should be set by the global function)
|
||||
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
|
||||
this.installedPlugins = window.installedPlugins;
|
||||
this.updatePluginTabs();
|
||||
}
|
||||
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
|
||||
await window.pluginManager.loadInstalledPlugins();
|
||||
// Update tabs after loading
|
||||
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
|
||||
this.installedPlugins = window.installedPlugins;
|
||||
this.updatePluginTabs();
|
||||
}
|
||||
} else {
|
||||
// Fallback to direct loading (which already calls updatePluginTabs)
|
||||
await this.loadInstalledPluginsDirectly();
|
||||
}
|
||||
},
|
||||
updatePluginTabs: function() {
|
||||
// Basic implementation for stub - will be replaced by full implementation
|
||||
// Debounce to prevent multiple rapid calls
|
||||
if (this._updatePluginTabsTimeout) {
|
||||
clearTimeout(this._updatePluginTabsTimeout);
|
||||
}
|
||||
|
||||
this._updatePluginTabsTimeout = setTimeout(() => {
|
||||
debugLog('[STUB] updatePluginTabs: Executing with', this.installedPlugins?.length || 0, 'plugins');
|
||||
const pluginTabsRow = document.getElementById('plugin-tabs-row');
|
||||
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
|
||||
if (!pluginTabsRow || !pluginTabsNav) {
|
||||
console.warn('[STUB] updatePluginTabs: Plugin tabs container not found');
|
||||
return;
|
||||
}
|
||||
if (!this.installedPlugins || this.installedPlugins.length === 0) {
|
||||
debugLog('[STUB] updatePluginTabs: No plugins to display');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if tabs are already correct by comparing plugin IDs
|
||||
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
||||
const existingIds = Array.from(existingTabs).map(tab => tab.getAttribute('data-plugin-id')).sort().join(',');
|
||||
const currentIds = this.installedPlugins.map(p => p.id).sort().join(',');
|
||||
|
||||
if (existingIds === currentIds && existingTabs.length === this.installedPlugins.length) {
|
||||
debugLog('[STUB] updatePluginTabs: Tabs already match, skipping update');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing plugin tabs (except Plugin Manager)
|
||||
existingTabs.forEach(tab => { tab.remove(); });
|
||||
debugLog('[STUB] updatePluginTabs: Cleared', existingTabs.length, 'existing tabs');
|
||||
|
||||
// Add tabs for each installed plugin
|
||||
this.installedPlugins.forEach(plugin => {
|
||||
const tabButton = document.createElement('button');
|
||||
tabButton.type = 'button';
|
||||
tabButton.setAttribute('data-plugin-id', plugin.id);
|
||||
tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`;
|
||||
tabButton.onclick = () => {
|
||||
this.activeTab = plugin.id;
|
||||
if (typeof this.updatePluginTabStates === 'function') {
|
||||
this.updatePluginTabStates();
|
||||
}
|
||||
};
|
||||
// DOM APIs instead of innerHTML: manifest
|
||||
// icon/name are semi-trusted input.
|
||||
const tabIcon = document.createElement('i');
|
||||
tabIcon.className = plugin.icon || 'fas fa-puzzle-piece';
|
||||
tabButton.textContent = '';
|
||||
tabButton.appendChild(tabIcon);
|
||||
tabButton.appendChild(document.createTextNode(plugin.name || plugin.id));
|
||||
pluginTabsNav.appendChild(tabButton);
|
||||
});
|
||||
debugLog('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
|
||||
}, 100);
|
||||
},
|
||||
showNotification: function(message, type) {},
|
||||
escapeHtml: function(text) { return String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
};
|
||||
};
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
/* global debugLog */
|
||||
// HTMX swap/script-execution configuration and section toggle helpers
|
||||
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
|
||||
// Configure HTMX to evaluate scripts in swapped content and fix insertBefore errors
|
||||
(function() {
|
||||
function setupScriptExecution() {
|
||||
if (document.body) {
|
||||
// Fix HTMX insertBefore errors by validating targets before swap
|
||||
document.body.addEventListener('htmx:beforeSwap', function(event) {
|
||||
try {
|
||||
const target = event.detail.target;
|
||||
if (!target) {
|
||||
console.warn('[HTMX] Target is null, skipping swap');
|
||||
event.detail.shouldSwap = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if target is a valid DOM element
|
||||
if (!(target instanceof Element)) {
|
||||
console.warn('[HTMX] Target is not a valid Element, skipping swap');
|
||||
event.detail.shouldSwap = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if target has a parent node (required for insertBefore)
|
||||
if (!target.parentNode) {
|
||||
console.warn('[HTMX] Target has no parent node, skipping swap');
|
||||
event.detail.shouldSwap = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure target is in the DOM
|
||||
if (!document.body.contains(target) && !document.head.contains(target)) {
|
||||
console.warn('[HTMX] Target is not in DOM, skipping swap');
|
||||
event.detail.shouldSwap = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional check: ensure parent is also in DOM
|
||||
if (target.parentNode && !document.body.contains(target.parentNode) && !document.head.contains(target.parentNode)) {
|
||||
console.warn('[HTMX] Target parent is not in DOM, skipping swap');
|
||||
event.detail.shouldSwap = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// All checks passed, allow swap
|
||||
return true;
|
||||
} catch (e) {
|
||||
// If validation fails, cancel swap
|
||||
console.warn('[HTMX] Error validating target:', e);
|
||||
event.detail.shouldSwap = false;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Suppress HTMX insertBefore errors and other noisy errors - they're harmless but noisy
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
console.error = function(...args) {
|
||||
const errorStr = args.join(' ');
|
||||
const errorStack = args.find(arg => arg && typeof arg === 'string' && arg.includes('htmx')) || '';
|
||||
|
||||
// Suppress HTMX insertBefore errors (comprehensive check)
|
||||
// These occur when HTMX tries to swap content but the target element is null
|
||||
// Usually happens due to timing/race conditions and is harmless
|
||||
if (errorStr.includes("insertBefore") ||
|
||||
errorStr.includes("Cannot read properties of null") ||
|
||||
errorStr.includes("reading 'insertBefore'")) {
|
||||
// Check if it's from HTMX by looking at stack trace or error string
|
||||
// Also check the call stack if available
|
||||
const isHtmxError = errorStr.includes('htmx') ||
|
||||
errorStack.includes('htmx') ||
|
||||
args.some(arg => {
|
||||
if (typeof arg === 'string') {
|
||||
return arg.includes('htmx');
|
||||
}
|
||||
// Check error objects for stack traces
|
||||
if (arg && typeof arg === 'object' && arg.stack) {
|
||||
return arg.stack.includes('htmx');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (isHtmxError) {
|
||||
return; // Suppress - this is a harmless HTMX timing/race condition issue
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress script execution errors from malformed HTML
|
||||
if (errorStr.includes("Failed to execute 'appendChild' on 'Node'") ||
|
||||
errorStr.includes("Failed to execute 'insertBefore' on 'Node'")) {
|
||||
if (errorStr.includes('Unexpected token')) {
|
||||
return; // Suppress malformed HTML errors
|
||||
}
|
||||
}
|
||||
originalError.apply(console, args);
|
||||
};
|
||||
|
||||
console.warn = function(...args) {
|
||||
const warnStr = args.join(' ');
|
||||
// Suppress Permissions-Policy warnings (harmless browser warnings)
|
||||
if (warnStr.includes('Permissions-Policy header') ||
|
||||
warnStr.includes('Unrecognized feature') ||
|
||||
warnStr.includes('Origin trial controlled feature') ||
|
||||
warnStr.includes('browsing-topics') ||
|
||||
warnStr.includes('run-ad-auction') ||
|
||||
warnStr.includes('join-ad-interest-group') ||
|
||||
warnStr.includes('private-state-token') ||
|
||||
warnStr.includes('private-aggregation') ||
|
||||
warnStr.includes('attribution-reporting')) {
|
||||
return; // Suppress - these are harmless browser feature warnings
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
|
||||
// Handle HTMX errors gracefully with detailed logging
|
||||
document.body.addEventListener('htmx:responseError', function(event) {
|
||||
const detail = event.detail;
|
||||
const xhr = detail.xhr;
|
||||
const target = detail.target;
|
||||
|
||||
// Enhanced error logging
|
||||
console.error('HTMX response error:', {
|
||||
status: xhr?.status,
|
||||
statusText: xhr?.statusText,
|
||||
url: xhr?.responseURL,
|
||||
target: target?.id || target?.tagName,
|
||||
responseText: xhr?.responseText
|
||||
});
|
||||
|
||||
// For form submissions, log field names only — values
|
||||
// may contain API keys, passwords, or other secrets
|
||||
// that must never reach the console.
|
||||
if (target && target.tagName === 'FORM') {
|
||||
const formData = new FormData(target);
|
||||
const fieldNames = [];
|
||||
for (const [key] of formData.entries()) {
|
||||
fieldNames.push(key);
|
||||
}
|
||||
console.error('Form fields (values redacted):', fieldNames);
|
||||
|
||||
// Try to parse error response for validation details
|
||||
if (xhr?.responseText) {
|
||||
try {
|
||||
const errorData = JSON.parse(xhr.responseText);
|
||||
console.error('Error details:', {
|
||||
message: errorData.message,
|
||||
details: errorData.details,
|
||||
validation_errors: errorData.validation_errors,
|
||||
context: errorData.context
|
||||
});
|
||||
} catch {
|
||||
console.error('Error response (non-JSON):', xhr.responseText.substring(0, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:swapError', function(event) {
|
||||
// Log but don't break the app
|
||||
console.warn('HTMX swap error:', event.detail);
|
||||
});
|
||||
|
||||
// Execute <script> tags in swapped content ourselves, on
|
||||
// htmx:afterSwap (synchronous, right after the swap) rather
|
||||
// than relying on htmx's own script handling, which runs
|
||||
// during its later "settle" phase (~20ms after swap, per
|
||||
// htmx's defaultSettleDelay). Alpine's MutationObserver
|
||||
// processes newly-inserted x-data elements synchronously
|
||||
// as soon as the swap lands, which is BEFORE htmx's settle
|
||||
// phase - so any partial whose x-data component function
|
||||
// (e.g. wifiSetup()) is defined by an inline <script> in
|
||||
// that same partial would have that script still un-run
|
||||
// when Alpine evaluates x-data, permanently failing with
|
||||
// "wifiSetup is not defined" (Alpine does not retry).
|
||||
// Disable htmx's own native script re-execution so the
|
||||
// same script doesn't also run a second time via settle.
|
||||
if (typeof htmx !== 'undefined' && htmx.config) {
|
||||
htmx.config.allowScriptTags = false;
|
||||
}
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
const target = event.detail && event.detail.target;
|
||||
if (!target || !(target instanceof Element)) return;
|
||||
target.querySelectorAll('script').forEach(function(oldScript) {
|
||||
const newScript = document.createElement('script');
|
||||
for (const attr of oldScript.attributes) {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
newScript.textContent = oldScript.textContent;
|
||||
oldScript.replaceWith(newScript);
|
||||
});
|
||||
});
|
||||
|
||||
// Mark tab containers as loaded once their content settles, so switching
|
||||
// away and back doesn't re-fetch. Scoped to the "loadtab" trigger (tab
|
||||
// containers only) so modals and plugin config panels can still reload.
|
||||
document.body.addEventListener('htmx:afterSettle', function(event) {
|
||||
if (event.detail && event.detail.target) {
|
||||
const target = event.detail.target;
|
||||
const trigger = target.getAttribute('hx-trigger') || '';
|
||||
if (trigger.includes('loadtab')) {
|
||||
target.setAttribute('data-loaded', 'true');
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupScriptExecution);
|
||||
} else {
|
||||
setTimeout(setupScriptExecution, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
setupScriptExecution();
|
||||
|
||||
// Section toggle function - define early so it's available for HTMX-loaded content
|
||||
window.toggleSection = function(sectionId) {
|
||||
const section = document.getElementById(sectionId);
|
||||
const icon = document.getElementById(sectionId + '-icon');
|
||||
if (!section) {
|
||||
console.warn('toggleSection: Could not find section for', sectionId);
|
||||
return;
|
||||
}
|
||||
if (!icon) {
|
||||
console.warn('toggleSection: Could not find icon for', sectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if currently hidden by checking both class and computed display
|
||||
const hasHiddenClass = section.classList.contains('hidden');
|
||||
const computedDisplay = window.getComputedStyle(section).display;
|
||||
const isHidden = hasHiddenClass || computedDisplay === 'none';
|
||||
|
||||
if (isHidden) {
|
||||
// Show the section - remove hidden class and explicitly set display to block
|
||||
section.classList.remove('hidden');
|
||||
section.style.display = 'block';
|
||||
icon.classList.remove('fa-chevron-right');
|
||||
icon.classList.add('fa-chevron-down');
|
||||
} else {
|
||||
// Hide the section - add hidden class and set display to none
|
||||
section.classList.add('hidden');
|
||||
section.style.display = 'none';
|
||||
icon.classList.remove('fa-chevron-down');
|
||||
icon.classList.add('fa-chevron-right');
|
||||
}
|
||||
|
||||
// Keep assistive tech in sync: any toggle button that declares
|
||||
// aria-controls for this section mirrors the expanded state.
|
||||
const controlBtn = document.querySelector(`[aria-controls="${sectionId}"]`);
|
||||
if (controlBtn) {
|
||||
controlBtn.setAttribute('aria-expanded', String(isHidden));
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -173,7 +173,14 @@
|
||||
|
||||
function setActiveTab(tab) {
|
||||
var data = getAppData();
|
||||
if (data) { data.activeTab = tab; return true; }
|
||||
if (data) {
|
||||
data.activeTab = tab;
|
||||
// Navigating from a search result should also dismiss the mobile
|
||||
// nav drawer (harmless no-op on desktop, where the drawer CSS
|
||||
// doesn't apply).
|
||||
if ('mobileNavOpen' in data) data.mobileNavOpen = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||
removeButton.setAttribute('aria-label', 'Remove feed');
|
||||
removeButton.addEventListener('click', function() {
|
||||
window.removeCustomFeedRow(this);
|
||||
});
|
||||
@@ -333,6 +334,7 @@
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||
removeButton.setAttribute('aria-label', 'Remove feed');
|
||||
removeButton.addEventListener('click', function() {
|
||||
window.removeCustomFeedRow(this);
|
||||
});
|
||||
@@ -410,6 +412,9 @@
|
||||
// carries the result in a top-level "uploaded_files" key, not nested
|
||||
// under "data". file-upload-single.js's working upload flow uses this
|
||||
// same contract.
|
||||
// Backend contract (api_v3.upload_plugin_asset): field must be named
|
||||
// "files" (request.files.getlist('files')), and the response carries
|
||||
// results in a top-level "uploaded_files" key, not nested under "data".
|
||||
formData.append('files', file);
|
||||
formData.append('plugin_id', pluginId);
|
||||
|
||||
@@ -501,8 +506,6 @@
|
||||
// Append container to logoCell
|
||||
logoCell.appendChild(container);
|
||||
}
|
||||
// Allow re-uploading the same file
|
||||
event.target.value = '';
|
||||
} else {
|
||||
const notifyFn = window.showNotification || alert;
|
||||
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
|
||||
@@ -512,6 +515,12 @@
|
||||
console.error('Upload error:', error);
|
||||
const notifyFn = window.showNotification || alert;
|
||||
notifyFn('Upload failed: ' + error.message, 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
// Reset regardless of outcome, so the same file can be re-selected
|
||||
// to retry after a failure (browsers won't fire "change" again
|
||||
// for an input that still holds that exact file).
|
||||
event.target.value = '';
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
|
||||
// Track active notifications
|
||||
let activeNotifications = [];
|
||||
// onAction callbacks for notifications with an inline action button,
|
||||
// keyed by notification id (cleaned up on dismiss). A Map rather than a
|
||||
// plain object so ids can never collide with prototype properties.
|
||||
const actionCallbacks = new Map();
|
||||
let notificationCounter = 0;
|
||||
|
||||
/**
|
||||
@@ -113,6 +117,7 @@
|
||||
|
||||
// Remove from tracking array
|
||||
activeNotifications = activeNotifications.filter(id => id !== notificationId);
|
||||
actionCallbacks.delete(notificationId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +163,20 @@
|
||||
|
||||
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`;
|
||||
|
||||
// Optional inline action button (e.g. "Restart Now" on a restart nudge).
|
||||
// The callback is stored by id and invoked via triggerAction, which
|
||||
// also dismisses the notification.
|
||||
if (options.actionLabel && typeof options.onAction === 'function') {
|
||||
actionCallbacks.set(notificationId, options.onAction);
|
||||
html += `
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')"
|
||||
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150">
|
||||
${escapeHtml(options.actionLabel)}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
if (dismissible) {
|
||||
html += `
|
||||
<button type="button"
|
||||
@@ -227,6 +246,17 @@
|
||||
removeNotification(notificationId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Invoke a notification's onAction callback (see options.actionLabel /
|
||||
* options.onAction on show) and dismiss it.
|
||||
* @param {string} notificationId - Notification ID whose action to run
|
||||
*/
|
||||
triggerAction: function(notificationId) {
|
||||
const cb = actionCallbacks.get(notificationId);
|
||||
removeNotification(notificationId);
|
||||
if (typeof cb === 'function') cb();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all notifications
|
||||
*/
|
||||
@@ -262,9 +292,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Global shorthand function (backwards compatible with existing code)
|
||||
// Global shorthand function (backwards compatible with existing code).
|
||||
// Accepts either the legacy type string or a full options object
|
||||
// ({ type, duration, actionLabel, onAction, ... }).
|
||||
window.showNotification = function(message, type = 'info') {
|
||||
return showNotification(message, { type: type });
|
||||
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
|
||||
};
|
||||
|
||||
// Initialize container on load
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Plugin Order List — shared drag-and-drop reorder list of enabled plugins.
|
||||
*
|
||||
* Factored out of the Vegas Scroll section of display.html so both Vegas mode
|
||||
* and the primary rotation (Durations tab) use one implementation. Renders
|
||||
* one draggable row per enabled plugin into a container and keeps a hidden
|
||||
* input's value in sync as a JSON array of plugin ids in display order.
|
||||
*
|
||||
* Usage:
|
||||
* PluginOrderList.init({
|
||||
* containerId: 'vegas_plugin_order', // rows render here
|
||||
* orderInputId: 'vegas_plugin_order_value', // hidden input, JSON array of ids
|
||||
* excludedInputId: 'vegas_excluded_plugins_value', // optional: adds an
|
||||
* // include-checkbox per row; unchecked ids collect here (JSON array)
|
||||
* showVegasModeBadge: true // optional: Scroll/Fixed/Static badge
|
||||
* });
|
||||
*
|
||||
* The container re-renders from /api/v3/plugins/installed each init; the
|
||||
* hidden input(s) must already hold the saved order/exclusions (JSON).
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const MODE_LABELS = new Map([
|
||||
['scroll', { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }],
|
||||
['fixed', { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }],
|
||||
['static', { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' }]
|
||||
]);
|
||||
|
||||
function init(options) {
|
||||
const container = document.getElementById(options.containerId);
|
||||
const orderInput = document.getElementById(options.orderInputId);
|
||||
const excludedInput = options.excludedInputId ? document.getElementById(options.excludedInputId) : null;
|
||||
if (!container || !orderInput) return;
|
||||
|
||||
function syncInputs() {
|
||||
const order = [];
|
||||
const excluded = [];
|
||||
container.querySelectorAll('.plugin-order-item').forEach(item => {
|
||||
const pluginId = item.dataset.pluginId;
|
||||
order.push(pluginId);
|
||||
const checkbox = item.querySelector('.plugin-order-include');
|
||||
if (checkbox && !checkbox.checked) excluded.push(pluginId);
|
||||
});
|
||||
orderInput.value = JSON.stringify(order);
|
||||
if (excludedInput) excludedInput.value = JSON.stringify(excluded);
|
||||
}
|
||||
|
||||
function setupDragAndDrop() {
|
||||
let draggedItem = null;
|
||||
container.querySelectorAll('.plugin-order-item').forEach(item => {
|
||||
item.addEventListener('dragstart', function(e) {
|
||||
draggedItem = this;
|
||||
this.style.opacity = '0.5';
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
item.addEventListener('dragend', function() {
|
||||
this.style.opacity = '1';
|
||||
draggedItem = null;
|
||||
syncInputs();
|
||||
});
|
||||
item.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const rect = this.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
if (e.clientY < midY) {
|
||||
this.style.borderTop = '2px solid #3b82f6';
|
||||
this.style.borderBottom = '';
|
||||
} else {
|
||||
this.style.borderBottom = '2px solid #3b82f6';
|
||||
this.style.borderTop = '';
|
||||
}
|
||||
});
|
||||
item.addEventListener('dragleave', function() {
|
||||
this.style.borderTop = '';
|
||||
this.style.borderBottom = '';
|
||||
});
|
||||
item.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.borderTop = '';
|
||||
this.style.borderBottom = '';
|
||||
if (draggedItem && draggedItem !== this) {
|
||||
const rect = this.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
if (e.clientY < midY) {
|
||||
container.insertBefore(draggedItem, this);
|
||||
} else {
|
||||
container.insertBefore(draggedItem, this.nextSibling);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fetch('/api/v3/plugins/installed')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const allPlugins = (data.data && data.data.plugins) || data.plugins || [];
|
||||
const plugins = allPlugins.filter(p => p.enabled);
|
||||
if (plugins.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'text-sm text-gray-500 italic';
|
||||
empty.textContent = 'No enabled plugins';
|
||||
container.textContent = '';
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
let currentOrder = [];
|
||||
let excluded = [];
|
||||
try {
|
||||
currentOrder = JSON.parse(orderInput.value || '[]');
|
||||
if (excludedInput) excluded = JSON.parse(excludedInput.value || '[]');
|
||||
} catch (e) {
|
||||
console.error('Error parsing saved plugin order:', e);
|
||||
}
|
||||
// JSON.parse can succeed and still return null/objects
|
||||
// (e.g. a saved value of "null"); normalize to arrays.
|
||||
if (!Array.isArray(currentOrder)) currentOrder = [];
|
||||
if (!Array.isArray(excluded)) excluded = [];
|
||||
|
||||
// Saved order first, then any newly enabled plugins.
|
||||
const orderedPlugins = [];
|
||||
currentOrder.forEach(id => {
|
||||
const plugin = plugins.find(p => p.id === id);
|
||||
if (plugin) orderedPlugins.push(plugin);
|
||||
});
|
||||
plugins.forEach(plugin => {
|
||||
if (!orderedPlugins.find(p => p.id === plugin.id)) orderedPlugins.push(plugin);
|
||||
});
|
||||
|
||||
// Rows are built with DOM APIs rather than innerHTML — plugin
|
||||
// ids/names come from installed manifests (semi-trusted).
|
||||
container.textContent = '';
|
||||
orderedPlugins.forEach(plugin => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center p-2 bg-gray-50 rounded border border-gray-200 cursor-move plugin-order-item';
|
||||
row.dataset.pluginId = plugin.id;
|
||||
row.draggable = true;
|
||||
|
||||
const grip = document.createElement('i');
|
||||
grip.className = 'fas fa-grip-vertical text-gray-400 mr-3';
|
||||
row.appendChild(grip);
|
||||
|
||||
if (excludedInput) {
|
||||
const isExcluded = excluded.includes(plugin.id);
|
||||
const label = document.createElement('label');
|
||||
label.className = 'flex items-center flex-1';
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'plugin-order-include h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2';
|
||||
checkbox.checked = !isExcluded;
|
||||
const name = document.createElement('span');
|
||||
name.className = 'text-sm font-medium text-gray-700';
|
||||
name.textContent = plugin.name || plugin.id;
|
||||
label.appendChild(checkbox);
|
||||
label.appendChild(name);
|
||||
row.appendChild(label);
|
||||
} else {
|
||||
const name = document.createElement('span');
|
||||
name.className = 'text-sm font-medium text-gray-700 flex-1';
|
||||
name.textContent = plugin.name || plugin.id;
|
||||
row.appendChild(name);
|
||||
}
|
||||
|
||||
if (options.showVegasModeBadge) {
|
||||
const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed';
|
||||
const modeInfo = MODE_LABELS.get(vegasMode) || MODE_LABELS.get('fixed');
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `text-xs ${modeInfo.color} ml-2`;
|
||||
badge.title = `Vegas display mode: ${modeInfo.label}`;
|
||||
const badgeIcon = document.createElement('i');
|
||||
badgeIcon.className = `fas ${modeInfo.icon} mr-1`;
|
||||
badge.appendChild(badgeIcon);
|
||||
badge.appendChild(document.createTextNode(modeInfo.label));
|
||||
row.appendChild(badge);
|
||||
}
|
||||
|
||||
// Up/down buttons: touch- and keyboard-accessible
|
||||
// reordering alongside native drag-and-drop (HTML5 drag
|
||||
// events don't fire on most mobile browsers).
|
||||
const pluginLabel = plugin.name || plugin.id;
|
||||
[['up', 'fa-chevron-up', `Move ${pluginLabel} up`],
|
||||
['down', 'fa-chevron-down', `Move ${pluginLabel} down`]].forEach(([dir, iconCls, ariaLabel]) => {
|
||||
const moveBtn = document.createElement('button');
|
||||
moveBtn.type = 'button';
|
||||
moveBtn.className = 'plugin-order-move text-gray-400 hover:text-gray-700 px-2 py-1';
|
||||
moveBtn.setAttribute('aria-label', ariaLabel);
|
||||
const moveIcon = document.createElement('i');
|
||||
moveIcon.className = `fas ${iconCls} text-xs`;
|
||||
moveBtn.appendChild(moveIcon);
|
||||
moveBtn.addEventListener('click', function() {
|
||||
if (dir === 'up' && row.previousElementSibling) {
|
||||
container.insertBefore(row, row.previousElementSibling);
|
||||
} else if (dir === 'down' && row.nextElementSibling) {
|
||||
container.insertBefore(row.nextElementSibling, row);
|
||||
}
|
||||
syncInputs();
|
||||
moveBtn.focus();
|
||||
});
|
||||
row.appendChild(moveBtn);
|
||||
});
|
||||
|
||||
container.appendChild(row);
|
||||
});
|
||||
|
||||
setupDragAndDrop();
|
||||
container.querySelectorAll('.plugin-order-include').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', syncInputs);
|
||||
});
|
||||
syncInputs();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching plugins:', error);
|
||||
const err = document.createElement('p');
|
||||
err.className = 'text-sm text-red-500';
|
||||
err.textContent = 'Error loading plugins';
|
||||
container.textContent = '';
|
||||
container.appendChild(err);
|
||||
});
|
||||
}
|
||||
|
||||
window.PluginOrderList = { init: init };
|
||||
})();
|
||||
Reference in New Issue
Block a user