diff --git a/web_interface/static/v3/js/app-early.js b/web_interface/static/v3/js/app-early.js
new file mode 100644
index 00000000..91ded445
--- /dev/null
+++ b/web_interface/static/v3/js/app-early.js
@@ -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 = `${(plugin.name || plugin.id).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 = `${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, '>'); }
+ };
+ };
+ })();
diff --git a/web_interface/static/v3/js/app-shell.js b/web_interface/static/v3/js/app-shell.js
new file mode 100644
index 00000000..f7049106
--- /dev/null
+++ b/web_interface/static/v3/js/app-shell.js
@@ -0,0 +1,2997 @@
+// SSE wiring + full Alpine app() implementation and tab logic
+// Extracted from templates/v3/base.html so browsers cache it as a static asset.
+ // Assign to window so reconnectSSE() in app.js can reach them.
+ window.statsSource = new EventSource('/api/v3/stream/stats');
+ window.displaySource = new EventSource('/api/v3/stream/display');
+
+ window.statsSource.onmessage = function(event) {
+ const data = JSON.parse(event.data);
+ updateSystemStats(data);
+ };
+
+ window.displaySource.onmessage = function(event) {
+ const data = JSON.parse(event.data);
+ updateDisplayPreview(data);
+ };
+
+ function _setConnectionStatus(connected, reconnecting) {
+ const el = document.getElementById('connection-status');
+ if (!el) return;
+ if (connected) {
+ el.innerHTML = `
+
+ Connected
+ `;
+ } else if (reconnecting) {
+ el.innerHTML = `
+
+ Reconnecting…
+ `;
+ } else {
+ el.innerHTML = `
+
+ Disconnected
+ `;
+ }
+ }
+
+ var _statsErrorCount = 0;
+
+ // Named on window so reconnectSSE() in app.js can reattach them after
+ // replacing the EventSource instances.
+ window._statsOpenHandler = function() {
+ _statsErrorCount = 0;
+ _setConnectionStatus(true, false);
+ };
+ window._statsErrorHandler = function() {
+ _statsErrorCount++;
+ // EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED
+ var reconnecting = window.statsSource.readyState === EventSource.CONNECTING;
+ _setConnectionStatus(false, reconnecting && _statsErrorCount <= 3);
+ };
+ window._displayErrorHandler = function() {
+ // Display stream errors don't change the status badge but log to console
+ // so failures aren't completely silent.
+ console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
+ };
+
+ window.statsSource.addEventListener('open', window._statsOpenHandler);
+ window.statsSource.addEventListener('error', window._statsErrorHandler);
+ window.displaySource.addEventListener('error', window._displayErrorHandler);
+
+ // Reset any time the currently-active warning clears, so a future
+ // (new) occurrence shows the banner again even if this one was dismissed.
+ window._powerWarningDismissed = false;
+
+ window.dismissPowerWarningBanner = function() {
+ const banner = document.getElementById('power-warning-banner');
+ if (banner) banner.style.display = 'none';
+ window._powerWarningDismissed = true;
+ };
+
+ // Labels for whichever flags from _get_power_status() are set (pass
+ // suffix='_occurred' for the "happened earlier" variant), used to
+ // build accurate banner/tooltip text instead of hardcoding
+ // "under-voltage" for what may actually be throttling/freq-capping/
+ // thermal limiting.
+ function _activePowerConditionLabels(power, suffix) {
+ suffix = suffix || '_now';
+ const labels = [];
+ if (power['under_voltage' + suffix]) labels.push('under-voltage');
+ if (power['throttled' + suffix]) labels.push('throttling');
+ if (power['freq_capped' + suffix]) labels.push('CPU frequency capped');
+ if (power['soft_temp_limit' + suffix]) labels.push('soft thermal limit');
+ return labels;
+ }
+
+ function updatePowerStatus(power) {
+ const statEl = document.getElementById('power-stat');
+ const banner = document.getElementById('power-warning-banner');
+ const bannerText = document.getElementById('power-warning-banner-text');
+
+ if (!power) {
+ if (statEl) statEl.classList.add('hidden');
+ if (banner) {
+ banner.style.display = 'none';
+ // Let a future occurrence show the banner again rather
+ // than leaving stale text/visibility from before this
+ // (likely transient) missing-data tick.
+ window._powerWarningDismissed = false;
+ }
+ return;
+ }
+
+ const activeNow = power.under_voltage_now || power.throttled_now ||
+ power.freq_capped_now || power.soft_temp_limit_now;
+ const occurredEarlier = power.under_voltage_occurred || power.throttled_occurred ||
+ power.freq_capped_occurred || power.soft_temp_limit_occurred;
+
+ if (statEl) {
+ statEl.classList.remove('text-red-600', 'text-yellow-600');
+ if (activeNow) {
+ statEl.classList.remove('hidden');
+ statEl.classList.add('flex', 'text-red-600');
+ statEl.title = _activePowerConditionLabels(power).join('/') +
+ ' detected right now — check your power supply and cooling';
+ } else if (occurredEarlier) {
+ statEl.classList.remove('hidden');
+ statEl.classList.add('flex', 'text-yellow-600');
+ const occurredLabels = _activePowerConditionLabels(power, '_occurred');
+ statEl.title = (occurredLabels.length ? occurredLabels.join('/') : 'An issue') +
+ ' was detected earlier (currently OK)';
+ } else {
+ statEl.classList.add('hidden');
+ }
+ }
+
+ if (banner) {
+ if (activeNow) {
+ if (bannerText) {
+ const labels = _activePowerConditionLabels(power);
+ bannerText.textContent = (labels.length ? labels.join('/') : 'A power/thermal issue') +
+ ' detected right now — the display may flicker or degrade. Check your power supply and cooling.';
+ }
+ if (!window._powerWarningDismissed) {
+ banner.style.display = '';
+ }
+ } else {
+ banner.style.display = 'none';
+ // Let a future occurrence show the banner again.
+ window._powerWarningDismissed = false;
+ }
+ }
+ }
+
+ function updateSystemStats(data) {
+ // Update CPU in header
+ const cpuEl = document.getElementById('cpu-stat');
+ if (cpuEl && data.cpu_percent !== undefined) {
+ const spans = cpuEl.querySelectorAll('span');
+ if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_percent + '%';
+ }
+
+ // Update Memory in header
+ const memEl = document.getElementById('memory-stat');
+ if (memEl && data.memory_used_percent !== undefined) {
+ const spans = memEl.querySelectorAll('span');
+ if (spans.length > 0) spans[spans.length - 1].textContent = data.memory_used_percent + '%';
+ }
+
+ // Update Temperature in header
+ const tempEl = document.getElementById('temp-stat');
+ if (tempEl && data.cpu_temp !== undefined) {
+ const spans = tempEl.querySelectorAll('span');
+ if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_temp + '°C';
+ }
+
+ // Update Power (under-voltage / throttling) status in header + banner
+ updatePowerStatus(data.power);
+
+ // Update Overview tab stats (if visible)
+ const cpuUsageEl = document.getElementById('cpu-usage');
+ if (cpuUsageEl && data.cpu_percent !== undefined) {
+ cpuUsageEl.textContent = data.cpu_percent + '%';
+ }
+
+ const memUsageEl = document.getElementById('memory-usage');
+ if (memUsageEl && data.memory_used_percent !== undefined) {
+ memUsageEl.textContent = data.memory_used_percent + '%';
+ }
+
+ const cpuTempEl = document.getElementById('cpu-temp');
+ if (cpuTempEl && data.cpu_temp !== undefined) {
+ cpuTempEl.textContent = data.cpu_temp + '°C';
+ }
+
+ const displayStatusEl = document.getElementById('display-status');
+ if (displayStatusEl) {
+ displayStatusEl.textContent = data.service_active ? 'Active' : 'Inactive';
+ displayStatusEl.className = data.service_active ?
+ 'text-lg font-medium text-green-600' :
+ 'text-lg font-medium text-red-600';
+ }
+ }
+
+ window.__onDemandStore = window.__onDemandStore || {
+ loading: true,
+ state: {},
+ service: {},
+ error: null,
+ lastUpdated: null
+ };
+
+ document.addEventListener('alpine:init', () => {
+ // On-Demand state store
+ if (window.Alpine && !window.Alpine.store('onDemand')) {
+ window.Alpine.store('onDemand', {
+ loading: window.__onDemandStore.loading,
+ state: window.__onDemandStore.state,
+ service: window.__onDemandStore.service,
+ error: window.__onDemandStore.error,
+ lastUpdated: window.__onDemandStore.lastUpdated
+ });
+ }
+ if (window.Alpine) {
+ window.__onDemandStore = window.Alpine.store('onDemand');
+ }
+
+ // Plugin state store - centralized state management for plugins
+ // Used primarily by HTMX-loaded plugin config partials
+ if (window.Alpine && !window.Alpine.store('plugins')) {
+ window.Alpine.store('plugins', {
+ // Track which plugin configs have been loaded
+ loadedConfigs: {},
+
+ // Mark a plugin config as loaded
+ markLoaded(pluginId) {
+ this.loadedConfigs[pluginId] = true;
+ },
+
+ // Check if a plugin config is loaded
+ isLoaded(pluginId) {
+ return !!this.loadedConfigs[pluginId];
+ },
+
+ // Refresh a plugin config tab via HTMX
+ refreshConfig(pluginId) {
+ const container = document.querySelector(`#plugin-config-${pluginId}`);
+ if (container && window.htmx) {
+ htmx.ajax('GET', `/v3/partials/plugin-config/${pluginId}`, {
+ target: container,
+ swap: 'innerHTML'
+ });
+ }
+ }
+ });
+ }
+ });
+
+
+ // Alpine.js app function - full implementation
+ function app() {
+ // If Alpine is already initialized, get the current component and enhance it
+ let baseComponent = {};
+ if (window.Alpine) {
+ const appElement = document.querySelector('[x-data]');
+ if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
+ baseComponent = appElement._x_dataStack[0];
+ }
+ }
+
+ const fullImplementation = {
+ activeTab: (function() {
+ // Auto-open WiFi tab when in AP mode (192.168.4.x)
+ const isAPMode = window.location.hostname === '192.168.4.1' ||
+ window.location.hostname.startsWith('192.168.4.');
+ return isAPMode ? 'wifi' : 'overview';
+ })(),
+ mobileNavOpen: false,
+ installedPlugins: [],
+
+ init() {
+ // Prevent multiple initializations
+ if (this._initialized) {
+ return;
+ }
+ this._initialized = true;
+
+ // Load plugins on page load so tabs are available on any page, regardless of active tab
+ // First check if plugins are already in window.installedPlugins (from plugins_manager.js)
+ if (typeof window.installedPlugins !== 'undefined' && Array.isArray(window.installedPlugins) && window.installedPlugins.length > 0) {
+ this.installedPlugins = window.installedPlugins;
+ debugLog('Initialized installedPlugins from global:', this.installedPlugins.length);
+ // Ensure tabs are updated immediately
+ this.$nextTick(() => {
+ this.updatePluginTabs();
+ });
+ } else if (!this.installedPlugins || this.installedPlugins.length === 0) {
+ // Load plugins asynchronously, but ensure tabs update when done
+ this.loadInstalledPlugins().then(() => {
+ // Ensure tabs are updated after loading
+ this.$nextTick(() => {
+ this.updatePluginTabs();
+ });
+ }).catch(err => {
+ console.error('Error loading plugins in init:', err);
+ // Still try to update tabs in case some plugins are available
+ this.$nextTick(() => {
+ this.updatePluginTabs();
+ });
+ });
+ } else {
+ // Plugins already loaded, just update tabs
+ this.$nextTick(() => {
+ this.updatePluginTabs();
+ });
+ }
+
+ // Ensure content loads for the active tab
+ this.$watch('activeTab', (newTab, oldTab) => {
+ // Update plugin tab states when activeTab changes
+ if (typeof this.updatePluginTabStates === 'function') {
+ this.updatePluginTabStates();
+ }
+ // Trigger content load when tab changes
+ this.$nextTick(() => {
+ this.loadTabContent(newTab);
+ });
+ });
+
+ // Load initial tab content
+ this.$nextTick(() => {
+ this.loadTabContent(this.activeTab);
+ });
+
+ // Listen for plugin updates from pluginManager
+ document.addEventListener('pluginsUpdated', (event) => {
+ debugLog('Received pluginsUpdated event:', event.detail.plugins.length, 'plugins');
+ this.installedPlugins = event.detail.plugins;
+ this.updatePluginTabs();
+ });
+
+ // Also listen for direct window.installedPlugins changes
+ // Store the actual value in a private property to avoid infinite loops
+ let _installedPluginsValue = this.installedPlugins || [];
+
+ // Only define the property if it doesn't already exist or if it's configurable
+ const existingDescriptor = Object.getOwnPropertyDescriptor(window, 'installedPlugins');
+ if (!existingDescriptor || existingDescriptor.configurable) {
+ // Delete existing property if it exists and is configurable
+ if (existingDescriptor) {
+ delete window.installedPlugins;
+ }
+
+ Object.defineProperty(window, 'installedPlugins', {
+ set: (value) => {
+ const newPlugins = value || [];
+ const oldIds = (_installedPluginsValue || []).map(p => p.id).sort().join(',');
+ const newIds = newPlugins.map(p => p.id).sort().join(',');
+
+ // Only update if plugin list actually changed
+ if (oldIds !== newIds) {
+ debugLog('window.installedPlugins changed:', newPlugins.length, 'plugins');
+ _installedPluginsValue = newPlugins;
+ this.installedPlugins = newPlugins;
+ this.updatePluginTabs();
+ }
+ },
+ get: () => _installedPluginsValue,
+ configurable: true // Allow redefinition if needed
+ });
+ } else {
+ // Property already exists and is not configurable, just update the value
+ if (typeof window.installedPlugins !== 'undefined') {
+ _installedPluginsValue = window.installedPlugins;
+ }
+ }
+
+ },
+
+ loadTabContent(tab) {
+ const contentEl = document.getElementById(tab + '-content');
+ // data-loaded: already fetched. data-loading: a fetch is queued or in
+ // flight. Both guard against re-entry so a panel loads exactly once, even
+ // if the tab is reopened before an in-progress (or polling) load settles.
+ if (!contentEl || contentEl.hasAttribute('data-loaded') || contentEl.hasAttribute('data-loading')) return;
+ const url = contentEl.getAttribute('hx-get');
+ if (!url) return;
+
+ contentEl.setAttribute('data-loading', 'true');
+
+ // htmx.ajax issues the request and swaps the response into the panel
+ // directly, so it works even before htmx has wired up the element's
+ // hx-trigger listeners. data-loaded is stamped on success so the panel
+ // loads once; the activeTab check drops loads for a tab the user navigated
+ // away from while htmx was still loading (avoids fetching hidden panels).
+ const swap = contentEl.getAttribute('hx-swap') || 'innerHTML';
+ const load = () => {
+ if (this.activeTab !== tab || contentEl.hasAttribute('data-loaded')) {
+ contentEl.removeAttribute('data-loading');
+ return;
+ }
+ return htmx.ajax('GET', url, { target: contentEl, swap: swap })
+ .then(() => contentEl.setAttribute('data-loaded', 'true'))
+ .catch(() => {}) // leave unstamped on failure so it can retry
+ .finally(() => contentEl.removeAttribute('data-loading'));
+ };
+
+ if (typeof htmx !== 'undefined') {
+ load();
+ return;
+ }
+
+ // htmx is loaded from a CDN and may not be ready yet. Poll until it is,
+ // then load; if it never arrives, fall back to a direct fetch.
+ let tries = 0;
+ const timer = setInterval(() => {
+ if (typeof htmx !== 'undefined') {
+ clearInterval(timer);
+ load();
+ } else if (++tries > 100) { // ~10s
+ clearInterval(timer);
+ contentEl.removeAttribute('data-loading');
+ if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect();
+ else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect();
+ else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect();
+ else if (tab === 'tools') {
+ fetch('/v3/partials/tools')
+ .then(r => {
+ if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
+ return r.text();
+ })
+ .then(html => {
+ contentEl.innerHTML = html;
+ contentEl.setAttribute('data-loaded', 'true');
+ if (window.Alpine) window.Alpine.initTree(contentEl);
+ })
+ .catch(err => {
+ console.error('Failed to load tools content:', err);
+ contentEl.innerHTML = '
Failed to load Tools. Please refresh the page.
';
+ });
+ }
+ }
+ }, 100);
+ },
+
+ async loadInstalledPlugins() {
+ // If pluginManager exists (plugins.html is loaded), delegate to it
+ if (window.pluginManager) {
+ debugLog('[FULL] Delegating plugin loading to pluginManager...');
+ await window.pluginManager.loadInstalledPlugins();
+ // pluginManager should set window.installedPlugins, so update our component
+ if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
+ this.installedPlugins = window.installedPlugins;
+ debugLog('[FULL] Updated component plugins from window.installedPlugins:', this.installedPlugins.length);
+ }
+ this.updatePluginTabs();
+ return;
+ }
+
+ // Otherwise, load plugins directly (fallback for when plugins.html isn't loaded)
+ try {
+ debugLog('[FULL] Loading installed plugins directly...');
+ const data = await getInstalledPluginsSafe();
+
+ if (data.status === 'success') {
+ this.installedPlugins = data.data.plugins || [];
+ // Also update window.installedPlugins for consistency
+ window.installedPlugins = this.installedPlugins;
+ debugLog(`[FULL] Loaded ${this.installedPlugins.length} plugins:`, this.installedPlugins.map(p => p.id));
+
+ // Debug: Log enabled status for each plugin
+ this.installedPlugins.forEach(plugin => {
+ debugLog(`[DEBUG Alpine] Plugin ${plugin.id}: enabled=${plugin.enabled} (type: ${typeof plugin.enabled})`);
+ });
+
+ this.updatePluginTabs();
+ } else {
+ console.error('[FULL] Failed to load plugins:', data.message);
+ }
+ } catch (error) {
+ console.error('[FULL] Error loading installed plugins:', error);
+ }
+ },
+
+ updatePluginTabs(retryCount = 0) {
+ debugLog('[FULL] updatePluginTabs called (retryCount:', retryCount, ')');
+ const maxRetries = 5;
+
+ // Debounce: Clear any pending update
+ if (this._updatePluginTabsTimeout) {
+ clearTimeout(this._updatePluginTabsTimeout);
+ }
+
+ // For first call or retries, execute immediately to ensure tabs appear quickly
+ if (retryCount === 0) {
+ // First call - execute immediately, then debounce subsequent calls
+ this._doUpdatePluginTabs(retryCount);
+ } else {
+ // Retry - execute immediately
+ this._doUpdatePluginTabs(retryCount);
+ }
+ },
+
+ _doUpdatePluginTabs(retryCount = 0) {
+ const maxRetries = 5;
+
+ // Use component's installedPlugins first (most up-to-date), then global, then empty array
+ const pluginsToShow = (this.installedPlugins && this.installedPlugins.length > 0)
+ ? this.installedPlugins
+ : (window.installedPlugins || []);
+
+ debugLog('[FULL] _doUpdatePluginTabs called with:', pluginsToShow.length, 'plugins (attempt', retryCount + 1, ')');
+ debugLog('[FULL] Plugin sources:', {
+ componentPlugins: this.installedPlugins?.length || 0,
+ windowPlugins: window.installedPlugins?.length || 0,
+ using: pluginsToShow.length > 0 ? (this.installedPlugins?.length > 0 ? 'component' : 'window') : 'none'
+ });
+
+ // Check if plugin list actually changed by comparing IDs
+ const currentPluginIds = pluginsToShow.map(p => p.id).sort().join(',');
+ const lastRenderedIds = (this._lastRenderedPluginIds || '');
+
+ // Only skip if we have plugins and they match (don't skip if both are empty)
+ if (currentPluginIds === lastRenderedIds && retryCount === 0 && currentPluginIds.length > 0) {
+ // Plugin list hasn't changed, skip update
+ debugLog('[FULL] Plugin list unchanged, skipping update');
+ return;
+ }
+
+ // If we have no plugins and haven't rendered anything yet, still try to render (might be first load)
+ if (pluginsToShow.length === 0 && retryCount === 0) {
+ debugLog('[FULL] No plugins to show, but will retry in case they load...');
+ if (retryCount < maxRetries) {
+ setTimeout(() => {
+ this._doUpdatePluginTabs(retryCount + 1);
+ }, 500);
+ }
+ return;
+ }
+
+ // Store the current plugin IDs for next comparison
+ this._lastRenderedPluginIds = currentPluginIds;
+
+ const pluginTabsRow = document.getElementById('plugin-tabs-row');
+ const pluginTabsNav = pluginTabsRow?.querySelector('nav');
+
+ debugLog('[FULL] Plugin tabs elements:', {
+ pluginTabsRow: !!pluginTabsRow,
+ pluginTabsNav: !!pluginTabsNav,
+ bodyExists: !!document.body,
+ installedPlugins: pluginsToShow.length,
+ pluginIds: pluginsToShow.map(p => p.id)
+ });
+
+ if (!pluginTabsRow || !pluginTabsNav) {
+ if (retryCount < maxRetries) {
+ console.warn('[FULL] Plugin tabs container not found, retrying in 500ms... (attempt', retryCount + 1, 'of', maxRetries, ')');
+ setTimeout(() => {
+ this._doUpdatePluginTabs(retryCount + 1);
+ }, 500);
+ } else {
+ console.error('[FULL] Plugin tabs container not found after maximum retries. Elements:', {
+ pluginTabsRow: document.getElementById('plugin-tabs-row'),
+ pluginTabsNav: document.getElementById('plugin-tabs-row')?.querySelector('nav'),
+ allNavs: document.querySelectorAll('nav').length
+ });
+ }
+ return;
+ }
+
+ debugLog(`[FULL] Updating plugin tabs for ${pluginsToShow.length} plugins`);
+
+ // Always show the plugin tabs row (Plugin Manager should always be available)
+ debugLog('[FULL] Ensuring plugin tabs row is visible');
+ pluginTabsRow.style.display = 'block';
+
+ // Clear existing plugin tabs (except the Plugin Manager tab)
+ const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
+ debugLog(`[FULL] Removing ${existingTabs.length} existing plugin tabs`);
+ existingTabs.forEach(tab => tab.remove());
+
+ // Add tabs for each installed plugin
+ debugLog('[FULL] Adding tabs for plugins:', pluginsToShow.map(p => p.id));
+ pluginsToShow.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();
+ }
+ };
+ // Build the + label as DOM nodes so a
+ // hostile plugin.icon (e.g. containing a quote) can't
+ // break out of the attribute. escapeHtml only escapes
+ // <, >, &, not ", so attribute-context interpolation
+ // would be unsafe.
+ const iconEl = document.createElement('i');
+ iconEl.className = plugin.icon || 'fas fa-puzzle-piece';
+ const labelNode = document.createTextNode(plugin.name || plugin.id);
+ tabButton.replaceChildren(iconEl, labelNode);
+
+ // Insert before the closing tag
+ pluginTabsNav.appendChild(tabButton);
+ debugLog('[FULL] Added tab for plugin:', plugin.id);
+ });
+
+ debugLog('[FULL] Plugin tabs update completed. Total tabs:', pluginTabsNav.querySelectorAll('.plugin-tab').length);
+ },
+
+ updatePluginTabStates() {
+ // Update active state of all plugin tabs when activeTab changes
+ const pluginTabsNav = document.getElementById('plugin-tabs-row')?.querySelector('nav');
+ if (!pluginTabsNav) return;
+
+ const pluginTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
+ pluginTabs.forEach(tab => {
+ const pluginId = tab.getAttribute('data-plugin-id');
+ if (pluginId && this.activeTab === pluginId) {
+ tab.classList.add('nav-tab-active');
+ } else {
+ tab.classList.remove('nav-tab-active');
+ }
+ });
+ },
+
+ showNotification(message, type = 'info') {
+ // Use global notification widget
+ if (typeof window.showNotification === 'function') {
+ window.showNotification(message, type);
+ } else {
+ debugLog(`[${type.toUpperCase()}]`, message);
+ }
+ },
+
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ },
+
+ async refreshPlugins() {
+ await this.loadInstalledPlugins();
+ await this.searchPluginStore();
+ this.showNotification('Plugin list refreshed', 'success');
+ },
+
+
+
+ async loadPluginConfig(pluginId) {
+ debugLog('Loading config for plugin:', pluginId);
+ this.loading = true;
+
+ try {
+ // Load config, schema, and installed plugins (for web_ui_actions) in parallel
+ // Use batched API if available for better performance
+ let configData, schemaData, pluginsData;
+
+ if (window.PluginAPI && window.PluginAPI.batch) {
+ // PluginAPI.batch returns already-parsed JSON objects
+ try {
+ const results = await window.PluginAPI.batch([
+ {endpoint: `/plugins/config?plugin_id=${pluginId}`, method: 'GET'},
+ {endpoint: `/plugins/schema?plugin_id=${pluginId}`, method: 'GET'},
+ {endpoint: '/plugins/installed', method: 'GET'}
+ ]);
+ [configData, schemaData, pluginsData] = results;
+ } catch (batchError) {
+ console.error('Batch API request failed, falling back to individual requests:', batchError);
+ // Fall back to individual requests
+ const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
+ fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
+ fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
+ fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
+ ]);
+ configData = configResponse;
+ schemaData = schemaResponse;
+ pluginsData = pluginsResponse;
+ }
+ } else {
+ // Direct fetch returns Response objects that need parsing
+ const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
+ fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
+ fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
+ fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
+ ]);
+ configData = configResponse;
+ schemaData = schemaResponse;
+ pluginsData = pluginsResponse;
+ }
+
+ if (configData && configData.status === 'success') {
+ this.config = configData.data;
+ } else {
+ console.warn('Config API returned non-success status:', configData);
+ // Set defaults if config failed to load
+ this.config = { enabled: true, display_duration: 30 };
+ }
+
+ if (schemaData && schemaData.status === 'success') {
+ this.schema = schemaData.data.schema || {};
+ } else {
+ console.warn('Schema API returned non-success status:', schemaData);
+ // Set empty schema as fallback
+ this.schema = {};
+ }
+
+ // Extract web_ui_actions from installed plugins and update plugin data
+ if (pluginsData && pluginsData.status === 'success' && pluginsData.data && pluginsData.data.plugins) {
+ // Update window.installedPlugins with fresh data (includes commit info)
+ // The setter will check if data actually changed before updating tabs
+ window.installedPlugins = pluginsData.data.plugins;
+ // Update Alpine.js app data
+ this.installedPlugins = pluginsData.data.plugins;
+
+ const pluginInfo = pluginsData.data.plugins.find(p => p.id === pluginId);
+ this.webUiActions = pluginInfo ? (pluginInfo.web_ui_actions || []) : [];
+ debugLog('[DEBUG] Loaded web_ui_actions for', pluginId, ':', this.webUiActions.length, 'actions');
+ debugLog('[DEBUG] Updated plugin data with commit info:', pluginInfo ? {
+ last_commit: pluginInfo.last_commit,
+ branch: pluginInfo.branch,
+ last_updated: pluginInfo.last_updated
+ } : 'plugin not found');
+ } else {
+ console.warn('Plugins API returned non-success status:', pluginsData);
+ this.webUiActions = [];
+ }
+
+ debugLog('Loaded config, schema, and actions for', pluginId);
+ } catch (error) {
+ console.error('Error loading plugin config:', error);
+ this.config = { enabled: true, display_duration: 30 };
+ this.schema = {};
+ this.webUiActions = [];
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ generateConfigForm(pluginId, config, schema, webUiActions = []) {
+ // Safety check - if schema/config not ready, return empty
+ if (!pluginId || !config) {
+ return '
Loading configuration...
';
+ }
+
+ // Only log once per plugin to avoid spam (Alpine.js may call this multiple times during rendering)
+ if (!this._configFormLogged || this._configFormLogged !== pluginId) {
+ debugLog('[DEBUG] generateConfigForm called for', pluginId, 'with', webUiActions?.length || 0, 'actions');
+ // Debug: Check if image_config.images has x-widget in schema
+ if (schema && schema.properties && schema.properties.image_config) {
+ const imgConfig = schema.properties.image_config;
+ if (imgConfig.properties && imgConfig.properties.images) {
+ const imagesProp = imgConfig.properties.images;
+ debugLog('[DEBUG] Schema check - image_config.images:', {
+ type: imagesProp.type,
+ 'x-widget': imagesProp['x-widget'],
+ 'has x-widget': 'x-widget' in imagesProp,
+ keys: Object.keys(imagesProp)
+ });
+ }
+ }
+ this._configFormLogged = pluginId;
+ }
+ if (!schema || !schema.properties) {
+ return this.generateSimpleConfigForm(config, webUiActions, pluginId);
+ }
+
+ // Helper function to get schema property by full key path
+ const getSchemaProperty = (schemaObj, keyPath) => {
+ if (!schemaObj || !schemaObj.properties) return null;
+ const keys = keyPath.split('.');
+ let current = schemaObj.properties;
+ for (let i = 0; i < keys.length; i++) {
+ const k = keys[i];
+ if (!current || !current[k]) {
+ return null;
+ }
+
+ const prop = current[k];
+ // If this is the last key, return the property
+ if (i === keys.length - 1) {
+ return prop;
+ }
+
+ // If this property has nested properties, navigate deeper
+ if (prop && typeof prop === 'object' && prop.properties) {
+ current = prop.properties;
+ } else {
+ // Can't navigate deeper
+ return null;
+ }
+ }
+ return null;
+ };
+
+ const generateFieldHtml = (key, prop, value, prefix = '') => {
+ const fullKey = prefix ? `${prefix}.${key}` : key;
+ const label = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
+ const description = prop.description || '';
+ let html = '';
+
+ // Debug: Log property structure for arrays to help diagnose file-upload widget issues
+ if (prop.type === 'array') {
+ // Also check schema directly as fallback
+ const schemaProp = getSchemaProperty(schema, fullKey);
+ const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
+
+ debugLog('[DEBUG generateFieldHtml] Array property:', fullKey, {
+ 'prop.x-widget': prop['x-widget'],
+ 'prop.x_widget': prop['x_widget'],
+ 'schema.x-widget': xWidgetFromSchema,
+ 'hasOwnProperty(x-widget)': prop.hasOwnProperty('x-widget'),
+ 'x-widget in prop': 'x-widget' in prop,
+ 'all prop keys': Object.keys(prop),
+ 'schemaProp keys': schemaProp ? Object.keys(schemaProp) : 'null'
+ });
+ }
+
+ // Handle nested objects
+ if (prop.type === 'object' && prop.properties) {
+ const sectionId = `section-${fullKey.replace(/\./g, '-')}`;
+ const nestedConfig = value || {};
+ const sectionLabel = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
+ // Calculate nesting depth for better spacing
+ const nestingDepth = (fullKey.match(/\./g) || []).length;
+ const marginClass = nestingDepth > 1 ? 'mb-6' : 'mb-4';
+
+ html += `
+
+
+
+ `;
+
+ // Recursively generate fields for nested properties
+ // Get ordered properties if x-propertyOrder is defined
+ let nestedPropertyEntries = Object.entries(prop.properties);
+ if (prop['x-propertyOrder'] && Array.isArray(prop['x-propertyOrder'])) {
+ const order = prop['x-propertyOrder'];
+ const orderedEntries = [];
+ const unorderedEntries = [];
+
+ // Separate ordered and unordered properties
+ nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
+ const index = order.indexOf(nestedKey);
+ if (index !== -1) {
+ orderedEntries[index] = [nestedKey, nestedProp];
+ } else {
+ unorderedEntries.push([nestedKey, nestedProp]);
+ }
+ });
+
+ // Combine ordered entries (filter out undefined from sparse array) with unordered entries
+ nestedPropertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
+ }
+
+ nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
+ // Use config value if it exists and is not null (including false), otherwise use schema default
+ // Check if key exists in config and value is not null/undefined
+ const hasValue = nestedKey in nestedConfig && nestedConfig[nestedKey] !== null && nestedConfig[nestedKey] !== undefined;
+ // For nested objects, if the value is an empty object, still use it (don't fall back to default)
+ const isNestedObject = nestedProp.type === 'object' && nestedProp.properties;
+ const nestedValue = hasValue ? nestedConfig[nestedKey] :
+ (nestedProp.default !== undefined ? nestedProp.default :
+ (isNestedObject ? {} : (nestedProp.type === 'array' ? [] : (nestedProp.type === 'boolean' ? false : ''))));
+
+ // Debug logging for file-upload widgets
+ if (nestedProp.type === 'array' && (nestedProp['x-widget'] === 'file-upload' || nestedProp['x_widget'] === 'file-upload')) {
+ debugLog('[DEBUG] Found file-upload widget in nested property:', nestedKey, 'fullKey:', fullKey + '.' + nestedKey, 'prop:', nestedProp);
+ }
+
+ html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey);
+ });
+
+ html += `
+
+
+ `;
+
+ // Add extra spacing after nested sections to prevent overlap with next section
+ if (nestingDepth > 0) {
+ html += ``;
+ }
+
+ return html;
+ }
+
+ // Regular (non-nested) field
+ html += `
`;
+ html += ``;
+
+ if (description) {
+ html += `
${description}
`;
+ }
+
+ // Generate appropriate input based on type
+ if (prop.type === 'boolean') {
+ html += ``;
+ } else if (prop.type === 'number' || prop.type === 'integer' ||
+ (Array.isArray(prop.type) && (prop.type.includes('number') || prop.type.includes('integer')))) {
+ // Handle union types like ["integer", "null"]
+ const isUnionType = Array.isArray(prop.type);
+ const allowsNull = isUnionType && prop.type.includes('null');
+ const isInteger = prop.type === 'integer' || (isUnionType && prop.type.includes('integer'));
+ const isNumber = prop.type === 'number' || (isUnionType && prop.type.includes('number'));
+ const min = prop.minimum !== undefined ? `min="${prop.minimum}"` : '';
+ const max = prop.maximum !== undefined ? `max="${prop.maximum}"` : '';
+ const step = isInteger ? 'step="1"' : 'step="any"';
+
+ // For union types with null, don't show default if value is null (leave empty)
+ // This allows users to explicitly set null by leaving it empty
+ let fieldValue = '';
+ if (value !== undefined && value !== null) {
+ fieldValue = value;
+ } else if (!allowsNull && prop.default !== undefined) {
+ // Only use default if null is not allowed
+ fieldValue = prop.default;
+ }
+
+ // Ensure value respects min/max constraints
+ if (fieldValue !== '' && fieldValue !== undefined && fieldValue !== null) {
+ const numValue = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue;
+ if (!isNaN(numValue)) {
+ // Clamp value to min/max if constraints exist
+ if (prop.minimum !== undefined && numValue < prop.minimum) {
+ fieldValue = prop.minimum;
+ } else if (prop.maximum !== undefined && numValue > prop.maximum) {
+ fieldValue = prop.maximum;
+ } else {
+ fieldValue = numValue;
+ }
+ }
+ }
+
+ // Add placeholder/help text for null-able fields
+ const placeholder = allowsNull ? 'Leave empty to use current time (random)' : '';
+ const helpText = allowsNull && description && description.includes('null') ?
+ `
${description}
` : '';
+
+ html += ``;
+ if (helpText) {
+ html += helpText;
+ }
+ } else if (prop.type === 'array') {
+ // AGGRESSIVE file upload widget detection
+ // For 'images' field in static-image plugin, always check schema directly
+ let isFileUpload = false;
+ let uploadConfig = {};
+
+ // Direct check: if this is the 'images' field and schema has it with x-widget
+ if (fullKey === 'images' && schema && schema.properties && schema.properties.images) {
+ const imagesSchema = schema.properties.images;
+ if (imagesSchema['x-widget'] === 'file-upload' || imagesSchema['x_widget'] === 'file-upload') {
+ isFileUpload = true;
+ uploadConfig = imagesSchema['x-upload-config'] || imagesSchema['x_upload_config'] || {};
+ debugLog('[DEBUG] ✅ Direct detection: images field has file-upload widget', uploadConfig);
+ }
+ }
+
+ // Fallback: check prop object (should have x-widget if schema loaded correctly)
+ if (!isFileUpload) {
+ const xWidgetFromProp = prop['x-widget'] || prop['x_widget'] || prop.xWidget;
+ if (xWidgetFromProp === 'file-upload') {
+ isFileUpload = true;
+ uploadConfig = prop['x-upload-config'] || prop['x_upload_config'] || {};
+ debugLog('[DEBUG] ✅ Detection via prop object');
+ }
+ }
+
+ // Fallback: schema property lookup
+ if (!isFileUpload) {
+ let schemaProp = getSchemaProperty(schema, fullKey);
+ if (!schemaProp && fullKey === 'images' && schema && schema.properties && schema.properties.images) {
+ schemaProp = schema.properties.images;
+ }
+ const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
+ if (xWidgetFromSchema === 'file-upload') {
+ isFileUpload = true;
+ uploadConfig = schemaProp['x-upload-config'] || schemaProp['x_upload_config'] || {};
+ debugLog('[DEBUG] ✅ Detection via schema lookup');
+ }
+ }
+
+ // Debug logging for ALL array fields to diagnose
+ debugLog('[DEBUG] Array field check:', fullKey, {
+ 'isFileUpload': isFileUpload,
+ 'prop keys': Object.keys(prop),
+ 'prop.x-widget': prop['x-widget'],
+ 'schema.properties.images exists': !!(schema && schema.properties && schema.properties.images),
+ 'schema.properties.images.x-widget': (schema && schema.properties && schema.properties.images) ? schema.properties.images['x-widget'] : null,
+ 'uploadConfig': uploadConfig
+ });
+
+ if (isFileUpload) {
+ debugLog('[DEBUG] ✅ Rendering file-upload widget for', fullKey, 'with config:', uploadConfig);
+ // Use the file upload widget from plugins.html
+ // We'll need to call a function that exists in the global scope
+ const maxFiles = uploadConfig.max_files || 10;
+ const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
+ const maxSizeMB = uploadConfig.max_size_mb || 5;
+
+ const currentImages = Array.isArray(value) ? value : [];
+ const fieldId = fullKey.replace(/\./g, '_');
+ const safePluginId = (uploadConfig.plugin_id || pluginId || 'static-image').toString().replace(/[^a-zA-Z0-9_-]/g, '_');
+
+ html += `
+
+
+
+
+
+
Drag and drop images here or click to browse
+
Max ${maxFiles} files, ${maxSizeMB}MB each (PNG, JPG, GIF, BMP)