mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
Add AbortController with 10s timeout so a hanging fetch doesn't leave data-loaded set and block retries. Timer is cleared in both success and error paths. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4842 lines
267 KiB
HTML
4842 lines
267 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>LED Matrix Control Panel - v3</title>
|
|
|
|
<!-- Theme initialization (must run before CSS to prevent flash) -->
|
|
<script>
|
|
(function() {
|
|
// Safely read from localStorage (may throw in private browsing / restricted contexts)
|
|
function getStorage(key) {
|
|
try { return localStorage.getItem(key); } catch (e) { return null; }
|
|
}
|
|
function setStorage(key, value) {
|
|
try { localStorage.setItem(key, value); } catch (e) { /* no-op */ }
|
|
}
|
|
|
|
// Safely query prefers-color-scheme (matchMedia may be unavailable)
|
|
function prefersDark() {
|
|
try {
|
|
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
} catch (e) { return false; }
|
|
}
|
|
|
|
var saved = getStorage('theme');
|
|
var theme = saved || (prefersDark() ? 'dark' : 'light');
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
|
|
// Theme toggle function
|
|
window.toggleTheme = function() {
|
|
try {
|
|
var current = document.documentElement.getAttribute('data-theme');
|
|
var next = current === 'dark' ? 'light' : 'dark';
|
|
document.documentElement.setAttribute('data-theme', next);
|
|
setStorage('theme', next);
|
|
window.updateThemeIcon(next);
|
|
} catch (e) { /* no-op */ }
|
|
};
|
|
|
|
// Update icon visibility and ARIA state based on current theme
|
|
window.updateThemeIcon = function(theme) {
|
|
var darkIcon = document.getElementById('theme-icon-dark');
|
|
var lightIcon = document.getElementById('theme-icon-light');
|
|
var btn = document.getElementById('theme-toggle');
|
|
if (darkIcon && lightIcon) {
|
|
if (theme === 'dark') {
|
|
darkIcon.classList.add('hidden');
|
|
lightIcon.classList.remove('hidden');
|
|
} else {
|
|
darkIcon.classList.remove('hidden');
|
|
lightIcon.classList.add('hidden');
|
|
}
|
|
}
|
|
if (btn) {
|
|
var isDark = theme === 'dark';
|
|
btn.setAttribute('aria-pressed', String(isDark));
|
|
var label = isDark ? 'Switch to light mode' : 'Switch to dark mode';
|
|
btn.setAttribute('aria-label', label);
|
|
btn.setAttribute('title', label);
|
|
}
|
|
};
|
|
|
|
// Initialize icon state once DOM is ready
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
window.updateThemeIcon(document.documentElement.getAttribute('data-theme') || 'light');
|
|
});
|
|
|
|
// Listen for OS theme changes (only when no explicit user preference)
|
|
try {
|
|
var mql = window.matchMedia('(prefers-color-scheme: dark)');
|
|
var handler = function(e) {
|
|
if (!getStorage('theme')) {
|
|
var t = e.matches ? 'dark' : 'light';
|
|
document.documentElement.setAttribute('data-theme', t);
|
|
window.updateThemeIcon(t);
|
|
}
|
|
};
|
|
if (mql.addEventListener) {
|
|
mql.addEventListener('change', handler);
|
|
} else if (mql.addListener) {
|
|
mql.addListener(handler);
|
|
}
|
|
} catch (e) { /* matchMedia unavailable */ }
|
|
})();
|
|
</script>
|
|
|
|
<!-- Resource hints for CDN resources -->
|
|
<link rel="preconnect" href="https://unpkg.com" crossorigin>
|
|
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
|
|
<link rel="dns-prefetch" href="https://unpkg.com">
|
|
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
|
|
|
|
<!-- HTMX for dynamic content loading -->
|
|
<!-- Use local files when in AP mode (192.168.4.x) to avoid CDN dependency -->
|
|
<script>
|
|
(function() {
|
|
// Detect AP mode by IP address
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
|
|
// In AP mode, use local files; otherwise use CDN
|
|
const htmxSrc = isAPMode ? '/static/v3/js/htmx.min.js' : 'https://unpkg.com/htmx.org@1.9.10';
|
|
const sseSrc = isAPMode ? '/static/v3/js/htmx-sse.js' : 'https://unpkg.com/htmx.org/dist/ext/sse.js';
|
|
const jsonEncSrc = isAPMode ? '/static/v3/js/htmx-json-enc.js' : 'https://unpkg.com/htmx.org/dist/ext/json-enc.js';
|
|
|
|
// Load HTMX with fallback
|
|
function loadScript(src, fallback, onLoad) {
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.onload = onLoad || (() => {});
|
|
script.onerror = function() {
|
|
if (fallback && src !== fallback) {
|
|
console.warn(`Failed to load ${src}, trying fallback ${fallback}`);
|
|
const fallbackScript = document.createElement('script');
|
|
fallbackScript.src = fallback;
|
|
fallbackScript.onload = onLoad || (() => {});
|
|
document.head.appendChild(fallbackScript);
|
|
} else {
|
|
console.error(`Failed to load script: ${src}`);
|
|
}
|
|
};
|
|
document.head.appendChild(script);
|
|
}
|
|
|
|
// Load HTMX core
|
|
loadScript(htmxSrc, isAPMode ? 'https://unpkg.com/htmx.org@1.9.10' : '/static/v3/js/htmx.min.js', function() {
|
|
// Wait a moment for HTMX to initialize, then verify
|
|
setTimeout(function() {
|
|
// Verify HTMX loaded
|
|
if (typeof htmx === 'undefined') {
|
|
console.error('HTMX failed to load, trying fallback...');
|
|
const fallbackSrc = isAPMode ? 'https://unpkg.com/htmx.org@1.9.10' : '/static/v3/js/htmx.min.js';
|
|
if (fallbackSrc !== htmxSrc) {
|
|
loadScript(fallbackSrc, null, function() {
|
|
setTimeout(function() {
|
|
if (typeof htmx !== 'undefined') {
|
|
console.log('HTMX loaded from fallback');
|
|
// Load extensions after core loads
|
|
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
|
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
|
} else {
|
|
console.error('HTMX failed to load from both primary and fallback sources');
|
|
// Trigger fallback content loading
|
|
window.dispatchEvent(new Event('htmx-load-failed'));
|
|
}
|
|
}, 100);
|
|
});
|
|
} else {
|
|
console.error('HTMX failed to load and no fallback available');
|
|
window.dispatchEvent(new Event('htmx-load-failed'));
|
|
}
|
|
} else {
|
|
console.log('HTMX loaded successfully');
|
|
// Load extensions after core loads
|
|
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
|
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
|
}
|
|
}, 100);
|
|
});
|
|
})();
|
|
</script>
|
|
<script>
|
|
// 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
|
|
}
|
|
}
|
|
});
|
|
} 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');
|
|
}
|
|
};
|
|
})();
|
|
</script>
|
|
|
|
<!-- Fallback for loading plugins if HTMX fails -->
|
|
<script>
|
|
function loadPluginsDirect() {
|
|
const content = document.getElementById('plugins-content');
|
|
if (content && !content.hasAttribute('data-loaded')) {
|
|
content.setAttribute('data-loaded', 'true');
|
|
console.log('Loading plugins directly via fetch (HTMX fallback)...');
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
fetch('/v3/partials/plugins', { signal: controller.signal })
|
|
.then(r => {
|
|
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
|
|
return r.text();
|
|
})
|
|
.then(html => {
|
|
clearTimeout(timeout);
|
|
content.innerHTML = html;
|
|
// Trigger full initialization chain
|
|
if (window.pluginManager) {
|
|
window.pluginManager.initialized = false;
|
|
window.pluginManager.initializing = false;
|
|
}
|
|
if (window.initPluginsPage) {
|
|
window.initPluginsPage();
|
|
}
|
|
})
|
|
.catch(err => {
|
|
clearTimeout(timeout);
|
|
console.error('Failed to load plugins:', err);
|
|
content.removeAttribute('data-loaded');
|
|
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Plugin Manager. Please refresh the page.</p></div>';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fallback if HTMX doesn't load within 5 seconds
|
|
setTimeout(() => {
|
|
if (typeof htmx === 'undefined') {
|
|
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
|
|
// Load plugins tab content directly regardless of active tab,
|
|
// so it's ready when the user navigates to it
|
|
loadPluginsDirect();
|
|
}
|
|
}, 5000);
|
|
</script>
|
|
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
|
|
<script>
|
|
// 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) {
|
|
console.log('[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')) {
|
|
console.log('[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();
|
|
}
|
|
}
|
|
}
|
|
};
|
|
tabButton.innerHTML = `<i class="fas fa-puzzle-piece"></i>${(plugin.name || plugin.id).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}`;
|
|
pluginTabsNav.appendChild(tabButton);
|
|
});
|
|
console.log('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
|
|
}
|
|
});
|
|
|
|
// 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',
|
|
installedPlugins: [],
|
|
|
|
init() {
|
|
// Try to enhance immediately with full implementation
|
|
const tryEnhance = () => {
|
|
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')) {
|
|
// Full implementation is available, copy all methods
|
|
// But preserve _initialized flag to prevent double init
|
|
const wasInitialized = this._initialized;
|
|
Object.assign(this, fullApp);
|
|
// Restore _initialized flag if it was set
|
|
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) => {
|
|
console.log('[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;
|
|
console.log('[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) {
|
|
console.log('[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) {
|
|
console.log('[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) {
|
|
console.log('[STUB] init: Fallback timer - fetching plugins directly');
|
|
this.loadInstalledPluginsDirectly();
|
|
}
|
|
}, 2000);
|
|
}
|
|
},
|
|
|
|
// Direct plugin loading for stub (before full implementation loads)
|
|
async loadInstalledPluginsDirectly() {
|
|
try {
|
|
console.log('[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 || [];
|
|
console.log('[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 }
|
|
}));
|
|
console.log('[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(() => {
|
|
console.log('[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) {
|
|
console.log('[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) {
|
|
console.log('[STUB] updatePluginTabs: Tabs already match, skipping update');
|
|
return;
|
|
}
|
|
|
|
// Clear existing plugin tabs (except Plugin Manager)
|
|
existingTabs.forEach(tab => tab.remove());
|
|
console.log('[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;
|
|
tabButton.innerHTML = `<i class="fas fa-puzzle-piece"></i>${div.innerHTML}`;
|
|
pluginTabsNav.appendChild(tabButton);
|
|
});
|
|
console.log('[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, '>'); }
|
|
};
|
|
};
|
|
})();
|
|
</script>
|
|
|
|
<!-- Alpine.js for reactive components -->
|
|
<!-- Use local file when in AP mode (192.168.4.x) to avoid CDN dependency -->
|
|
<script>
|
|
(function() {
|
|
// Prevent Alpine from auto-initializing by setting deferLoadingAlpine before it loads
|
|
window.deferLoadingAlpine = function(callback) {
|
|
// Wait for DOM to be ready
|
|
function waitForReady() {
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', waitForReady);
|
|
return;
|
|
}
|
|
|
|
// app() is already defined in head, so we can initialize Alpine
|
|
if (callback && typeof callback === 'function') {
|
|
callback();
|
|
} else if (window.Alpine && typeof window.Alpine.start === 'function') {
|
|
// If callback not provided but Alpine is available, start it
|
|
try {
|
|
window.Alpine.start();
|
|
} catch (e) {
|
|
// Alpine may already be initialized, ignore
|
|
console.warn('Alpine start error (may already be initialized):', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
waitForReady();
|
|
};
|
|
|
|
// Detect AP mode by IP address
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
|
|
const alpineSrc = isAPMode ? '/static/v3/js/alpinejs.min.js' : 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js';
|
|
const alpineFallback = isAPMode ? 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js' : '/static/v3/js/alpinejs.min.js';
|
|
|
|
const script = document.createElement('script');
|
|
script.defer = true;
|
|
script.src = alpineSrc;
|
|
script.onerror = function() {
|
|
if (alpineSrc !== alpineFallback) {
|
|
const fallback = document.createElement('script');
|
|
fallback.defer = true;
|
|
fallback.src = alpineFallback;
|
|
document.head.appendChild(fallback);
|
|
}
|
|
};
|
|
document.head.appendChild(script);
|
|
})();
|
|
</script>
|
|
|
|
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
|
|
<link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
|
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css"></noscript>
|
|
<link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/monokai.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
|
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/monokai.min.css"></noscript>
|
|
<!-- CodeMirror scripts loaded on demand when JSON editor is opened -->
|
|
<script>
|
|
// Lazy load CodeMirror when needed
|
|
window.loadCodeMirror = function() {
|
|
if (window.CodeMirror) return Promise.resolve();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const scripts = [
|
|
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js',
|
|
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/javascript/javascript.min.js',
|
|
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/json/json.min.js',
|
|
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/edit/closebrackets.min.js',
|
|
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/edit/matchbrackets.min.js'
|
|
];
|
|
|
|
let loaded = 0;
|
|
scripts.forEach((src, index) => {
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.defer = true;
|
|
script.onload = () => {
|
|
loaded++;
|
|
if (loaded === scripts.length) resolve();
|
|
};
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
});
|
|
};
|
|
</script>
|
|
|
|
<!-- Font Awesome icons -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
|
|
|
<!-- Custom v3 styles -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}?v=20260216b">
|
|
</head>
|
|
<body x-data="app()" class="bg-gray-50 min-h-screen">
|
|
<!-- Header -->
|
|
<header class="bg-white shadow-md border-b border-gray-200">
|
|
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16" style="max-width: 100%;">
|
|
<div class="flex justify-between items-center h-16">
|
|
<div class="flex items-center">
|
|
<h1 class="text-xl font-bold text-gray-900">
|
|
<i class="fas fa-tv text-blue-600 mr-2"></i>
|
|
LED Matrix Control - v3
|
|
</h1>
|
|
</div>
|
|
|
|
<!-- Connection status and theme toggle -->
|
|
<div class="flex items-center space-x-4">
|
|
<!-- Theme toggle -->
|
|
<button id="theme-toggle"
|
|
type="button"
|
|
onclick="toggleTheme()"
|
|
class="theme-toggle-btn p-2 rounded-md"
|
|
title="Switch to dark mode"
|
|
aria-label="Switch to dark mode"
|
|
aria-pressed="false">
|
|
<i class="fas fa-moon" id="theme-icon-dark" aria-hidden="true"></i>
|
|
<i class="fas fa-sun hidden" id="theme-icon-light" aria-hidden="true"></i>
|
|
</button>
|
|
|
|
<div id="connection-status" class="flex items-center space-x-2 text-sm">
|
|
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
|
<span class="text-gray-600">Disconnected</span>
|
|
</div>
|
|
|
|
<!-- System stats (populated via SSE) -->
|
|
<div class="hidden lg:flex items-center space-x-4 text-sm text-gray-600 xl:space-x-6 2xl:space-x-8">
|
|
<span id="cpu-stat" class="flex items-center space-x-1">
|
|
<i class="fas fa-microchip"></i>
|
|
<span>--%</span>
|
|
</span>
|
|
<span id="memory-stat" class="flex items-center space-x-1">
|
|
<i class="fas fa-memory"></i>
|
|
<span>--%</span>
|
|
</span>
|
|
<span id="temp-stat" class="flex items-center space-x-1">
|
|
<i class="fas fa-thermometer-half"></i>
|
|
<span>--°C</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main content -->
|
|
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
|
|
<!-- Navigation tabs -->
|
|
<nav class="mb-8">
|
|
<!-- First row - System tabs -->
|
|
<div class="border-b border-gray-200 mb-4">
|
|
<nav class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
|
|
<button @click="activeTab = 'overview'"
|
|
:class="activeTab === 'overview' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-tachometer-alt"></i>Overview
|
|
</button>
|
|
<button @click="activeTab = 'general'"
|
|
:class="activeTab === 'general' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-sliders-h"></i>General
|
|
</button>
|
|
<button @click="activeTab = 'wifi'"
|
|
:class="activeTab === 'wifi' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-wifi"></i>WiFi
|
|
</button>
|
|
<button @click="activeTab = 'schedule'"
|
|
:class="activeTab === 'schedule' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-clock"></i>Schedule
|
|
</button>
|
|
<button @click="activeTab = 'display'"
|
|
:class="activeTab === 'display' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-desktop"></i>Display
|
|
</button>
|
|
<button @click="activeTab = 'config-editor'"
|
|
:class="activeTab === 'config-editor' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-file-code"></i>Config Editor
|
|
</button>
|
|
<button @click="activeTab = 'fonts'"
|
|
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-font"></i>Fonts
|
|
</button>
|
|
<button @click="activeTab = 'logs'"
|
|
:class="activeTab === 'logs' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-file-alt"></i>Logs
|
|
</button>
|
|
<button @click="activeTab = 'cache'"
|
|
:class="activeTab === 'cache' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-database"></i>Cache
|
|
</button>
|
|
<button @click="activeTab = 'operation-history'"
|
|
:class="activeTab === 'operation-history' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-history"></i>Operation History
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Second row - Plugin tabs (populated dynamically) -->
|
|
<div id="plugin-tabs-row" class="border-b border-gray-200">
|
|
<nav class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
|
|
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPluginsDirect()); }"
|
|
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-plug"></i>Plugin Manager
|
|
</button>
|
|
<!-- Installed plugin tabs will be added here dynamically -->
|
|
</nav>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Tab content -->
|
|
<div id="tab-content" class="space-y-6">
|
|
<!-- Overview tab -->
|
|
<div x-show="activeTab === 'overview'" x-transition>
|
|
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="revealed" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
|
|
<div class="animate-pulse">
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
|
<div class="h-32 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Fallback: Load overview content directly if HTMX fails
|
|
function loadOverviewDirect() {
|
|
const overviewContent = document.getElementById('overview-content');
|
|
if (overviewContent && !overviewContent.hasAttribute('data-loaded')) {
|
|
fetch('/v3/partials/overview')
|
|
.then(response => response.text())
|
|
.then(html => {
|
|
overviewContent.innerHTML = html;
|
|
overviewContent.setAttribute('data-loaded', 'true');
|
|
// Re-initialize Alpine.js for the new content
|
|
if (window.Alpine) {
|
|
window.Alpine.initTree(overviewContent);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load overview content:', err);
|
|
overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load overview page. Please refresh the page.</p></div>';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Listen for HTMX load failure
|
|
window.addEventListener('htmx-load-failed', function() {
|
|
console.warn('HTMX failed to load, setting up direct content loading fallbacks');
|
|
// Try to load content directly after a delay
|
|
setTimeout(() => {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement.__x) {
|
|
const activeTab = appElement.__x.$data.activeTab || 'overview';
|
|
if (activeTab === 'overview') {
|
|
loadOverviewDirect();
|
|
}
|
|
}
|
|
}, 2000);
|
|
});
|
|
|
|
// Also try direct load if HTMX doesn't load within 5 seconds
|
|
setTimeout(() => {
|
|
if (typeof htmx === 'undefined') {
|
|
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement.__x) {
|
|
const activeTab = appElement.__x.$data.activeTab || 'overview';
|
|
if (activeTab === 'overview') {
|
|
loadOverviewDirect();
|
|
}
|
|
}
|
|
}
|
|
}, 5000);
|
|
</script>
|
|
|
|
<!-- General tab -->
|
|
<div x-show="activeTab === 'general'" x-transition>
|
|
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<div class="animate-pulse">
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
|
<div class="space-y-4">
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- WiFi tab -->
|
|
<div x-show="activeTab === 'wifi'" x-transition>
|
|
<div id="wifi-content"
|
|
hx-get="/v3/partials/wifi"
|
|
hx-trigger="revealed"
|
|
hx-swap="innerHTML"
|
|
hx-on::htmx:response-error="loadWifiDirect()">
|
|
<div class="animate-pulse">
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
|
<div class="space-y-4">
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Fallback: Load WiFi content directly if HTMX fails
|
|
function loadWifiDirect() {
|
|
const wifiContent = document.getElementById('wifi-content');
|
|
if (wifiContent && !wifiContent.hasAttribute('data-loaded')) {
|
|
fetch('/v3/partials/wifi')
|
|
.then(response => response.text())
|
|
.then(html => {
|
|
wifiContent.innerHTML = html;
|
|
wifiContent.setAttribute('data-loaded', 'true');
|
|
// Re-initialize Alpine.js for the new content
|
|
if (window.Alpine) {
|
|
window.Alpine.initTree(wifiContent);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load WiFi content:', err);
|
|
wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load WiFi setup page. Please refresh the page.</p></div>';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Also try direct load if HTMX doesn't load within 3 seconds (AP mode detection)
|
|
setTimeout(() => {
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
if (isAPMode && typeof htmx === 'undefined') {
|
|
console.warn('HTMX not loaded, using direct fetch for WiFi content');
|
|
loadWifiDirect();
|
|
}
|
|
}, 3000);
|
|
</script>
|
|
|
|
<!-- Schedule tab -->
|
|
<div x-show="activeTab === 'schedule'" x-transition>
|
|
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<div class="animate-pulse">
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
|
<div class="space-y-4">
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Display tab -->
|
|
<div x-show="activeTab === 'display'" x-transition>
|
|
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<div class="animate-pulse">
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div class="h-32 bg-gray-200 rounded"></div>
|
|
<div class="h-32 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Config Editor tab -->
|
|
<div x-show="activeTab === 'config-editor'" x-transition>
|
|
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<div class="animate-pulse">
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
|
<div class="space-y-4">
|
|
<div class="h-64 bg-gray-200 rounded"></div>
|
|
<div class="h-64 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plugins tab -->
|
|
<div x-show="activeTab === 'plugins'" x-transition>
|
|
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="revealed" hx-swap="innerHTML"
|
|
hx-on::response-error="loadPluginsDirect()">
|
|
<div class="animate-pulse">
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<div class="h-24 bg-gray-200 rounded"></div>
|
|
<div class="h-24 bg-gray-200 rounded"></div>
|
|
<div class="h-24 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fonts tab -->
|
|
<div x-show="activeTab === 'fonts'" x-transition>
|
|
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<div class="animate-pulse">
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
|
<div class="space-y-4">
|
|
<div class="h-20 bg-gray-200 rounded"></div>
|
|
<div class="h-20 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Logs tab -->
|
|
<div x-show="activeTab === 'logs'" x-transition>
|
|
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<div class="animate-pulse">
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
|
<div class="h-96 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cache tab -->
|
|
<div x-show="activeTab === 'cache'" x-transition>
|
|
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<div class="animate-pulse">
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
|
<div class="h-64 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Operation History tab -->
|
|
<div x-show="activeTab === 'operation-history'" x-transition>
|
|
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<div class="animate-pulse">
|
|
<div class="bg-white rounded-lg shadow p-6">
|
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
|
<div class="h-64 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dynamic Plugin Tabs - HTMX Lazy Loading -->
|
|
<!--
|
|
Architecture: Server-side rendered plugin configuration forms
|
|
- Each plugin tab loads its config via HTMX when first viewed
|
|
- Forms are generated server-side using Jinja2 macros
|
|
- Reduces client-side complexity and improves performance
|
|
- Uses x-init to trigger HTMX after Alpine renders the element
|
|
-->
|
|
<template x-for="plugin in installedPlugins" :key="plugin.id">
|
|
<div x-show="activeTab === plugin.id" x-transition>
|
|
<!-- Only load content when tab is active (lazy loading) -->
|
|
<template x-if="activeTab === plugin.id">
|
|
<div class="bg-white rounded-lg shadow p-6 plugin-config-tab"
|
|
:id="'plugin-config-' + plugin.id"
|
|
x-init="$nextTick(() => {
|
|
if (window.htmx && !$el.dataset.htmxLoaded) {
|
|
$el.dataset.htmxLoaded = 'true';
|
|
htmx.ajax('GET', '/v3/partials/plugin-config/' + plugin.id, {target: $el, swap: 'innerHTML'});
|
|
}
|
|
})">
|
|
<!-- Loading skeleton shown until HTMX loads server-rendered content -->
|
|
<div class="animate-pulse space-y-6">
|
|
<div class="border-b border-gray-200 pb-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="space-y-2">
|
|
<div class="h-6 bg-gray-200 rounded w-48"></div>
|
|
<div class="h-4 bg-gray-200 rounded w-96"></div>
|
|
</div>
|
|
<div class="h-6 bg-gray-200 rounded w-24"></div>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div class="bg-gray-50 rounded-lg p-4 space-y-4">
|
|
<div class="h-5 bg-gray-200 rounded w-32"></div>
|
|
<div class="space-y-3">
|
|
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
|
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
|
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-gray-50 rounded-lg p-4 space-y-4">
|
|
<div class="h-5 bg-gray-200 rounded w-32"></div>
|
|
<div class="space-y-3">
|
|
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
|
<div class="h-10 bg-gray-200 rounded w-full"></div>
|
|
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
|
<div class="h-10 bg-gray-200 rounded w-full"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Notifications -->
|
|
<div id="notifications" class="fixed top-4 right-4 z-50 space-y-2"></div>
|
|
|
|
<!-- SSE connection for real-time updates -->
|
|
<script>
|
|
// Connect to SSE streams
|
|
const statsSource = new EventSource('/api/v3/stream/stats');
|
|
const displaySource = new EventSource('/api/v3/stream/display');
|
|
|
|
statsSource.onmessage = function(event) {
|
|
const data = JSON.parse(event.data);
|
|
updateSystemStats(data);
|
|
};
|
|
|
|
displaySource.onmessage = function(event) {
|
|
const data = JSON.parse(event.data);
|
|
updateDisplayPreview(data);
|
|
};
|
|
|
|
// Connection status
|
|
statsSource.addEventListener('open', function() {
|
|
document.getElementById('connection-status').innerHTML = `
|
|
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
<span class="text-gray-600">Connected</span>
|
|
`;
|
|
});
|
|
|
|
statsSource.addEventListener('error', function() {
|
|
document.getElementById('connection-status').innerHTML = `
|
|
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
|
<span class="text-gray-600">Disconnected</span>
|
|
`;
|
|
});
|
|
|
|
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 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'
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ===== DEPRECATED: pluginConfigData =====
|
|
// This function is no longer used - plugin configuration forms are now
|
|
// rendered server-side and loaded via HTMX. Kept for backwards compatibility.
|
|
// See: /v3/partials/plugin-config/<plugin_id> for the new implementation.
|
|
function pluginConfigData(plugin) {
|
|
if (!plugin) {
|
|
console.error('pluginConfigData called with undefined plugin');
|
|
return {
|
|
plugin: { id: 'unknown', name: 'Unknown Plugin', enabled: false },
|
|
loading: false,
|
|
config: {},
|
|
schema: {},
|
|
webUiActions: [],
|
|
onDemandRefreshing: false,
|
|
onDemandStopping: false
|
|
};
|
|
}
|
|
return {
|
|
plugin: plugin,
|
|
loading: true,
|
|
config: {},
|
|
schema: {},
|
|
webUiActions: [],
|
|
onDemandRefreshing: false,
|
|
onDemandStopping: false,
|
|
get onDemandStore() {
|
|
if (window.Alpine && typeof Alpine.store === 'function' && Alpine.store('onDemand')) {
|
|
return Alpine.store('onDemand');
|
|
}
|
|
return window.__onDemandStore || { loading: true, state: {}, service: {}, error: null, lastUpdated: null };
|
|
},
|
|
get isOnDemandLoading() {
|
|
const store = this.onDemandStore || {};
|
|
return !!store.loading;
|
|
},
|
|
get onDemandState() {
|
|
const store = this.onDemandStore || {};
|
|
return store.state || {};
|
|
},
|
|
get onDemandService() {
|
|
const store = this.onDemandStore || {};
|
|
return store.service || {};
|
|
},
|
|
get onDemandError() {
|
|
const store = this.onDemandStore || {};
|
|
return store.error || null;
|
|
},
|
|
get onDemandActive() {
|
|
const state = this.onDemandState;
|
|
return !!(state.active && state.plugin_id === plugin.id);
|
|
},
|
|
resolvePluginName() {
|
|
return plugin.name || plugin.id;
|
|
},
|
|
resolvePluginDisplayName(id) {
|
|
if (!id) {
|
|
return 'Another plugin';
|
|
}
|
|
const list = window.installedPlugins || [];
|
|
const match = Array.isArray(list) ? list.find(p => p.id === id) : null;
|
|
return match ? (match.name || match.id) : id;
|
|
},
|
|
formatDuration(value) {
|
|
if (value === undefined || value === null) {
|
|
return '';
|
|
}
|
|
const total = Number(value);
|
|
if (Number.isNaN(total)) {
|
|
return '';
|
|
}
|
|
const seconds = Math.max(0, Math.round(total));
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
if (minutes > 0) {
|
|
return `${minutes}m${remainingSeconds > 0 ? ` ${remainingSeconds}s` : ''}`;
|
|
}
|
|
return `${remainingSeconds}s`;
|
|
},
|
|
get onDemandStatusText() {
|
|
if (this.isOnDemandLoading) {
|
|
return 'Loading on-demand status...';
|
|
}
|
|
if (this.onDemandError) {
|
|
return `On-demand error: ${this.onDemandError}`;
|
|
}
|
|
const state = this.onDemandState;
|
|
if (state.active) {
|
|
const activeName = this.resolvePluginDisplayName(state.plugin_id);
|
|
if (state.plugin_id !== plugin.id) {
|
|
return `${activeName} is running on-demand.`;
|
|
}
|
|
const modeLabel = state.mode ? ` (${state.mode})` : '';
|
|
const remaining = this.formatDuration(state.remaining);
|
|
const duration = this.formatDuration(state.duration);
|
|
let message = `${this.resolvePluginName()}${modeLabel} is running on-demand`;
|
|
if (remaining) {
|
|
message += ` — ${remaining} remaining`;
|
|
} else if (duration) {
|
|
message += ` — duration ${duration}`;
|
|
} else {
|
|
message += ' — until stopped';
|
|
}
|
|
return message;
|
|
}
|
|
const lastEvent = state.last_event ? state.last_event.replace(/-/g, ' ') : null;
|
|
if (lastEvent && lastEvent !== 'cleared') {
|
|
return `No on-demand session active (last event: ${lastEvent})`;
|
|
}
|
|
return 'No on-demand session active.';
|
|
},
|
|
get onDemandStatusClass() {
|
|
if (this.isOnDemandLoading) return 'text-blue-600';
|
|
if (this.onDemandError) return 'text-red-600';
|
|
if (this.onDemandActive) return 'text-green-600';
|
|
return 'text-blue-600';
|
|
},
|
|
get onDemandServiceText() {
|
|
if (this.isOnDemandLoading) {
|
|
return 'Checking display service status...';
|
|
}
|
|
if (this.onDemandError) {
|
|
return 'Display service status unavailable.';
|
|
}
|
|
if (this.onDemandService.active) {
|
|
return 'Display service is running.';
|
|
}
|
|
const serviceError = this.onDemandService.stderr || this.onDemandService.error;
|
|
return serviceError ? `Display service inactive (${serviceError})` : 'Display service is not running.';
|
|
},
|
|
get onDemandServiceClass() {
|
|
if (this.isOnDemandLoading) return 'text-blue-500';
|
|
if (this.onDemandError) return 'text-red-500';
|
|
return this.onDemandService.active ? 'text-blue-500' : 'text-red-500';
|
|
},
|
|
get onDemandLastUpdated() {
|
|
const store = this.onDemandStore || {};
|
|
if (!store.lastUpdated) {
|
|
return '';
|
|
}
|
|
const deltaSeconds = Math.round((Date.now() - store.lastUpdated) / 1000);
|
|
if (deltaSeconds < 5) return 'Just now';
|
|
if (deltaSeconds < 60) return `${deltaSeconds}s ago`;
|
|
const minutes = Math.round(deltaSeconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.round(minutes / 60);
|
|
return `${hours}h ago`;
|
|
},
|
|
get canStopOnDemand() {
|
|
if (this.isOnDemandLoading) return false;
|
|
if (this.onDemandError) return true;
|
|
return this.onDemandActive;
|
|
},
|
|
get disableRunButton() {
|
|
return !plugin.enabled;
|
|
},
|
|
get showEnableHint() {
|
|
return !plugin.enabled;
|
|
},
|
|
notify(message, type = 'info') {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(message, type);
|
|
}
|
|
},
|
|
refreshOnDemandStatus() {
|
|
if (typeof window.loadOnDemandStatus !== 'function') {
|
|
this.notify('On-demand status controls unavailable. Refresh the Plugin Manager tab.', 'error');
|
|
return;
|
|
}
|
|
this.onDemandRefreshing = true;
|
|
Promise.resolve(window.loadOnDemandStatus(true))
|
|
.finally(() => {
|
|
this.onDemandRefreshing = false;
|
|
});
|
|
},
|
|
runOnDemand() {
|
|
// Note: On-demand can work with disabled plugins - the backend will temporarily enable them
|
|
if (typeof window.openOnDemandModal === 'function') {
|
|
window.openOnDemandModal(plugin.id);
|
|
} else {
|
|
this.notify('On-demand modal unavailable. Refresh the Plugin Manager tab.', 'error');
|
|
}
|
|
},
|
|
stopOnDemandWithEvent(stopService = false) {
|
|
if (typeof window.requestOnDemandStop !== 'function') {
|
|
this.notify('Unable to stop on-demand mode. Refresh the Plugin Manager tab.', 'error');
|
|
return;
|
|
}
|
|
this.onDemandStopping = true;
|
|
Promise.resolve(window.requestOnDemandStop({ stopService }))
|
|
.finally(() => {
|
|
this.onDemandStopping = false;
|
|
});
|
|
},
|
|
async loadPluginConfig(pluginId) {
|
|
// Use PluginConfigHelpers to load config directly into this component
|
|
if (window.PluginConfigHelpers) {
|
|
await window.PluginConfigHelpers.loadPluginConfig(pluginId, this);
|
|
this.loading = false;
|
|
return;
|
|
}
|
|
console.error('loadPluginConfig not available');
|
|
this.loading = false;
|
|
}
|
|
// Note: generateConfigForm and savePluginConfig are now called via window.PluginConfigHelpers
|
|
// to avoid delegation recursion and ensure proper access to app component.
|
|
// See template usage:
|
|
// x-html="window.PluginConfigHelpers.generateConfigForm(...)" and
|
|
// x-on:submit.prevent="window.PluginConfigHelpers.savePluginConfig(...)"
|
|
};
|
|
}
|
|
|
|
// 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';
|
|
})(),
|
|
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;
|
|
console.log('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) => {
|
|
console.log('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) {
|
|
console.log('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) {
|
|
// Try to load content for the active tab
|
|
if (typeof htmx !== 'undefined') {
|
|
const contentId = tab + '-content';
|
|
const contentEl = document.getElementById(contentId);
|
|
if (contentEl && !contentEl.hasAttribute('data-loaded')) {
|
|
// Trigger HTMX load
|
|
htmx.trigger(contentEl, 'revealed');
|
|
}
|
|
} else {
|
|
// HTMX not available, use direct fetch
|
|
console.warn('HTMX not available, using direct fetch for tab:', tab);
|
|
if (tab === 'overview' && typeof loadOverviewDirect === 'function') {
|
|
loadOverviewDirect();
|
|
} else if (tab === 'wifi' && typeof loadWifiDirect === 'function') {
|
|
loadWifiDirect();
|
|
}
|
|
}
|
|
},
|
|
|
|
async loadInstalledPlugins() {
|
|
// If pluginManager exists (plugins.html is loaded), delegate to it
|
|
if (window.pluginManager) {
|
|
console.log('[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;
|
|
console.log('[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 {
|
|
console.log('[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;
|
|
console.log(`[FULL] Loaded ${this.installedPlugins.length} plugins:`, this.installedPlugins.map(p => p.id));
|
|
|
|
// Debug: Log enabled status for each plugin
|
|
this.installedPlugins.forEach(plugin => {
|
|
console.log(`[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) {
|
|
console.log('[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 || []);
|
|
|
|
console.log('[FULL] _doUpdatePluginTabs called with:', pluginsToShow.length, 'plugins (attempt', retryCount + 1, ')');
|
|
console.log('[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
|
|
console.log('[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) {
|
|
console.log('[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');
|
|
|
|
console.log('[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;
|
|
}
|
|
|
|
console.log(`[FULL] Updating plugin tabs for ${pluginsToShow.length} plugins`);
|
|
|
|
// Always show the plugin tabs row (Plugin Manager should always be available)
|
|
console.log('[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');
|
|
console.log(`[FULL] Removing ${existingTabs.length} existing plugin tabs`);
|
|
existingTabs.forEach(tab => tab.remove());
|
|
|
|
// Add tabs for each installed plugin
|
|
console.log('[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();
|
|
}
|
|
};
|
|
tabButton.innerHTML = `
|
|
<i class="fas fa-puzzle-piece"></i>${this.escapeHtml(plugin.name || plugin.id)}
|
|
`;
|
|
|
|
// Insert before the closing </nav> tag
|
|
pluginTabsNav.appendChild(tabButton);
|
|
console.log('[FULL] Added tab for plugin:', plugin.id);
|
|
});
|
|
|
|
console.log('[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 {
|
|
console.log(`[${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) {
|
|
console.log('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 || []) : [];
|
|
console.log('[DEBUG] Loaded web_ui_actions for', pluginId, ':', this.webUiActions.length, 'actions');
|
|
console.log('[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 = [];
|
|
}
|
|
|
|
console.log('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 '<div class="text-gray-500">Loading configuration...</div>';
|
|
}
|
|
|
|
// Only log once per plugin to avoid spam (Alpine.js may call this multiple times during rendering)
|
|
if (!this._configFormLogged || this._configFormLogged !== pluginId) {
|
|
console.log('[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;
|
|
console.log('[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;
|
|
|
|
console.log('[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 += `
|
|
<div class="nested-section border border-gray-300 rounded-lg ${marginClass}">
|
|
<button type="button"
|
|
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors"
|
|
onclick="toggleNestedSection('${sectionId}', event); return false;">
|
|
<div class="flex-1">
|
|
<h4 class="font-semibold text-gray-900">${sectionLabel}</h4>
|
|
${description ? `<p class="text-sm text-gray-600 mt-1">${description}</p>` : ''}
|
|
</div>
|
|
<i id="${sectionId}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
|
</button>
|
|
<div id="${sectionId}" class="nested-content collapsed bg-gray-50 px-4 py-4 space-y-3" style="max-height: 0; display: none;">
|
|
`;
|
|
|
|
// 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')) {
|
|
console.log('[DEBUG] Found file-upload widget in nested property:', nestedKey, 'fullKey:', fullKey + '.' + nestedKey, 'prop:', nestedProp);
|
|
}
|
|
|
|
html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey);
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add extra spacing after nested sections to prevent overlap with next section
|
|
if (nestingDepth > 0) {
|
|
html += `<div class="mb-2"></div>`;
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
// Regular (non-nested) field
|
|
html += `<div class="form-group">`;
|
|
html += `<label class="block text-sm font-medium text-gray-700 mb-1">${label}</label>`;
|
|
|
|
if (description) {
|
|
html += `<p class="text-sm text-gray-600 mb-2">${description}</p>`;
|
|
}
|
|
|
|
// Generate appropriate input based on type
|
|
if (prop.type === 'boolean') {
|
|
html += `<label class="flex items-center">`;
|
|
html += `<input type="checkbox" name="${fullKey}" ${value ? 'checked' : ''} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">`;
|
|
html += `<span class="ml-2 text-sm">Enabled</span>`;
|
|
html += `</label>`;
|
|
} 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') ?
|
|
`<p class="text-xs text-gray-500 mt-1">${description}</p>` : '';
|
|
|
|
html += `<input type="number" name="${fullKey}" value="${fieldValue}" ${min} ${max} ${step} placeholder="${placeholder}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
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'] || {};
|
|
console.log('[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'] || {};
|
|
console.log('[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'] || {};
|
|
console.log('[DEBUG] ✅ Detection via schema lookup');
|
|
}
|
|
}
|
|
|
|
// Debug logging for ALL array fields to diagnose
|
|
console.log('[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) {
|
|
console.log('[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 += `
|
|
<div id="${fieldId}_upload_widget" class="mt-1">
|
|
<!-- File Upload Drop Zone -->
|
|
<div id="${fieldId}_drop_zone"
|
|
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
|
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
|
|
ondragover="event.preventDefault()"
|
|
data-field-id="${fieldId}"
|
|
onclick="document.getElementById(this.dataset.fieldId + '_file_input').click()">
|
|
<input type="file"
|
|
id="${fieldId}_file_input"
|
|
multiple
|
|
accept="${allowedTypes.join(',')}"
|
|
style="display: none;"
|
|
data-field-id="${fieldId}"
|
|
onchange="window.handleFileSelect(event, this.dataset.fieldId)">
|
|
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
|
<p class="text-sm text-gray-600">Drag and drop images here or click to browse</p>
|
|
<p class="text-xs text-gray-500 mt-1">Max ${maxFiles} files, ${maxSizeMB}MB each (PNG, JPG, GIF, BMP)</p>
|
|
</div>
|
|
|
|
<!-- Uploaded Images List -->
|
|
<div id="${fieldId}_image_list" class="mt-4 space-y-2">
|
|
${currentImages.map((img, idx) => {
|
|
const imgSchedule = img.schedule || {};
|
|
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
|
|
let scheduleSummary = 'Always shown';
|
|
if (hasSchedule && window.getScheduleSummary) {
|
|
try {
|
|
scheduleSummary = window.getScheduleSummary(imgSchedule) || 'Scheduled';
|
|
} catch (e) {
|
|
scheduleSummary = 'Scheduled';
|
|
}
|
|
} else if (hasSchedule) {
|
|
scheduleSummary = 'Scheduled';
|
|
}
|
|
// Escape the summary for HTML
|
|
scheduleSummary = String(scheduleSummary).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
|
|
return `
|
|
<div id="img_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center space-x-3 flex-1">
|
|
<img src="/${(img.path || '').replace(/&/g, '&').replace(/"/g, '"')}"
|
|
alt="${(img.filename || '').replace(/"/g, '"')}"
|
|
class="w-16 h-16 object-cover rounded"
|
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
|
<div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
|
|
<i class="fas fa-image text-gray-400"></i>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium text-gray-900 truncate">${String(img.original_filename || img.filename || 'Image').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</p>
|
|
<p class="text-xs text-gray-500">${img.size ? (Math.round(img.size / 1024) + ' KB') : ''} • ${(img.uploaded_at || '').replace(/&/g, '&')}</p>
|
|
<p class="text-xs text-blue-600 mt-1">
|
|
<i class="fas fa-clock mr-1"></i>${scheduleSummary}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-2 ml-4">
|
|
<button type="button"
|
|
data-field-id="${fieldId}"
|
|
data-image-id="${img.id || ''}"
|
|
data-image-idx="${idx}"
|
|
onclick="window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId || null, parseInt(this.dataset.imageIdx))"
|
|
class="text-blue-600 hover:text-blue-800 p-2"
|
|
title="Schedule this image">
|
|
<i class="fas fa-calendar-alt"></i>
|
|
</button>
|
|
<button type="button"
|
|
data-field-id="${fieldId}"
|
|
data-image-id="${img.id || ''}"
|
|
data-plugin-id="${safePluginId}"
|
|
onclick="window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId)"
|
|
class="text-red-600 hover:text-red-800 p-2"
|
|
title="Delete image">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- Schedule widget will be inserted here when opened -->
|
|
<div id="schedule_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
|
|
<!-- Hidden input to store image data -->
|
|
<input type="hidden" id="${fieldId}_images_data" name="${fullKey}" value="${JSON.stringify(currentImages).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')}">
|
|
</div>
|
|
`;
|
|
} else {
|
|
// Regular array input
|
|
const arrayValue = Array.isArray(value) ? value.join(', ') : '';
|
|
html += `<input type="text" name="${fullKey}" value="${arrayValue}" placeholder="Enter values separated by commas" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
html += `<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>`;
|
|
}
|
|
} else if (prop.enum) {
|
|
html += `<select name="${fullKey}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
prop.enum.forEach(option => {
|
|
const selected = value === option ? 'selected' : '';
|
|
html += `<option value="${option}" ${selected}>${option}</option>`;
|
|
});
|
|
html += `</select>`;
|
|
} else if (prop.type === 'string' && prop['x-widget'] === 'file-upload') {
|
|
// File upload widget for string fields (e.g., credentials.json)
|
|
const uploadConfig = prop['x-upload-config'] || {};
|
|
const uploadEndpoint = uploadConfig.upload_endpoint || '/api/v3/plugins/assets/upload';
|
|
const maxSizeMB = uploadConfig.max_size_mb || 1;
|
|
const allowedExtensions = uploadConfig.allowed_extensions || ['.json'];
|
|
const targetFilename = uploadConfig.target_filename || 'file.json';
|
|
const fieldId = fullKey.replace(/\./g, '_');
|
|
const hasFile = value && value !== '';
|
|
|
|
html += `
|
|
<div id="${fieldId}_upload_widget" class="mt-1">
|
|
<div id="${fieldId}_file_upload"
|
|
class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
|
onclick="document.getElementById('${fieldId}_file_input').click()">
|
|
<input type="file"
|
|
id="${fieldId}_file_input"
|
|
accept="${allowedExtensions.join(',')}"
|
|
style="display: none;"
|
|
data-field-id="${fieldId}"
|
|
data-upload-endpoint="${uploadEndpoint}"
|
|
data-target-filename="${targetFilename}"
|
|
onchange="window.handleCredentialsUpload(event, this.dataset.fieldId, this.dataset.uploadEndpoint, this.dataset.targetFilename)">
|
|
<i class="fas fa-file-upload text-2xl text-gray-400 mb-2"></i>
|
|
<p class="text-sm text-gray-600" id="${fieldId}_status">
|
|
${hasFile ? `Current file: ${value}` : 'Click to upload ' + targetFilename}
|
|
</p>
|
|
<p class="text-xs text-gray-500 mt-1">Max ${maxSizeMB}MB (${allowedExtensions.join(', ')})</p>
|
|
</div>
|
|
<input type="hidden" name="${fullKey}" value="${value || ''}" id="${fieldId}_hidden">
|
|
</div>
|
|
`;
|
|
} else {
|
|
// Default to text input
|
|
const maxLength = prop.maxLength || '';
|
|
const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : '';
|
|
html += `<input type="text" name="${fullKey}" value="${value !== undefined ? value : ''}" ${maxLengthAttr} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
return html;
|
|
};
|
|
|
|
let formHtml = '';
|
|
// Get ordered properties if x-propertyOrder is defined
|
|
let propertyEntries = Object.entries(schema.properties);
|
|
if (schema['x-propertyOrder'] && Array.isArray(schema['x-propertyOrder'])) {
|
|
const order = schema['x-propertyOrder'];
|
|
const orderedEntries = [];
|
|
const unorderedEntries = [];
|
|
|
|
// Separate ordered and unordered properties
|
|
propertyEntries.forEach(([key, prop]) => {
|
|
const index = order.indexOf(key);
|
|
if (index !== -1) {
|
|
orderedEntries[index] = [key, prop];
|
|
} else {
|
|
unorderedEntries.push([key, prop]);
|
|
}
|
|
});
|
|
|
|
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
|
|
propertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
|
|
}
|
|
|
|
propertyEntries.forEach(([key, prop]) => {
|
|
// Skip the 'enabled' property - it's managed separately via the header toggle
|
|
if (key === 'enabled') return;
|
|
// Use config value if key exists and is not null/undefined, otherwise use schema default
|
|
// Check if key exists in config and value is not null/undefined
|
|
const hasValue = key in config && config[key] !== null && config[key] !== undefined;
|
|
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
|
|
const isNestedObject = prop.type === 'object' && prop.properties;
|
|
const value = hasValue ? config[key] :
|
|
(prop.default !== undefined ? prop.default :
|
|
(isNestedObject ? {} : (prop.type === 'array' ? [] : (prop.type === 'boolean' ? false : ''))));
|
|
formHtml += generateFieldHtml(key, prop, value);
|
|
});
|
|
|
|
// Add web UI actions section if plugin defines any
|
|
if (webUiActions && webUiActions.length > 0) {
|
|
console.log('[DEBUG] Rendering', webUiActions.length, 'actions in tab form');
|
|
|
|
// Map color names to explicit Tailwind classes
|
|
const colorMap = {
|
|
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
|
|
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
|
|
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
|
|
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
|
|
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
|
|
};
|
|
|
|
formHtml += `
|
|
<div class="border-t border-gray-200 pt-4 mt-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
|
|
<p class="text-sm text-gray-600 mb-4">${webUiActions[0].section_description || 'Perform actions for this plugin'}</p>
|
|
|
|
<div class="space-y-3">
|
|
`;
|
|
|
|
webUiActions.forEach((action, index) => {
|
|
const actionId = `action-${action.id}-${index}`;
|
|
const statusId = `action-status-${action.id}-${index}`;
|
|
const bgColor = action.color || 'blue';
|
|
const colors = colorMap[bgColor] || colorMap['blue'];
|
|
// Ensure pluginId is valid for template interpolation
|
|
const safePluginId = pluginId || '';
|
|
|
|
formHtml += `
|
|
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex-1">
|
|
<h4 class="font-medium ${colors.text} mb-1">
|
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
|
|
</h4>
|
|
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
|
|
</div>
|
|
<button type="button"
|
|
id="${actionId}"
|
|
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
|
data-plugin-id="${safePluginId}"
|
|
data-action-id="${action.id}"
|
|
class="btn ${colors.btn} text-white px-4 py-2 rounded-md whitespace-nowrap">
|
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
|
</button>
|
|
</div>
|
|
<div id="${statusId}" class="mt-3 hidden"></div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
formHtml += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return formHtml;
|
|
},
|
|
|
|
generateSimpleConfigForm(config, webUiActions = [], pluginId = '') {
|
|
let actionsHtml = '';
|
|
if (webUiActions && webUiActions.length > 0) {
|
|
const colorMap = {
|
|
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
|
|
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
|
|
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
|
|
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
|
|
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
|
|
};
|
|
|
|
actionsHtml = `
|
|
<div class="border-t border-gray-200 pt-4 mt-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
|
|
<div class="space-y-3">
|
|
`;
|
|
webUiActions.forEach((action, index) => {
|
|
const actionId = `action-${action.id}-${index}`;
|
|
const statusId = `action-status-${action.id}-${index}`;
|
|
const bgColor = action.color || 'blue';
|
|
const colors = colorMap[bgColor] || colorMap['blue'];
|
|
// Ensure pluginId is valid for template interpolation
|
|
const safePluginId = pluginId || '';
|
|
actionsHtml += `
|
|
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex-1">
|
|
<h4 class="font-medium ${colors.text} mb-1">
|
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
|
|
</h4>
|
|
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
|
|
</div>
|
|
<button type="button"
|
|
id="${actionId}"
|
|
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
|
data-plugin-id="${safePluginId}"
|
|
data-action-id="${action.id}"
|
|
class="btn ${colors.btn} text-white px-4 py-2 rounded-md">
|
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
|
</button>
|
|
</div>
|
|
<div id="${statusId}" class="mt-3 hidden"></div>
|
|
</div>
|
|
`;
|
|
});
|
|
actionsHtml += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="form-group">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
|
|
<input type="number" name="display_duration" value="${Math.max(5, Math.min(300, config.display_duration || 30))}" min="5" max="300" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
|
<p class="text-sm text-gray-600 mt-1">How long to show this plugin's content</p>
|
|
</div>
|
|
${actionsHtml}
|
|
`;
|
|
},
|
|
|
|
// Helper function to get schema property type for a field path
|
|
getSchemaPropertyType(schema, path) {
|
|
if (!schema || !schema.properties) return null;
|
|
|
|
const parts = path.split('.');
|
|
let current = schema.properties;
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i];
|
|
if (current && current[part]) {
|
|
if (i === parts.length - 1) {
|
|
return current[part];
|
|
} else if (current[part].properties) {
|
|
current = current[part].properties;
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
// Helper function to escape CSS selector special characters
|
|
escapeCssSelector(str) {
|
|
if (typeof str !== 'string') {
|
|
str = String(str);
|
|
}
|
|
// Use CSS.escape() when available (handles unicode, leading digits, and edge cases)
|
|
if (typeof CSS !== 'undefined' && CSS.escape) {
|
|
return CSS.escape(str);
|
|
}
|
|
// Fallback to regex-based escaping for older browsers
|
|
// First, handle leading digits and whitespace (must be done before regex)
|
|
let escaped = str;
|
|
let hasLeadingHexEscape = false;
|
|
if (escaped.length > 0) {
|
|
const firstChar = escaped[0];
|
|
const firstCode = firstChar.charCodeAt(0);
|
|
|
|
// Escape leading digit (0-9: U+0030-U+0039)
|
|
if (firstCode >= 0x30 && firstCode <= 0x39) {
|
|
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
|
|
escaped = '\\' + hex + ' ' + escaped.slice(1);
|
|
hasLeadingHexEscape = true;
|
|
}
|
|
// Escape leading whitespace (space: U+0020, tab: U+0009, etc.)
|
|
else if (/\s/.test(firstChar)) {
|
|
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
|
|
escaped = '\\' + hex + ' ' + escaped.slice(1);
|
|
hasLeadingHexEscape = true;
|
|
}
|
|
}
|
|
|
|
// Escape special characters
|
|
escaped = escaped.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
|
|
|
|
// Escape internal spaces (replace spaces with \ ), but preserve space in hex escape
|
|
if (hasLeadingHexEscape) {
|
|
// Skip the first 6 characters (e.g., "\0030 ") when replacing spaces
|
|
escaped = escaped.slice(0, 6) + escaped.slice(6).replace(/ /g, '\\ ');
|
|
} else {
|
|
escaped = escaped.replace(/ /g, '\\ ');
|
|
}
|
|
|
|
return escaped;
|
|
},
|
|
|
|
async savePluginConfig(pluginId, event) {
|
|
try {
|
|
// Get the form element for this plugin
|
|
const form = event ? event.target : null;
|
|
if (!form) {
|
|
throw new Error('Form element not found');
|
|
}
|
|
const formData = new FormData(form);
|
|
const schema = this.schema || {};
|
|
|
|
// First, collect all checkbox states (including unchecked ones)
|
|
// Unchecked checkboxes don't appear in FormData, so we need to iterate form elements
|
|
const flatConfig = {};
|
|
|
|
// Process all form elements to capture all field states
|
|
for (let i = 0; i < form.elements.length; i++) {
|
|
const element = form.elements[i];
|
|
const name = element.name;
|
|
|
|
// Skip elements without names or submit buttons
|
|
if (!name || element.type === 'submit' || element.type === 'button') {
|
|
continue;
|
|
}
|
|
|
|
// Handle checkboxes explicitly (both checked and unchecked)
|
|
if (element.type === 'checkbox') {
|
|
// Check if this is a checkbox group (name ends with [])
|
|
if (name.endsWith('[]')) {
|
|
const baseName = name.slice(0, -2); // Remove '[]' suffix
|
|
if (!flatConfig[baseName]) {
|
|
flatConfig[baseName] = [];
|
|
}
|
|
if (element.checked) {
|
|
flatConfig[baseName].push(element.value);
|
|
}
|
|
} else {
|
|
// Regular checkbox (boolean)
|
|
flatConfig[name] = element.checked;
|
|
}
|
|
}
|
|
// Handle radio buttons
|
|
else if (element.type === 'radio') {
|
|
if (element.checked) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
// Handle select elements (including multi-select)
|
|
else if (element.tagName === 'SELECT') {
|
|
if (element.multiple) {
|
|
// Multi-select: get all selected options
|
|
const selectedValues = Array.from(element.selectedOptions).map(opt => opt.value);
|
|
flatConfig[name] = selectedValues;
|
|
} else {
|
|
// Single select: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
// Handle textarea
|
|
else if (element.tagName === 'TEXTAREA') {
|
|
// Textarea: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now process FormData for other field types
|
|
for (const [key, value] of formData.entries()) {
|
|
// Skip checkboxes - we already handled them above
|
|
// Use querySelector to reliably find element by name (handles dot notation)
|
|
const escapedKey = this.escapeCssSelector(key);
|
|
const element = form.querySelector(`[name="${escapedKey}"]`);
|
|
if (element && element.type === 'checkbox') {
|
|
// Also skip checkbox groups (name ends with [])
|
|
if (key.endsWith('[]')) {
|
|
continue; // Already processed
|
|
}
|
|
continue; // Already processed
|
|
}
|
|
// Skip multi-select - we already handled them above
|
|
if (element && element.tagName === 'SELECT' && element.multiple) {
|
|
continue; // Already processed
|
|
}
|
|
|
|
// Get schema property type if available
|
|
const propSchema = this.getSchemaPropertyType(schema, key);
|
|
const propType = propSchema ? propSchema.type : null;
|
|
|
|
// Handle based on schema type or field name patterns
|
|
if (propType === 'array') {
|
|
// Check if this is a file upload widget (JSON array in hidden input)
|
|
if (propSchema && propSchema['x-widget'] === 'file-upload') {
|
|
try {
|
|
// Unescape HTML entities that were escaped when setting the value
|
|
let unescapedValue = value;
|
|
if (typeof value === 'string') {
|
|
// Reverse the HTML escaping: " -> ", ' -> ', & -> &
|
|
unescapedValue = value
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
// Try to parse as JSON
|
|
const jsonValue = JSON.parse(unescapedValue);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[key] = jsonValue;
|
|
console.log(`File upload array field ${key}: parsed JSON array with ${jsonValue.length} items`);
|
|
} else {
|
|
// Fallback to empty array
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Failed to parse JSON for file upload field ${key}:`, e, 'Value:', value);
|
|
// Not valid JSON, use empty array or try comma-separated
|
|
if (value && value.trim()) {
|
|
// Try to unescape and parse again
|
|
try {
|
|
const unescaped = value
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&');
|
|
const jsonValue = JSON.parse(unescaped);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[key] = jsonValue;
|
|
} else {
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e2) {
|
|
// If still fails, try comma-separated or empty array
|
|
const arrayValue = value.split(',').map(v => v.trim()).filter(v => v);
|
|
flatConfig[key] = arrayValue.length > 0 ? arrayValue : [];
|
|
}
|
|
} else {
|
|
flatConfig[key] = [];
|
|
}
|
|
}
|
|
} else {
|
|
// Regular array: convert comma-separated string to array
|
|
const arrayValue = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[key] = arrayValue;
|
|
}
|
|
} else if (propType === 'integer' || (Array.isArray(propType) && propType.includes('integer'))) {
|
|
// Handle union types - if null is allowed and value is empty, keep as empty string (backend will convert to null)
|
|
if (Array.isArray(propType) && propType.includes('null') && (!value || value.trim() === '')) {
|
|
flatConfig[key] = ''; // Send empty string, backend will normalize to null
|
|
} else {
|
|
const numValue = parseInt(value, 10);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
}
|
|
} else if (propType === 'number' || (Array.isArray(propType) && propType.includes('number'))) {
|
|
// Handle union types - if null is allowed and value is empty, keep as empty string (backend will convert to null)
|
|
if (Array.isArray(propType) && propType.includes('null') && (!value || value.trim() === '')) {
|
|
flatConfig[key] = ''; // Send empty string, backend will normalize to null
|
|
} else {
|
|
const numValue = parseFloat(value);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
}
|
|
} else if (propType === 'boolean') {
|
|
// Boolean from FormData (shouldn't happen for checkboxes, but handle it)
|
|
flatConfig[key] = value === 'on' || value === 'true' || value === true;
|
|
} else {
|
|
// String or other types
|
|
// Check if it's a number field by name pattern (fallback if no schema)
|
|
if (!propType && (key.includes('duration') || key.includes('interval') ||
|
|
key.includes('timeout') || key.includes('teams') || key.includes('fps') ||
|
|
key.includes('bits') || key.includes('nanoseconds') || key.includes('hz'))) {
|
|
const numValue = parseFloat(value);
|
|
if (!isNaN(numValue)) {
|
|
flatConfig[key] = Number.isInteger(numValue) ? parseInt(value, 10) : numValue;
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle unchecked checkboxes using schema (if available)
|
|
if (schema && schema.properties) {
|
|
const collectBooleanFields = (props, prefix = '') => {
|
|
const boolFields = [];
|
|
for (const [key, prop] of Object.entries(props)) {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
if (prop.type === 'boolean') {
|
|
boolFields.push(fullKey);
|
|
} else if (prop.type === 'object' && prop.properties) {
|
|
boolFields.push(...collectBooleanFields(prop.properties, fullKey));
|
|
}
|
|
}
|
|
return boolFields;
|
|
};
|
|
|
|
const allBoolFields = collectBooleanFields(schema.properties);
|
|
allBoolFields.forEach(key => {
|
|
// Only set to false if the field is completely missing from flatConfig
|
|
// Don't override existing false values - they're explicitly set by the user
|
|
if (!(key in flatConfig)) {
|
|
flatConfig[key] = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Convert dot notation to nested object
|
|
const dotToNested = (obj) => {
|
|
const result = {};
|
|
for (const key in obj) {
|
|
const parts = key.split('.');
|
|
let current = result;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
if (!current[parts[i]]) {
|
|
current[parts[i]] = {};
|
|
}
|
|
current = current[parts[i]];
|
|
}
|
|
current[parts[parts.length - 1]] = obj[key];
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const config = dotToNested(flatConfig);
|
|
|
|
// Save to backend
|
|
const response = await fetch('/api/v3/plugins/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
plugin_id: pluginId,
|
|
config: config
|
|
})
|
|
});
|
|
|
|
let data;
|
|
try {
|
|
data = await response.json();
|
|
} catch (e) {
|
|
console.error('Failed to parse JSON response:', e);
|
|
console.error('Response status:', response.status, response.statusText);
|
|
console.error('Response text:', await response.text());
|
|
throw new Error(`Failed to parse server response: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
console.log('Response status:', response.status, 'Response OK:', response.ok);
|
|
console.log('Response data:', JSON.stringify(data, null, 2));
|
|
|
|
if (!response.ok || data.status !== 'success') {
|
|
let errorMessage = data.message || 'Failed to save configuration';
|
|
if (data.validation_errors && Array.isArray(data.validation_errors)) {
|
|
console.error('Validation errors:', data.validation_errors);
|
|
errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n');
|
|
}
|
|
if (data.config_keys && data.schema_keys) {
|
|
console.error('Config keys sent:', data.config_keys);
|
|
console.error('Schema keys expected:', data.schema_keys);
|
|
const extraKeys = data.config_keys.filter(k => !data.schema_keys.includes(k));
|
|
const missingKeys = data.schema_keys.filter(k => !data.config_keys.includes(k));
|
|
if (extraKeys.length > 0) {
|
|
errorMessage += '\n\nExtra keys (not in schema): ' + extraKeys.join(', ');
|
|
}
|
|
if (missingKeys.length > 0) {
|
|
errorMessage += '\n\nMissing keys (in schema): ' + missingKeys.join(', ');
|
|
}
|
|
}
|
|
this.showNotification(errorMessage, 'error');
|
|
console.error('Config save failed - Full error response:', JSON.stringify(data, null, 2));
|
|
} else {
|
|
this.showNotification('Configuration saved successfully', 'success');
|
|
// Reload plugin config to reflect changes
|
|
await this.loadPluginConfig(pluginId);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving plugin config:', error);
|
|
this.showNotification('Error saving configuration: ' + error.message, 'error');
|
|
}
|
|
},
|
|
|
|
formatCommitInfo(commit, branch) {
|
|
// Handle null, undefined, or empty string
|
|
const commitStr = (commit && String(commit).trim()) || '';
|
|
const branchStr = (branch && String(branch).trim()) || '';
|
|
|
|
if (!commitStr && !branchStr) return 'Unknown';
|
|
|
|
const shortCommit = commitStr.length >= 7 ? commitStr.substring(0, 7) : commitStr;
|
|
|
|
if (branchStr && shortCommit) {
|
|
return `${branchStr} · ${shortCommit}`;
|
|
}
|
|
if (branchStr) {
|
|
return branchStr;
|
|
}
|
|
if (shortCommit) {
|
|
return shortCommit;
|
|
}
|
|
return 'Unknown';
|
|
},
|
|
|
|
formatDateInfo(dateString) {
|
|
// Handle null, undefined, or empty string
|
|
if (!dateString || !String(dateString).trim()) return 'Unknown';
|
|
|
|
try {
|
|
const date = new Date(dateString);
|
|
// Check if date is valid
|
|
if (isNaN(date.getTime())) {
|
|
return 'Unknown';
|
|
}
|
|
|
|
const now = new Date();
|
|
const diffTime = Math.abs(now - date);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays < 1) {
|
|
return 'Today';
|
|
} else if (diffDays < 2) {
|
|
return 'Yesterday';
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays} days ago`;
|
|
} else if (diffDays < 30) {
|
|
const weeks = Math.floor(diffDays / 7);
|
|
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
|
} else {
|
|
// Return formatted date for older items
|
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
}
|
|
} catch (e) {
|
|
console.error('Error formatting date:', e, dateString);
|
|
return 'Unknown';
|
|
}
|
|
}
|
|
};
|
|
|
|
// Update window.app to return full implementation
|
|
window.app = function() {
|
|
return fullImplementation;
|
|
};
|
|
|
|
// If Alpine is already initialized, update the existing component immediately
|
|
if (window.Alpine) {
|
|
// Use requestAnimationFrame for immediate execution without blocking
|
|
requestAnimationFrame(() => {
|
|
const appElement = document.querySelector('[x-data]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
const existingComponent = appElement._x_dataStack[0];
|
|
// Replace all properties and methods from full implementation
|
|
Object.keys(fullImplementation).forEach(key => {
|
|
existingComponent[key] = fullImplementation[key];
|
|
});
|
|
// Call init to load plugins and set up watchers (only if not already initialized)
|
|
if (typeof existingComponent.init === 'function' && !existingComponent._initialized) {
|
|
existingComponent.init();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return fullImplementation;
|
|
}
|
|
|
|
// Make app() available globally
|
|
window.app = app;
|
|
|
|
// ===== DEPRECATED: Plugin Configuration Functions (Global Access) =====
|
|
// These functions are no longer the primary method for loading plugin configs.
|
|
// Plugin configuration forms are now rendered server-side via HTMX.
|
|
// See: /v3/partials/plugin-config/<plugin_id> for the new implementation.
|
|
// Kept for backwards compatibility with any remaining client-side code.
|
|
window.PluginConfigHelpers = {
|
|
loadPluginConfig: async function(pluginId, componentContext) {
|
|
// This function can be called from inline components
|
|
// It loads config, schema, and updates the component context
|
|
if (!componentContext) {
|
|
console.error('loadPluginConfig requires component context');
|
|
return;
|
|
}
|
|
|
|
console.log('Loading config for plugin:', pluginId);
|
|
componentContext.loading = true;
|
|
|
|
try {
|
|
// Load config, schema, and installed plugins (for web_ui_actions) in parallel
|
|
let configData, schemaData, pluginsData;
|
|
|
|
if (window.PluginAPI && window.PluginAPI.batch) {
|
|
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 {
|
|
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') {
|
|
componentContext.config = configData.data;
|
|
} else {
|
|
console.warn('Config API returned non-success status:', configData);
|
|
// Set defaults if config failed to load
|
|
componentContext.config = { enabled: true, display_duration: 30 };
|
|
}
|
|
|
|
if (schemaData && schemaData.status === 'success') {
|
|
componentContext.schema = schemaData.data.schema || {};
|
|
} else {
|
|
console.warn('Schema API returned non-success status:', schemaData);
|
|
// Set empty schema as fallback
|
|
componentContext.schema = {};
|
|
}
|
|
|
|
if (pluginsData && pluginsData.status === 'success' && pluginsData.data && pluginsData.data.plugins) {
|
|
const pluginInfo = pluginsData.data.plugins.find(p => p.id === pluginId);
|
|
componentContext.webUiActions = pluginInfo ? (pluginInfo.web_ui_actions || []) : [];
|
|
} else {
|
|
console.warn('Plugins API returned non-success status:', pluginsData);
|
|
componentContext.webUiActions = [];
|
|
}
|
|
|
|
console.log('Loaded config, schema, and actions for', pluginId);
|
|
} catch (error) {
|
|
console.error('Error loading plugin config:', error);
|
|
componentContext.config = { enabled: true, display_duration: 30 };
|
|
componentContext.schema = {};
|
|
componentContext.webUiActions = [];
|
|
} finally {
|
|
componentContext.loading = false;
|
|
}
|
|
},
|
|
|
|
generateConfigForm: function(pluginId, config, schema, webUiActions, componentContext) {
|
|
// Try to get the app component
|
|
let appComponent = null;
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
appComponent = appElement._x_dataStack[0];
|
|
}
|
|
}
|
|
|
|
// If we have access to the app component, use its method
|
|
if (appComponent && typeof appComponent.generateConfigForm === 'function') {
|
|
return appComponent.generateConfigForm(pluginId, config, schema, webUiActions);
|
|
}
|
|
|
|
// Fallback: return loading message if function not available
|
|
if (!pluginId || !config) {
|
|
return '<div class="text-gray-500">Loading configuration...</div>';
|
|
}
|
|
return '<div class="text-gray-500">Configuration form not available yet...</div>';
|
|
},
|
|
|
|
savePluginConfig: async function(pluginId, event, componentContext) {
|
|
// Try to get the app component
|
|
let appComponent = null;
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
appComponent = appElement._x_dataStack[0];
|
|
}
|
|
}
|
|
|
|
// If we have access to the app component, use its method
|
|
if (appComponent && typeof appComponent.savePluginConfig === 'function') {
|
|
return appComponent.savePluginConfig(pluginId, event);
|
|
}
|
|
|
|
console.error('savePluginConfig not available');
|
|
throw new Error('Save configuration method not available');
|
|
}
|
|
};
|
|
|
|
// ===== Nested Section Toggle =====
|
|
window.toggleNestedSection = function(sectionId, event) {
|
|
// Prevent event bubbling if event is provided
|
|
if (event) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
}
|
|
|
|
const content = document.getElementById(sectionId);
|
|
const icon = document.getElementById(sectionId + '-icon');
|
|
|
|
if (!content || !icon) {
|
|
console.warn('[toggleNestedSection] Content or icon not found for:', sectionId);
|
|
return;
|
|
}
|
|
|
|
// Check if content is currently collapsed (has 'collapsed' class or display:none)
|
|
const isCollapsed = content.classList.contains('collapsed') ||
|
|
content.style.display === 'none' ||
|
|
(content.style.display === '' && !content.classList.contains('expanded'));
|
|
|
|
if (isCollapsed) {
|
|
// Expand the section
|
|
content.classList.remove('collapsed');
|
|
content.classList.add('expanded');
|
|
content.style.display = 'block';
|
|
content.style.overflow = 'hidden'; // Prevent content jumping during animation
|
|
|
|
// CRITICAL FIX: Use setTimeout to ensure browser has time to layout the element
|
|
// When element goes from display:none to display:block, scrollHeight might be 0
|
|
// We need to wait for the browser to calculate the layout
|
|
setTimeout(() => {
|
|
// Force reflow to ensure transition works
|
|
void content.offsetHeight;
|
|
|
|
// Now measure the actual content height after layout
|
|
const scrollHeight = content.scrollHeight;
|
|
if (scrollHeight > 0) {
|
|
content.style.maxHeight = scrollHeight + 'px';
|
|
} else {
|
|
// Fallback: if scrollHeight is still 0, try measuring again after a brief delay
|
|
setTimeout(() => {
|
|
const retryHeight = content.scrollHeight;
|
|
content.style.maxHeight = retryHeight > 0 ? retryHeight + 'px' : '500px';
|
|
}, 10);
|
|
}
|
|
}, 10);
|
|
|
|
icon.classList.remove('fa-chevron-right');
|
|
icon.classList.add('fa-chevron-down');
|
|
|
|
// After animation completes, remove max-height constraint to allow natural expansion
|
|
setTimeout(() => {
|
|
if (content.classList.contains('expanded') && !content.classList.contains('collapsed')) {
|
|
content.style.maxHeight = 'none';
|
|
content.style.overflow = '';
|
|
}
|
|
}, 320); // Slightly longer than transition duration
|
|
} else {
|
|
// Collapse the section
|
|
content.classList.add('collapsed');
|
|
content.classList.remove('expanded');
|
|
content.style.overflow = 'hidden'; // Prevent content jumping during animation
|
|
|
|
// Set max-height to current scroll height first (required for smooth animation)
|
|
const currentHeight = content.scrollHeight;
|
|
content.style.maxHeight = currentHeight + 'px';
|
|
|
|
// Force reflow to apply the height
|
|
void content.offsetHeight;
|
|
|
|
// Then animate to 0
|
|
setTimeout(() => {
|
|
content.style.maxHeight = '0';
|
|
}, 10);
|
|
|
|
// Hide after transition completes
|
|
setTimeout(() => {
|
|
if (content.classList.contains('collapsed')) {
|
|
content.style.display = 'none';
|
|
content.style.overflow = '';
|
|
}
|
|
}, 320); // Match the CSS transition duration + small buffer
|
|
|
|
icon.classList.remove('fa-chevron-down');
|
|
icon.classList.add('fa-chevron-right');
|
|
}
|
|
};
|
|
|
|
// ===== Display Preview Functions (from v2) =====
|
|
|
|
function updateDisplayPreview(data) {
|
|
const preview = document.getElementById('displayPreview');
|
|
const stage = document.getElementById('previewStage');
|
|
const img = document.getElementById('displayImage');
|
|
const canvas = document.getElementById('gridOverlay');
|
|
const ledCanvas = document.getElementById('ledCanvas');
|
|
const placeholder = document.getElementById('displayPlaceholder');
|
|
|
|
if (!stage || !img || !placeholder) return; // Not on overview page
|
|
|
|
if (data.image) {
|
|
// Show stage
|
|
placeholder.style.display = 'none';
|
|
stage.style.display = 'inline-block';
|
|
|
|
// Current scale from slider
|
|
const scale = parseInt(document.getElementById('scaleRange')?.value || '8');
|
|
|
|
// Update image and meta label
|
|
img.style.imageRendering = 'pixelated';
|
|
img.onload = () => {
|
|
renderLedDots();
|
|
};
|
|
img.src = `data:image/png;base64,${data.image}`;
|
|
|
|
const meta = document.getElementById('previewMeta');
|
|
if (meta) {
|
|
meta.textContent = `${data.width || 128} x ${data.height || 64} @ ${scale}x`;
|
|
}
|
|
|
|
// Size the canvases to match
|
|
const width = (data.width || 128) * scale;
|
|
const height = (data.height || 64) * scale;
|
|
img.style.width = width + 'px';
|
|
img.style.height = height + 'px';
|
|
ledCanvas.width = width;
|
|
ledCanvas.height = height;
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
drawGrid(canvas, data.width || 128, data.height || 64, scale);
|
|
renderLedDots();
|
|
} else {
|
|
stage.style.display = 'none';
|
|
placeholder.style.display = 'block';
|
|
placeholder.innerHTML = `<div class="text-center text-gray-400 py-8">
|
|
<i class="fas fa-exclamation-triangle text-4xl mb-3"></i>
|
|
<p>No display data available</p>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function renderLedDots() {
|
|
const ledCanvas = document.getElementById('ledCanvas');
|
|
const img = document.getElementById('displayImage');
|
|
const toggle = document.getElementById('toggleLedDots');
|
|
|
|
if (!ledCanvas || !img || !toggle) {
|
|
return;
|
|
}
|
|
|
|
const show = toggle.checked;
|
|
|
|
if (!show) {
|
|
// LED mode OFF: Show image, hide canvas
|
|
img.style.visibility = 'visible';
|
|
ledCanvas.style.display = 'none';
|
|
const ctx = ledCanvas.getContext('2d');
|
|
ctx.clearRect(0, 0, ledCanvas.width, ledCanvas.height);
|
|
return;
|
|
}
|
|
|
|
// LED mode ON: Hide image (but keep layout space), show only dots on canvas
|
|
img.style.visibility = 'hidden';
|
|
ledCanvas.style.display = 'block';
|
|
|
|
const scale = parseInt(document.getElementById('scaleRange')?.value || '8');
|
|
const fillPct = parseInt(document.getElementById('dotFillRange')?.value || '75');
|
|
const dotRadius = Math.max(1, Math.floor((scale * fillPct) / 200)); // radius in px
|
|
|
|
const ctx = ledCanvas.getContext('2d', { willReadFrequently: true });
|
|
ctx.clearRect(0, 0, ledCanvas.width, ledCanvas.height);
|
|
|
|
// Create an offscreen canvas to sample pixel colors
|
|
const off = document.createElement('canvas');
|
|
const logicalWidth = Math.floor(ledCanvas.width / scale);
|
|
const logicalHeight = Math.floor(ledCanvas.height / scale);
|
|
off.width = logicalWidth;
|
|
off.height = logicalHeight;
|
|
const offCtx = off.getContext('2d', { willReadFrequently: true });
|
|
|
|
// Draw the current image scaled down to logical LEDs to sample colors
|
|
try {
|
|
offCtx.drawImage(img, 0, 0, logicalWidth, logicalHeight);
|
|
} catch (e) {
|
|
console.error('Failed to draw image to offscreen canvas:', e);
|
|
return;
|
|
}
|
|
|
|
// Fill canvas with black background (LED matrix bezel)
|
|
ctx.fillStyle = 'rgb(0, 0, 0)';
|
|
ctx.fillRect(0, 0, ledCanvas.width, ledCanvas.height);
|
|
|
|
// Draw circular dots for each LED pixel
|
|
let drawn = 0;
|
|
for (let y = 0; y < logicalHeight; y++) {
|
|
for (let x = 0; x < logicalWidth; x++) {
|
|
const pixel = offCtx.getImageData(x, y, 1, 1).data;
|
|
const r = pixel[0], g = pixel[1], b = pixel[2], a = pixel[3];
|
|
|
|
// Skip fully transparent or black pixels to reduce overdraw
|
|
if (a === 0 || (r|g|b) === 0) continue;
|
|
|
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
|
const cx = Math.floor(x * scale + scale / 2);
|
|
const cy = Math.floor(y * scale + scale / 2);
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, dotRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
drawn++;
|
|
}
|
|
}
|
|
|
|
// If nothing was drawn (e.g., image not ready), hide overlay to show base image
|
|
if (drawn === 0) {
|
|
ledCanvas.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function drawGrid(canvas, pixelWidth, pixelHeight, scale) {
|
|
const toggle = document.getElementById('toggleGrid');
|
|
if (!toggle || !toggle.checked) {
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
return;
|
|
}
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
|
|
ctx.lineWidth = 1;
|
|
|
|
for (let x = 0; x <= pixelWidth; x++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x * scale, 0);
|
|
ctx.lineTo(x * scale, pixelHeight * scale);
|
|
ctx.stroke();
|
|
}
|
|
|
|
for (let y = 0; y <= pixelHeight; y++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y * scale);
|
|
ctx.lineTo(pixelWidth * scale, y * scale);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function takeScreenshot() {
|
|
const img = document.getElementById('displayImage');
|
|
if (img && img.src) {
|
|
const link = document.createElement('a');
|
|
link.download = `led_matrix_${new Date().getTime()}.png`;
|
|
link.href = img.src;
|
|
link.click();
|
|
}
|
|
}
|
|
|
|
// ===== Plugin Management Functions =====
|
|
|
|
// Make togglePluginFromTab global so Alpine.js can access it
|
|
window.togglePluginFromTab = async function(pluginId, enabled) {
|
|
try {
|
|
const response = await fetch('/api/v3/plugins/toggle', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId, enabled })
|
|
});
|
|
const data = await response.json();
|
|
|
|
showNotification(data.message, data.status);
|
|
|
|
if (data.status === 'success') {
|
|
// Update the plugin in window.installedPlugins
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin) {
|
|
plugin.enabled = enabled;
|
|
}
|
|
}
|
|
|
|
// Refresh the plugin list to ensure both management page and config page stay in sync
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
}
|
|
} else {
|
|
// Revert the toggle if API call failed
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin) {
|
|
plugin.enabled = !enabled;
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
showNotification('Error toggling plugin: ' + error.message, 'error');
|
|
// Revert on error
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin) {
|
|
plugin.enabled = !enabled;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to get schema property type for a field path
|
|
function getSchemaPropertyType(schema, path) {
|
|
if (!schema || !schema.properties) return null;
|
|
|
|
const parts = path.split('.');
|
|
let current = schema.properties;
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i];
|
|
if (current && current[part]) {
|
|
if (i === parts.length - 1) {
|
|
return current[part];
|
|
} else if (current[part].properties) {
|
|
current = current[part].properties;
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Helper function to escape CSS selector special characters
|
|
function escapeCssSelector(str) {
|
|
if (typeof str !== 'string') {
|
|
str = String(str);
|
|
}
|
|
// Use CSS.escape() when available (handles unicode, leading digits, and edge cases)
|
|
if (typeof CSS !== 'undefined' && CSS.escape) {
|
|
return CSS.escape(str);
|
|
}
|
|
// Fallback to regex-based escaping for older browsers
|
|
// First, handle leading digits and whitespace (must be done before regex)
|
|
let escaped = str;
|
|
let hasLeadingHexEscape = false;
|
|
if (escaped.length > 0) {
|
|
const firstChar = escaped[0];
|
|
const firstCode = firstChar.charCodeAt(0);
|
|
|
|
// Escape leading digit (0-9: U+0030-U+0039)
|
|
if (firstCode >= 0x30 && firstCode <= 0x39) {
|
|
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
|
|
escaped = '\\' + hex + ' ' + escaped.slice(1);
|
|
hasLeadingHexEscape = true;
|
|
}
|
|
// Escape leading whitespace (space: U+0020, tab: U+0009, etc.)
|
|
else if (/\s/.test(firstChar)) {
|
|
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
|
|
escaped = '\\' + hex + ' ' + escaped.slice(1);
|
|
hasLeadingHexEscape = true;
|
|
}
|
|
}
|
|
|
|
// Escape special characters
|
|
escaped = escaped.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
|
|
|
|
// Escape internal spaces (replace spaces with \ ), but preserve space in hex escape
|
|
if (hasLeadingHexEscape) {
|
|
// Skip the first 6 characters (e.g., "\0030 ") when replacing spaces
|
|
escaped = escaped.slice(0, 6) + escaped.slice(6).replace(/ /g, '\\ ');
|
|
} else {
|
|
escaped = escaped.replace(/ /g, '\\ ');
|
|
}
|
|
|
|
return escaped;
|
|
}
|
|
|
|
async function savePluginConfig(pluginId) {
|
|
try {
|
|
console.log('Saving config for plugin:', pluginId);
|
|
|
|
// Load schema for type detection
|
|
let schema = {};
|
|
try {
|
|
const schemaResponse = await fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`);
|
|
const schemaData = await schemaResponse.json();
|
|
if (schemaData.status === 'success' && schemaData.data.schema) {
|
|
schema = schemaData.data.schema;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not load schema for type detection:', e);
|
|
}
|
|
|
|
// Find the form in the active plugin tab
|
|
// Alpine.js hides/shows elements with display:none, so we look for the currently visible one
|
|
const allForms = document.querySelectorAll('form[x-on\\:submit\\.prevent]');
|
|
console.log('Found forms:', allForms.length);
|
|
|
|
let form = null;
|
|
for (const f of allForms) {
|
|
const parent = f.closest('[x-show]');
|
|
if (parent && parent.style.display !== 'none' && parent.offsetParent !== null) {
|
|
form = f;
|
|
console.log('Found visible form');
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!form) {
|
|
throw new Error('Form not found for plugin ' + pluginId);
|
|
}
|
|
|
|
const formData = new FormData(form);
|
|
const flatConfig = {};
|
|
|
|
// First, collect all checkbox states (including unchecked ones)
|
|
// Unchecked checkboxes don't appear in FormData, so we need to iterate form elements
|
|
for (let i = 0; i < form.elements.length; i++) {
|
|
const element = form.elements[i];
|
|
const name = element.name;
|
|
|
|
// Skip elements without names or submit buttons
|
|
if (!name || element.type === 'submit' || element.type === 'button') {
|
|
continue;
|
|
}
|
|
|
|
// Handle checkboxes explicitly (both checked and unchecked)
|
|
if (element.type === 'checkbox') {
|
|
flatConfig[name] = element.checked;
|
|
}
|
|
// Handle radio buttons
|
|
else if (element.type === 'radio') {
|
|
if (element.checked) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
// Handle select elements (including multi-select)
|
|
else if (element.tagName === 'SELECT') {
|
|
if (element.multiple) {
|
|
// Multi-select: get all selected options
|
|
const selectedValues = Array.from(element.selectedOptions).map(opt => opt.value);
|
|
flatConfig[name] = selectedValues;
|
|
} else {
|
|
// Single select: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
// Handle textarea
|
|
else if (element.tagName === 'TEXTAREA') {
|
|
// Textarea: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now process FormData for other field types
|
|
for (const [key, value] of formData.entries()) {
|
|
// Skip checkboxes - we already handled them above
|
|
// Use querySelector to reliably find element by name (handles dot notation)
|
|
const escapedKey = escapeCssSelector(key);
|
|
const element = form.querySelector(`[name="${escapedKey}"]`);
|
|
if (element && element.type === 'checkbox') {
|
|
continue; // Already processed
|
|
}
|
|
// Skip multi-select - we already handled them above
|
|
if (element && element.tagName === 'SELECT' && element.multiple) {
|
|
continue; // Already processed
|
|
}
|
|
|
|
// Get schema property type if available
|
|
const propSchema = getSchemaPropertyType(schema, key);
|
|
const propType = propSchema ? propSchema.type : null;
|
|
|
|
// Handle based on schema type or field name patterns
|
|
if (propType === 'array') {
|
|
// Check if this is a file upload widget (JSON array in hidden input)
|
|
if (propSchema && propSchema['x-widget'] === 'file-upload') {
|
|
try {
|
|
// Unescape HTML entities that were escaped when setting the value
|
|
let unescapedValue = value;
|
|
if (typeof value === 'string') {
|
|
// Reverse the HTML escaping: " -> ", ' -> ', & -> &
|
|
unescapedValue = value
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
try {
|
|
const jsonValue = JSON.parse(unescapedValue);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[key] = jsonValue;
|
|
console.log(`File upload array field ${key}: parsed JSON array with ${jsonValue.length} items`);
|
|
} else {
|
|
// Fallback to empty array
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Failed to parse JSON for file upload field ${key}:`, e, 'Value:', value);
|
|
// Fallback to empty array
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e) {
|
|
// Not valid JSON, use empty array or try comma-separated
|
|
if (value && value.trim()) {
|
|
const arrayValue = value.split(',').map(v => v.trim()).filter(v => v);
|
|
flatConfig[key] = arrayValue;
|
|
} else {
|
|
flatConfig[key] = [];
|
|
}
|
|
}
|
|
} else {
|
|
// Regular array: convert comma-separated string to array
|
|
const arrayValue = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[key] = arrayValue;
|
|
}
|
|
} else if (propType === 'integer') {
|
|
const numValue = parseInt(value, 10);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
} else if (propType === 'number') {
|
|
const numValue = parseFloat(value);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
} else if (propType === 'boolean') {
|
|
// Boolean from FormData (shouldn't happen for checkboxes, but handle it)
|
|
flatConfig[key] = value === 'on' || value === 'true' || value === true;
|
|
} else {
|
|
// String or other types
|
|
// Check if it's a number field by name pattern (fallback if no schema)
|
|
if (!propType && (key.includes('duration') || key.includes('interval') ||
|
|
key.includes('timeout') || key.includes('teams') || key.includes('fps') ||
|
|
key.includes('bits') || key.includes('nanoseconds') || key.includes('hz'))) {
|
|
const numValue = parseFloat(value);
|
|
if (!isNaN(numValue)) {
|
|
flatConfig[key] = Number.isInteger(numValue) ? parseInt(value, 10) : numValue;
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle unchecked checkboxes using schema (if available)
|
|
if (schema && schema.properties) {
|
|
const collectBooleanFields = (props, prefix = '') => {
|
|
const boolFields = [];
|
|
for (const [key, prop] of Object.entries(props)) {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
if (prop.type === 'boolean') {
|
|
boolFields.push(fullKey);
|
|
} else if (prop.type === 'object' && prop.properties) {
|
|
boolFields.push(...collectBooleanFields(prop.properties, fullKey));
|
|
}
|
|
}
|
|
return boolFields;
|
|
};
|
|
|
|
const allBoolFields = collectBooleanFields(schema.properties);
|
|
allBoolFields.forEach(key => {
|
|
if (!(key in flatConfig)) {
|
|
flatConfig[key] = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Convert dot notation to nested object
|
|
const dotToNested = (obj) => {
|
|
const result = {};
|
|
for (const key in obj) {
|
|
const parts = key.split('.');
|
|
let current = result;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
if (!current[parts[i]]) {
|
|
current[parts[i]] = {};
|
|
}
|
|
current = current[parts[i]];
|
|
}
|
|
current[parts[parts.length - 1]] = obj[key];
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const config = dotToNested(flatConfig);
|
|
|
|
console.log('Saving config for', pluginId, ':', config);
|
|
console.log('Flat config before nesting:', flatConfig);
|
|
|
|
// Save to backend
|
|
const response = await fetch('/api/v3/plugins/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId, config })
|
|
});
|
|
|
|
let data;
|
|
try {
|
|
data = await response.json();
|
|
} catch (e) {
|
|
throw new Error(`Failed to parse server response: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
if (!response.ok || data.status !== 'success') {
|
|
let errorMessage = data.message || 'Failed to save configuration';
|
|
if (data.validation_errors && Array.isArray(data.validation_errors)) {
|
|
errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n');
|
|
}
|
|
throw new Error(errorMessage);
|
|
} else {
|
|
showNotification(`Configuration saved for ${pluginId}`, 'success');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error saving plugin configuration:', error);
|
|
showNotification('Error saving plugin configuration: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Notification helper function
|
|
// Fix invalid number inputs before form submission
|
|
// This prevents "invalid form control is not focusable" errors
|
|
window.fixInvalidNumberInputs = function(form) {
|
|
if (!form) return;
|
|
const allInputs = form.querySelectorAll('input[type="number"]');
|
|
allInputs.forEach(input => {
|
|
const min = parseFloat(input.getAttribute('min'));
|
|
const max = parseFloat(input.getAttribute('max'));
|
|
const value = parseFloat(input.value);
|
|
|
|
if (!isNaN(value)) {
|
|
if (!isNaN(min) && value < min) {
|
|
input.value = min;
|
|
} else if (!isNaN(max) && value > max) {
|
|
input.value = max;
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
// showNotification is provided by notification.js widget
|
|
// This fallback is only used if the widget hasn't loaded yet
|
|
if (typeof window.showNotification !== 'function') {
|
|
window.showNotification = function(message, type = 'info') {
|
|
console.log(`[${type.toUpperCase()}]`, message);
|
|
const notification = document.createElement('div');
|
|
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${
|
|
type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500'
|
|
} text-white`;
|
|
notification.textContent = message;
|
|
document.body.appendChild(notification);
|
|
setTimeout(() => { notification.style.opacity = '0'; setTimeout(() => notification.remove(), 500); }, 3000);
|
|
};
|
|
}
|
|
|
|
// Section toggle function - already defined earlier, but ensure it's not overwritten
|
|
// (duplicate definition removed - function is defined in early script block above)
|
|
|
|
// Plugin config handler functions (idempotent initialization)
|
|
if (!window.__pluginConfigHandlersInitialized) {
|
|
window.__pluginConfigHandlersInitialized = true;
|
|
|
|
// Initialize state on window object
|
|
window.pluginConfigRefreshInProgress = window.pluginConfigRefreshInProgress || new Set();
|
|
|
|
// Validate plugin config form and show helpful error messages
|
|
window.validatePluginConfigForm = function(form, pluginId) {
|
|
// Check HTML5 validation
|
|
if (!form.checkValidity()) {
|
|
// Find all invalid fields
|
|
const invalidFields = Array.from(form.querySelectorAll(':invalid'));
|
|
const errors = [];
|
|
let firstInvalidField = null;
|
|
|
|
invalidFields.forEach((field, index) => {
|
|
// Build error message
|
|
let fieldName = field.name || field.id || 'field';
|
|
// Make field name more readable (remove plugin ID prefix, convert dots/underscores)
|
|
fieldName = fieldName.replace(new RegExp('^' + pluginId + '-'), '')
|
|
.replace(/\./g, ' → ')
|
|
.replace(/_/g, ' ')
|
|
.replace(/\b\w/g, l => l.toUpperCase()); // Capitalize words
|
|
|
|
let errorMsg = field.validationMessage || 'Invalid value';
|
|
|
|
// Get more specific error message based on validation state
|
|
if (field.validity.valueMissing) {
|
|
errorMsg = 'This field is required';
|
|
} else if (field.validity.rangeUnderflow) {
|
|
errorMsg = `Value must be at least ${field.min || 'the minimum'}`;
|
|
} else if (field.validity.rangeOverflow) {
|
|
errorMsg = `Value must be at most ${field.max || 'the maximum'}`;
|
|
} else if (field.validity.stepMismatch) {
|
|
errorMsg = `Value must be a multiple of ${field.step || 1}`;
|
|
} else if (field.validity.typeMismatch) {
|
|
errorMsg = 'Invalid format (e.g., text in number field)';
|
|
} else if (field.validity.patternMismatch) {
|
|
errorMsg = 'Value does not match required pattern';
|
|
} else if (field.validity.tooShort) {
|
|
errorMsg = `Value must be at least ${field.minLength} characters`;
|
|
} else if (field.validity.tooLong) {
|
|
errorMsg = `Value must be at most ${field.maxLength} characters`;
|
|
} else if (field.validity.badInput) {
|
|
errorMsg = 'Invalid input type';
|
|
}
|
|
|
|
errors.push(`${fieldName}: ${errorMsg}`);
|
|
|
|
// Track first invalid field for focusing
|
|
if (index === 0) {
|
|
firstInvalidField = field;
|
|
}
|
|
|
|
// If field is in a collapsed section, expand it
|
|
const nestedContent = field.closest('.nested-content');
|
|
if (nestedContent && nestedContent.classList.contains('hidden')) {
|
|
// Find the toggle button for this section
|
|
const sectionId = nestedContent.id;
|
|
if (sectionId) {
|
|
// Try multiple selectors to find the toggle button
|
|
const toggleBtn = document.querySelector(`button[aria-controls="${sectionId}"], button[onclick*="${sectionId}"], [data-toggle-section="${sectionId}"]`) ||
|
|
nestedContent.previousElementSibling?.querySelector('button');
|
|
if (toggleBtn && toggleBtn.onclick) {
|
|
toggleBtn.click(); // Expand the section
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Focus and scroll to first invalid field after a brief delay
|
|
// (allows collapsed sections to expand first)
|
|
setTimeout(() => {
|
|
if (firstInvalidField) {
|
|
firstInvalidField.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
firstInvalidField.focus();
|
|
}
|
|
}, 200);
|
|
|
|
// Show error notification with details
|
|
if (errors.length > 0) {
|
|
// Format error message nicely
|
|
const errorList = errors.slice(0, 5).join('\n'); // Show first 5 errors
|
|
const moreErrors = errors.length > 5 ? `\n... and ${errors.length - 5} more error(s)` : '';
|
|
const errorMessage = `Validation failed:\n${errorList}${moreErrors}`;
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(errorMessage, 'error');
|
|
} else {
|
|
alert(errorMessage); // Fallback if showNotification not available
|
|
}
|
|
|
|
// Also log to console for debugging
|
|
console.error('Form validation errors:', errors);
|
|
}
|
|
|
|
// Report validation failure to browser (shows native validation tooltips)
|
|
form.reportValidity();
|
|
|
|
return false; // Prevent form submission
|
|
}
|
|
|
|
return true; // Validation passed
|
|
};
|
|
|
|
// Handle config save response with detailed error logging
|
|
window.handleConfigSave = function(event, pluginId) {
|
|
const btn = event.target.querySelector('[type=submit]');
|
|
if (btn) btn.disabled = false;
|
|
|
|
const xhr = event.detail.xhr;
|
|
const status = xhr?.status || 0;
|
|
|
|
// Check if request was successful (2xx status codes)
|
|
if (status >= 200 && status < 300) {
|
|
// Try to get message from response JSON
|
|
let message = 'Configuration saved successfully!';
|
|
try {
|
|
if (xhr?.responseJSON?.message) {
|
|
message = xhr.responseJSON.message;
|
|
} else if (xhr?.responseText) {
|
|
const responseData = JSON.parse(xhr.responseText);
|
|
message = responseData.message || message;
|
|
}
|
|
} catch (e) {
|
|
// Use default message if parsing fails
|
|
}
|
|
showNotification(message, 'success');
|
|
} else {
|
|
// Request failed - log detailed error information
|
|
console.error('Config save failed:', {
|
|
status: status,
|
|
statusText: xhr?.statusText,
|
|
responseText: xhr?.responseText
|
|
});
|
|
|
|
// Try to parse error response
|
|
let errorMessage = 'Failed to save configuration';
|
|
try {
|
|
if (xhr?.responseJSON) {
|
|
const errorData = xhr.responseJSON;
|
|
errorMessage = errorData.message || errorData.details || errorMessage;
|
|
if (errorData.validation_errors) {
|
|
errorMessage += ': ' + errorData.validation_errors.join(', ');
|
|
}
|
|
} else if (xhr?.responseText) {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
errorMessage = errorData.message || errorData.details || errorMessage;
|
|
if (errorData.validation_errors) {
|
|
errorMessage += ': ' + errorData.validation_errors.join(', ');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// If parsing fails, use status text
|
|
errorMessage = xhr?.statusText || errorMessage;
|
|
}
|
|
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
};
|
|
|
|
// Handle toggle response
|
|
window.handleToggleResponse = function(event, pluginId) {
|
|
const xhr = event.detail.xhr;
|
|
const status = xhr?.status || 0;
|
|
|
|
if (status >= 200 && status < 300) {
|
|
// Update UI in place instead of refreshing to avoid duplication
|
|
const checkbox = document.getElementById(`plugin-enabled-${pluginId}`);
|
|
const label = checkbox?.nextElementSibling;
|
|
|
|
if (checkbox && label) {
|
|
const isEnabled = checkbox.checked;
|
|
label.textContent = isEnabled ? 'Enabled' : 'Disabled';
|
|
label.className = `ml-2 text-sm ${isEnabled ? 'text-green-600' : 'text-gray-500'}`;
|
|
}
|
|
|
|
// Try to get message from response
|
|
let message = 'Plugin status updated';
|
|
try {
|
|
if (xhr?.responseJSON?.message) {
|
|
message = xhr.responseJSON.message;
|
|
} else if (xhr?.responseText) {
|
|
const responseData = JSON.parse(xhr.responseText);
|
|
message = responseData.message || message;
|
|
}
|
|
} catch (e) {
|
|
// Use default message
|
|
}
|
|
showNotification(message, 'success');
|
|
} else {
|
|
// Revert checkbox state on error
|
|
const checkbox = document.getElementById(`plugin-enabled-${pluginId}`);
|
|
if (checkbox) {
|
|
checkbox.checked = !checkbox.checked;
|
|
}
|
|
|
|
// Try to get error message from response
|
|
let errorMessage = 'Failed to update plugin status';
|
|
try {
|
|
if (xhr?.responseJSON?.message) {
|
|
errorMessage = xhr.responseJSON.message;
|
|
} else if (xhr?.responseText) {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
errorMessage = errorData.message || errorData.details || errorMessage;
|
|
}
|
|
} catch (e) {
|
|
// Use default message
|
|
}
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
};
|
|
|
|
// Handle plugin update response
|
|
window.handlePluginUpdate = function(event, pluginId) {
|
|
const xhr = event.detail.xhr;
|
|
const status = xhr?.status || 0;
|
|
|
|
// Check if request was successful (2xx status)
|
|
if (status >= 200 && status < 300) {
|
|
// Try to parse the response to get the actual message from server
|
|
let message = 'Plugin updated successfully';
|
|
|
|
if (xhr && xhr.responseText) {
|
|
try {
|
|
const data = JSON.parse(xhr.responseText);
|
|
// Use the server's message, ensuring it says "update" not "save"
|
|
message = data.message || message;
|
|
// Ensure message is about updating, not saving
|
|
if (message.toLowerCase().includes('save') && !message.toLowerCase().includes('update')) {
|
|
message = message.replace(/save/i, 'update');
|
|
}
|
|
} catch (e) {
|
|
// If parsing fails, use default message
|
|
console.warn('Could not parse update response:', e);
|
|
}
|
|
}
|
|
|
|
showNotification(message, 'success');
|
|
} else {
|
|
console.error('Plugin update failed:', {
|
|
status: status,
|
|
statusText: xhr?.statusText,
|
|
responseText: xhr?.responseText
|
|
});
|
|
|
|
// Try to parse error response for better error message
|
|
let errorMessage = 'Failed to update plugin';
|
|
if (xhr?.responseText) {
|
|
try {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
errorMessage = errorData.message || errorMessage;
|
|
} catch (e) {
|
|
// If parsing fails, use default
|
|
}
|
|
}
|
|
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
};
|
|
|
|
// Refresh plugin config (with duplicate prevention)
|
|
window.refreshPluginConfig = function(pluginId) {
|
|
// Prevent concurrent refreshes
|
|
if (window.pluginConfigRefreshInProgress.has(pluginId)) {
|
|
return;
|
|
}
|
|
|
|
const container = document.getElementById(`plugin-config-${pluginId}`);
|
|
if (container && window.htmx) {
|
|
window.pluginConfigRefreshInProgress.add(pluginId);
|
|
|
|
// Clear container first, then reload
|
|
container.innerHTML = '';
|
|
window.htmx.ajax('GET', `/v3/partials/plugin-config/${pluginId}`, {
|
|
target: container,
|
|
swap: 'innerHTML'
|
|
});
|
|
|
|
// Clear flag after delay
|
|
setTimeout(() => {
|
|
window.pluginConfigRefreshInProgress.delete(pluginId);
|
|
}, 1000);
|
|
}
|
|
};
|
|
|
|
// Plugin action handlers
|
|
window.runPluginOnDemand = function(pluginId) {
|
|
if (typeof window.openOnDemandModal === 'function') {
|
|
window.openOnDemandModal(pluginId);
|
|
} else {
|
|
showNotification('On-demand modal not available', 'error');
|
|
}
|
|
};
|
|
|
|
window.stopOnDemand = function() {
|
|
if (typeof window.requestOnDemandStop === 'function') {
|
|
window.requestOnDemandStop({});
|
|
} else {
|
|
showNotification('Stop function not available', 'error');
|
|
}
|
|
};
|
|
|
|
window.executePluginAction = function(pluginId, actionId) {
|
|
fetch(`/api/v3/plugins/action?plugin_id=${pluginId}&action_id=${actionId}`, {
|
|
method: 'POST'
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showNotification(data.message || 'Action executed', 'success');
|
|
} else {
|
|
showNotification(data.message || 'Action failed', 'error');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
showNotification('Failed to execute action', 'error');
|
|
});
|
|
};
|
|
}
|
|
|
|
function getAppComponent() {
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
return appElement._x_dataStack[0];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function updatePlugin(pluginId) {
|
|
try {
|
|
showNotification(`Updating ${pluginId}...`, 'info');
|
|
|
|
const response = await fetch('/api/v3/plugins/update', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
showNotification(data.message, data.status);
|
|
|
|
if (data.status === 'success') {
|
|
// Refresh the plugin list
|
|
const appComponent = getAppComponent();
|
|
if (appComponent && typeof appComponent.loadInstalledPlugins === 'function') {
|
|
await appComponent.loadInstalledPlugins();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showNotification('Error updating plugin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function updateAllPlugins() {
|
|
try {
|
|
const plugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : [];
|
|
|
|
if (!plugins.length) {
|
|
showNotification('No installed plugins to update.', 'warning');
|
|
return;
|
|
}
|
|
|
|
showNotification(`Checking ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} for updates...`, 'info');
|
|
|
|
let successCount = 0;
|
|
let failureCount = 0;
|
|
|
|
for (const plugin of plugins) {
|
|
const pluginId = plugin.id;
|
|
const pluginName = plugin.name || pluginId;
|
|
|
|
try {
|
|
const response = await fetch('/api/v3/plugins/update', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
});
|
|
|
|
const data = await response.json();
|
|
const status = data.status || 'info';
|
|
const message = data.message || `Checked ${pluginName}`;
|
|
|
|
showNotification(message, status);
|
|
|
|
if (status === 'success') {
|
|
successCount += 1;
|
|
} else {
|
|
failureCount += 1;
|
|
}
|
|
} catch (error) {
|
|
failureCount += 1;
|
|
showNotification(`Error updating ${pluginName}: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
const appComponent = getAppComponent();
|
|
if (appComponent && typeof appComponent.loadInstalledPlugins === 'function') {
|
|
await appComponent.loadInstalledPlugins();
|
|
}
|
|
|
|
if (failureCount === 0) {
|
|
showNotification(`Finished checking ${successCount} plugin${successCount === 1 ? '' : 's'} for updates.`, 'success');
|
|
} else {
|
|
showNotification(`Updated ${successCount} plugin${successCount === 1 ? '' : 's'} with ${failureCount} failure${failureCount === 1 ? '' : 's'}. Check logs for details.`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Bulk plugin update failed:', error);
|
|
showNotification('Failed to update all plugins: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
window.updateAllPlugins = updateAllPlugins;
|
|
|
|
|
|
async function uninstallPlugin(pluginId) {
|
|
try {
|
|
// Get plugin info from window.installedPlugins
|
|
const plugin = window.installedPlugins ? window.installedPlugins.find(p => p.id === pluginId) : null;
|
|
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
|
|
|
|
if (!confirm(`Are you sure you want to uninstall ${pluginName}?`)) {
|
|
return;
|
|
}
|
|
|
|
showNotification(`Uninstalling ${pluginName}...`, 'info');
|
|
|
|
const response = await fetch('/api/v3/plugins/uninstall', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
// Check if operation was queued
|
|
if (data.status === 'success' && data.data && data.data.operation_id) {
|
|
// Operation was queued, poll for completion
|
|
const operationId = data.data.operation_id;
|
|
showNotification(`Uninstall queued for ${pluginName}...`, 'info');
|
|
await pollUninstallOperation(operationId, pluginId, pluginName);
|
|
} else if (data.status === 'success') {
|
|
// Direct uninstall completed immediately
|
|
showNotification(data.message || `Plugin ${pluginName} uninstalled successfully`, 'success');
|
|
// Refresh the plugin list
|
|
await app.loadInstalledPlugins();
|
|
} else {
|
|
// Error response
|
|
showNotification(data.message || 'Failed to uninstall plugin', data.status || 'error');
|
|
}
|
|
} catch (error) {
|
|
showNotification('Error uninstalling plugin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts = 60, attempt = 0) {
|
|
if (attempt >= maxAttempts) {
|
|
showNotification(`Uninstall operation timed out for ${pluginName}`, 'error');
|
|
// Refresh plugin list to see actual state
|
|
await app.loadInstalledPlugins();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v3/plugins/operation/${operationId}`);
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success' && data.data) {
|
|
const operation = data.data;
|
|
const status = operation.status;
|
|
|
|
if (status === 'completed') {
|
|
// Operation completed successfully
|
|
showNotification(`Plugin ${pluginName} uninstalled successfully`, 'success');
|
|
await app.loadInstalledPlugins();
|
|
} else if (status === 'failed') {
|
|
// Operation failed
|
|
const errorMsg = operation.error || operation.message || `Failed to uninstall ${pluginName}`;
|
|
showNotification(errorMsg, 'error');
|
|
// Refresh plugin list to see actual state
|
|
await app.loadInstalledPlugins();
|
|
} else if (status === 'pending' || status === 'in_progress') {
|
|
// Still in progress, poll again
|
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
|
|
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
} else {
|
|
// Unknown status, poll again
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}
|
|
} else {
|
|
// Error getting operation status, try again
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error polling operation status:', error);
|
|
// On error, refresh plugin list to see actual state
|
|
await app.loadInstalledPlugins();
|
|
}
|
|
}
|
|
|
|
// Assign to window for global access
|
|
window.uninstallPlugin = uninstallPlugin;
|
|
|
|
async function refreshPlugin(pluginId) {
|
|
try {
|
|
// Switch to the plugin manager tab briefly to refresh
|
|
const originalTab = app.activeTab;
|
|
app.activeTab = 'plugins';
|
|
|
|
// Wait a moment then switch back
|
|
setTimeout(() => {
|
|
app.activeTab = originalTab;
|
|
app.showNotification(`Refreshed ${pluginId}`, 'success');
|
|
}, 100);
|
|
|
|
} catch (error) {
|
|
app.showNotification('Error refreshing plugin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Format commit information for display
|
|
function formatCommitInfo(commit, branch) {
|
|
if (!commit && !branch) return 'Unknown';
|
|
const shortCommit = commit ? String(commit).substring(0, 7) : '';
|
|
const branchText = branch ? String(branch) : '';
|
|
|
|
if (branchText && shortCommit) {
|
|
return `${branchText} · ${shortCommit}`;
|
|
}
|
|
if (branchText) {
|
|
return branchText;
|
|
}
|
|
if (shortCommit) {
|
|
return shortCommit;
|
|
}
|
|
return 'Latest';
|
|
}
|
|
|
|
// Format date for display
|
|
function formatDateInfo(dateString) {
|
|
if (!dateString) return 'Unknown';
|
|
|
|
try {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffTime = Math.abs(now - date);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays < 1) {
|
|
return 'Today';
|
|
} else if (diffDays < 2) {
|
|
return 'Yesterday';
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays} days ago`;
|
|
} else if (diffDays < 30) {
|
|
const weeks = Math.floor(diffDays / 7);
|
|
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
|
} else {
|
|
// Return formatted date for older items
|
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
}
|
|
} catch (e) {
|
|
return dateString;
|
|
}
|
|
}
|
|
|
|
// Make functions available to Alpine.js
|
|
window.formatCommitInfo = formatCommitInfo;
|
|
window.formatDateInfo = formatDateInfo;
|
|
|
|
</script>
|
|
|
|
<!-- Custom v3 JavaScript -->
|
|
<script src="{{ url_for('static', filename='v3/app.js') }}" defer></script>
|
|
|
|
<!-- Modular Plugin Management JavaScript -->
|
|
<!-- Load utilities first -->
|
|
<script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script>
|
|
|
|
<!-- Load core API client first (used by other modules) -->
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/api_client.js') }}" defer></script>
|
|
|
|
<!-- Load plugin management modules -->
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/store_manager.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/config_manager.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
|
|
|
|
<!-- Load config utilities -->
|
|
<script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script>
|
|
|
|
<!-- Widget Registry System -->
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/registry.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/base-widget.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/notification.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/file-upload.js') }}?v=20260307" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/checkbox-group.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/array-table.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/schedule-picker.js') }}" defer></script>
|
|
<!-- Basic input widgets -->
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/text-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/number-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/textarea.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/select-dropdown.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/font-selector.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/toggle-switch.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/radio-group.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/date-picker.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/slider.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/color-picker.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/email-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/url-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/password-input.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
|
|
|
|
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
|
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
|
|
|
|
<!-- Custom feeds table helper functions -->
|
|
<script>
|
|
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);
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<!-- On-Demand Modal (moved here from plugins.html so it's always available) -->
|
|
<div id="on-demand-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;">
|
|
<div class="modal-content p-6 w-full max-w-md bg-white rounded-lg shadow-lg">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h3>
|
|
<button id="close-on-demand-modal" class="text-gray-400 hover:text-gray-600">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<!-- Service Status Alert -->
|
|
<div id="on-demand-service-warning" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
|
<div class="flex items-start">
|
|
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2"></i>
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium text-yellow-800">Display service is not running</p>
|
|
<p class="text-xs text-yellow-700 mt-1">
|
|
The on-demand request will be queued but won't display until the service starts.
|
|
Enable "Start display service" below to automatically start it.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="on-demand-form" class="space-y-4">
|
|
<div>
|
|
<label for="on-demand-mode" class="block text-sm font-medium text-gray-700 mb-1">Display Mode</label>
|
|
<select id="on-demand-mode" name="mode"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
|
</select>
|
|
<p id="on-demand-mode-hint" class="text-xs text-gray-500 mt-1"></p>
|
|
</div>
|
|
<div>
|
|
<label for="on-demand-duration" class="block text-sm font-medium text-gray-700 mb-1">
|
|
Duration (seconds, optional)
|
|
</label>
|
|
<input type="number" min="0" id="on-demand-duration" name="duration"
|
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="Leave blank to use plugin default">
|
|
<p class="text-xs text-gray-500 mt-1">
|
|
Use 0 or leave empty to keep the plugin running until stopped manually.
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<input id="on-demand-pinned" name="pinned" type="checkbox"
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<label for="on-demand-pinned" class="ml-2 block text-sm text-gray-700">
|
|
Pin plugin to prevent rotation until stopped
|
|
</label>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<input id="on-demand-start-service" name="start_service" type="checkbox" checked
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<label for="on-demand-start-service" class="ml-2 block text-sm text-gray-700">
|
|
Start display service if it is not running
|
|
</label>
|
|
</div>
|
|
<div class="flex justify-end gap-3 pt-3">
|
|
<button type="button" id="cancel-on-demand"
|
|
class="px-4 py-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md">
|
|
Cancel
|
|
</button>
|
|
<button type="submit"
|
|
class="px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md font-semibold">
|
|
Start On-Demand
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
|