mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
perf(web): extract 3,850 lines of inline JS from base.html to cacheable static files
base.html shipped ~4,200 lines of inline JavaScript inside the HTML
document, re-downloaded and re-parsed on every page load (gzip helps the
transfer, but inline scripts can never be browser-cached). The four
largest blocks - none containing any Jinja syntax, verified by scanning
every inline block for {{ }} / {% %} - now live as static files served
with the app's existing mtime-versioned immutable caching:
- js/htmx-config.js (246 lines): HTMX swap/script-execution config,
toggleSection helpers
- js/app-early.js (346 lines): early helpers + the app() stub that must
precede Alpine init
- js/app-shell.js (2,997 lines): SSE wiring + the full Alpine app()
implementation and tab logic
- js/custom-feeds-helpers.js (262 lines): custom-feeds table helpers
Each replacement <script src> is CLASSIC (no defer/async) at the exact
position of the inline block it replaces - identical execution timing and
DOM visibility to inline scripts, so relative ordering with the deferred
scripts and with each other is unchanged. base.html drops from ~4,940 to
1,079 lines.
Validation: extraction proven lossless by programmatically reassembling
the four files back into the template and comparing against git HEAD -
byte-for-byte identical. Jinja parse passes; script open/close tags
balanced (53/53, after excluding a literal "<script>" inside an HTML
comment).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
a10152c995
commit
84c41dfbf0
@@ -0,0 +1,346 @@
|
|||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const iconClass = (plugin.icon || 'fas fa-puzzle-piece').replace(/"/g, '"');
|
||||||
|
tabButton.innerHTML = `<i class="${iconClass}"></i>${(plugin.name || plugin.id).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}`;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = plugin.name || plugin.id;
|
||||||
|
const iconClass = (plugin.icon || 'fas fa-puzzle-piece').replace(/"/g, '"');
|
||||||
|
tabButton.innerHTML = `<i class="${iconClass}"></i>${div.innerHTML}`;
|
||||||
|
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,262 @@
|
|||||||
|
// Custom feeds table helper functions
|
||||||
|
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
|
||||||
|
function addCustomFeedRow(fieldId, fullKey, maxItems, pluginId) {
|
||||||
|
const tbody = document.getElementById(fieldId + '_tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
const currentRows = tbody.querySelectorAll('.custom-feed-row');
|
||||||
|
if (currentRows.length >= maxItems) {
|
||||||
|
alert(`Maximum ${maxItems} feeds allowed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newIndex = currentRows.length;
|
||||||
|
const newRow = document.createElement('tr');
|
||||||
|
newRow.className = 'custom-feed-row';
|
||||||
|
newRow.setAttribute('data-index', newIndex);
|
||||||
|
|
||||||
|
// Create name cell
|
||||||
|
const nameCell = document.createElement('td');
|
||||||
|
nameCell.className = 'px-4 py-3 whitespace-nowrap';
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.type = 'text';
|
||||||
|
nameInput.name = `${fullKey}.${newIndex}.name`;
|
||||||
|
nameInput.value = '';
|
||||||
|
nameInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
||||||
|
nameInput.placeholder = 'Feed Name';
|
||||||
|
nameInput.required = true;
|
||||||
|
nameCell.appendChild(nameInput);
|
||||||
|
|
||||||
|
// Create URL cell
|
||||||
|
const urlCell = document.createElement('td');
|
||||||
|
urlCell.className = 'px-4 py-3 whitespace-nowrap';
|
||||||
|
const urlInput = document.createElement('input');
|
||||||
|
urlInput.type = 'url';
|
||||||
|
urlInput.name = `${fullKey}.${newIndex}.url`;
|
||||||
|
urlInput.value = '';
|
||||||
|
urlInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
||||||
|
urlInput.placeholder = 'https://example.com/feed';
|
||||||
|
urlInput.required = true;
|
||||||
|
urlCell.appendChild(urlInput);
|
||||||
|
|
||||||
|
// Create logo cell
|
||||||
|
const logoCell = document.createElement('td');
|
||||||
|
logoCell.className = 'px-4 py-3 whitespace-nowrap';
|
||||||
|
const logoContainer = document.createElement('div');
|
||||||
|
logoContainer.className = 'flex items-center space-x-2';
|
||||||
|
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.id = `${fieldId}_logo_${newIndex}`;
|
||||||
|
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
fileInput.dataset.index = String(newIndex);
|
||||||
|
// Use addEventListener with dataset index to allow reindexing
|
||||||
|
fileInput.addEventListener('change', function(e) {
|
||||||
|
const idx = parseInt(e.target.dataset.index || '0', 10);
|
||||||
|
handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadButton = document.createElement('button');
|
||||||
|
uploadButton.type = 'button';
|
||||||
|
uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded';
|
||||||
|
// Use fileInput directly instead of getElementById for reindexing compatibility
|
||||||
|
uploadButton.addEventListener('click', function() {
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
const uploadIcon = document.createElement('i');
|
||||||
|
uploadIcon.className = 'fas fa-upload mr-1';
|
||||||
|
uploadButton.appendChild(uploadIcon);
|
||||||
|
uploadButton.appendChild(document.createTextNode(' Upload'));
|
||||||
|
|
||||||
|
const noLogoSpan = document.createElement('span');
|
||||||
|
noLogoSpan.className = 'text-xs text-gray-400';
|
||||||
|
noLogoSpan.textContent = 'No logo';
|
||||||
|
|
||||||
|
logoContainer.appendChild(fileInput);
|
||||||
|
logoContainer.appendChild(uploadButton);
|
||||||
|
logoContainer.appendChild(noLogoSpan);
|
||||||
|
logoCell.appendChild(logoContainer);
|
||||||
|
|
||||||
|
// Create enabled cell
|
||||||
|
const enabledCell = document.createElement('td');
|
||||||
|
enabledCell.className = 'px-4 py-3 whitespace-nowrap text-center';
|
||||||
|
const enabledInput = document.createElement('input');
|
||||||
|
enabledInput.type = 'checkbox';
|
||||||
|
enabledInput.name = `${fullKey}.${newIndex}.enabled`;
|
||||||
|
enabledInput.checked = true;
|
||||||
|
enabledInput.value = 'true';
|
||||||
|
enabledInput.className = 'h-4 w-4 text-blue-600';
|
||||||
|
enabledCell.appendChild(enabledInput);
|
||||||
|
|
||||||
|
// Create remove cell
|
||||||
|
const removeCell = document.createElement('td');
|
||||||
|
removeCell.className = 'px-4 py-3 whitespace-nowrap text-center';
|
||||||
|
const removeButton = document.createElement('button');
|
||||||
|
removeButton.type = 'button';
|
||||||
|
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||||
|
// Use addEventListener instead of string-based onclick to prevent injection
|
||||||
|
removeButton.addEventListener('click', function() {
|
||||||
|
removeCustomFeedRow(this);
|
||||||
|
});
|
||||||
|
const removeIcon = document.createElement('i');
|
||||||
|
removeIcon.className = 'fas fa-trash';
|
||||||
|
removeButton.appendChild(removeIcon);
|
||||||
|
removeCell.appendChild(removeButton);
|
||||||
|
|
||||||
|
// Append all cells to row
|
||||||
|
newRow.appendChild(nameCell);
|
||||||
|
newRow.appendChild(urlCell);
|
||||||
|
newRow.appendChild(logoCell);
|
||||||
|
newRow.appendChild(enabledCell);
|
||||||
|
newRow.appendChild(removeCell);
|
||||||
|
tbody.appendChild(newRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCustomFeedRow(button) {
|
||||||
|
const row = button.closest('tr');
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
if (confirm('Remove this feed?')) {
|
||||||
|
const tbody = row.parentElement;
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
row.remove();
|
||||||
|
|
||||||
|
// Re-index remaining rows
|
||||||
|
const rows = tbody.querySelectorAll('.custom-feed-row');
|
||||||
|
rows.forEach((r, index) => {
|
||||||
|
const oldIndex = r.getAttribute('data-index');
|
||||||
|
r.setAttribute('data-index', index);
|
||||||
|
// Update all input names with new index
|
||||||
|
r.querySelectorAll('input, button').forEach(input => {
|
||||||
|
const name = input.getAttribute('name');
|
||||||
|
if (name) {
|
||||||
|
// Replace pattern like "feeds.custom_feeds.0.name" with "feeds.custom_feeds.1.name"
|
||||||
|
input.setAttribute('name', name.replace(/\.\d+\./, `.${index}.`));
|
||||||
|
}
|
||||||
|
const id = input.id;
|
||||||
|
if (id) {
|
||||||
|
// Keep IDs aligned after reindex (supports both _logo_<n> and _logo_preview_<n>)
|
||||||
|
input.id = id
|
||||||
|
.replace(/_logo_preview_\d+$/, `_logo_preview_${index}`)
|
||||||
|
.replace(/_logo_\d+$/, `_logo_${index}`);
|
||||||
|
}
|
||||||
|
// Keep dataset index aligned so event handlers remain correct after reindex
|
||||||
|
if (input.dataset && 'index' in input.dataset) {
|
||||||
|
input.dataset.index = String(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCustomFeedLogoUpload(event, fieldId, index, pluginId, fullKey) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('plugin_id', pluginId);
|
||||||
|
|
||||||
|
fetch('/api/v3/plugins/assets/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
// Check HTTP status before parsing JSON
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.text().then(text => {
|
||||||
|
throw new Error(`Upload failed: ${response.status} ${response.statusText}${text ? ': ' + text : ''}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
|
||||||
|
const uploadedFile = data.data.files[0];
|
||||||
|
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
|
||||||
|
if (row) {
|
||||||
|
const logoCell = row.querySelector('td:nth-child(3)');
|
||||||
|
const existingPathInput = logoCell.querySelector('input[name*=".logo.path"]');
|
||||||
|
const existingIdInput = logoCell.querySelector('input[name*=".logo.id"]');
|
||||||
|
const pathName = existingPathInput ? existingPathInput.name : `${fullKey}.${index}.logo.path`;
|
||||||
|
const idName = existingIdInput ? existingIdInput.name : `${fullKey}.${index}.logo.id`;
|
||||||
|
|
||||||
|
// Normalize path: remove leading slashes, then add single leading slash
|
||||||
|
const normalizedPath = String(uploadedFile.path || '').replace(/^\/+/, '');
|
||||||
|
const imageSrc = '/' + normalizedPath;
|
||||||
|
|
||||||
|
// Clear logoCell and build DOM safely to prevent XSS
|
||||||
|
logoCell.textContent = ''; // Clear existing content
|
||||||
|
|
||||||
|
// Create container div
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'flex items-center space-x-2';
|
||||||
|
|
||||||
|
// Create file input
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.id = `${fieldId}_logo_${index}`;
|
||||||
|
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
fileInput.dataset.index = String(index);
|
||||||
|
// Use addEventListener with dataset index to allow reindexing
|
||||||
|
fileInput.addEventListener('change', function(e) {
|
||||||
|
const idx = parseInt(e.target.dataset.index || '0', 10);
|
||||||
|
handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create upload button
|
||||||
|
const uploadButton = document.createElement('button');
|
||||||
|
uploadButton.type = 'button';
|
||||||
|
uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded';
|
||||||
|
// Use fileInput directly instead of getElementById for reindexing compatibility
|
||||||
|
uploadButton.addEventListener('click', function() {
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
const uploadIcon = document.createElement('i');
|
||||||
|
uploadIcon.className = 'fas fa-upload mr-1';
|
||||||
|
uploadButton.appendChild(uploadIcon);
|
||||||
|
uploadButton.appendChild(document.createTextNode(' Upload'));
|
||||||
|
|
||||||
|
// Create img element - use normalized path, set src via property to prevent XSS
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = imageSrc; // Use property assignment with normalized path
|
||||||
|
img.alt = 'Logo';
|
||||||
|
img.className = 'w-8 h-8 object-cover rounded border';
|
||||||
|
img.id = `${fieldId}_logo_preview_${index}`;
|
||||||
|
|
||||||
|
// Create hidden input for path - set value via property to prevent XSS
|
||||||
|
const pathInput = document.createElement('input');
|
||||||
|
pathInput.type = 'hidden';
|
||||||
|
pathInput.name = pathName;
|
||||||
|
pathInput.value = imageSrc;
|
||||||
|
|
||||||
|
// Create hidden input for id - set value via property to prevent XSS
|
||||||
|
const idInput = document.createElement('input');
|
||||||
|
idInput.type = 'hidden';
|
||||||
|
idInput.name = idName;
|
||||||
|
idInput.value = String(uploadedFile.id); // Ensure it's a string
|
||||||
|
|
||||||
|
// Append all elements to container
|
||||||
|
container.appendChild(fileInput);
|
||||||
|
container.appendChild(uploadButton);
|
||||||
|
container.appendChild(img);
|
||||||
|
container.appendChild(pathInput);
|
||||||
|
container.appendChild(idInput);
|
||||||
|
|
||||||
|
// Append container to logoCell
|
||||||
|
logoCell.appendChild(container);
|
||||||
|
}
|
||||||
|
// Allow re-uploading the same file (change event won't fire otherwise)
|
||||||
|
event.target.value = '';
|
||||||
|
} else {
|
||||||
|
alert('Upload failed: ' + (data.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
alert('Upload failed: ' + error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
// 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.org') ||
|
||||||
|
errorStr.includes('htmx') ||
|
||||||
|
errorStack.includes('htmx') ||
|
||||||
|
args.some(arg => {
|
||||||
|
if (typeof arg === 'string') {
|
||||||
|
return arg.includes('htmx.org') || 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 the form data
|
||||||
|
if (target && target.tagName === 'FORM') {
|
||||||
|
const formData = new FormData(target);
|
||||||
|
const formPayload = {};
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
formPayload[key] = value;
|
||||||
|
}
|
||||||
|
console.error('Form payload:', formPayload);
|
||||||
|
|
||||||
|
// 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 (e) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||||
|
if (event.detail && event.detail.target) {
|
||||||
|
try {
|
||||||
|
const scripts = event.detail.target.querySelectorAll('script');
|
||||||
|
scripts.forEach(function(oldScript) {
|
||||||
|
try {
|
||||||
|
if (oldScript.innerHTML.trim() || oldScript.src) {
|
||||||
|
const newScript = document.createElement('script');
|
||||||
|
if (oldScript.src) newScript.src = oldScript.src;
|
||||||
|
if (oldScript.type) newScript.type = oldScript.type;
|
||||||
|
if (oldScript.innerHTML) newScript.textContent = oldScript.innerHTML;
|
||||||
|
if (oldScript.parentNode) {
|
||||||
|
oldScript.parentNode.insertBefore(newScript, oldScript);
|
||||||
|
oldScript.parentNode.removeChild(oldScript);
|
||||||
|
} else {
|
||||||
|
// If no parent, append to head or body
|
||||||
|
(document.head || document.body).appendChild(newScript);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silently ignore script execution errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Silently ignore errors in script processing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
var target = event.detail.target;
|
||||||
|
var 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user