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');
|
||||
}
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user