mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
* feat: integrate Starlark/Tronbyte app support into plugin system Add starlark-apps plugin that renders Tidbyt/Tronbyte .star apps via Pixlet binary and integrates them into the existing Plugin Manager UI as virtual plugins. Includes vegas scroll support, Tronbyte repository browsing, and per-app configuration. - Extract working starlark plugin code from starlark branch onto fresh main - Fix plugin conventions (get_logger, VegasDisplayMode, BasePlugin) - Add 13 starlark API endpoints to api_v3.py (CRUD, browse, install, render) - Virtual plugin entries (starlark:<app_id>) in installed plugins list - Starlark-aware toggle and config routing in pages_v3.py - Tronbyte repository browser section in Plugin Store UI - Pixlet binary download script (scripts/download_pixlet.sh) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(starlark): use bare imports instead of relative imports Plugin loader uses spec_from_file_location without package context, so relative imports (.pixlet_renderer) fail. Use bare imports like all other plugins do. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(starlark): make API endpoints work standalone in web service The web service runs as a separate process with display_manager=None, so plugins aren't instantiated. Refactor starlark API endpoints to read/write the manifest file directly when the plugin isn't loaded, enabling full CRUD operations from the web UI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(starlark): make config partial work standalone in web service Read starlark app data from manifest file directly when the plugin isn't loaded, matching the api_v3.py standalone pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(starlark): always show editable timing settings in config panel Render interval and display duration are now always editable in the starlark app config panel, not just shown as read-only status text. App-specific settings from schema still appear below when present. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(store): add sort, filter, search, and pagination to Plugin Store and Starlark Apps Plugin Store: - Live search with 300ms debounce (replaces Search button) - Sort dropdown: A→Z, Z→A, Category, Author, Newest - Installed toggle filter (All / Installed / Not Installed) - Per-page selector (12/24/48) with pagination controls - "Installed" badge and "Reinstall" button on already-installed plugins - Active filter count badge + clear filters button Starlark Apps: - Parallel bulk manifest fetching via ThreadPoolExecutor (20 workers) - Server-side 2-hour cache for all 500+ Tronbyte app manifests - Auto-loads all apps when section expands (no Browse button) - Live search, sort (A→Z, Z→A, Category, Author), author dropdown - Installed toggle filter, per-page selector (24/48/96), pagination - "Installed" badge on cards, "Reinstall" button variant Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(store): move storeFilterState to global scope to fix scoping bug storeFilterState, pluginStoreCache, and related variables were declared inside an IIFE but referenced by top-level functions, causing ReferenceError that broke all plugin loading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(starlark): schema-driven config forms + critical security fixes ## Schema-Driven Config UI - Render type-appropriate form inputs from schema.json (text, dropdown, toggle, color, datetime, location) - Pre-populate config.json with schema defaults on install - Auto-merge schema defaults when loading existing apps (handles schema updates) - Location fields: 3-part mini-form (lat/lng/timezone) assembles into JSON - Toggle fields: support both boolean and string "true"/"false" values - Unsupported field types (oauth2, photo_select) show warning banners - Fallback to raw key/value inputs for apps without schema ## Critical Security Fixes (P0) - **Path Traversal**: Verify path safety BEFORE mkdir to prevent TOCTOU - **Race Conditions**: Add file locking (fcntl) + atomic writes to manifest operations - **Command Injection**: Validate config keys/values with regex before passing to Pixlet subprocess ## Major Logic Fixes (P1) - **Config/Manifest Separation**: Store timing keys (render_interval, display_duration) ONLY in manifest - **Location Validation**: Validate lat [-90,90] and lng [-180,180] ranges, reject malformed JSON - **Schema Defaults Merge**: Auto-apply new schema defaults to existing app configs on load - **Config Key Validation**: Enforce alphanumeric+underscore format, prevent prototype pollution ## Files Changed - web_interface/templates/v3/partials/starlark_config.html — schema-driven form rendering - plugin-repos/starlark-apps/manager.py — file locking, path safety, config validation, schema merge - plugin-repos/starlark-apps/pixlet_renderer.py — config value sanitization - web_interface/blueprints/api_v3.py — timing key separation, safe manifest updates Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(starlark): use manifest filename field for .star downloads Tronbyte apps don't always name their .star file to match the directory. For example, the "analogclock" app has "analog_clock.star" (with underscore). The manifest.yaml contains a "filename" field with the correct name. Changes: - download_star_file() now accepts optional filename parameter - Install endpoint passes metadata['filename'] to download_star_file() - Falls back to {app_id}.star if filename not in manifest Fixes: "Failed to download .star file for analogclock" error Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(starlark): reload tronbyte_repository module to pick up code changes The web service caches imported modules in sys.modules. When deploying code updates, the old cached version was still being used. Now uses importlib.reload() when module is already loaded. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(starlark): use correct 'fileName' field from manifest (camelCase) The Tronbyte manifest uses 'fileName' (camelCase), not 'filename' (lowercase). This caused the download to fall back to {app_id}.star which doesn't exist for apps like analogclock (which has analog_clock.star). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat(starlark): extract schema during standalone install The standalone install function (_install_star_file) wasn't extracting schema from .star files, so apps installed via the web service had no schema.json and the config panel couldn't render schema-driven forms. Now uses PixletRenderer to extract schema during standalone install, same as the plugin does. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat(starlark): implement source code parser for schema extraction Pixlet CLI doesn't support schema extraction (--print-schema flag doesn't exist), so apps were being installed without schemas even when they have them. Implemented regex-based .star file parser that: - Extracts get_schema() function from source code - Parses schema.Schema(version, fields) structure - Handles variable-referenced dropdown options (e.g., options = dialectOptions) - Supports Location, Text, Toggle, Dropdown, Color, DateTime fields - Gracefully handles unsupported fields (OAuth2, LocationBased, etc.) - Returns formatted JSON matching web UI template expectations Coverage: 90%+ of Tronbyte apps (static schemas + variable references) Changes: - Replace extract_schema() to parse .star files directly instead of using Pixlet CLI - Add 6 helper methods for parsing schema structure - Handle nested parentheses and brackets properly - Resolve variable references for dropdown options Tested with: - analog_clock.star (Location field) ✓ - Multi-field test (Text + Dropdown + Toggle) ✓ - Variable-referenced options ✓ Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(starlark): add List to typing imports for schema parser Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(starlark): load schema from schema.json in standalone mode The standalone API endpoint was returning schema: null because it didn't load the schema.json file. Now reads schema from disk when returning app details via web service. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat(starlark): implement schema extraction, asset download, and config persistence ## Schema Extraction - Replace broken `pixlet serve --print-schema` with regex-based source parser - Extract schema by parsing `get_schema()` function from .star files - Support all field types: Location, Text, Toggle, Dropdown, Color, DateTime - Handle variable-referenced dropdown options (e.g., `options = teamOptions`) - Gracefully handle complex/unsupported field types (OAuth2, PhotoSelect, etc.) - Extract schema for 90%+ of Tronbyte apps ## Asset Download - Add `download_app_assets()` to fetch images/, sources/, fonts/ directories - Download assets in binary mode for proper image/font handling - Validate all paths to prevent directory traversal attacks - Copy asset directories during app installation - Enable apps like AnalogClock that require image assets ## Config Persistence - Create config.json file during installation with schema defaults - Update both config.json and manifest when saving configuration - Load config from config.json (not manifest) for consistency with plugin - Separate timing keys (render_interval, display_duration) from app config - Fix standalone web service mode to read/write config.json ## Pixlet Command Fix - Fix Pixlet CLI invocation: config params are positional, not flags - Change from `pixlet render file.star -c key=value` to `pixlet render file.star key=value -o output` - Properly handle JSON config values (e.g., location objects) - Enable config to be applied during rendering ## Security & Reliability - Add threading.Lock for cache operations to prevent race conditions - Reduce ThreadPoolExecutor workers from 20 to 5 for Raspberry Pi - Add path traversal validation in download_star_file() - Add YAML error logging in manifest fetching - Add file size validation (5MB limit) for .star uploads - Use sanitized app_id consistently in install endpoints - Use atomic manifest updates to prevent race conditions - Add missing Optional import for type hints ## Web UI - Fix standalone mode schema loading in config partial - Schema-driven config forms now render correctly for all apps - Location fields show lat/lng/timezone inputs - Dropdown, toggle, text, color, and datetime fields all supported Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(starlark): code review fixes - security, robustness, and schema parsing ## Security Fixes - manager.py: Check _update_manifest_safe return values to prevent silent failures - manager.py: Improve temp file cleanup in _save_manifest to prevent leaks - manager.py: Fix uninstall order (manifest → memory → disk) for consistency - api_v3.py: Add path traversal validation in uninstall endpoint - api_v3.py: Implement atomic writes for manifest files with temp + rename - pixlet_renderer.py: Relax config validation to only block dangerous shell metacharacters ## Frontend Robustness - plugins_manager.js: Add safeLocalStorage wrapper for restricted contexts (private browsing) - starlark_config.html: Scope querySelector to container to prevent modal conflicts ## Schema Parsing Improvements - pixlet_renderer.py: Indentation-aware get_schema() extraction (handles nested functions) - pixlet_renderer.py: Handle quoted defaults with commas (e.g., "New York, NY") - tronbyte_repository.py: Validate file_name is string before path traversal checks ## Dependencies - requirements.txt: Update Pillow (10.4.0), PyYAML (6.0.2), requests (2.32.0) ## Documentation - docs/STARLARK_APPS_GUIDE.md: Comprehensive guide explaining: - How Starlark apps work - That apps come from Tronbyte (not LEDMatrix) - Installation, configuration, troubleshooting - Links to upstream projects All changes improve security, reliability, and user experience. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(starlark): convert Path to str in spec_from_file_location calls The module import helpers were passing Path objects directly to spec_from_file_location(), which caused spec to be None. This broke the Starlark app store browser. - Convert module_path to string in both _get_tronbyte_repository_class and _get_pixlet_renderer_class - Add None checks with clear error messages for debugging Fixes: spec not found for the module 'tronbyte_repository' Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(starlark): restore Starlark Apps section in plugins.html The Starlark Apps UI section was lost during merge conflict resolution with main branch. Restored from commit942663abwhich had the complete implementation with filtering, sorting, and pagination. Fixes: Starlark section not visible on plugin manager page Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(starlark): restore Starlark JS functionality lost in merge During the merge with main, all Starlark-specific JavaScript (104 lines) was removed from plugins_manager.js, including: - starlarkFilterState and filtering logic - loadStarlarkApps() function - Starlark app install/uninstall handlers - Starlark section collapse/expand logic - Pagination and sorting for Starlark apps Restored from commit942663aband re-applied safeLocalStorage wrapper from our code review fixes. Fixes: Starlark Apps section non-functional in web UI Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(starlark): security and race condition improvements Security fixes: - Add path traversal validation for output_path in download_star_file - Remove XSS-vulnerable inline onclick handlers, use delegated events - Add type hints to helper functions for better type safety Race condition fixes: - Lock manifest file BEFORE creating temp file in _save_manifest - Hold exclusive lock for entire read-modify-write cycle in _update_manifest_safe - Prevent concurrent writers from racing on manifest updates Other improvements: - Fix pages_v3.py standalone mode to load config.json from disk - Improve error handling with proper logging in cleanup blocks - Add explicit type annotations to Starlark helper functions Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(starlark): critical bug fixes and code quality improvements Critical fixes: - Fix stack overflow in safeLocalStorage (was recursively calling itself) - Fix duplicate event listeners on Starlark grid (added sentinel check) - Fix JSON validation to fail fast on malformed data instead of silently passing Error handling improvements: - Narrow exception catches to specific types (OSError, json.JSONDecodeError, ValueError) - Use logger.exception() with exc_info=True for better stack traces - Replace generic "except Exception" with specific exception types Logging improvements: - Add "[Starlark Pixlet]" context tags to pixlet_renderer logs - Redact sensitive config values from debug logs (API keys, etc.) - Add file_path context to schema parsing warnings Documentation: - Fix markdown lint issues (add language tags to code blocks) - Fix time unit spacing: "(5min)" -> "(5 min)" Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(starlark): critical path traversal and exception handling fixes Path traversal security fixes (CRITICAL): - Add _validate_starlark_app_path() helper to check for path traversal attacks - Validate app_id in get_starlark_app(), uninstall_starlark_app(), get_starlark_app_config(), and update_starlark_app_config() - Check for '..' and path separators before any filesystem access - Verify resolved paths are within _STARLARK_APPS_DIR using Path.relative_to() - Prevents unauthorized file access via crafted app_id like '../../../etc/passwd' Exception handling improvements (tronbyte_repository.py): - Replace broad "except Exception" with specific types - _make_request: catch requests.Timeout, requests.RequestException, json.JSONDecodeError - _fetch_raw_file: catch requests.Timeout, requests.RequestException separately - download_app_assets: narrow to OSError, ValueError - Add "[Tronbyte Repo]" context prefix to all log messages - Use exc_info=True for better stack traces API improvements: - Narrow exception catches to OSError, json.JSONDecodeError in config loading - Remove duplicate path traversal checks (now centralized in helper) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(starlark): logging improvements and code quality fixes Logging improvements (pages_v3.py): - Add logging import and create module logger - Replace print() calls with logger.warning() with "[Pages V3]" prefix - Use logger.exception() for outer try/catch with exc_info=True - Narrow exception handling to OSError, json.JSONDecodeError for file operations API improvements (api_v3.py): - Remove unnecessary f-strings (Ruff F541) from ImportError messages - Narrow upload exception handling to ValueError, OSError, IOError - Use logger.exception() with context for better debugging - Remove early return in get_starlark_status() to allow standalone mode fallback - Sanitize error messages returned to client (don't expose internal details) Benefits: - Better log context with consistent prefixes - More specific exception handling prevents masking unexpected errors - Standalone/web-service-only mode now works for status endpoint - Stack traces preserved for debugging without exposing to clients Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
8156 lines
364 KiB
JavaScript
8156 lines
364 KiB
JavaScript
// ─── LocalStorage Safety Wrappers ────────────────────────────────────────────
|
|
// Handles environments where localStorage is unavailable or restricted (private browsing, etc.)
|
|
const safeLocalStorage = {
|
|
getItem(key) {
|
|
try {
|
|
if (typeof localStorage !== 'undefined') {
|
|
return localStorage.getItem(key);
|
|
}
|
|
} catch (e) {
|
|
console.warn(`safeLocalStorage.getItem failed for key "${key}":`, e.message);
|
|
}
|
|
return null;
|
|
},
|
|
setItem(key, value) {
|
|
try {
|
|
if (typeof localStorage !== 'undefined') {
|
|
localStorage.setItem(key, value);
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
console.warn(`safeLocalStorage.setItem failed for key "${key}":`, e.message);
|
|
}
|
|
return false;
|
|
},
|
|
removeItem(key) {
|
|
try {
|
|
if (typeof localStorage !== 'undefined') {
|
|
localStorage.removeItem(key);
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
console.warn(`localStorage.removeItem failed for key "${key}":`, e.message);
|
|
}
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Define critical functions immediately so they're available before any HTML is rendered
|
|
// Debug logging controlled by safeLocalStorage.setItem('pluginDebug', 'true')
|
|
const _PLUGIN_DEBUG_EARLY = safeLocalStorage.getItem('pluginDebug') === 'true';
|
|
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS SCRIPT] Defining configurePlugin and togglePlugin at top level...');
|
|
|
|
// Expose on-demand functions early as stubs (will be replaced when IIFE runs)
|
|
window.openOnDemandModal = function(pluginId) {
|
|
console.warn('openOnDemandModal called before initialization, waiting...');
|
|
// Wait for the real function to be available
|
|
let attempts = 0;
|
|
const maxAttempts = 50; // 2.5 seconds
|
|
const checkInterval = setInterval(() => {
|
|
attempts++;
|
|
if (window.__openOnDemandModalImpl) {
|
|
clearInterval(checkInterval);
|
|
window.__openOnDemandModalImpl(pluginId);
|
|
} else if (attempts >= maxAttempts) {
|
|
clearInterval(checkInterval);
|
|
console.error('openOnDemandModal not available after waiting');
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('On-demand modal unavailable. Please refresh the page.', 'error');
|
|
}
|
|
}
|
|
}, 50);
|
|
};
|
|
|
|
window.requestOnDemandStop = function({ stopService = false } = {}) {
|
|
console.warn('requestOnDemandStop called before initialization, waiting...');
|
|
// Wait for the real function to be available
|
|
let attempts = 0;
|
|
const maxAttempts = 50; // 2.5 seconds
|
|
const checkInterval = setInterval(() => {
|
|
attempts++;
|
|
if (window.__requestOnDemandStopImpl) {
|
|
clearInterval(checkInterval);
|
|
return window.__requestOnDemandStopImpl({ stopService });
|
|
} else if (attempts >= maxAttempts) {
|
|
clearInterval(checkInterval);
|
|
console.error('requestOnDemandStop not available after waiting');
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('On-demand stop unavailable. Please refresh the page.', 'error');
|
|
}
|
|
return Promise.reject(new Error('Function not available'));
|
|
}
|
|
}, 50);
|
|
return Promise.resolve();
|
|
};
|
|
|
|
// Define updatePlugin early as a stub to ensure it's always available
|
|
window.updatePlugin = window.updatePlugin || function(pluginId) {
|
|
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] updatePlugin called for', pluginId);
|
|
|
|
// Validate pluginId
|
|
if (!pluginId || typeof pluginId !== 'string') {
|
|
console.error('Invalid pluginId:', pluginId);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Invalid plugin ID', 'error');
|
|
}
|
|
return Promise.reject(new Error('Invalid plugin ID'));
|
|
}
|
|
|
|
// Show immediate feedback
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`Updating ${pluginId}...`, 'info');
|
|
}
|
|
|
|
// Prepare request body
|
|
const requestBody = { plugin_id: pluginId };
|
|
const requestBodyJson = JSON.stringify(requestBody);
|
|
|
|
console.log('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson });
|
|
|
|
// Make the API call directly
|
|
return fetch('/api/v3/plugins/update', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: requestBodyJson
|
|
})
|
|
.then(async response => {
|
|
// Check if response is OK before parsing
|
|
if (!response.ok) {
|
|
// Try to parse error response
|
|
let errorData;
|
|
try {
|
|
const text = await response.text();
|
|
console.error('[UPDATE] Error response:', { status: response.status, statusText: response.statusText, body: text });
|
|
errorData = JSON.parse(text);
|
|
} catch (e) {
|
|
errorData = { message: `Server error: ${response.status} ${response.statusText}` };
|
|
}
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(errorData.message || `Update failed: ${response.status}`, 'error');
|
|
}
|
|
throw new Error(errorData.message || `Update failed: ${response.status}`);
|
|
}
|
|
|
|
// Parse successful response
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message || 'Update initiated', data.status || 'info');
|
|
}
|
|
// Refresh installed plugins if available
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
|
|
window.pluginManager.loadInstalledPlugins();
|
|
}
|
|
return data;
|
|
})
|
|
.catch(error => {
|
|
console.error('[UPDATE] Error updating plugin:', error);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error updating plugin: ' + error.message, 'error');
|
|
}
|
|
throw error;
|
|
});
|
|
};
|
|
|
|
// Define uninstallPlugin early as a stub
|
|
window.uninstallPlugin = window.uninstallPlugin || function(pluginId) {
|
|
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] uninstallPlugin called for', pluginId);
|
|
|
|
if (!confirm(`Are you sure you want to uninstall ${pluginId}?`)) {
|
|
return Promise.resolve({ cancelled: true });
|
|
}
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`Uninstalling ${pluginId}...`, 'info');
|
|
}
|
|
|
|
return fetch('/api/v3/plugins/uninstall', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message || 'Uninstall initiated', data.status || 'info');
|
|
}
|
|
// Refresh installed plugins if available
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
|
|
window.pluginManager.loadInstalledPlugins();
|
|
}
|
|
return data;
|
|
})
|
|
.catch(error => {
|
|
console.error('Error uninstalling plugin:', error);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error uninstalling plugin: ' + error.message, 'error');
|
|
}
|
|
throw error;
|
|
});
|
|
};
|
|
|
|
// Define configurePlugin early to ensure it's always available
|
|
window.configurePlugin = window.configurePlugin || async function(pluginId) {
|
|
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] configurePlugin called for', pluginId);
|
|
|
|
// Switch to the plugin's configuration tab instead of opening a modal
|
|
// This matches the behavior of clicking the plugin tab at the top
|
|
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;
|
|
}
|
|
|
|
const appComponent = getAppComponent();
|
|
if (appComponent) {
|
|
// Set the active tab to the plugin ID
|
|
appComponent.activeTab = pluginId;
|
|
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] Switched to plugin tab:', pluginId);
|
|
|
|
// Scroll to top of page to ensure the tab is visible
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
} else {
|
|
console.error('Alpine.js app instance not found');
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Unable to switch to plugin configuration. Please refresh the page.', 'error');
|
|
}
|
|
}
|
|
};
|
|
|
|
// Initialize per-plugin toggle request token map for race condition protection
|
|
if (!window._pluginToggleRequests) {
|
|
window._pluginToggleRequests = {};
|
|
}
|
|
|
|
// Define togglePlugin early to ensure it's always available
|
|
window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
|
|
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] togglePlugin called for', pluginId, 'enabled:', enabled);
|
|
|
|
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
|
|
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
|
|
const action = enabled ? 'enabling' : 'disabling';
|
|
|
|
// Generate unique token for this toggle request to prevent race conditions
|
|
const requestToken = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
window._pluginToggleRequests[pluginId] = requestToken;
|
|
|
|
// Update UI immediately for better UX
|
|
const toggleCheckbox = document.getElementById(`toggle-${pluginId}`);
|
|
const toggleLabel = document.getElementById(`toggle-label-${pluginId}`);
|
|
const wrapperDiv = toggleCheckbox?.parentElement?.querySelector('.flex.items-center.gap-2');
|
|
const toggleTrack = wrapperDiv?.querySelector('.relative.w-14');
|
|
const toggleHandle = toggleTrack?.querySelector('.absolute');
|
|
|
|
// Disable checkbox and add disabled class to prevent overlapping requests
|
|
if (toggleCheckbox) {
|
|
toggleCheckbox.checked = enabled;
|
|
toggleCheckbox.disabled = true;
|
|
toggleCheckbox.classList.add('opacity-50', 'cursor-not-allowed');
|
|
}
|
|
|
|
// Disable wrapper to provide visual feedback
|
|
if (wrapperDiv) {
|
|
wrapperDiv.classList.add('opacity-50', 'pointer-events-none');
|
|
}
|
|
|
|
// Update wrapper background and border
|
|
if (wrapperDiv) {
|
|
if (enabled) {
|
|
wrapperDiv.classList.remove('bg-gray-50', 'border-gray-300');
|
|
wrapperDiv.classList.add('bg-green-50', 'border-green-500');
|
|
} else {
|
|
wrapperDiv.classList.remove('bg-green-50', 'border-green-500');
|
|
wrapperDiv.classList.add('bg-gray-50', 'border-gray-300');
|
|
}
|
|
}
|
|
|
|
// Update toggle track
|
|
if (toggleTrack) {
|
|
if (enabled) {
|
|
toggleTrack.classList.remove('bg-gray-300');
|
|
toggleTrack.classList.add('bg-green-500');
|
|
} else {
|
|
toggleTrack.classList.remove('bg-green-500');
|
|
toggleTrack.classList.add('bg-gray-300');
|
|
}
|
|
}
|
|
|
|
// Update toggle handle
|
|
if (toggleHandle) {
|
|
if (enabled) {
|
|
toggleHandle.classList.add('translate-x-full', 'border-green-500');
|
|
toggleHandle.classList.remove('border-gray-400');
|
|
toggleHandle.innerHTML = '<i class="fas fa-check text-green-600 text-xs"></i>';
|
|
} else {
|
|
toggleHandle.classList.remove('translate-x-full', 'border-green-500');
|
|
toggleHandle.classList.add('border-gray-400');
|
|
toggleHandle.innerHTML = '<i class="fas fa-times text-gray-400 text-xs"></i>';
|
|
}
|
|
}
|
|
|
|
// Update label with icon and text
|
|
if (toggleLabel) {
|
|
if (enabled) {
|
|
toggleLabel.className = 'text-sm font-semibold text-green-700 flex items-center gap-1.5';
|
|
toggleLabel.innerHTML = '<i class="fas fa-toggle-on text-green-600"></i><span>Enabled</span>';
|
|
} else {
|
|
toggleLabel.className = 'text-sm font-semibold text-gray-600 flex items-center gap-1.5';
|
|
toggleLabel.innerHTML = '<i class="fas fa-toggle-off text-gray-400"></i><span>Disabled</span>';
|
|
}
|
|
}
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`${action.charAt(0).toUpperCase() + action.slice(1)} ${pluginName}...`, 'info');
|
|
}
|
|
|
|
fetch('/api/v3/plugins/toggle', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId, enabled: enabled })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
// Verify this response is for the latest request (prevent race conditions)
|
|
if (window._pluginToggleRequests[pluginId] !== requestToken) {
|
|
console.log(`[togglePlugin] Ignoring out-of-order response for ${pluginId}`);
|
|
return;
|
|
}
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message, data.status);
|
|
}
|
|
if (data.status === 'success') {
|
|
// Update local state
|
|
if (plugin) {
|
|
plugin.enabled = enabled;
|
|
}
|
|
// Refresh the list to ensure consistency
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
}
|
|
} else {
|
|
// Revert the toggle if API call failed
|
|
if (plugin) {
|
|
plugin.enabled = !enabled;
|
|
}
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
}
|
|
}
|
|
|
|
// Clear token and re-enable UI
|
|
delete window._pluginToggleRequests[pluginId];
|
|
if (toggleCheckbox) {
|
|
toggleCheckbox.disabled = false;
|
|
toggleCheckbox.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
}
|
|
if (wrapperDiv) {
|
|
wrapperDiv.classList.remove('opacity-50', 'pointer-events-none');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
// Verify this error is for the latest request (prevent race conditions)
|
|
if (window._pluginToggleRequests[pluginId] !== requestToken) {
|
|
console.log(`[togglePlugin] Ignoring out-of-order error for ${pluginId}`);
|
|
return;
|
|
}
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error toggling plugin: ' + error.message, 'error');
|
|
}
|
|
// Revert the toggle if API call failed
|
|
if (plugin) {
|
|
plugin.enabled = !enabled;
|
|
}
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
}
|
|
|
|
// Clear token and re-enable UI
|
|
delete window._pluginToggleRequests[pluginId];
|
|
if (toggleCheckbox) {
|
|
toggleCheckbox.disabled = false;
|
|
toggleCheckbox.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
}
|
|
if (wrapperDiv) {
|
|
wrapperDiv.classList.remove('opacity-50', 'pointer-events-none');
|
|
}
|
|
});
|
|
};
|
|
|
|
// Cleanup orphaned modals from previous executions to prevent duplicates when moving to body
|
|
try {
|
|
const existingModals = document.querySelectorAll('#plugin-config-modal');
|
|
if (existingModals.length > 0) {
|
|
existingModals.forEach(el => {
|
|
// Only remove modals that were moved to body (orphaned from previous loads)
|
|
// The new modal in the current content should be inside a container, not direct body child
|
|
if (el.parentElement === document.body) {
|
|
console.log('[PLUGINS SCRIPT] Cleaning up orphaned plugin modal');
|
|
el.remove();
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.warn('[PLUGINS SCRIPT] Error cleaning up modals:', e);
|
|
}
|
|
|
|
// Track pending render data for when DOM isn't ready yet
|
|
window.__pendingInstalledPlugins = window.__pendingInstalledPlugins || null;
|
|
window.__pendingStorePlugins = window.__pendingStorePlugins || null;
|
|
window.__pluginDomReady = window.__pluginDomReady || false;
|
|
|
|
// Set up global event delegation for plugin actions (works even before plugins are loaded)
|
|
(function setupGlobalEventDelegation() {
|
|
// Use document-level delegation so it works for dynamically added content
|
|
const handleGlobalPluginAction = function(event) {
|
|
// Only handle if it's a plugin action
|
|
const button = event.target.closest('button[data-action][data-plugin-id]') ||
|
|
event.target.closest('input[data-action][data-plugin-id]');
|
|
if (!button) return;
|
|
|
|
const action = button.getAttribute('data-action');
|
|
const pluginId = button.getAttribute('data-plugin-id');
|
|
|
|
// For toggle and configure, ensure functions are available
|
|
if (action === 'toggle' || action === 'configure') {
|
|
const funcName = action === 'toggle' ? 'togglePlugin' : 'configurePlugin';
|
|
if (!window[funcName] || typeof window[funcName] !== 'function') {
|
|
// Prevent default and stop propagation immediately to avoid double handling
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
console.warn(`[GLOBAL DELEGATION] ${funcName} not available yet, waiting...`);
|
|
|
|
// Capture state synchronously from plugin data (source of truth)
|
|
let targetChecked = false;
|
|
if (action === 'toggle') {
|
|
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
|
|
|
|
let currentEnabled;
|
|
if (plugin) {
|
|
currentEnabled = Boolean(plugin.enabled);
|
|
} else if (button.type === 'checkbox') {
|
|
currentEnabled = button.checked;
|
|
} else {
|
|
currentEnabled = false;
|
|
}
|
|
|
|
targetChecked = !currentEnabled; // Toggle to opposite state
|
|
}
|
|
|
|
// Wait for function to be available
|
|
let attempts = 0;
|
|
const maxAttempts = 20; // 1 second total
|
|
const checkInterval = setInterval(() => {
|
|
attempts++;
|
|
if (window[funcName] && typeof window[funcName] === 'function') {
|
|
clearInterval(checkInterval);
|
|
// Call the function directly
|
|
if (action === 'toggle') {
|
|
window.togglePlugin(pluginId, targetChecked);
|
|
} else {
|
|
window.configurePlugin(pluginId);
|
|
}
|
|
} else if (attempts >= maxAttempts) {
|
|
clearInterval(checkInterval);
|
|
console.error(`[GLOBAL DELEGATION] ${funcName} not available after ${maxAttempts} attempts`);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`${funcName} not loaded. Please refresh the page.`, 'error');
|
|
}
|
|
}
|
|
}, 50);
|
|
return; // Don't proceed with normal handling
|
|
}
|
|
}
|
|
|
|
// Prevent default and stop propagation to avoid double handling
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
// If handlePluginAction exists, use it; otherwise handle directly
|
|
if (typeof handlePluginAction === 'function') {
|
|
handlePluginAction(event);
|
|
} else {
|
|
// Fallback: handle directly if functions are available
|
|
if (action === 'toggle' && window.togglePlugin) {
|
|
// Get the current enabled state from plugin data (source of truth)
|
|
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
|
|
|
|
let currentEnabled;
|
|
if (plugin) {
|
|
currentEnabled = Boolean(plugin.enabled);
|
|
} else if (button.type === 'checkbox') {
|
|
currentEnabled = button.checked;
|
|
} else {
|
|
currentEnabled = false;
|
|
}
|
|
|
|
// Toggle the state - we want the opposite of current state
|
|
const isChecked = !currentEnabled;
|
|
|
|
// Prevent default behavior to avoid double-toggling and change event
|
|
// (Already done at start of function, but safe to repeat)
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
console.log('[DEBUG toggle fallback] Plugin:', pluginId, 'Current enabled (from data):', currentEnabled, 'New state:', isChecked);
|
|
|
|
window.togglePlugin(pluginId, isChecked);
|
|
} else if (action === 'configure' && window.configurePlugin) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
window.configurePlugin(pluginId);
|
|
} else if (action === 'update' && window.updatePlugin) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
console.log('[DEBUG update fallback] Updating plugin:', pluginId);
|
|
window.updatePlugin(pluginId);
|
|
} else if (action === 'uninstall' && window.uninstallPlugin) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
console.log('[DEBUG uninstall fallback] Uninstalling plugin:', pluginId);
|
|
if (confirm(`Are you sure you want to uninstall ${pluginId}?`)) {
|
|
window.uninstallPlugin(pluginId);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Set up delegation on document (capture phase for better reliability)
|
|
document.addEventListener('click', handleGlobalPluginAction, true);
|
|
document.addEventListener('change', handleGlobalPluginAction, true);
|
|
console.log('[PLUGINS SCRIPT] Global event delegation set up');
|
|
})();
|
|
|
|
// Note: configurePlugin and togglePlugin are now defined at the top of the file (after uninstallPlugin)
|
|
// to ensure they're available immediately when the script loads
|
|
|
|
// Verify functions are defined (debug only)
|
|
if (_PLUGIN_DEBUG_EARLY) {
|
|
console.log('[PLUGINS SCRIPT] Functions defined:', {
|
|
configurePlugin: typeof window.configurePlugin,
|
|
togglePlugin: typeof window.togglePlugin
|
|
});
|
|
if (typeof window.configurePlugin === 'function') {
|
|
console.log('[PLUGINS SCRIPT] ✓ configurePlugin ready');
|
|
}
|
|
if (typeof window.togglePlugin === 'function') {
|
|
console.log('[PLUGINS SCRIPT] ✓ togglePlugin ready');
|
|
}
|
|
}
|
|
|
|
// GitHub Token Collapse Handler - Define early so it's available before IIFE
|
|
console.log('[DEFINE] Defining attachGithubTokenCollapseHandler function...');
|
|
window.attachGithubTokenCollapseHandler = function() {
|
|
console.log('[attachGithubTokenCollapseHandler] Starting...');
|
|
const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse');
|
|
console.log('[attachGithubTokenCollapseHandler] Button found:', !!toggleTokenCollapseBtn);
|
|
if (!toggleTokenCollapseBtn) {
|
|
console.warn('[attachGithubTokenCollapseHandler] GitHub token collapse button not found');
|
|
return;
|
|
}
|
|
|
|
console.log('[attachGithubTokenCollapseHandler] Checking toggleGithubTokenContent...', {
|
|
exists: typeof window.toggleGithubTokenContent
|
|
});
|
|
if (!window.toggleGithubTokenContent) {
|
|
console.warn('[attachGithubTokenCollapseHandler] toggleGithubTokenContent function not defined');
|
|
return;
|
|
}
|
|
|
|
// Remove any existing listeners by cloning the button
|
|
const parent = toggleTokenCollapseBtn.parentNode;
|
|
if (!parent) {
|
|
console.warn('[attachGithubTokenCollapseHandler] Button parent not found');
|
|
return;
|
|
}
|
|
|
|
const newBtn = toggleTokenCollapseBtn.cloneNode(true);
|
|
parent.replaceChild(newBtn, toggleTokenCollapseBtn);
|
|
|
|
// Attach listener to the new button
|
|
newBtn.addEventListener('click', function(e) {
|
|
console.log('[attachGithubTokenCollapseHandler] Button clicked, calling toggleGithubTokenContent');
|
|
window.toggleGithubTokenContent(e);
|
|
});
|
|
|
|
console.log('[attachGithubTokenCollapseHandler] Handler attached to button:', newBtn.id);
|
|
};
|
|
|
|
// Toggle GitHub Token Settings section
|
|
console.log('[DEFINE] Defining toggleGithubTokenContent function...');
|
|
window.toggleGithubTokenContent = function(e) {
|
|
console.log('[toggleGithubTokenContent] called', e);
|
|
|
|
if (e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}
|
|
|
|
const tokenContent = document.getElementById('github-token-content');
|
|
const tokenIconCollapse = document.getElementById('github-token-icon-collapse');
|
|
const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse');
|
|
|
|
console.log('[toggleGithubTokenContent] Elements found:', {
|
|
tokenContent: !!tokenContent,
|
|
tokenIconCollapse: !!tokenIconCollapse,
|
|
toggleTokenCollapseBtn: !!toggleTokenCollapseBtn
|
|
});
|
|
|
|
if (!tokenContent || !toggleTokenCollapseBtn) {
|
|
console.warn('[toggleGithubTokenContent] GitHub token content or button not found');
|
|
return;
|
|
}
|
|
|
|
const hasHiddenClass = tokenContent.classList.contains('hidden');
|
|
const computedDisplay = window.getComputedStyle(tokenContent).display;
|
|
|
|
console.log('[toggleGithubTokenContent] Current state:', {
|
|
hasHiddenClass,
|
|
computedDisplay,
|
|
buttonText: toggleTokenCollapseBtn.querySelector('span')?.textContent
|
|
});
|
|
|
|
if (hasHiddenClass || computedDisplay === 'none') {
|
|
// Show content - remove hidden class, add block class, remove inline display
|
|
tokenContent.classList.remove('hidden');
|
|
tokenContent.classList.add('block');
|
|
tokenContent.style.removeProperty('display');
|
|
if (tokenIconCollapse) {
|
|
tokenIconCollapse.classList.remove('fa-chevron-down');
|
|
tokenIconCollapse.classList.add('fa-chevron-up');
|
|
}
|
|
const span = toggleTokenCollapseBtn.querySelector('span');
|
|
if (span) span.textContent = 'Collapse';
|
|
console.log('[toggleGithubTokenContent] Content shown - removed hidden, added block');
|
|
} else {
|
|
// Hide content - add hidden class, remove block class, ensure display is none
|
|
tokenContent.classList.add('hidden');
|
|
tokenContent.classList.remove('block');
|
|
tokenContent.style.display = 'none';
|
|
if (tokenIconCollapse) {
|
|
tokenIconCollapse.classList.remove('fa-chevron-up');
|
|
tokenIconCollapse.classList.add('fa-chevron-down');
|
|
}
|
|
const span = toggleTokenCollapseBtn.querySelector('span');
|
|
if (span) span.textContent = 'Expand';
|
|
console.log('[toggleGithubTokenContent] Content hidden - added hidden, removed block, set display:none');
|
|
}
|
|
};
|
|
|
|
// Simple standalone handler for GitHub plugin installation
|
|
// Defined early and globally to ensure it's always available
|
|
console.log('[DEFINE] Defining handleGitHubPluginInstall function...');
|
|
window.handleGitHubPluginInstall = function() {
|
|
console.log('[handleGitHubPluginInstall] Function called!');
|
|
|
|
const urlInput = document.getElementById('github-plugin-url');
|
|
const statusDiv = document.getElementById('github-plugin-status');
|
|
const branchInput = document.getElementById('plugin-branch-input');
|
|
const installBtn = document.getElementById('install-plugin-from-url');
|
|
|
|
if (!urlInput) {
|
|
console.error('[handleGitHubPluginInstall] URL input not found');
|
|
alert('Error: Could not find URL input field');
|
|
return;
|
|
}
|
|
|
|
const repoUrl = urlInput.value.trim();
|
|
console.log('[handleGitHubPluginInstall] Repo URL:', repoUrl);
|
|
|
|
if (!repoUrl) {
|
|
if (statusDiv) {
|
|
statusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a GitHub URL</span>';
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!repoUrl.includes('github.com')) {
|
|
if (statusDiv) {
|
|
statusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Disable button and show loading
|
|
if (installBtn) {
|
|
installBtn.disabled = true;
|
|
installBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Installing...';
|
|
}
|
|
if (statusDiv) {
|
|
statusDiv.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Installing plugin...</span>';
|
|
}
|
|
|
|
const branch = branchInput?.value?.trim() || null;
|
|
const requestBody = { repo_url: repoUrl };
|
|
if (branch) {
|
|
requestBody.branch = branch;
|
|
}
|
|
|
|
console.log('[handleGitHubPluginInstall] Sending request:', requestBody);
|
|
|
|
fetch('/api/v3/plugins/install-from-url', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
})
|
|
.then(response => {
|
|
console.log('[handleGitHubPluginInstall] Response status:', response.status);
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
console.log('[handleGitHubPluginInstall] Response data:', data);
|
|
if (data.status === 'success') {
|
|
if (statusDiv) {
|
|
statusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Successfully installed: ${data.plugin_id}</span>`;
|
|
}
|
|
urlInput.value = '';
|
|
|
|
// Show notification if available
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success');
|
|
}
|
|
|
|
// Refresh installed plugins list if function available
|
|
setTimeout(() => {
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
} else if (typeof window.loadInstalledPlugins === 'function') {
|
|
window.loadInstalledPlugins();
|
|
}
|
|
}, 1000);
|
|
} else {
|
|
if (statusDiv) {
|
|
statusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>${data.message || 'Installation failed'}</span>`;
|
|
}
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message || 'Installation failed', 'error');
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('[handleGitHubPluginInstall] Error:', error);
|
|
if (statusDiv) {
|
|
statusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${error.message}</span>`;
|
|
}
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error installing plugin: ' + error.message, 'error');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (installBtn) {
|
|
installBtn.disabled = false;
|
|
installBtn.innerHTML = '<i class="fas fa-download mr-2"></i>Install';
|
|
}
|
|
});
|
|
};
|
|
console.log('[DEFINE] handleGitHubPluginInstall defined and ready');
|
|
|
|
// GitHub Authentication Status - Define early so it's available in IIFE
|
|
// Shows warning banner only when token is missing or invalid
|
|
// The token itself is never exposed to the frontend for security
|
|
// Returns a Promise so it can be awaited
|
|
console.log('[DEFINE] Defining checkGitHubAuthStatus function...');
|
|
window.checkGitHubAuthStatus = function checkGitHubAuthStatus() {
|
|
console.log('[checkGitHubAuthStatus] Starting...');
|
|
return fetch('/api/v3/plugins/store/github-status')
|
|
.then(response => {
|
|
console.log('checkGitHubAuthStatus: Response status:', response.status);
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
console.log('checkGitHubAuthStatus: Data received:', data);
|
|
if (data.status === 'success') {
|
|
const authData = data.data;
|
|
const tokenStatus = authData.token_status || (authData.authenticated ? 'valid' : 'none');
|
|
console.log('checkGitHubAuthStatus: Token status:', tokenStatus);
|
|
const warning = document.getElementById('github-auth-warning');
|
|
const settings = document.getElementById('github-token-settings');
|
|
const rateLimit = document.getElementById('rate-limit-count');
|
|
console.log('checkGitHubAuthStatus: Elements found:', {
|
|
warning: !!warning,
|
|
settings: !!settings,
|
|
rateLimit: !!rateLimit
|
|
});
|
|
|
|
// Show warning only when token is missing ('none') or invalid ('invalid')
|
|
if (tokenStatus === 'none' || tokenStatus === 'invalid') {
|
|
// Check if user has dismissed the warning (stored in session storage)
|
|
const dismissed = sessionStorage.getItem('github-auth-warning-dismissed');
|
|
if (!dismissed) {
|
|
if (warning && rateLimit) {
|
|
rateLimit.textContent = authData.rate_limit;
|
|
|
|
// Update warning message for invalid tokens
|
|
if (tokenStatus === 'invalid' && authData.error) {
|
|
const warningText = warning.querySelector('p.text-sm.text-yellow-700');
|
|
if (warningText) {
|
|
// Clear existing content
|
|
warningText.textContent = '';
|
|
|
|
// Create safe error message with fallback
|
|
const errorMsg = (authData.message || authData.error || 'Unknown error').toString();
|
|
|
|
// Create <strong> element for "Token Invalid:" label
|
|
const strong = document.createElement('strong');
|
|
strong.textContent = 'Token Invalid:';
|
|
|
|
// Create text node for error message and suffix
|
|
const errorText = document.createTextNode(` ${errorMsg}. Please update your GitHub token to increase API rate limits to 5,000 requests/hour.`);
|
|
|
|
// Append elements safely (no innerHTML)
|
|
warningText.appendChild(strong);
|
|
warningText.appendChild(errorText);
|
|
}
|
|
}
|
|
// For 'none' status, use the default message from HTML template
|
|
|
|
// Show warning using both classList and style.display
|
|
warning.classList.remove('hidden');
|
|
warning.style.display = '';
|
|
console.log(`GitHub token status: ${tokenStatus} - showing API limit warning`);
|
|
}
|
|
}
|
|
|
|
// Ensure settings panel is accessible when token is missing or invalid
|
|
// Panel can be opened via "Configure Token" link in warning
|
|
// Don't force it to be visible, but don't prevent it from being shown
|
|
} else if (tokenStatus === 'valid') {
|
|
// Token is valid - hide warning and ensure settings panel is visible but collapsed
|
|
if (warning) {
|
|
// Hide warning using both classList and style.display
|
|
warning.classList.add('hidden');
|
|
warning.style.display = 'none';
|
|
console.log('GitHub token is valid - hiding API limit warning');
|
|
}
|
|
|
|
// Make settings panel visible but collapsed (accessible for token management)
|
|
if (settings) {
|
|
// Remove hidden class from panel itself - make it visible using both methods
|
|
settings.classList.remove('hidden');
|
|
settings.style.display = '';
|
|
|
|
// Always collapse the content when token is valid (user must click expand)
|
|
const tokenContent = document.getElementById('github-token-content');
|
|
if (tokenContent) {
|
|
// Collapse the content - add hidden, remove block, set display none
|
|
tokenContent.classList.add('hidden');
|
|
tokenContent.classList.remove('block');
|
|
tokenContent.style.display = 'none';
|
|
}
|
|
|
|
// Update collapse button state to show "Expand"
|
|
const tokenIconCollapse = document.getElementById('github-token-icon-collapse');
|
|
if (tokenIconCollapse) {
|
|
tokenIconCollapse.classList.remove('fa-chevron-up');
|
|
tokenIconCollapse.classList.add('fa-chevron-down');
|
|
}
|
|
|
|
const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse');
|
|
if (toggleTokenCollapseBtn) {
|
|
const span = toggleTokenCollapseBtn.querySelector('span');
|
|
if (span) span.textContent = 'Expand';
|
|
|
|
// Ensure event listener is attached
|
|
if (window.attachGithubTokenCollapseHandler) {
|
|
window.attachGithubTokenCollapseHandler();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear dismissal flag when token becomes valid
|
|
sessionStorage.removeItem('github-auth-warning-dismissed');
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error checking GitHub auth status:', error);
|
|
console.error('Error stack:', error.stack || 'No stack trace');
|
|
});
|
|
};
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
if (_PLUGIN_DEBUG_EARLY) console.log('Plugin manager script starting...');
|
|
|
|
// Local variables for this instance
|
|
let installedPlugins = [];
|
|
window.currentPluginConfig = null;
|
|
let pluginStoreCache = null; // Cache for plugin store to speed up subsequent loads
|
|
let cacheTimestamp = null;
|
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
|
let storeFilteredList = [];
|
|
|
|
// ── Plugin Store Filter State ───────────────────────────────────────────
|
|
const storeFilterState = {
|
|
sort: safeLocalStorage.getItem('storeSort') || 'a-z',
|
|
filterCategory: '',
|
|
filterInstalled: null, // null=all, true=installed, false=not-installed
|
|
searchQuery: '',
|
|
page: 1,
|
|
perPage: parseInt(safeLocalStorage.getItem('storePerPage')) || 12,
|
|
persist() {
|
|
safeLocalStorage.setItem('storeSort', this.sort);
|
|
safeLocalStorage.setItem('storePerPage', this.perPage);
|
|
},
|
|
reset() {
|
|
this.sort = 'a-z';
|
|
this.filterCategory = '';
|
|
this.filterInstalled = null;
|
|
this.searchQuery = '';
|
|
this.page = 1;
|
|
},
|
|
activeCount() {
|
|
let n = 0;
|
|
if (this.searchQuery) n++;
|
|
if (this.filterInstalled !== null) n++;
|
|
if (this.filterCategory) n++;
|
|
if (this.sort !== 'a-z') n++;
|
|
return n;
|
|
}
|
|
};
|
|
let onDemandStatusInterval = null;
|
|
let currentOnDemandPluginId = null;
|
|
let hasLoadedOnDemandStatus = false;
|
|
|
|
// Shared on-demand status store (mirrors Alpine store when available)
|
|
window.__onDemandStore = window.__onDemandStore || {
|
|
loading: true,
|
|
state: {},
|
|
service: {},
|
|
error: null,
|
|
lastUpdated: null
|
|
};
|
|
|
|
function ensureOnDemandStore() {
|
|
if (window.Alpine && typeof Alpine.store === 'function') {
|
|
if (!Alpine.store('onDemand')) {
|
|
Alpine.store('onDemand', {
|
|
loading: window.__onDemandStore.loading,
|
|
state: window.__onDemandStore.state,
|
|
service: window.__onDemandStore.service,
|
|
error: window.__onDemandStore.error,
|
|
lastUpdated: window.__onDemandStore.lastUpdated
|
|
});
|
|
}
|
|
const store = Alpine.store('onDemand');
|
|
window.__onDemandStore = store;
|
|
return store;
|
|
}
|
|
return window.__onDemandStore;
|
|
}
|
|
|
|
function markOnDemandLoading() {
|
|
const store = ensureOnDemandStore();
|
|
store.loading = true;
|
|
store.error = null;
|
|
}
|
|
|
|
function updateOnDemandSnapshot(store) {
|
|
if (!window.__onDemandStore) {
|
|
window.__onDemandStore = {};
|
|
}
|
|
window.__onDemandStore.loading = store.loading;
|
|
window.__onDemandStore.state = store.state;
|
|
window.__onDemandStore.service = store.service;
|
|
window.__onDemandStore.error = store.error;
|
|
window.__onDemandStore.lastUpdated = store.lastUpdated;
|
|
}
|
|
|
|
function updateOnDemandStore(data) {
|
|
const store = ensureOnDemandStore();
|
|
store.loading = false;
|
|
store.state = data?.state || {};
|
|
store.service = data?.service || {};
|
|
store.error = (data?.state?.status === 'error') ? (data.state.error || data.message || 'On-demand error') : null;
|
|
store.lastUpdated = Date.now();
|
|
updateOnDemandSnapshot(store);
|
|
document.dispatchEvent(new CustomEvent('onDemand:updated', {
|
|
detail: {
|
|
state: store.state,
|
|
service: store.service,
|
|
error: store.error,
|
|
lastUpdated: store.lastUpdated
|
|
}
|
|
}));
|
|
}
|
|
|
|
function setOnDemandError(message) {
|
|
const store = ensureOnDemandStore();
|
|
store.loading = false;
|
|
store.state = {};
|
|
store.service = {};
|
|
store.error = message || 'Failed to load on-demand status';
|
|
store.lastUpdated = Date.now();
|
|
updateOnDemandSnapshot(store);
|
|
document.dispatchEvent(new CustomEvent('onDemand:updated', {
|
|
detail: {
|
|
state: store.state,
|
|
service: store.service,
|
|
error: store.error,
|
|
lastUpdated: store.lastUpdated
|
|
}
|
|
}));
|
|
}
|
|
|
|
// Track initialization state
|
|
window.pluginManager = window.pluginManager || {};
|
|
window.pluginManager.initialized = false;
|
|
window.pluginManager.initializing = false; // Track if initialization is in progress
|
|
|
|
// Initialize when DOM is ready or when HTMX loads content
|
|
window.initPluginsPage = function() {
|
|
// Prevent duplicate initialization
|
|
if (window.pluginManager.initialized || window.pluginManager.initializing) {
|
|
console.log('Plugin page already initialized or initializing, skipping...');
|
|
return;
|
|
}
|
|
|
|
// Check if required elements exist
|
|
const installedGrid = document.getElementById('installed-plugins-grid');
|
|
if (!installedGrid) {
|
|
console.log('Plugin elements not ready yet');
|
|
return false;
|
|
}
|
|
|
|
window.pluginManager.initializing = true;
|
|
window.__pluginDomReady = true;
|
|
|
|
// Check GitHub auth status immediately (don't wait for full initialization)
|
|
// This can run in parallel with other initialization
|
|
if (window.checkGitHubAuthStatus) {
|
|
console.log('[INIT] Checking GitHub auth status immediately...');
|
|
window.checkGitHubAuthStatus();
|
|
}
|
|
|
|
// If we fetched data before the DOM existed, render it now
|
|
if (window.__pendingInstalledPlugins) {
|
|
console.log('[RENDER] Applying pending installed plugins data');
|
|
renderInstalledPlugins(window.__pendingInstalledPlugins);
|
|
window.__pendingInstalledPlugins = null;
|
|
}
|
|
if (window.__pendingStorePlugins) {
|
|
console.log('[RENDER] Applying pending plugin store data');
|
|
pluginStoreCache = window.__pendingStorePlugins;
|
|
cacheTimestamp = Date.now();
|
|
window.__pendingStorePlugins = null;
|
|
applyStoreFiltersAndSort();
|
|
}
|
|
|
|
initializePlugins();
|
|
|
|
// Event listeners (remove old ones first to prevent duplicates)
|
|
const refreshBtn = document.getElementById('refresh-plugins-btn');
|
|
const updateAllBtn = document.getElementById('update-all-plugins-btn');
|
|
const restartBtn = document.getElementById('restart-display-btn');
|
|
const closeBtn = document.getElementById('close-plugin-config');
|
|
const closeOnDemandModalBtn = document.getElementById('close-on-demand-modal');
|
|
const cancelOnDemandBtn = document.getElementById('cancel-on-demand');
|
|
const onDemandForm = document.getElementById('on-demand-form');
|
|
const onDemandModal = document.getElementById('on-demand-modal');
|
|
|
|
console.log('[initPluginsPage] Setting up button listeners:', {
|
|
refreshBtn: !!refreshBtn,
|
|
updateAllBtn: !!updateAllBtn,
|
|
restartBtn: !!restartBtn
|
|
});
|
|
|
|
if (refreshBtn) {
|
|
refreshBtn.replaceWith(refreshBtn.cloneNode(true));
|
|
document.getElementById('refresh-plugins-btn').addEventListener('click', refreshPlugins);
|
|
console.log('[initPluginsPage] Attached refreshPlugins listener');
|
|
}
|
|
if (updateAllBtn) {
|
|
updateAllBtn.replaceWith(updateAllBtn.cloneNode(true));
|
|
document.getElementById('update-all-plugins-btn').addEventListener('click', runUpdateAllPlugins);
|
|
console.log('[initPluginsPage] Attached runUpdateAllPlugins listener');
|
|
}
|
|
if (restartBtn) {
|
|
restartBtn.replaceWith(restartBtn.cloneNode(true));
|
|
document.getElementById('restart-display-btn').addEventListener('click', restartDisplay);
|
|
console.log('[initPluginsPage] Attached restartDisplay listener');
|
|
}
|
|
// Restore persisted store sort/perPage
|
|
const storeSortEl = document.getElementById('store-sort');
|
|
if (storeSortEl) storeSortEl.value = storeFilterState.sort;
|
|
const storePpEl = document.getElementById('store-per-page');
|
|
if (storePpEl) storePpEl.value = storeFilterState.perPage;
|
|
setupStoreFilterListeners();
|
|
|
|
if (closeBtn) {
|
|
closeBtn.replaceWith(closeBtn.cloneNode(true));
|
|
document.getElementById('close-plugin-config').addEventListener('click', closePluginConfigModal);
|
|
|
|
// View toggle buttons
|
|
document.getElementById('view-toggle-form')?.addEventListener('click', () => switchPluginConfigView('form'));
|
|
document.getElementById('view-toggle-json')?.addEventListener('click', () => switchPluginConfigView('json'));
|
|
|
|
// Reset to defaults button
|
|
document.getElementById('reset-to-defaults-btn')?.addEventListener('click', resetPluginConfigToDefaults);
|
|
|
|
// JSON editor save button
|
|
document.getElementById('save-json-config-btn')?.addEventListener('click', saveConfigFromJsonEditor);
|
|
}
|
|
if (closeOnDemandModalBtn) {
|
|
closeOnDemandModalBtn.replaceWith(closeOnDemandModalBtn.cloneNode(true));
|
|
document.getElementById('close-on-demand-modal').addEventListener('click', closeOnDemandModal);
|
|
}
|
|
if (cancelOnDemandBtn) {
|
|
cancelOnDemandBtn.replaceWith(cancelOnDemandBtn.cloneNode(true));
|
|
document.getElementById('cancel-on-demand').addEventListener('click', closeOnDemandModal);
|
|
}
|
|
if (onDemandForm) {
|
|
onDemandForm.replaceWith(onDemandForm.cloneNode(true));
|
|
document.getElementById('on-demand-form').addEventListener('submit', submitOnDemandRequest);
|
|
}
|
|
if (onDemandModal) {
|
|
onDemandModal.onclick = closeOnDemandModalOnBackdrop;
|
|
}
|
|
|
|
// Load on-demand status silently (false = don't show notification)
|
|
loadOnDemandStatus(false);
|
|
startOnDemandStatusPolling();
|
|
|
|
window.pluginManager.initialized = true;
|
|
window.pluginManager.initializing = false;
|
|
return true;
|
|
}
|
|
|
|
// Consolidated initialization function
|
|
function initializePluginPageWhenReady() {
|
|
console.log('Checking for plugin elements...');
|
|
return window.initPluginsPage();
|
|
}
|
|
|
|
// Single initialization entry point
|
|
(function() {
|
|
console.log('Plugin manager script loaded, setting up initialization...');
|
|
|
|
let initTimer = null;
|
|
|
|
function attemptInit() {
|
|
// Clear any pending timer
|
|
if (initTimer) {
|
|
clearTimeout(initTimer);
|
|
initTimer = null;
|
|
}
|
|
|
|
// Try immediate initialization
|
|
if (initializePluginPageWhenReady()) {
|
|
console.log('Initialized immediately');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Strategy 1: Immediate check (for direct page loads)
|
|
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
|
// DOM is already ready, try immediately with a small delay to ensure scripts are loaded
|
|
initTimer = setTimeout(attemptInit, 50);
|
|
} else {
|
|
// Strategy 2: DOMContentLoaded (for direct page loads)
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initTimer = setTimeout(attemptInit, 50);
|
|
});
|
|
}
|
|
|
|
// Strategy 3: HTMX afterSwap event (for HTMX-loaded content)
|
|
// This is the primary way plugins content is loaded
|
|
if (typeof htmx !== 'undefined') {
|
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
|
const target = event.detail.target;
|
|
// Check if plugins content was swapped in
|
|
if (target.id === 'plugins-content' ||
|
|
target.querySelector('#installed-plugins-grid') ||
|
|
document.getElementById('installed-plugins-grid')) {
|
|
console.log('HTMX swap detected for plugins, initializing...');
|
|
// Reset initialization flag to allow re-initialization after HTMX swap
|
|
window.pluginManager.initialized = false;
|
|
window.pluginManager.initializing = false;
|
|
initTimer = setTimeout(attemptInit, 100);
|
|
}
|
|
}, { once: false }); // Allow multiple swaps
|
|
}
|
|
})();
|
|
|
|
// Initialization guard to prevent multiple initializations
|
|
let pluginsInitialized = false;
|
|
|
|
function initializePlugins() {
|
|
console.log('[initializePlugins] FUNCTION CALLED, pluginsInitialized:', pluginsInitialized);
|
|
// Guard against multiple initializations
|
|
if (pluginsInitialized) {
|
|
console.log('[initializePlugins] Already initialized, skipping (but still setting up handlers)');
|
|
// Still set up handlers even if already initialized (in case page was HTMX swapped)
|
|
console.log('[initializePlugins] Force setting up GitHub handlers anyway...');
|
|
if (typeof setupGitHubInstallHandlers === 'function') {
|
|
setupGitHubInstallHandlers();
|
|
} else {
|
|
console.error('[initializePlugins] setupGitHubInstallHandlers not found!');
|
|
}
|
|
return;
|
|
}
|
|
pluginsInitialized = true;
|
|
|
|
console.log('[initializePlugins] Starting initialization...');
|
|
pluginLog('[INIT] Initializing plugins...');
|
|
|
|
// Check GitHub authentication status
|
|
console.log('[INIT] Checking for checkGitHubAuthStatus function...', {
|
|
exists: typeof window.checkGitHubAuthStatus,
|
|
type: typeof window.checkGitHubAuthStatus
|
|
});
|
|
if (window.checkGitHubAuthStatus) {
|
|
console.log('[INIT] Calling checkGitHubAuthStatus...');
|
|
try {
|
|
window.checkGitHubAuthStatus();
|
|
} catch (error) {
|
|
console.error('[INIT] Error calling checkGitHubAuthStatus:', error);
|
|
}
|
|
} else {
|
|
console.warn('[INIT] checkGitHubAuthStatus not available yet');
|
|
}
|
|
|
|
// Load both installed plugins and plugin store
|
|
loadInstalledPlugins();
|
|
searchPluginStore(true); // Load plugin store with fresh metadata from GitHub
|
|
|
|
// Setup search functionality (with guard against duplicate listeners)
|
|
const searchInput = document.getElementById('plugin-search');
|
|
const categorySelect = document.getElementById('plugin-category');
|
|
|
|
if (searchInput && !searchInput._listenerSetup) {
|
|
searchInput._listenerSetup = true;
|
|
searchInput.addEventListener('input', debounce(searchPluginStore, 300));
|
|
}
|
|
if (categorySelect && !categorySelect._listenerSetup) {
|
|
categorySelect._listenerSetup = true;
|
|
categorySelect.addEventListener('change', searchPluginStore);
|
|
}
|
|
|
|
// Setup GitHub installation handlers
|
|
console.log('[initializePlugins] About to call setupGitHubInstallHandlers...');
|
|
if (typeof setupGitHubInstallHandlers === 'function') {
|
|
console.log('[initializePlugins] setupGitHubInstallHandlers is a function, calling it...');
|
|
setupGitHubInstallHandlers();
|
|
console.log('[initializePlugins] setupGitHubInstallHandlers called');
|
|
} else {
|
|
console.error('[initializePlugins] ERROR: setupGitHubInstallHandlers is not a function! Type:', typeof setupGitHubInstallHandlers);
|
|
}
|
|
|
|
// Setup collapsible section handlers
|
|
setupCollapsibleSections();
|
|
|
|
// Load saved repositories
|
|
loadSavedRepositories();
|
|
|
|
pluginLog('[INIT] Plugins initialized');
|
|
}
|
|
|
|
// Track in-flight requests to prevent duplicates
|
|
// ===== PLUGIN LOADING WITH REQUEST DEDUPLICATION & CACHING =====
|
|
// Prevents redundant API calls by caching results for a short time
|
|
const pluginLoadCache = {
|
|
promise: null, // Current in-flight request
|
|
data: null, // Cached plugin data
|
|
timestamp: 0, // When cache was last updated
|
|
TTL: 3000, // Cache valid for 3 seconds
|
|
isValid() {
|
|
return this.data && (Date.now() - this.timestamp < this.TTL);
|
|
},
|
|
invalidate() {
|
|
this.data = null;
|
|
this.timestamp = 0;
|
|
}
|
|
};
|
|
|
|
// Debug flag - set via safeLocalStorage.setItem('pluginDebug', 'true')
|
|
const PLUGIN_DEBUG = typeof localStorage !== 'undefined' && safeLocalStorage.getItem('pluginDebug') === 'true';
|
|
function pluginLog(...args) {
|
|
if (PLUGIN_DEBUG) console.log(...args);
|
|
}
|
|
|
|
function loadInstalledPlugins(forceRefresh = false) {
|
|
// Return cached data if valid and not forcing refresh
|
|
if (!forceRefresh && pluginLoadCache.isValid()) {
|
|
pluginLog('[CACHE] Returning cached plugin data');
|
|
// Update window.installedPlugins from cache
|
|
window.installedPlugins = pluginLoadCache.data;
|
|
// Dispatch event to notify Alpine component
|
|
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
|
|
detail: { plugins: pluginLoadCache.data }
|
|
}));
|
|
pluginLog('[CACHE] Dispatched pluginsUpdated event from cache');
|
|
// Still render to ensure UI is updated
|
|
renderInstalledPlugins(pluginLoadCache.data);
|
|
return Promise.resolve(pluginLoadCache.data);
|
|
}
|
|
|
|
// If a request is already in progress, return the existing promise
|
|
if (pluginLoadCache.promise) {
|
|
pluginLog('[CACHE] Request in progress, returning existing promise');
|
|
return pluginLoadCache.promise;
|
|
}
|
|
|
|
pluginLog('[FETCH] Loading installed plugins...');
|
|
|
|
// Use PluginAPI if available, otherwise fall back to direct fetch
|
|
const fetchPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ?
|
|
window.PluginAPI.getInstalledPlugins().then(plugins => {
|
|
const pluginsArray = Array.isArray(plugins) ? plugins : [];
|
|
return { status: 'success', data: { plugins: pluginsArray } };
|
|
}) :
|
|
fetch('/api/v3/plugins/installed').then(response => response.json());
|
|
|
|
// Store the promise
|
|
pluginLoadCache.promise = fetchPromise
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
const pluginsData = data.data?.plugins;
|
|
installedPlugins = Array.isArray(pluginsData) ? pluginsData : [];
|
|
|
|
// Update cache
|
|
pluginLoadCache.data = installedPlugins;
|
|
pluginLoadCache.timestamp = Date.now();
|
|
|
|
// Always update window.installedPlugins to ensure Alpine component can detect changes
|
|
const currentPlugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : [];
|
|
const currentIds = currentPlugins.map(p => p.id).sort().join(',');
|
|
const newIds = installedPlugins.map(p => p.id).sort().join(',');
|
|
const pluginsChanged = currentIds !== newIds;
|
|
|
|
if (pluginsChanged) {
|
|
window.installedPlugins = installedPlugins;
|
|
} else {
|
|
// Even if IDs haven't changed, update the array reference to trigger Alpine reactivity
|
|
window.installedPlugins = installedPlugins;
|
|
}
|
|
|
|
// Dispatch event to notify Alpine component to update tabs
|
|
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
|
|
detail: { plugins: installedPlugins }
|
|
}));
|
|
pluginLog('[FETCH] Dispatched pluginsUpdated event with', installedPlugins.length, 'plugins');
|
|
|
|
pluginLog('[FETCH] Loaded', installedPlugins.length, 'plugins');
|
|
|
|
// Debug logging only when enabled
|
|
if (PLUGIN_DEBUG) {
|
|
installedPlugins.forEach(plugin => {
|
|
console.log(`[DEBUG] Plugin ${plugin.id}: enabled=${plugin.enabled}`);
|
|
});
|
|
}
|
|
|
|
renderInstalledPlugins(installedPlugins);
|
|
|
|
// Update count
|
|
const countEl = document.getElementById('installed-count');
|
|
if (countEl) {
|
|
countEl.textContent = installedPlugins.length + ' installed';
|
|
}
|
|
return installedPlugins;
|
|
} else {
|
|
const errorMsg = 'Failed to load installed plugins: ' + data.message;
|
|
showError(errorMsg);
|
|
throw new Error(errorMsg);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading installed plugins:', error);
|
|
let errorMsg = 'Error loading plugins: ' + error.message;
|
|
if (error.message && error.message.includes('Failed to Fetch')) {
|
|
errorMsg += ' - Please try refreshing your browser.';
|
|
}
|
|
showError(errorMsg);
|
|
throw error;
|
|
})
|
|
.finally(() => {
|
|
// Clear the in-flight promise (but keep cache data)
|
|
pluginLoadCache.promise = null;
|
|
});
|
|
|
|
return pluginLoadCache.promise;
|
|
}
|
|
|
|
// Force refresh function for explicit user actions
|
|
function refreshInstalledPlugins() {
|
|
pluginLoadCache.invalidate();
|
|
return loadInstalledPlugins(true);
|
|
}
|
|
|
|
// Expose loadInstalledPlugins on window.pluginManager for Alpine.js integration
|
|
window.pluginManager.loadInstalledPlugins = loadInstalledPlugins;
|
|
// Note: searchPluginStore will be exposed after its definition (see below)
|
|
|
|
function renderInstalledPlugins(plugins) {
|
|
const container = document.getElementById('installed-plugins-grid');
|
|
if (!container) {
|
|
console.warn('[RENDER] installed-plugins-grid not yet available, deferring render until plugin tab loads');
|
|
window.__pendingInstalledPlugins = plugins;
|
|
return;
|
|
}
|
|
|
|
// Always update window.installedPlugins to ensure Alpine component reactivity
|
|
window.installedPlugins = plugins;
|
|
pluginLog('[RENDER] Set window.installedPlugins to:', plugins.length, 'plugins');
|
|
|
|
// Dispatch event to notify Alpine component to update tabs
|
|
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
|
|
detail: { plugins: plugins }
|
|
}));
|
|
pluginLog('[RENDER] Dispatched pluginsUpdated event');
|
|
|
|
// Also try direct Alpine update as fallback
|
|
if (window.Alpine && document.querySelector('[x-data="app()"]')) {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
appElement._x_dataStack[0].installedPlugins = plugins;
|
|
if (typeof appElement._x_dataStack[0].updatePluginTabs === 'function') {
|
|
appElement._x_dataStack[0].updatePluginTabs();
|
|
pluginLog('[RENDER] Triggered Alpine.js to update plugin tabs directly');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (plugins.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="col-span-full empty-state">
|
|
<div class="empty-state-icon">
|
|
<i class="fas fa-plug"></i>
|
|
</div>
|
|
<p class="text-lg font-medium text-gray-700 mb-1">No plugins installed</p>
|
|
<p class="text-sm text-gray-500">Install plugins from the store to get started</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Helper function to escape attributes for use in HTML
|
|
const escapeAttr = (text) => {
|
|
return (text || '').replace(/'/g, "\\'").replace(/"/g, '"');
|
|
};
|
|
|
|
// Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping)
|
|
// JSON.stringify returns a quoted string, so we can use it directly in JavaScript
|
|
const escapeJs = (text) => {
|
|
return JSON.stringify(text || '');
|
|
};
|
|
|
|
container.innerHTML = plugins.map(plugin => {
|
|
// Convert enabled to boolean for consistent rendering
|
|
const enabledBool = Boolean(plugin.enabled);
|
|
|
|
// Debug: Log enabled status during rendering (only when debug enabled)
|
|
if (PLUGIN_DEBUG) {
|
|
console.log(`[DEBUG RENDER] Plugin ${plugin.id}: enabled=${enabledBool}`);
|
|
}
|
|
|
|
// Escape plugin ID for use in HTML attributes and JavaScript
|
|
const escapedPluginId = escapeAttr(plugin.id);
|
|
|
|
return `
|
|
<div class="plugin-card">
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center flex-wrap gap-2 mb-2">
|
|
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(plugin.name || plugin.id)}</h4>
|
|
${plugin.is_starlark_app ? '<span class="badge badge-warning"><i class="fas fa-star mr-1"></i>Starlark</span>' : ''}
|
|
${plugin.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : ''}
|
|
</div>
|
|
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
|
|
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p>
|
|
${plugin.version ? `<p class="flex items-center"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}</p>` : ''}
|
|
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
|
|
</div>
|
|
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p>
|
|
</div>
|
|
<!-- Toggle Switch in Top Right -->
|
|
<div class="flex-shrink-0 ml-4">
|
|
<label class="relative inline-flex items-center cursor-pointer group">
|
|
<input type="checkbox"
|
|
class="sr-only peer"
|
|
id="toggle-${escapedPluginId}"
|
|
${enabledBool ? 'checked' : ''}
|
|
data-plugin-id="${escapedPluginId}"
|
|
data-action="toggle">
|
|
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg border-2 transition-all duration-200 ${enabledBool ? 'bg-green-50 border-green-500' : 'bg-gray-50 border-gray-300'} hover:shadow-md group-hover:scale-105">
|
|
<!-- Toggle Switch -->
|
|
<div class="relative w-14 h-7 ${enabledBool ? 'bg-green-500' : 'bg-gray-300'} peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:bg-green-500 transition-colors duration-200 ease-in-out shadow-inner">
|
|
<div class="absolute top-[3px] left-[3px] bg-white ${enabledBool ? 'translate-x-full' : ''} border-2 ${enabledBool ? 'border-green-500' : 'border-gray-400'} rounded-full h-5 w-5 transition-all duration-200 ease-in-out shadow-sm flex items-center justify-center">
|
|
${enabledBool ? '<i class="fas fa-check text-green-600 text-xs"></i>' : '<i class="fas fa-times text-gray-400 text-xs"></i>'}
|
|
</div>
|
|
</div>
|
|
<!-- Label with Icon -->
|
|
<span class="text-sm font-semibold ${enabledBool ? 'text-green-700' : 'text-gray-600'} flex items-center gap-1.5" id="toggle-label-${escapedPluginId}">
|
|
${enabledBool ? '<i class="fas fa-toggle-on text-green-600"></i>' : '<i class="fas fa-toggle-off text-gray-400"></i>'}
|
|
<span>${enabledBool ? 'Enabled' : 'Disabled'}</span>
|
|
</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plugin Tags -->
|
|
${plugin.tags && plugin.tags.length > 0 ? `
|
|
<div class="flex flex-wrap gap-1.5 mb-4">
|
|
${plugin.tags.map(tag => `<span class="badge badge-info">${escapeHtml(tag)}</span>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Plugin Actions -->
|
|
<div style="display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #e5e7eb;">
|
|
<button class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-semibold"
|
|
style="display: flex; width: 100%; justify-content: center;"
|
|
data-plugin-id="${escapedPluginId}"
|
|
data-action="configure">
|
|
<i class="fas fa-cog mr-2"></i>Configure
|
|
</button>
|
|
<div style="display: flex; gap: 0.5rem;">
|
|
<button class="btn bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-md text-sm font-semibold"
|
|
style="flex: 1;"
|
|
data-plugin-id="${escapedPluginId}"
|
|
data-action="update">
|
|
<i class="fas fa-sync mr-2"></i>Update
|
|
</button>
|
|
<button class="btn bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-semibold"
|
|
style="flex: 1;"
|
|
data-plugin-id="${escapedPluginId}"
|
|
data-action="uninstall">
|
|
<i class="fas fa-trash mr-2"></i>Uninstall
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Set up event delegation for plugin action buttons (fallback if onclick doesn't work)
|
|
// Only set up once per container to avoid redundant listeners
|
|
const setupEventDelegation = () => {
|
|
const container = document.getElementById('installed-plugins-grid');
|
|
if (!container) {
|
|
pluginLog('[RENDER] installed-plugins-grid not found for event delegation');
|
|
return;
|
|
}
|
|
|
|
// Skip if already set up (guard against multiple calls)
|
|
if (container._eventDelegationSetup) {
|
|
pluginLog('[RENDER] Event delegation already set up, skipping');
|
|
return;
|
|
}
|
|
|
|
// Mark as set up
|
|
container._eventDelegationSetup = true;
|
|
container._pluginActionHandler = handlePluginAction;
|
|
|
|
// Add listeners for both click and change events
|
|
container.addEventListener('click', handlePluginAction, true);
|
|
container.addEventListener('change', handlePluginAction, true);
|
|
pluginLog('[RENDER] Event delegation set up for installed-plugins-grid');
|
|
};
|
|
|
|
// Set up immediately
|
|
setupEventDelegation();
|
|
|
|
// Also retry after a short delay to ensure it's attached even if container wasn't ready
|
|
setTimeout(setupEventDelegation, 100);
|
|
}
|
|
|
|
function handlePluginAction(event) {
|
|
// Check for both button and input (for toggle)
|
|
const button = event.target.closest('button[data-action]') || event.target.closest('input[data-action]');
|
|
if (!button) return;
|
|
|
|
const action = button.getAttribute('data-action');
|
|
const pluginId = button.getAttribute('data-plugin-id');
|
|
|
|
if (!pluginId) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
console.log('[EVENT DELEGATION] Plugin action:', action, 'Plugin ID:', pluginId);
|
|
|
|
// Helper function to wait for a function to be available
|
|
const waitForFunction = (funcName, maxAttempts = 10, delay = 50) => {
|
|
return new Promise((resolve, reject) => {
|
|
let attempts = 0;
|
|
const check = () => {
|
|
attempts++;
|
|
if (window[funcName] && typeof window[funcName] === 'function') {
|
|
resolve(window[funcName]);
|
|
} else if (attempts >= maxAttempts) {
|
|
reject(new Error(`${funcName} not available after ${maxAttempts} attempts`));
|
|
} else {
|
|
setTimeout(check, delay);
|
|
}
|
|
};
|
|
check();
|
|
});
|
|
};
|
|
|
|
switch(action) {
|
|
case 'toggle':
|
|
// Get the current enabled state from plugin data (source of truth)
|
|
// rather than from the checkbox DOM which might be out of sync
|
|
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
|
|
|
|
// Special handling: If plugin data isn't found or is stale, fallback to DOM but be careful
|
|
// If the user clicked the checkbox, the 'checked' property has *already* toggled in the DOM
|
|
// (even though we preventDefault later, sometimes it's too late for the property read)
|
|
// However, we used preventDefault() in the global handler, so the checkbox state *should* be reliable if we didn't touch it.
|
|
|
|
// BUT: The issue is that 'currentEnabled' calculation might be wrong if window.installedPlugins is outdated.
|
|
// If the user toggles ON, enabled becomes true. If they click again, we want enabled=false.
|
|
|
|
// Let's try a simpler approach: Use the checkbox state as the source of truth for the *desired* state
|
|
// Since we preventDefault(), the checkbox state reflects the *old* state (before the click)
|
|
// wait... if we preventDefault() on 'click', the checkbox does NOT change visually or internally.
|
|
// So button.checked is the OLD state.
|
|
// We want the NEW state to be !button.checked.
|
|
|
|
let currentEnabled;
|
|
|
|
if (plugin) {
|
|
currentEnabled = Boolean(plugin.enabled);
|
|
} else if (button.type === 'checkbox') {
|
|
currentEnabled = button.checked;
|
|
} else {
|
|
currentEnabled = false;
|
|
}
|
|
|
|
// Toggle the state - we want the opposite of current state
|
|
const isChecked = !currentEnabled;
|
|
|
|
console.log('[DEBUG toggle] Plugin:', pluginId, 'Current enabled (from data):', currentEnabled, 'New state:', isChecked, 'Event type:', event.type);
|
|
|
|
waitForFunction('togglePlugin', 10, 50)
|
|
.then(toggleFunc => {
|
|
toggleFunc(pluginId, isChecked);
|
|
})
|
|
.catch(error => {
|
|
console.error('[EVENT DELEGATION]', error.message);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Toggle function not loaded. Please refresh the page.', 'error');
|
|
} else {
|
|
alert('Toggle function not loaded. Please refresh the page.');
|
|
}
|
|
});
|
|
break;
|
|
case 'configure':
|
|
waitForFunction('configurePlugin', 10, 50)
|
|
.then(configureFunc => {
|
|
configureFunc(pluginId);
|
|
})
|
|
.catch(error => {
|
|
console.error('[EVENT DELEGATION]', error.message);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Configure function not loaded. Please refresh the page.', 'error');
|
|
} else {
|
|
alert('Configure function not loaded. Please refresh the page.');
|
|
}
|
|
});
|
|
break;
|
|
case 'update':
|
|
waitForFunction('updatePlugin', 10, 50)
|
|
.then(updateFunc => {
|
|
updateFunc(pluginId);
|
|
})
|
|
.catch(error => {
|
|
console.error('[EVENT DELEGATION]', error.message);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Update function not loaded. Please refresh the page.', 'error');
|
|
} else {
|
|
alert('Update function not loaded. Please refresh the page.');
|
|
}
|
|
});
|
|
break;
|
|
case 'uninstall':
|
|
if (pluginId.startsWith('starlark:')) {
|
|
// Starlark app uninstall uses dedicated endpoint
|
|
const starlarkAppId = pluginId.slice('starlark:'.length);
|
|
if (!confirm(`Uninstall Starlark app "${starlarkAppId}"?`)) break;
|
|
fetch(`/api/v3/starlark/apps/${encodeURIComponent(starlarkAppId)}`, {method: 'DELETE'})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
if (typeof showNotification === 'function') showNotification('Starlark app uninstalled', 'success');
|
|
else alert('Starlark app uninstalled');
|
|
if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
|
|
else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins();
|
|
} else {
|
|
alert('Uninstall failed: ' + (data.message || 'Unknown error'));
|
|
}
|
|
})
|
|
.catch(err => alert('Uninstall failed: ' + err.message));
|
|
} else {
|
|
waitForFunction('uninstallPlugin', 10, 50)
|
|
.then(uninstallFunc => {
|
|
uninstallFunc(pluginId);
|
|
})
|
|
.catch(error => {
|
|
console.error('[EVENT DELEGATION]', error.message);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Uninstall function not loaded. Please refresh the page.', 'error');
|
|
} else {
|
|
alert('Uninstall function not loaded. Please refresh the page.');
|
|
}
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
function findInstalledPlugin(pluginId) {
|
|
const plugins = window.installedPlugins || installedPlugins || [];
|
|
if (!plugins || plugins.length === 0) {
|
|
return undefined;
|
|
}
|
|
return plugins.find(plugin => plugin.id === pluginId);
|
|
}
|
|
|
|
function resolvePluginDisplayName(pluginId) {
|
|
const plugin = findInstalledPlugin(pluginId);
|
|
if (!plugin) {
|
|
return pluginId;
|
|
}
|
|
return plugin.name || pluginId;
|
|
}
|
|
|
|
function loadOnDemandStatus(fromRefreshButton = false) {
|
|
if (!hasLoadedOnDemandStatus || fromRefreshButton) {
|
|
markOnDemandLoading();
|
|
}
|
|
|
|
return fetch('/api/v3/display/on-demand/status')
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.status === 'success') {
|
|
updateOnDemandStore(result.data);
|
|
hasLoadedOnDemandStatus = true;
|
|
if (fromRefreshButton && typeof showNotification === 'function') {
|
|
showNotification('On-demand status refreshed', 'success');
|
|
}
|
|
} else {
|
|
const message = result.message || 'Failed to load on-demand status';
|
|
setOnDemandError(message);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(message, 'error');
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching on-demand status:', error);
|
|
setOnDemandError(error?.message || 'Error fetching on-demand status');
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error fetching on-demand status: ' + error.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function startOnDemandStatusPolling() {
|
|
if (onDemandStatusInterval) {
|
|
clearInterval(onDemandStatusInterval);
|
|
}
|
|
onDemandStatusInterval = setInterval(() => loadOnDemandStatus(false), 15000);
|
|
}
|
|
|
|
window.loadOnDemandStatus = loadOnDemandStatus;
|
|
|
|
async function runUpdateAllPlugins() {
|
|
console.log('[runUpdateAllPlugins] Button clicked, checking for updates...');
|
|
const button = document.getElementById('update-all-plugins-btn');
|
|
|
|
if (!button) {
|
|
showNotification('Unable to locate bulk update controls. Refresh the Plugin Manager tab.', 'error');
|
|
return;
|
|
}
|
|
|
|
if (button.dataset.running === 'true') {
|
|
return;
|
|
}
|
|
|
|
const plugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : [];
|
|
if (!plugins.length) {
|
|
showNotification('No installed plugins to update.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const originalContent = button.innerHTML;
|
|
button.dataset.running = 'true';
|
|
button.disabled = true;
|
|
button.classList.add('opacity-60', 'cursor-wait');
|
|
|
|
let updated = 0, upToDate = 0, failed = 0;
|
|
|
|
try {
|
|
for (let i = 0; i < plugins.length; i++) {
|
|
const plugin = plugins[i];
|
|
const pluginId = plugin.id;
|
|
button.innerHTML = `<i class="fas fa-sync fa-spin mr-2"></i>Updating ${i + 1}/${plugins.length}...`;
|
|
|
|
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();
|
|
|
|
if (data.status === 'success') {
|
|
if (data.message && data.message.includes('already up to date')) {
|
|
upToDate++;
|
|
} else {
|
|
updated++;
|
|
}
|
|
} else {
|
|
failed++;
|
|
}
|
|
} catch (error) {
|
|
failed++;
|
|
console.error(`Error updating ${pluginId}:`, error);
|
|
}
|
|
}
|
|
|
|
// Refresh plugin list once at the end
|
|
if (updated > 0) {
|
|
loadInstalledPlugins(true);
|
|
}
|
|
|
|
const parts = [];
|
|
if (updated > 0) parts.push(`${updated} updated`);
|
|
if (upToDate > 0) parts.push(`${upToDate} already up to date`);
|
|
if (failed > 0) parts.push(`${failed} failed`);
|
|
const type = failed > 0 ? (updated > 0 ? 'warning' : 'error') : 'success';
|
|
showNotification(parts.join(', '), type);
|
|
} catch (error) {
|
|
console.error('Bulk plugin update failed:', error);
|
|
showNotification('Failed to update all plugins: ' + error.message, 'error');
|
|
} finally {
|
|
button.innerHTML = originalContent;
|
|
button.disabled = false;
|
|
button.classList.remove('opacity-60', 'cursor-wait');
|
|
button.dataset.running = 'false';
|
|
}
|
|
}
|
|
|
|
// Initialize on-demand modal setup (runs unconditionally since modal is in base.html)
|
|
function initializeOnDemandModal() {
|
|
const closeOnDemandModalBtn = document.getElementById('close-on-demand-modal');
|
|
const cancelOnDemandBtn = document.getElementById('cancel-on-demand');
|
|
const onDemandForm = document.getElementById('on-demand-form');
|
|
const onDemandModal = document.getElementById('on-demand-modal');
|
|
|
|
if (closeOnDemandModalBtn && !closeOnDemandModalBtn.dataset.initialized) {
|
|
closeOnDemandModalBtn.replaceWith(closeOnDemandModalBtn.cloneNode(true));
|
|
const newBtn = document.getElementById('close-on-demand-modal');
|
|
if (newBtn) {
|
|
newBtn.dataset.initialized = 'true';
|
|
newBtn.addEventListener('click', closeOnDemandModal);
|
|
}
|
|
}
|
|
if (cancelOnDemandBtn && !cancelOnDemandBtn.dataset.initialized) {
|
|
cancelOnDemandBtn.replaceWith(cancelOnDemandBtn.cloneNode(true));
|
|
const newBtn = document.getElementById('cancel-on-demand');
|
|
if (newBtn) {
|
|
newBtn.dataset.initialized = 'true';
|
|
newBtn.addEventListener('click', closeOnDemandModal);
|
|
}
|
|
}
|
|
if (onDemandForm && !onDemandForm.dataset.initialized) {
|
|
onDemandForm.replaceWith(onDemandForm.cloneNode(true));
|
|
const newForm = document.getElementById('on-demand-form');
|
|
if (newForm) {
|
|
newForm.dataset.initialized = 'true';
|
|
newForm.addEventListener('submit', submitOnDemandRequest);
|
|
}
|
|
}
|
|
if (onDemandModal && !onDemandModal.dataset.initialized) {
|
|
onDemandModal.dataset.initialized = 'true';
|
|
onDemandModal.onclick = closeOnDemandModalOnBackdrop;
|
|
}
|
|
}
|
|
|
|
// Store the real implementation and replace the stub
|
|
window.__openOnDemandModalImpl = function(pluginId) {
|
|
console.log('[__openOnDemandModalImpl] Called with pluginId:', pluginId);
|
|
const plugin = findInstalledPlugin(pluginId);
|
|
console.log('[__openOnDemandModalImpl] Found plugin:', plugin ? plugin.id : 'NOT FOUND');
|
|
if (!plugin) {
|
|
console.warn('[__openOnDemandModalImpl] Plugin not found, installedPlugins:', window.installedPlugins?.length || 0);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`Plugin ${pluginId} not found`, 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Note: On-demand can work with disabled plugins - the backend will temporarily enable them
|
|
// We still log it for debugging but don't block the modal
|
|
if (!plugin.enabled) {
|
|
console.log('[__openOnDemandModalImpl] Plugin is disabled, but on-demand will temporarily enable it');
|
|
}
|
|
|
|
currentOnDemandPluginId = pluginId;
|
|
console.log('[__openOnDemandModalImpl] Setting currentOnDemandPluginId to:', pluginId);
|
|
|
|
// Ensure modal is initialized
|
|
console.log('[__openOnDemandModalImpl] Initializing modal...');
|
|
initializeOnDemandModal();
|
|
|
|
const modal = document.getElementById('on-demand-modal');
|
|
const modeSelect = document.getElementById('on-demand-mode');
|
|
const modeHint = document.getElementById('on-demand-mode-hint');
|
|
const durationInput = document.getElementById('on-demand-duration');
|
|
const pinnedCheckbox = document.getElementById('on-demand-pinned');
|
|
const startServiceCheckbox = document.getElementById('on-demand-start-service');
|
|
const modalTitle = document.getElementById('on-demand-modal-title');
|
|
|
|
console.log('[__openOnDemandModalImpl] Modal elements check:', {
|
|
modal: !!modal,
|
|
modeSelect: !!modeSelect,
|
|
modeHint: !!modeHint,
|
|
durationInput: !!durationInput,
|
|
pinnedCheckbox: !!pinnedCheckbox,
|
|
startServiceCheckbox: !!startServiceCheckbox,
|
|
modalTitle: !!modalTitle
|
|
});
|
|
|
|
if (!modal || !modeSelect || !modeHint || !durationInput || !pinnedCheckbox || !startServiceCheckbox || !modalTitle) {
|
|
console.error('On-demand modal elements not found', {
|
|
modal: !!modal,
|
|
modeSelect: !!modeSelect,
|
|
modeHint: !!modeHint,
|
|
durationInput: !!durationInput,
|
|
pinnedCheckbox: !!pinnedCheckbox,
|
|
startServiceCheckbox: !!startServiceCheckbox,
|
|
modalTitle: !!modalTitle
|
|
});
|
|
return;
|
|
}
|
|
|
|
console.log('[__openOnDemandModalImpl] All elements found, opening modal...');
|
|
|
|
modalTitle.textContent = `Run ${resolvePluginDisplayName(pluginId)} On-Demand`;
|
|
modeSelect.innerHTML = '';
|
|
|
|
const displayModes = Array.isArray(plugin.display_modes) && plugin.display_modes.length > 0
|
|
? plugin.display_modes
|
|
: [pluginId];
|
|
|
|
displayModes.forEach(mode => {
|
|
const option = document.createElement('option');
|
|
option.value = mode;
|
|
option.textContent = mode;
|
|
modeSelect.appendChild(option);
|
|
});
|
|
|
|
if (displayModes.length > 1) {
|
|
modeHint.textContent = 'Select the display mode to show on the matrix.';
|
|
} else {
|
|
modeHint.textContent = 'This plugin exposes a single display mode.';
|
|
}
|
|
|
|
durationInput.value = '';
|
|
pinnedCheckbox.checked = false;
|
|
startServiceCheckbox.checked = true;
|
|
|
|
// Check service status and show warning if needed
|
|
fetch('/api/v3/display/on-demand/status')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const serviceWarning = document.getElementById('on-demand-service-warning');
|
|
const serviceActive = data?.data?.service?.active || false;
|
|
|
|
if (serviceWarning) {
|
|
if (!serviceActive) {
|
|
serviceWarning.classList.remove('hidden');
|
|
// Auto-check the start service checkbox
|
|
startServiceCheckbox.checked = true;
|
|
} else {
|
|
serviceWarning.classList.add('hidden');
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error checking service status:', error);
|
|
});
|
|
|
|
console.log('[__openOnDemandModalImpl] Setting modal display to flex');
|
|
// Force modal to be visible and properly positioned
|
|
// Remove all inline styles that might interfere
|
|
modal.removeAttribute('style');
|
|
// Set explicit positioning to ensure it's visible
|
|
modal.style.cssText = 'position: fixed !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; display: flex !important; visibility: visible !important; opacity: 1 !important; z-index: 9999 !important; margin: 0 !important; padding: 0 !important;';
|
|
|
|
// Ensure modal content is centered
|
|
const modalContent = modal.querySelector('.modal-content');
|
|
if (modalContent) {
|
|
modalContent.style.margin = 'auto';
|
|
modalContent.style.maxHeight = '90vh';
|
|
modalContent.style.overflowY = 'auto';
|
|
}
|
|
|
|
// Scroll to top of page to ensure modal is visible
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
|
|
// Force a reflow to ensure styles are applied
|
|
modal.offsetHeight;
|
|
console.log('[__openOnDemandModalImpl] Modal display set, should be visible now. Modal element:', modal);
|
|
console.log('[__openOnDemandModalImpl] Modal computed styles:', {
|
|
display: window.getComputedStyle(modal).display,
|
|
visibility: window.getComputedStyle(modal).visibility,
|
|
opacity: window.getComputedStyle(modal).opacity,
|
|
zIndex: window.getComputedStyle(modal).zIndex,
|
|
position: window.getComputedStyle(modal).position
|
|
});
|
|
// Also check if modal is actually in the viewport
|
|
const rect = modal.getBoundingClientRect();
|
|
console.log('[__openOnDemandModalImpl] Modal bounding rect:', {
|
|
top: rect.top,
|
|
left: rect.left,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
visible: rect.width > 0 && rect.height > 0
|
|
});
|
|
};
|
|
|
|
// Replace the stub with the real implementation
|
|
window.openOnDemandModal = window.__openOnDemandModalImpl;
|
|
|
|
function closeOnDemandModal() {
|
|
const modal = document.getElementById('on-demand-modal');
|
|
if (modal) {
|
|
modal.style.display = 'none';
|
|
}
|
|
currentOnDemandPluginId = null;
|
|
}
|
|
|
|
function submitOnDemandRequest(event) {
|
|
event.preventDefault();
|
|
console.log('[submitOnDemandRequest] Form submitted, currentOnDemandPluginId:', currentOnDemandPluginId);
|
|
|
|
if (!currentOnDemandPluginId) {
|
|
console.error('[submitOnDemandRequest] No plugin ID set');
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Select a plugin before starting on-demand mode.', 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const form = document.getElementById('on-demand-form');
|
|
if (!form) {
|
|
console.error('[submitOnDemandRequest] Form not found');
|
|
return;
|
|
}
|
|
|
|
console.log('[submitOnDemandRequest] Form found, processing...');
|
|
|
|
const formData = new FormData(form);
|
|
const mode = formData.get('mode');
|
|
const pinned = formData.get('pinned') === 'on';
|
|
const startService = formData.get('start_service') === 'on';
|
|
const durationValue = formData.get('duration');
|
|
|
|
const payload = {
|
|
plugin_id: currentOnDemandPluginId,
|
|
mode,
|
|
pinned,
|
|
start_service: startService
|
|
};
|
|
|
|
if (durationValue !== null && durationValue !== '') {
|
|
const parsedDuration = parseInt(durationValue, 10);
|
|
if (!Number.isNaN(parsedDuration) && parsedDuration >= 0) {
|
|
payload.duration = parsedDuration;
|
|
}
|
|
}
|
|
|
|
console.log('[submitOnDemandRequest] Payload:', payload);
|
|
markOnDemandLoading();
|
|
|
|
fetch('/api/v3/display/on-demand/start', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(payload)
|
|
})
|
|
.then(response => {
|
|
console.log('[submitOnDemandRequest] Response status:', response.status);
|
|
return response.json();
|
|
})
|
|
.then(result => {
|
|
console.log('[submitOnDemandRequest] Response data:', result);
|
|
if (result.status === 'success') {
|
|
if (typeof showNotification === 'function') {
|
|
const pluginName = resolvePluginDisplayName(currentOnDemandPluginId);
|
|
showNotification(`Requested on-demand mode for ${pluginName}`, 'success');
|
|
}
|
|
closeOnDemandModal();
|
|
setTimeout(() => loadOnDemandStatus(true), 700);
|
|
} else {
|
|
console.error('[submitOnDemandRequest] Request failed:', result);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(result.message || 'Failed to start on-demand mode', 'error');
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('[submitOnDemandRequest] Error starting on-demand mode:', error);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error starting on-demand mode: ' + error.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function requestOnDemandStop({ stopService = false } = {}) {
|
|
markOnDemandLoading();
|
|
return fetch('/api/v3/display/on-demand/stop', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
stop_service: stopService
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.status === 'success') {
|
|
if (typeof showNotification === 'function') {
|
|
const message = stopService
|
|
? 'On-demand mode stop requested and display service will be stopped.'
|
|
: 'On-demand mode stop requested';
|
|
showNotification(message, 'success');
|
|
}
|
|
setTimeout(() => loadOnDemandStatus(true), 700);
|
|
} else {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(result.message || 'Failed to stop on-demand mode', 'error');
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error stopping on-demand mode:', error);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error stopping on-demand mode: ' + error.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function stopOnDemand(event) {
|
|
const stopService = event && event.shiftKey;
|
|
requestOnDemandStop({ stopService });
|
|
}
|
|
|
|
// Store the real implementation and replace the stub
|
|
window.__requestOnDemandStopImpl = requestOnDemandStop;
|
|
window.requestOnDemandStop = requestOnDemandStop;
|
|
|
|
function closeOnDemandModalOnBackdrop(event) {
|
|
if (event.target === event.currentTarget) {
|
|
closeOnDemandModal();
|
|
}
|
|
}
|
|
|
|
// configurePlugin is already defined at the top of the script - no need to redefine
|
|
|
|
window.showPluginConfigModal = function(pluginId, config) {
|
|
const modal = document.getElementById('plugin-config-modal');
|
|
const title = document.getElementById('plugin-config-title');
|
|
const content = document.getElementById('plugin-config-content');
|
|
|
|
if (!modal) {
|
|
console.error('[DEBUG] Plugin config modal element not found');
|
|
if (typeof showError === 'function') {
|
|
showError('Plugin configuration modal not found. Please refresh the page.');
|
|
} else if (typeof showNotification === 'function') {
|
|
showNotification('Plugin configuration modal not found. Please refresh the page.', 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
console.log('[DEBUG] ===== Opening plugin config modal =====');
|
|
console.log('[DEBUG] Plugin ID:', pluginId);
|
|
console.log('[DEBUG] Config:', config);
|
|
|
|
// Check if modal elements exist (already checked above, but double-check for safety)
|
|
if (!title) {
|
|
console.error('[DEBUG] Plugin config title element not found');
|
|
if (typeof showError === 'function') {
|
|
showError('Plugin configuration title element not found.');
|
|
} else if (typeof showNotification === 'function') {
|
|
showNotification('Plugin configuration title element not found.', 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!content) {
|
|
console.error('[DEBUG] Plugin config content element not found');
|
|
if (typeof showError === 'function') {
|
|
showError('Plugin configuration content element not found.');
|
|
} else if (typeof showNotification === 'function') {
|
|
showNotification('Plugin configuration content element not found.', 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Initialize state
|
|
currentPluginConfigState.pluginId = pluginId;
|
|
currentPluginConfigState.config = config || {};
|
|
currentPluginConfigState.jsonEditor = null;
|
|
|
|
// Reset view to form
|
|
switchPluginConfigView('form');
|
|
|
|
// Hide validation errors
|
|
displayValidationErrors([]);
|
|
|
|
title.textContent = `Configure ${pluginId}`;
|
|
|
|
// Show loading state while form is generated
|
|
content.innerHTML = '<div class="flex items-center justify-center py-8"><i class="fas fa-spinner fa-spin text-2xl text-blue-600"></i></div>';
|
|
|
|
// Move modal to body to avoid z-index/overflow issues
|
|
if (modal.parentElement !== document.body) {
|
|
document.body.appendChild(modal);
|
|
}
|
|
|
|
// Remove any inline display:none that might be in the HTML FIRST
|
|
// This is critical because the HTML template has style="display: none;" inline
|
|
// We need to remove it before setting new styles
|
|
let currentStyle = modal.getAttribute('style') || '';
|
|
if (currentStyle.includes('display: none') || currentStyle.includes('display:none')) {
|
|
currentStyle = currentStyle.replace(/display:\s*none[;]?/gi, '').trim();
|
|
// Clean up any double semicolons or trailing semicolons
|
|
currentStyle = currentStyle.replace(/;;+/g, ';').replace(/^;|;$/g, '');
|
|
if (currentStyle) {
|
|
modal.setAttribute('style', currentStyle);
|
|
} else {
|
|
modal.removeAttribute('style');
|
|
}
|
|
}
|
|
|
|
// Show modal immediately - use important to override any other styles
|
|
// Also ensure visibility, opacity, and z-index are set correctly
|
|
modal.style.setProperty('display', 'flex', 'important');
|
|
modal.style.setProperty('visibility', 'visible', 'important');
|
|
modal.style.setProperty('opacity', '1', 'important');
|
|
modal.style.setProperty('z-index', '9999', 'important');
|
|
modal.style.setProperty('position', 'fixed', 'important');
|
|
|
|
// Ensure modal content is also visible
|
|
const modalContent = modal.querySelector('.modal-content');
|
|
if (modalContent) {
|
|
modalContent.style.setProperty('display', 'block', 'important');
|
|
modalContent.style.setProperty('visibility', 'visible', 'important');
|
|
modalContent.style.setProperty('opacity', '1', 'important');
|
|
}
|
|
|
|
console.log('[DEBUG] Modal display set to flex');
|
|
console.log('[DEBUG] Modal computed style:', window.getComputedStyle(modal).display);
|
|
console.log('[DEBUG] Modal z-index:', window.getComputedStyle(modal).zIndex);
|
|
console.log('[DEBUG] Modal visibility:', window.getComputedStyle(modal).visibility);
|
|
console.log('[DEBUG] Modal opacity:', window.getComputedStyle(modal).opacity);
|
|
console.log('[DEBUG] Modal in DOM:', document.body.contains(modal));
|
|
console.log('[DEBUG] Modal parent:', modal.parentElement?.tagName);
|
|
console.log('[DEBUG] Modal rect:', modal.getBoundingClientRect());
|
|
|
|
// Load schema for validation
|
|
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`)
|
|
.then(r => r.json())
|
|
.then(schemaData => {
|
|
if (schemaData.status === 'success' && schemaData.data?.schema) {
|
|
currentPluginConfigState.schema = schemaData.data.schema;
|
|
}
|
|
})
|
|
.catch(err => console.warn('Could not load schema:', err));
|
|
|
|
// Generate form asynchronously
|
|
generatePluginConfigForm(pluginId, config)
|
|
.then(formHtml => {
|
|
console.log('[DEBUG] Form generated, setting content. HTML length:', formHtml.length);
|
|
content.innerHTML = formHtml;
|
|
|
|
// Attach form submit handler after form is inserted
|
|
const form = document.getElementById('plugin-config-form');
|
|
if (form) {
|
|
form.addEventListener('submit', handlePluginConfigSubmit);
|
|
console.log('Form submit handler attached');
|
|
}
|
|
|
|
})
|
|
.catch(error => {
|
|
console.error('Error generating config form:', error);
|
|
content.innerHTML = '<p class="text-red-600">Error loading configuration form</p>';
|
|
});
|
|
}
|
|
|
|
// Helper function to get the full property object from schema
|
|
function getSchemaProperty(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) {
|
|
// Last part - return the property
|
|
return current[part];
|
|
} else if (current[part].properties) {
|
|
// Navigate into nested object
|
|
current = current[part].properties;
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Helper function to find property type in nested schema using dot notation
|
|
function getSchemaPropertyType(schema, path) {
|
|
const prop = getSchemaProperty(schema, path);
|
|
return prop; // Return the full property object (was returning just type, but callers expect object)
|
|
}
|
|
|
|
// 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
|
|
return str.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
|
|
}
|
|
|
|
// Helper function to convert dot notation to nested object
|
|
function 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;
|
|
}
|
|
|
|
// Helper function to collect all boolean fields from schema (including nested)
|
|
function collectBooleanFields(schema, prefix = '') {
|
|
const boolFields = [];
|
|
|
|
if (!schema || !schema.properties) return boolFields;
|
|
|
|
Object.entries(schema.properties).forEach(([key, prop]) => {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
|
|
if (prop.type === 'boolean') {
|
|
boolFields.push(fullKey);
|
|
} else if (prop.type === 'object' && prop.properties) {
|
|
boolFields.push(...collectBooleanFields(prop, fullKey));
|
|
}
|
|
});
|
|
|
|
return boolFields;
|
|
}
|
|
|
|
function handlePluginConfigSubmit(e) {
|
|
e.preventDefault();
|
|
console.log('Form submitted');
|
|
|
|
if (!currentPluginConfig) {
|
|
showNotification('Plugin configuration not loaded', 'error');
|
|
return;
|
|
}
|
|
|
|
const pluginId = currentPluginConfig.pluginId;
|
|
const schema = currentPluginConfig.schema;
|
|
const form = e.target;
|
|
|
|
// Fix invalid hidden fields before submission
|
|
// This prevents "invalid form control is not focusable" errors
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
|
|
const formData = new FormData(form);
|
|
const flatConfig = {};
|
|
|
|
console.log('Schema loaded:', schema ? 'Yes' : 'No');
|
|
|
|
// Process form data with type conversion (using dot notation for nested fields)
|
|
for (const [key, value] of formData.entries()) {
|
|
// Check if this is a patternProperties or array-of-objects hidden input (contains JSON data)
|
|
// Only match keys ending with '_data' to avoid false positives like 'meta_data_field'
|
|
if (key.endsWith('_data')) {
|
|
try {
|
|
const baseKey = key.replace(/_data$/, '');
|
|
const jsonValue = JSON.parse(value);
|
|
// Handle both objects (patternProperties) and arrays (array-of-objects)
|
|
// Only treat as JSON-backed when it's a non-null object (null is typeof 'object' in JavaScript)
|
|
if (jsonValue !== null && typeof jsonValue === 'object') {
|
|
flatConfig[baseKey] = jsonValue;
|
|
console.log(`JSON data field ${baseKey}: parsed ${Array.isArray(jsonValue) ? 'array' : 'object'}`, jsonValue);
|
|
continue; // Skip normal processing for JSON data fields
|
|
}
|
|
} catch (e) {
|
|
// Not valid JSON, continue with normal processing
|
|
}
|
|
}
|
|
|
|
// Skip checkbox-group inputs with bracket notation (they're handled by the hidden _data input)
|
|
// Pattern: fieldName[] - these are individual checkboxes, actual data is in fieldName_data
|
|
if (key.endsWith('[]')) {
|
|
continue;
|
|
}
|
|
|
|
// Skip key_value pair inputs (they're handled by the hidden _data input)
|
|
if (key.includes('[key_') || key.includes('[value_')) {
|
|
continue;
|
|
}
|
|
|
|
// Skip array-of-objects per-item inputs (they're handled by the hidden _data input)
|
|
// Pattern: feeds_item_0_name, feeds_item_1_url, etc.
|
|
if (key.includes('_item_') && /_item_\d+_/.test(key)) {
|
|
continue;
|
|
}
|
|
|
|
// Try to get schema property - handle both dot notation and underscore notation
|
|
let propSchema = getSchemaPropertyType(schema, key);
|
|
let actualKey = key;
|
|
let actualValue = value;
|
|
|
|
// If not found with dots, try converting underscores to dots (for nested fields)
|
|
if (!propSchema && key.includes('_')) {
|
|
const dotKey = key.replace(/_/g, '.');
|
|
propSchema = getSchemaPropertyType(schema, dotKey);
|
|
if (propSchema) {
|
|
// Use the dot notation key for consistency
|
|
actualKey = dotKey;
|
|
actualValue = value;
|
|
}
|
|
}
|
|
|
|
if (propSchema) {
|
|
const propType = propSchema.type;
|
|
|
|
if (propType === 'array') {
|
|
// Check if this is a file upload widget (JSON array)
|
|
if (propSchema['x-widget'] === 'file-upload') {
|
|
// Try to parse as JSON first (for file uploads)
|
|
try {
|
|
// Handle HTML entity encoding (from hidden input)
|
|
let decodedValue = actualValue;
|
|
if (typeof actualValue === 'string') {
|
|
// Decode HTML entities if present
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.innerHTML = actualValue;
|
|
decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue;
|
|
}
|
|
|
|
const jsonValue = JSON.parse(decodedValue);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[actualKey] = jsonValue;
|
|
console.log(`File upload array field ${actualKey}: parsed JSON array with ${jsonValue.length} items`);
|
|
} else {
|
|
// Fallback to comma-separated
|
|
const arrayValue = decodedValue ? decodedValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[actualKey] = arrayValue;
|
|
}
|
|
} catch (e) {
|
|
// Not JSON, use comma-separated
|
|
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[actualKey] = arrayValue;
|
|
console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue);
|
|
}
|
|
} else {
|
|
// Regular array: convert comma-separated string to array
|
|
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[actualKey] = arrayValue;
|
|
console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue);
|
|
}
|
|
} else if (propType === 'integer') {
|
|
flatConfig[actualKey] = parseInt(actualValue, 10);
|
|
} else if (propType === 'number') {
|
|
flatConfig[actualKey] = parseFloat(actualValue);
|
|
} else if (propType === 'boolean') {
|
|
// Use querySelector to reliably find checkbox by name attribute
|
|
// Escape special CSS selector characters in the name
|
|
const escapedKey = escapeCssSelector(key);
|
|
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
|
|
|
|
if (formElement) {
|
|
// Element found - use its checked state
|
|
flatConfig[actualKey] = formElement.checked;
|
|
} else {
|
|
// Element not found - normalize string booleans and check FormData value
|
|
// Checkboxes send "on" when checked, nothing when unchecked
|
|
// Normalize string representations of booleans
|
|
if (typeof actualValue === 'string') {
|
|
const lowerValue = actualValue.toLowerCase().trim();
|
|
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
|
|
flatConfig[actualKey] = true;
|
|
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
|
|
flatConfig[actualKey] = false;
|
|
} else {
|
|
// Non-empty string that's not a boolean representation - treat as truthy
|
|
flatConfig[actualKey] = true;
|
|
}
|
|
} else if (actualValue === undefined || actualValue === null) {
|
|
flatConfig[actualKey] = false;
|
|
} else {
|
|
// Non-string value - coerce to boolean
|
|
flatConfig[actualKey] = Boolean(actualValue);
|
|
}
|
|
}
|
|
} else {
|
|
flatConfig[actualKey] = actualValue;
|
|
}
|
|
} else {
|
|
// No schema, try to infer type
|
|
// Check if value looks like a JSON string (starts with [ or {)
|
|
if (typeof actualValue === 'string' && (actualValue.trim().startsWith('[') || actualValue.trim().startsWith('{'))) {
|
|
try {
|
|
// Handle HTML entity encoding
|
|
let decodedValue = actualValue;
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.innerHTML = actualValue;
|
|
decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue;
|
|
|
|
const parsed = JSON.parse(decodedValue);
|
|
flatConfig[actualKey] = parsed;
|
|
console.log(`No schema for ${actualKey}, but parsed as JSON:`, parsed);
|
|
} catch (e) {
|
|
// Not valid JSON, save as string
|
|
flatConfig[actualKey] = actualValue;
|
|
}
|
|
} else {
|
|
// No schema - try to detect checkbox by finding the element
|
|
const escapedKey = escapeCssSelector(key);
|
|
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
|
|
|
|
if (formElement && formElement.type === 'checkbox') {
|
|
// Found checkbox element - use its checked state
|
|
flatConfig[actualKey] = formElement.checked;
|
|
} else {
|
|
// Not a checkbox or element not found - normalize string booleans
|
|
if (typeof actualValue === 'string') {
|
|
const lowerValue = actualValue.toLowerCase().trim();
|
|
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
|
|
flatConfig[actualKey] = true;
|
|
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
|
|
flatConfig[actualKey] = false;
|
|
} else {
|
|
// Non-empty string that's not a boolean representation - keep as string
|
|
flatConfig[actualKey] = actualValue;
|
|
}
|
|
} else {
|
|
// Non-string value - use as-is
|
|
flatConfig[actualKey] = actualValue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle unchecked checkboxes (not in FormData) - including nested ones
|
|
if (schema && schema.properties) {
|
|
const allBoolFields = collectBooleanFields(schema);
|
|
allBoolFields.forEach(key => {
|
|
if (!(key in flatConfig)) {
|
|
flatConfig[key] = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Convert dot notation to nested object
|
|
const config = dotToNested(flatConfig);
|
|
|
|
console.log('Flat config:', flatConfig);
|
|
console.log('Nested config to save:', config);
|
|
|
|
// Save the configuration
|
|
fetch('/api/v3/plugins/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
plugin_id: pluginId,
|
|
config: config
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
// Hide validation errors on success
|
|
displayValidationErrors([]);
|
|
showNotification('Configuration saved successfully', 'success');
|
|
closePluginConfigModal();
|
|
loadInstalledPlugins(); // Refresh to show updated config
|
|
} else {
|
|
// Display validation errors if present
|
|
if (data.validation_errors && Array.isArray(data.validation_errors)) {
|
|
displayValidationErrors(data.validation_errors);
|
|
}
|
|
showNotification('Error saving configuration: ' + data.message, 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error saving plugin config:', error);
|
|
showNotification('Error saving configuration: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
function generatePluginConfigForm(pluginId, config) {
|
|
console.log('[DEBUG] ===== Generating plugin config form =====');
|
|
console.log('[DEBUG] Plugin ID:', pluginId);
|
|
// Load plugin schema and actions for dynamic form generation
|
|
const installedPluginsPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ?
|
|
window.PluginAPI.getInstalledPlugins().then(plugins => ({ status: 'success', data: { plugins: plugins } })) :
|
|
fetch(`/api/v3/plugins/installed`).then(r => r.json());
|
|
|
|
return Promise.all([
|
|
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()),
|
|
installedPluginsPromise
|
|
])
|
|
.then(([schemaData, pluginsData]) => {
|
|
console.log('[DEBUG] Schema data received:', schemaData.status);
|
|
|
|
// Get plugin info including web_ui_actions
|
|
let pluginInfo = null;
|
|
if (pluginsData.status === 'success' && pluginsData.data && pluginsData.data.plugins) {
|
|
pluginInfo = pluginsData.data.plugins.find(p => p.id === pluginId);
|
|
console.log('[DEBUG] Plugin info found:', pluginInfo ? 'yes' : 'no');
|
|
if (pluginInfo) {
|
|
console.log('[DEBUG] Plugin info keys:', Object.keys(pluginInfo));
|
|
console.log('[DEBUG] web_ui_actions in pluginInfo:', 'web_ui_actions' in pluginInfo);
|
|
console.log('[DEBUG] web_ui_actions value:', pluginInfo.web_ui_actions);
|
|
}
|
|
} else {
|
|
console.log('[DEBUG] pluginsData status:', pluginsData.status);
|
|
}
|
|
const webUiActions = pluginInfo ? (pluginInfo.web_ui_actions || []) : [];
|
|
console.log('[DEBUG] Final webUiActions:', webUiActions, 'length:', webUiActions.length);
|
|
|
|
if (schemaData.status === 'success' && schemaData.data.schema) {
|
|
console.log('[DEBUG] Schema has properties:', Object.keys(schemaData.data.schema.properties || {}));
|
|
// Store plugin ID, schema, and actions for form submission
|
|
currentPluginConfig = {
|
|
pluginId: pluginId,
|
|
schema: schemaData.data.schema,
|
|
webUiActions: webUiActions
|
|
};
|
|
// Also assign to window for global access in template interpolations
|
|
window.currentPluginConfig = currentPluginConfig;
|
|
// Also update state
|
|
currentPluginConfigState.schema = schemaData.data.schema;
|
|
console.log('[DEBUG] Calling generateFormFromSchema...');
|
|
return generateFormFromSchema(schemaData.data.schema, config, webUiActions);
|
|
} else {
|
|
// Fallback to simple form if no schema
|
|
currentPluginConfig = { pluginId: pluginId, schema: null, webUiActions: webUiActions };
|
|
// Also assign to window for global access in template interpolations
|
|
window.currentPluginConfig = currentPluginConfig;
|
|
return generateSimpleConfigForm(config, webUiActions);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading schema:', error);
|
|
currentPluginConfig = { pluginId: pluginId, schema: null, webUiActions: [] };
|
|
// Also assign to window for global access in template interpolations
|
|
window.currentPluginConfig = currentPluginConfig;
|
|
return generateSimpleConfigForm(config, []);
|
|
});
|
|
}
|
|
|
|
// Helper to flatten nested config for form display (converts {nfl: {enabled: true}} to {'nfl.enabled': true})
|
|
function flattenConfig(obj, prefix = '') {
|
|
let result = {};
|
|
|
|
for (const key in obj) {
|
|
const value = obj[key];
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
|
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
// Recursively flatten nested objects
|
|
Object.assign(result, flattenConfig(value, fullKey));
|
|
} else {
|
|
result[fullKey] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Generate field HTML for a single property (used recursively)
|
|
// Helper function to render a single item in an array of objects
|
|
function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, index, itemsSchema) {
|
|
const item = itemValue || {};
|
|
const itemId = `${escapeAttribute(fieldId)}_item_${index}`;
|
|
// Store original item data in data attribute to preserve non-editable properties after reindexing
|
|
const itemDataJson = JSON.stringify(item);
|
|
const itemDataBase64 = btoa(unescape(encodeURIComponent(itemDataJson)));
|
|
let html = `<div id="${itemId}" class="border border-gray-300 rounded-lg p-4 bg-gray-50 array-object-item" data-index="${index}" data-item-data="${escapeAttribute(itemDataBase64)}">`;
|
|
|
|
// Render each property of the object
|
|
const propertyOrder = itemsSchema['x-propertyOrder'] || Object.keys(itemProperties);
|
|
propertyOrder.forEach(propKey => {
|
|
if (!itemProperties[propKey]) return;
|
|
|
|
const propSchema = itemProperties[propKey];
|
|
const propValue = item[propKey] !== undefined ? item[propKey] : propSchema.default;
|
|
const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
const propDescription = propSchema.description || '';
|
|
const propFullKey = `${fullKey}[${index}].${propKey}`;
|
|
|
|
html += `<div class="mb-3">`;
|
|
|
|
// Handle file-upload widget (for logo field)
|
|
if (propSchema['x-widget'] === 'file-upload') {
|
|
html += `<label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(propLabel)}</label>`;
|
|
if (propDescription) {
|
|
html += `<p class="text-xs text-gray-500 mb-2">${escapeHtml(propDescription)}</p>`;
|
|
}
|
|
const uploadConfig = propSchema['x-upload-config'] || {};
|
|
// Derive pluginId strictly from uploadConfig or currentPluginConfig, no hard-coded fallback
|
|
const pluginId = uploadConfig.plugin_id || (typeof currentPluginConfig !== 'undefined' ? currentPluginConfig?.pluginId : null) || (typeof window.currentPluginConfig !== 'undefined' ? window.currentPluginConfig?.pluginId : null) || null;
|
|
const logoValue = propValue || {};
|
|
// Use base64 encoding for JSON in data attributes to safely handle all characters
|
|
const logoDataJson = logoValue && Object.keys(logoValue).length > 0 ? JSON.stringify(logoValue) : '';
|
|
const logoDataBase64 = logoDataJson ? btoa(unescape(encodeURIComponent(logoDataJson))) : '';
|
|
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp'];
|
|
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
const pluginIdParam = pluginId ? `'${escapeAttribute(pluginId)}'` : 'null';
|
|
const uploadConfigJson = JSON.stringify({ allowed_types: allowedTypes, max_size_mb: maxSizeMB });
|
|
const uploadConfigBase64 = btoa(unescape(encodeURIComponent(uploadConfigJson)));
|
|
|
|
html += `
|
|
<div class="file-upload-widget-inline"${logoDataBase64 ? ` data-file-data="${escapeAttribute(logoDataBase64)}" data-prop-key="${escapeAttribute(propKey)}"` : ` data-prop-key="${escapeAttribute(propKey)}"`} data-upload-config="${escapeAttribute(uploadConfigBase64)}">
|
|
<input type="file"
|
|
id="${escapeAttribute(itemId)}_logo_file"
|
|
accept="${escapeAttribute(allowedTypes.join(','))}"
|
|
style="display: none;"
|
|
onchange="handleArrayObjectFileUpload(event, '${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}', ${pluginIdParam})">
|
|
<button type="button"
|
|
onclick="document.getElementById('${escapeAttribute(itemId)}_logo_file').click()"
|
|
class="px-3 py-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md transition-colors">
|
|
<i class="fas fa-upload mr-1"></i> Upload Logo
|
|
</button>
|
|
`;
|
|
|
|
if (logoValue.path) {
|
|
html += `
|
|
<div class="mt-2 flex items-center space-x-2 uploaded-image-container">
|
|
<img src="/${escapeAttribute(logoValue.path.replace(/^\/+/, ''))}" alt="Logo" class="w-16 h-16 object-cover rounded border">
|
|
<button type="button"
|
|
onclick="removeArrayObjectFile('${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}')"
|
|
class="text-red-600 hover:text-red-800">
|
|
<i class="fas fa-trash"></i> Remove
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
} else if (propSchema.type === 'boolean') {
|
|
// Boolean checkbox
|
|
html += `
|
|
<label class="flex items-center">
|
|
<input type="checkbox"
|
|
id="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}"
|
|
data-prop-key="${escapeAttribute(propKey)}"
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
|
${propValue ? 'checked' : ''}
|
|
onchange="updateArrayObjectData('${escapeAttribute(fieldId)}')">
|
|
<span class="ml-2 text-sm text-gray-700">${escapeHtml(propLabel)}</span>
|
|
</label>
|
|
`;
|
|
} else {
|
|
// Regular text/string input
|
|
html += `
|
|
<label for="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
${escapeHtml(propLabel)}
|
|
</label>
|
|
`;
|
|
if (propDescription) {
|
|
html += `<p class="text-xs text-gray-500 mb-1">${escapeHtml(propDescription)}</p>`;
|
|
}
|
|
const placeholder = propSchema.format === 'uri' ? 'https://example.com/feed' : '';
|
|
html += `
|
|
<input type="${propSchema.format === 'uri' ? 'url' : 'text'}"
|
|
id="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}"
|
|
data-prop-key="${escapeAttribute(propKey)}"
|
|
class="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 bg-white text-black"
|
|
value="${escapeAttribute(propValue || '')}"
|
|
placeholder="${escapeAttribute(placeholder)}"
|
|
onchange="updateArrayObjectData('${escapeAttribute(fieldId)}')">
|
|
`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
});
|
|
|
|
// Use schema-driven label for remove button, fallback to generic "Remove item"
|
|
const removeLabel = itemsSchema['x-removeLabel'] || 'Remove item';
|
|
html += `
|
|
<button type="button"
|
|
onclick="removeArrayObjectItem('${escapeAttribute(fieldId)}', ${index})"
|
|
class="mt-2 px-3 py-2 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors">
|
|
<i class="fas fa-trash mr-1"></i> ${escapeHtml(removeLabel)}
|
|
</button>
|
|
</div>`;
|
|
|
|
return html;
|
|
}
|
|
|
|
function 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 logging for categories field
|
|
if (key === 'categories') {
|
|
console.log(`[DEBUG] Processing categories field:`, {
|
|
type: prop.type,
|
|
hasAdditionalProperties: !!(prop.additionalProperties),
|
|
additionalPropertiesType: prop.additionalProperties?.type,
|
|
hasProperties: !!(prop.properties),
|
|
allKeys: Object.keys(prop)
|
|
});
|
|
}
|
|
|
|
// Handle patternProperties objects (dynamic key-value pairs like custom_feeds, feed_logo_map)
|
|
if (prop.type === 'object' && prop.patternProperties && !prop.properties) {
|
|
const fieldId = fullKey.replace(/\./g, '_');
|
|
const currentValue = value || {};
|
|
const patternProp = Object.values(prop.patternProperties)[0]; // Get the pattern property schema
|
|
const valueType = patternProp.type || 'string';
|
|
const maxProperties = prop.maxProperties || 50;
|
|
const entries = Object.entries(currentValue);
|
|
|
|
html += `
|
|
<div class="key-value-pairs-container">
|
|
<div class="mb-2">
|
|
<p class="text-sm text-gray-600 mb-2">${description || 'Add key-value pairs'}</p>
|
|
<div id="${fieldId}_pairs" class="space-y-2">
|
|
`;
|
|
|
|
// Render existing pairs
|
|
entries.forEach(([pairKey, pairValue], index) => {
|
|
html += `
|
|
<div class="flex items-center gap-2 key-value-pair" data-index="${index}">
|
|
<input type="text"
|
|
name="${fullKey}[key_${index}]"
|
|
value="${pairKey}"
|
|
placeholder="Key"
|
|
class="flex-1 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"
|
|
data-key-index="${index}"
|
|
onchange="updateKeyValuePairData('${fieldId}', '${fullKey}')">
|
|
<input type="${valueType === 'string' ? 'text' : valueType === 'number' || valueType === 'integer' ? 'number' : 'text'}"
|
|
name="${fullKey}[value_${index}]"
|
|
value="${pairValue}"
|
|
placeholder="Value"
|
|
class="flex-1 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"
|
|
data-value-index="${index}"
|
|
onchange="updateKeyValuePairData('${fieldId}', '${fullKey}')">
|
|
<button type="button"
|
|
onclick="removeKeyValuePair('${fieldId}', ${index})"
|
|
class="px-3 py-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"
|
|
title="Remove">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
<button type="button"
|
|
onclick="addKeyValuePair('${fieldId}', '${fullKey}', ${maxProperties})"
|
|
class="mt-2 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"
|
|
${entries.length >= maxProperties ? 'disabled style="opacity: 0.5; cursor: not-allowed;"' : ''}>
|
|
<i class="fas fa-plus mr-1"></i> Add Entry
|
|
</button>
|
|
<input type="hidden" id="${fieldId}_data" name="${fullKey}_data" value='${JSON.stringify(currentValue).replace(/'/g, "'")}'>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
return html;
|
|
}
|
|
|
|
// Handle objects with additionalProperties (dynamic keys with object values, like categories)
|
|
// Must have additionalProperties, no top-level properties, and additionalProperties must be an object type
|
|
const hasAdditionalProperties = prop.type === 'object' &&
|
|
(prop.properties === undefined || prop.properties === null) && // Explicitly exclude objects with properties (those use nested handler)
|
|
prop.additionalProperties &&
|
|
typeof prop.additionalProperties === 'object' &&
|
|
prop.additionalProperties !== null &&
|
|
prop.additionalProperties.type === 'object' &&
|
|
!prop.patternProperties; // Also exclude patternProperties objects
|
|
|
|
// Debug logging for categories field specifically
|
|
if (key === 'categories') {
|
|
console.log(`[DEBUG] Categories field check:`, {
|
|
type: prop.type,
|
|
hasProperties: !!prop.properties,
|
|
hasAdditionalProperties: !!prop.additionalProperties,
|
|
additionalPropertiesType: prop.additionalProperties?.type,
|
|
additionalPropertiesIsObject: typeof prop.additionalProperties === 'object',
|
|
matchesCondition: hasAdditionalProperties,
|
|
allPropKeys: Object.keys(prop)
|
|
});
|
|
}
|
|
|
|
if (hasAdditionalProperties) {
|
|
const fieldId = fullKey.replace(/\./g, '_');
|
|
const currentValue = value || {};
|
|
const categorySchema = prop.additionalProperties;
|
|
const entries = Object.entries(currentValue);
|
|
|
|
console.log(`[DEBUG] Rendering additionalProperties object for ${fullKey}:`, {
|
|
entries: entries.length,
|
|
keys: Object.keys(currentValue)
|
|
});
|
|
|
|
html += `
|
|
<div class="categories-container mb-4">
|
|
<div class="mb-4">
|
|
<h4 class="text-lg font-semibold text-gray-900 mb-2">${label}</h4>
|
|
${description ? `<p class="text-sm text-gray-600 mb-3">${description}</p>` : ''}
|
|
<div id="${fieldId}_categories" class="space-y-3">
|
|
`;
|
|
|
|
// Render each category
|
|
entries.forEach(([categoryKey, categoryValue]) => {
|
|
const categoryId = `${fieldId}_${categoryKey}`;
|
|
// Ensure categoryValue is an object
|
|
const catValue = typeof categoryValue === 'object' && categoryValue !== null ? categoryValue : {};
|
|
const enabled = catValue.enabled !== undefined ? catValue.enabled : (categorySchema.properties?.enabled?.default !== undefined ? categorySchema.properties.enabled.default : true);
|
|
// Safely extract string values, ensuring they're strings
|
|
const dataFile = (typeof catValue.data_file === 'string' ? catValue.data_file : '') || '';
|
|
const displayName = (typeof catValue.display_name === 'string' ? catValue.display_name : '') || categoryKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
|
|
html += `
|
|
<div class="category-item border border-gray-300 rounded-lg p-4 bg-white">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="flex items-center gap-3">
|
|
<label class="flex items-center cursor-pointer">
|
|
<input type="checkbox"
|
|
name="${fullKey}.${categoryKey}.enabled"
|
|
${enabled ? 'checked' : ''}
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded category-enabled-toggle"
|
|
data-category-key="${categoryKey}">
|
|
<span class="ml-2 font-medium text-gray-900">${escapeHtml(displayName)}</span>
|
|
</label>
|
|
</div>
|
|
<span class="text-xs text-gray-500 font-mono">${escapeHtml(categoryKey)}</span>
|
|
</div>
|
|
<div class="space-y-2 text-sm">
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 mb-1">Data File</label>
|
|
<input type="text"
|
|
name="${fullKey}.${categoryKey}.data_file"
|
|
value="${escapeHtml(dataFile)}"
|
|
readonly
|
|
class="w-full px-2 py-1 border border-gray-200 rounded bg-gray-50 text-gray-600 text-xs font-mono">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 mb-1">Display Name</label>
|
|
<input type="text"
|
|
name="${fullKey}.${categoryKey}.display_name"
|
|
value="${escapeHtml(displayName)}"
|
|
class="w-full px-2 py-1 border border-gray-300 rounded text-xs">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
if (entries.length === 0) {
|
|
html += `
|
|
<div class="text-center py-4 text-sm text-gray-500">
|
|
<i class="fas fa-info-circle mr-2"></i>
|
|
No categories configured. Use the File Manager below to add JSON files.
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
html += `
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
return html;
|
|
}
|
|
|
|
// Handle nested objects with known properties
|
|
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 rounded-t-lg"
|
|
onclick="toggleNestedSection('${sectionId}', event); return false;"
|
|
data-section-id="${sectionId}">
|
|
<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 rounded-b-lg" 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]) => {
|
|
const nestedValue = nestedConfig[nestedKey] !== undefined ? nestedConfig[nestedKey] : nestedProp.default;
|
|
console.log(`[DEBUG] Processing nested field ${fullKey}.${nestedKey}:`, {
|
|
type: nestedProp.type,
|
|
hasXWidget: nestedProp.hasOwnProperty('x-widget'),
|
|
xWidget: nestedProp['x-widget'],
|
|
allKeys: Object.keys(nestedProp)
|
|
});
|
|
html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey);
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add extra spacing after nested sections to prevent overlap with next section
|
|
html += `<div class="mb-4" style="clear: both;"></div>`;
|
|
|
|
return html;
|
|
}
|
|
|
|
// Regular (non-nested) field
|
|
html += `
|
|
<div class="form-group">
|
|
<label for="${fullKey}" 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">
|
|
<input type="checkbox" id="${fullKey}" name="${fullKey}" ${value ? 'checked' : ''} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<span class="ml-2 text-sm">Enabled</span>
|
|
</label>
|
|
`;
|
|
} else if (prop.type === 'number' || prop.type === 'integer') {
|
|
const min = prop.minimum !== undefined ? `min="${prop.minimum}"` : '';
|
|
const max = prop.maximum !== undefined ? `max="${prop.maximum}"` : '';
|
|
const step = prop.type === 'integer' ? 'step="1"' : 'step="any"';
|
|
|
|
// Ensure value respects min/max constraints
|
|
let fieldValue = value !== undefined ? value : (prop.default !== undefined ? prop.default : '');
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If still empty and we have a default, use it
|
|
if (fieldValue === '' && prop.default !== undefined) {
|
|
fieldValue = prop.default;
|
|
}
|
|
|
|
html += `
|
|
<input type="number" id="${fullKey}" name="${fullKey}" value="${fieldValue}" ${min} ${max} ${step} 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 bg-white text-black placeholder:text-gray-500">
|
|
`;
|
|
} else if (prop.type === 'array') {
|
|
// Check if this is an array of objects FIRST (before other checks)
|
|
if (prop.items && prop.items.type === 'object' && prop.items.properties) {
|
|
// Array of objects widget (like custom_feeds with name, url, enabled, logo)
|
|
console.log(`[DEBUG] ✅ Detected array-of-objects widget for ${fullKey}`);
|
|
const fieldId = fullKey.replace(/\./g, '_');
|
|
const itemsSchema = prop.items;
|
|
const itemProperties = itemsSchema.properties || {};
|
|
const maxItems = prop.maxItems || 50;
|
|
const currentItems = Array.isArray(value) ? value : [];
|
|
|
|
html += `
|
|
<div class="array-of-objects-container mt-1">
|
|
<div id="${fieldId}_items" class="space-y-4">
|
|
`;
|
|
|
|
// Render existing items
|
|
currentItems.forEach((item, index) => {
|
|
html += renderArrayObjectItem(fieldId, fullKey, itemProperties, item, index, itemsSchema);
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
<button type="button"
|
|
onclick="addArrayObjectItem('${fieldId}', '${fullKey}', ${maxItems})"
|
|
class="mt-3 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"
|
|
${currentItems.length >= maxItems ? 'disabled style="opacity: 0.5; cursor: not-allowed;"' : ''}>
|
|
<i class="fas fa-plus mr-1"></i> Add Feed
|
|
</button>
|
|
<input type="hidden" id="${fieldId}_data" name="${fullKey}_data" value="${escapeAttribute(JSON.stringify(currentItems))}">
|
|
</div>
|
|
`;
|
|
} else {
|
|
// Array - check for file upload widget first (to avoid breaking static-image plugin),
|
|
// then checkbox-group, then custom-feeds
|
|
const hasXWidget = prop.hasOwnProperty('x-widget');
|
|
const xWidgetValue = prop['x-widget'];
|
|
const xWidgetValue2 = prop['x-widget'] || prop['x_widget'] || prop.xWidget;
|
|
|
|
console.log(`[DEBUG] Array field ${fullKey}:`, {
|
|
type: prop.type,
|
|
hasItems: !!prop.items,
|
|
itemsType: prop.items?.type,
|
|
itemsHasProperties: !!prop.items?.properties,
|
|
hasXWidget: hasXWidget,
|
|
'x-widget': xWidgetValue,
|
|
'x-widget (alt)': xWidgetValue2,
|
|
'x-upload-config': prop['x-upload-config'],
|
|
propKeys: Object.keys(prop),
|
|
value: value
|
|
});
|
|
|
|
// Check for file-upload widget FIRST (to avoid breaking static-image plugin)
|
|
if (xWidgetValue === 'file-upload' || xWidgetValue2 === 'file-upload') {
|
|
console.log(`[DEBUG] ✅ Detected file-upload widget for ${fullKey} - rendering upload zone`);
|
|
const uploadConfig = prop['x-upload-config'] || {};
|
|
const pluginId = uploadConfig.plugin_id || currentPluginConfig?.pluginId || 'static-image';
|
|
const maxFiles = uploadConfig.max_files || 10;
|
|
const fileType = uploadConfig.file_type || 'image'; // 'image' or 'json'
|
|
const allowedTypes = uploadConfig.allowed_types || (fileType === 'json' ? ['application/json'] : ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']);
|
|
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
const customUploadEndpoint = uploadConfig.endpoint; // Custom endpoint if specified
|
|
const customDeleteEndpoint = uploadConfig.delete_endpoint; // Custom delete endpoint if specified
|
|
|
|
const currentFiles = Array.isArray(value) ? value : [];
|
|
const fieldId = fullKey.replace(/\./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="handleFileDrop(event, '${fieldId}')"
|
|
ondragover="event.preventDefault()"
|
|
onclick="document.getElementById('${fieldId}_file_input').click()">
|
|
<input type="file"
|
|
id="${fieldId}_file_input"
|
|
multiple
|
|
accept="${allowedTypes.join(',')}"
|
|
style="display: none;"
|
|
onchange="handleFileSelect(event, '${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 ${fileType === 'json' ? 'JSON files' : 'images'} here or click to browse</p>
|
|
<p class="text-xs text-gray-500 mt-1">Max ${maxFiles} files, ${maxSizeMB}MB each ${fileType === 'json' ? '(JSON)' : '(PNG, JPG, GIF, BMP)'}</p>
|
|
</div>
|
|
|
|
<!-- Uploaded Files List -->
|
|
<div id="${fieldId}_image_list" class="mt-4 space-y-2">
|
|
${currentFiles.map((file, idx) => {
|
|
const fileId = file.id || file.category_name || idx;
|
|
const fileName = file.original_filename || file.filename || (fileType === 'json' ? 'JSON File' : 'Image');
|
|
const entryCount = file.entry_count ? `${file.entry_count} entries` : '';
|
|
|
|
return `
|
|
<div id="file_${fileId}" 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">
|
|
${fileType === 'json' ? `
|
|
<div class="w-16 h-16 bg-blue-100 rounded flex items-center justify-center">
|
|
<i class="fas fa-file-code text-2xl text-blue-600"></i>
|
|
</div>
|
|
` : `
|
|
<img src="/${file.path || ''}"
|
|
alt="${fileName}"
|
|
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">${escapeHtml(fileName)}</p>
|
|
<p class="text-xs text-gray-500">${formatFileSize(file.size || 0)} • ${formatDate(file.uploaded_at)}</p>
|
|
${entryCount ? `<p class="text-xs text-blue-600 mt-1"><i class="fas fa-database mr-1"></i>${entryCount}</p>` : ''}
|
|
${fileType === 'image' && file.schedule ? `
|
|
<p class="text-xs text-blue-600 mt-1">
|
|
<i class="fas fa-clock mr-1"></i>${file.schedule.enabled && file.schedule.mode !== 'always' ? (window.getScheduleSummary ? window.getScheduleSummary(file.schedule) : 'Scheduled') : 'Always shown'}
|
|
</p>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-2 ml-4">
|
|
${fileType === 'image' ? `
|
|
<button type="button"
|
|
onclick="openImageSchedule('${fieldId}', '${fileId}', ${idx})"
|
|
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"
|
|
onclick="deleteUploadedFile('${fieldId}', '${fileId}', '${pluginId}', '${fileType}', ${customDeleteEndpoint ? `'${customDeleteEndpoint}'` : 'null'})"
|
|
class="text-red-600 hover:text-red-800 p-2"
|
|
title="Delete ${fileType === 'json' ? 'file' : 'image'}">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
${fileType === 'image' ? `<!-- Schedule widget will be inserted here when opened -->
|
|
<div id="schedule_${fileId}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
|
|
<!-- Hidden input to store file data -->
|
|
<input type="hidden" id="${fieldId}_images_data" name="${fullKey}" value="${JSON.stringify(currentFiles).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')}"
|
|
data-upload-endpoint="${customUploadEndpoint || '/api/v3/plugins/assets/upload'}"
|
|
data-file-type="${fileType}">
|
|
</div>
|
|
`;
|
|
} else if (xWidgetValue === 'checkbox-group' || xWidgetValue2 === 'checkbox-group') {
|
|
// Checkbox group widget for multi-select arrays with enum items
|
|
// Use _data hidden input pattern to serialize selected values correctly
|
|
console.log(`[DEBUG] ✅ Detected checkbox-group widget for ${fullKey} - rendering checkboxes`);
|
|
const arrayValue = Array.isArray(value) ? value : (prop.default || []);
|
|
const enumItems = prop.items && prop.items.enum ? prop.items.enum : [];
|
|
const xOptions = prop['x-options'] || {};
|
|
const labels = xOptions.labels || {};
|
|
const fieldId = fullKey.replace(/\./g, '_');
|
|
|
|
html += `<div class="mt-1 space-y-2">`;
|
|
enumItems.forEach((option) => {
|
|
const isChecked = arrayValue.includes(option);
|
|
const label = labels[option] || option.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
const checkboxId = `${fieldId}_${escapeHtml(option)}`;
|
|
html += `
|
|
<label class="flex items-center">
|
|
<input type="checkbox"
|
|
id="${checkboxId}"
|
|
name="${fullKey}[]"
|
|
data-checkbox-group="${fieldId}"
|
|
data-option-value="${escapeHtml(option)}"
|
|
value="${escapeHtml(option)}"
|
|
${isChecked ? 'checked' : ''}
|
|
onchange="updateCheckboxGroupData('${fieldId}')"
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<span class="ml-2 text-sm text-gray-700">${escapeHtml(label)}</span>
|
|
</label>
|
|
`;
|
|
});
|
|
html += `</div>`;
|
|
// Hidden input to store selected values as JSON array (like array-of-objects pattern)
|
|
html += `<input type="hidden" id="${fieldId}_data" name="${fullKey}_data" value='${JSON.stringify(arrayValue).replace(/'/g, "'")}'>`;
|
|
// Sentinel hidden input with bracket notation to allow clearing array to [] when all unchecked
|
|
// This ensures the field is always submitted, even when all checkboxes are unchecked
|
|
html += `<input type="hidden" name="${fullKey}[]" value="">`;
|
|
} else if (xWidgetValue === 'custom-feeds' || xWidgetValue2 === 'custom-feeds') {
|
|
// Custom feeds widget - check schema validation first
|
|
const itemsSchema = prop.items || {};
|
|
const itemProperties = itemsSchema.properties || {};
|
|
if (!itemProperties.name || !itemProperties.url) {
|
|
// Schema doesn't match expected structure - fallback to regular array input
|
|
console.log(`[DEBUG] ⚠️ Custom feeds widget requires 'name' and 'url' properties for ${fullKey}, using regular array input`);
|
|
let arrayValue = '';
|
|
if (value === null || value === undefined) {
|
|
arrayValue = Array.isArray(prop.default) ? prop.default.join(', ') : '';
|
|
} else if (Array.isArray(value)) {
|
|
arrayValue = value.join(', ');
|
|
} else {
|
|
arrayValue = '';
|
|
}
|
|
html += `
|
|
<input type="text" id="${fullKey}" 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 bg-white text-black placeholder:text-gray-500">
|
|
<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>
|
|
`;
|
|
} else {
|
|
// Custom feeds table interface - widget-specific implementation
|
|
// Note: This is handled by the template, but we include it here for consistency
|
|
// The template renders the custom feeds table, so JS-rendered forms should match
|
|
console.log(`[DEBUG] ✅ Detected custom-feeds widget for ${fullKey} - note: custom feeds table is typically rendered server-side`);
|
|
let arrayValue = '';
|
|
if (value === null || value === undefined) {
|
|
arrayValue = Array.isArray(prop.default) ? prop.default.join(', ') : '';
|
|
} else if (Array.isArray(value)) {
|
|
arrayValue = value.join(', ');
|
|
} else {
|
|
arrayValue = '';
|
|
}
|
|
html += `
|
|
<input type="text" id="${fullKey}" 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 bg-white text-black placeholder:text-gray-500">
|
|
<p class="text-sm text-gray-600 mt-1">Enter values separated by commas (custom feeds table rendered server-side)</p>
|
|
`;
|
|
}
|
|
} else {
|
|
// Regular array input (comma-separated)
|
|
console.log(`[DEBUG] ❌ No special widget detected for ${fullKey}, using regular array input`);
|
|
// Handle null/undefined values - use default if available
|
|
let arrayValue = '';
|
|
if (value === null || value === undefined) {
|
|
arrayValue = Array.isArray(prop.default) ? prop.default.join(', ') : '';
|
|
} else if (Array.isArray(value)) {
|
|
arrayValue = value.join(', ');
|
|
} else {
|
|
arrayValue = '';
|
|
}
|
|
html += `
|
|
<input type="text" id="${fullKey}" 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 bg-white text-black placeholder:text-gray-500">
|
|
<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>
|
|
`;
|
|
}
|
|
}
|
|
} else if (prop.enum) {
|
|
html += `<select id="${fullKey}" 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 bg-white text-black">`;
|
|
prop.enum.forEach(option => {
|
|
const selected = value === option ? 'selected' : '';
|
|
html += `<option value="${option}" ${selected}>${option}</option>`;
|
|
});
|
|
html += `</select>`;
|
|
} else if (prop['x-widget'] === 'custom-html') {
|
|
// Custom HTML widget - load HTML from plugin directory
|
|
const htmlFile = prop['x-html-file'];
|
|
const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || '';
|
|
const fieldId = fullKey.replace(/\./g, '_');
|
|
|
|
console.log(`[Custom HTML Widget] Generating widget for ${fullKey}:`, {
|
|
htmlFile,
|
|
pluginId,
|
|
fieldId,
|
|
hasPluginId: !!pluginId
|
|
});
|
|
|
|
if (htmlFile && pluginId) {
|
|
html += `
|
|
<div id="${fieldId}_custom_html"
|
|
data-plugin-id="${pluginId}"
|
|
data-html-file="${htmlFile}"
|
|
class="custom-html-widget">
|
|
<div class="animate-pulse text-center py-4">
|
|
<i class="fas fa-spinner fa-spin text-gray-400"></i>
|
|
<p class="text-sm text-gray-500 mt-2">Loading file manager...</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Load HTML asynchronously
|
|
setTimeout(() => {
|
|
loadCustomHtmlWidget(fieldId, pluginId, htmlFile);
|
|
}, 100);
|
|
} else {
|
|
console.error(`[Custom HTML Widget] Missing configuration for ${fullKey}:`, {
|
|
htmlFile,
|
|
pluginId,
|
|
currentPluginConfig: currentPluginConfig?.pluginId,
|
|
windowPluginConfig: window.currentPluginConfig?.pluginId
|
|
});
|
|
html += `
|
|
<div class="text-sm text-red-600 p-4 border border-red-200 rounded">
|
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
Custom HTML widget configuration error: missing html-file or plugin-id
|
|
<br><small>htmlFile: ${htmlFile || 'missing'}, pluginId: ${pluginId || 'missing'}</small>
|
|
</div>
|
|
`;
|
|
}
|
|
} else if (prop.type === 'object') {
|
|
// Fallback for objects that don't match any special case - render as JSON textarea
|
|
console.warn(`[DEBUG] Object field ${fullKey} doesn't match any special handler, rendering as JSON textarea`);
|
|
const jsonValue = typeof value === 'object' && value !== null ? JSON.stringify(value, null, 2) : (value || '{}');
|
|
html += `
|
|
<textarea id="${fullKey}" name="${fullKey}" rows="8" 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 font-mono text-xs bg-white text-black" style="font-family: 'Courier New', monospace;">${escapeHtml(jsonValue)}</textarea>
|
|
<p class="text-sm text-gray-600 mt-1">Edit as JSON object</p>
|
|
`;
|
|
} else {
|
|
// Check if this is a secret field
|
|
const isSecret = prop['x-secret'] === true;
|
|
const inputType = isSecret ? 'password' : 'text';
|
|
const maxLength = prop.maxLength || '';
|
|
const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : '';
|
|
const secretClass = isSecret ? 'pr-10' : '';
|
|
|
|
html += `
|
|
<div class="relative">
|
|
<input type="${inputType}" id="${fullKey}" 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 bg-white text-black placeholder:text-gray-500 ${secretClass}">
|
|
`;
|
|
|
|
if (isSecret) {
|
|
html += `
|
|
<button type="button" onclick="togglePasswordVisibility('${fullKey}')" class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700">
|
|
<i id="${fullKey}-icon" class="fas fa-eye"></i>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
|
|
return html;
|
|
}
|
|
|
|
// Load custom HTML widget from plugin directory
|
|
async function loadCustomHtmlWidget(fieldId, pluginId, htmlFile) {
|
|
try {
|
|
const container = document.getElementById(`${fieldId}_custom_html`);
|
|
if (!container) {
|
|
console.warn(`[Custom HTML Widget] Container not found: ${fieldId}_custom_html`);
|
|
return;
|
|
}
|
|
|
|
// Fetch HTML from plugin static files endpoint
|
|
const response = await fetch(`/api/v3/plugins/${pluginId}/static/${htmlFile}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load custom HTML: ${response.statusText}`);
|
|
}
|
|
|
|
const html = await response.text();
|
|
|
|
// Inject HTML into container
|
|
container.innerHTML = html;
|
|
|
|
// Execute any script tags in the loaded HTML
|
|
const scripts = container.querySelectorAll('script');
|
|
scripts.forEach(oldScript => {
|
|
const newScript = document.createElement('script');
|
|
Array.from(oldScript.attributes).forEach(attr => {
|
|
newScript.setAttribute(attr.name, attr.value);
|
|
});
|
|
newScript.appendChild(document.createTextNode(oldScript.innerHTML));
|
|
oldScript.parentNode.replaceChild(newScript, oldScript);
|
|
});
|
|
|
|
console.log(`[Custom HTML Widget] Loaded ${htmlFile} for plugin ${pluginId}`);
|
|
} catch (error) {
|
|
console.error(`[Custom HTML Widget] Error loading ${htmlFile} for plugin ${pluginId}:`, error);
|
|
const container = document.getElementById(`${fieldId}_custom_html`);
|
|
if (container) {
|
|
container.innerHTML = `
|
|
<div class="text-sm text-red-600 p-4 border border-red-200 rounded">
|
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
Failed to load custom HTML: ${error.message}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
}
|
|
|
|
function generateFormFromSchema(schema, config, webUiActions = []) {
|
|
console.log('[DEBUG] ===== generateFormFromSchema called =====');
|
|
console.log('[DEBUG] Schema properties:', Object.keys(schema.properties || {}));
|
|
console.log('[DEBUG] Web UI Actions:', webUiActions.length);
|
|
let formHtml = '<form id="plugin-config-form" class="space-y-4" novalidate>';
|
|
|
|
if (schema.properties) {
|
|
// 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;
|
|
|
|
let value = config[key] !== undefined ? config[key] : prop.default;
|
|
|
|
// Special handling: use uploaded_files from config if available (populated by backend from disk)
|
|
// No need to populate from categories here since backend does it
|
|
|
|
formHtml += generateFieldHtml(key, prop, value);
|
|
});
|
|
}
|
|
|
|
// Add web UI actions section if plugin defines any
|
|
console.log('[DEBUG] webUiActions:', webUiActions, 'length:', webUiActions ? webUiActions.length : 0);
|
|
if (webUiActions && webUiActions.length > 0) {
|
|
console.log('[DEBUG] Rendering', webUiActions.length, 'actions');
|
|
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';
|
|
|
|
// Map color names to explicit Tailwind classes to ensure they're included
|
|
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' }
|
|
};
|
|
|
|
const colors = colorMap[bgColor] || colorMap['blue'];
|
|
|
|
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}, '${window.currentPluginConfig?.pluginId || ''}')"
|
|
data-plugin-id="${window.currentPluginConfig?.pluginId || ''}"
|
|
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>
|
|
`;
|
|
} else {
|
|
console.log('[DEBUG] No webUiActions to render');
|
|
}
|
|
|
|
formHtml += `
|
|
<div class="flex justify-end space-x-2 pt-4 border-t border-gray-200">
|
|
<button type="button" onclick="closePluginConfigModal()" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
|
|
<i class="fas fa-save mr-2"></i>Save Configuration
|
|
</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
|
|
return Promise.resolve(formHtml);
|
|
}
|
|
|
|
// Functions to handle patternProperties key-value pairs
|
|
window.addKeyValuePair = function(fieldId, fullKey, maxProperties) {
|
|
const pairsContainer = document.getElementById(fieldId + '_pairs');
|
|
if (!pairsContainer) return;
|
|
|
|
const currentPairs = pairsContainer.querySelectorAll('.key-value-pair');
|
|
if (currentPairs.length >= maxProperties) {
|
|
alert(`Maximum ${maxProperties} entries allowed`);
|
|
return;
|
|
}
|
|
|
|
const newIndex = currentPairs.length;
|
|
const valueType = 'string'; // Default to string, could be determined from schema
|
|
|
|
const pairHtml = `
|
|
<div class="flex items-center gap-2 key-value-pair" data-index="${newIndex}">
|
|
<input type="text"
|
|
name="${fullKey}[key_${newIndex}]"
|
|
value=""
|
|
placeholder="Key"
|
|
class="flex-1 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"
|
|
data-key-index="${newIndex}"
|
|
onchange="updateKeyValuePairData('${fieldId}', '${fullKey}')">
|
|
<input type="text"
|
|
name="${fullKey}[value_${newIndex}]"
|
|
value=""
|
|
placeholder="Value"
|
|
class="flex-1 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"
|
|
data-value-index="${newIndex}"
|
|
onchange="updateKeyValuePairData('${fieldId}', '${fullKey}')">
|
|
<button type="button"
|
|
onclick="removeKeyValuePair('${fieldId}', ${newIndex})"
|
|
class="px-3 py-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"
|
|
title="Remove">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
pairsContainer.insertAdjacentHTML('beforeend', pairHtml);
|
|
updateKeyValuePairData(fieldId, fullKey);
|
|
|
|
// Update add button state
|
|
const addButton = pairsContainer.nextElementSibling;
|
|
if (addButton && currentPairs.length + 1 >= maxProperties) {
|
|
addButton.disabled = true;
|
|
addButton.style.opacity = '0.5';
|
|
addButton.style.cursor = 'not-allowed';
|
|
}
|
|
};
|
|
|
|
window.removeKeyValuePair = function(fieldId, index) {
|
|
const pairsContainer = document.getElementById(fieldId + '_pairs');
|
|
if (!pairsContainer) return;
|
|
|
|
const pair = pairsContainer.querySelector(`.key-value-pair[data-index="${index}"]`);
|
|
if (pair) {
|
|
pair.remove();
|
|
// Re-index remaining pairs
|
|
const remainingPairs = pairsContainer.querySelectorAll('.key-value-pair');
|
|
remainingPairs.forEach((p, newIndex) => {
|
|
p.setAttribute('data-index', newIndex);
|
|
const keyInput = p.querySelector('[data-key-index]');
|
|
const valueInput = p.querySelector('[data-value-index]');
|
|
if (keyInput) {
|
|
keyInput.setAttribute('name', keyInput.getAttribute('name').replace(/\[key_\d+\]/, `[key_${newIndex}]`));
|
|
keyInput.setAttribute('data-key-index', newIndex);
|
|
keyInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${keyInput.getAttribute('name').split('[')[0]}')`);
|
|
}
|
|
if (valueInput) {
|
|
valueInput.setAttribute('name', valueInput.getAttribute('name').replace(/\[value_\d+\]/, `[value_${newIndex}]`));
|
|
valueInput.setAttribute('data-value-index', newIndex);
|
|
valueInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${valueInput.getAttribute('name').split('[')[0]}')`);
|
|
}
|
|
const removeButton = p.querySelector('button[onclick*="removeKeyValuePair"]');
|
|
if (removeButton) {
|
|
removeButton.setAttribute('onclick', `removeKeyValuePair('${fieldId}', ${newIndex})`);
|
|
}
|
|
});
|
|
const hiddenInput = pairsContainer.closest('.key-value-pairs-container').querySelector('input[type="hidden"]');
|
|
if (hiddenInput) {
|
|
const hiddenName = hiddenInput.getAttribute('name').replace(/_data$/, '');
|
|
updateKeyValuePairData(fieldId, hiddenName);
|
|
}
|
|
|
|
// Update add button state
|
|
const addButton = pairsContainer.nextElementSibling;
|
|
if (addButton) {
|
|
const maxProperties = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]);
|
|
if (remainingPairs.length < maxProperties) {
|
|
addButton.disabled = false;
|
|
addButton.style.opacity = '1';
|
|
addButton.style.cursor = 'pointer';
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
window.updateKeyValuePairData = function(fieldId, fullKey) {
|
|
const pairsContainer = document.getElementById(fieldId + '_pairs');
|
|
const hiddenInput = document.getElementById(fieldId + '_data');
|
|
if (!pairsContainer || !hiddenInput) return;
|
|
|
|
const pairs = {};
|
|
const keyInputs = pairsContainer.querySelectorAll('[data-key-index]');
|
|
const valueInputs = pairsContainer.querySelectorAll('[data-value-index]');
|
|
|
|
keyInputs.forEach((keyInput, idx) => {
|
|
const key = keyInput.value.trim();
|
|
const valueInput = Array.from(valueInputs).find(v => v.getAttribute('data-value-index') === keyInput.getAttribute('data-key-index'));
|
|
if (key && valueInput) {
|
|
const value = valueInput.value.trim();
|
|
if (value) {
|
|
pairs[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
hiddenInput.value = JSON.stringify(pairs);
|
|
};
|
|
|
|
// Functions to handle array-of-objects
|
|
window.addArrayObjectItem = function(fieldId, fullKey, maxItems) {
|
|
const itemsContainer = document.getElementById(fieldId + '_items');
|
|
const hiddenInput = document.getElementById(fieldId + '_data');
|
|
if (!itemsContainer || !hiddenInput) return;
|
|
|
|
const currentItems = itemsContainer.querySelectorAll('.array-object-item');
|
|
if (currentItems.length >= maxItems) {
|
|
alert(`Maximum ${maxItems} items allowed`);
|
|
return;
|
|
}
|
|
|
|
// Get schema for item properties from the hidden input's data attribute or currentPluginConfig
|
|
const schema = (typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema) || (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema);
|
|
if (!schema) return;
|
|
|
|
// Navigate to the items schema
|
|
const keys = fullKey.split('.');
|
|
let itemsSchema = schema.properties;
|
|
for (const key of keys) {
|
|
if (itemsSchema && itemsSchema[key]) {
|
|
itemsSchema = itemsSchema[key];
|
|
if (itemsSchema.type === 'array' && itemsSchema.items) {
|
|
itemsSchema = itemsSchema.items;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!itemsSchema || !itemsSchema.properties) return;
|
|
|
|
const newIndex = currentItems.length;
|
|
const itemHtml = renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema);
|
|
itemsContainer.insertAdjacentHTML('beforeend', itemHtml);
|
|
updateArrayObjectData(fieldId);
|
|
|
|
// Update add button state
|
|
const addButton = itemsContainer.nextElementSibling;
|
|
if (addButton && currentItems.length + 1 >= maxItems) {
|
|
addButton.disabled = true;
|
|
addButton.style.opacity = '0.5';
|
|
addButton.style.cursor = 'not-allowed';
|
|
}
|
|
};
|
|
|
|
window.removeArrayObjectItem = function(fieldId, index) {
|
|
const itemsContainer = document.getElementById(fieldId + '_items');
|
|
if (!itemsContainer) return;
|
|
|
|
const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`);
|
|
if (item) {
|
|
item.remove();
|
|
// Re-index remaining items
|
|
const remainingItems = itemsContainer.querySelectorAll('.array-object-item');
|
|
remainingItems.forEach((itemEl, newIndex) => {
|
|
itemEl.setAttribute('data-index', newIndex);
|
|
// Update the id attribute to match new index (used by file upload selectors)
|
|
const newItemId = `${fieldId}_item_${newIndex}`;
|
|
itemEl.id = newItemId;
|
|
// Update all inputs within this item - need to update name/id attributes
|
|
itemEl.querySelectorAll('input, select, textarea').forEach(input => {
|
|
const name = input.getAttribute('name') || input.id;
|
|
if (name) {
|
|
// Update name/id attribute with new index
|
|
const newName = name.replace(/\[\d+\]/, `[${newIndex}]`);
|
|
if (input.getAttribute('name')) input.setAttribute('name', newName);
|
|
if (input.id) input.id = input.id.replace(/\d+/, newIndex);
|
|
}
|
|
});
|
|
// Update button onclick attributes
|
|
itemEl.querySelectorAll('button[onclick]').forEach(button => {
|
|
const onclick = button.getAttribute('onclick');
|
|
if (onclick) {
|
|
button.setAttribute('onclick', onclick.replace(/\d+/, newIndex));
|
|
}
|
|
});
|
|
});
|
|
updateArrayObjectData(fieldId);
|
|
|
|
// Update add button state
|
|
const addButton = itemsContainer.nextElementSibling;
|
|
if (addButton) {
|
|
const maxItems = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]);
|
|
if (remainingItems.length < maxItems) {
|
|
addButton.disabled = false;
|
|
addButton.style.opacity = '1';
|
|
addButton.style.cursor = 'pointer';
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
window.updateArrayObjectData = function(fieldId) {
|
|
const itemsContainer = document.getElementById(fieldId + '_items');
|
|
const hiddenInput = document.getElementById(fieldId + '_data');
|
|
if (!itemsContainer || !hiddenInput) return;
|
|
|
|
// Get existing items from hidden input to preserve non-editable properties
|
|
let existingItems = [];
|
|
try {
|
|
const existingData = hiddenInput.value.trim();
|
|
if (existingData) {
|
|
existingItems = JSON.parse(existingData);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error parsing existing items data:', e);
|
|
}
|
|
|
|
const items = [];
|
|
const itemElements = itemsContainer.querySelectorAll('.array-object-item');
|
|
|
|
itemElements.forEach((itemEl, index) => {
|
|
// Start with original item data from data attribute to preserve non-editable properties
|
|
// This avoids index-based corruption after deletions/reindexing
|
|
let existingItem = {};
|
|
const itemDataBase64 = itemEl.getAttribute('data-item-data');
|
|
if (itemDataBase64) {
|
|
try {
|
|
const itemDataJson = decodeURIComponent(escape(atob(itemDataBase64)));
|
|
existingItem = JSON.parse(itemDataJson);
|
|
} catch (e) {
|
|
console.error('Error parsing item data from data attribute:', e);
|
|
// Fallback to index-based lookup if data attribute is missing/corrupt
|
|
if (index < existingItems.length && existingItems[index]) {
|
|
existingItem = existingItems[index];
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback to index-based lookup if data attribute is missing
|
|
if (index < existingItems.length && existingItems[index]) {
|
|
existingItem = existingItems[index];
|
|
}
|
|
}
|
|
const item = Object.assign({}, existingItem); // Copy existing item
|
|
|
|
// Get all text inputs in this item and overlay their values with type coercion
|
|
itemEl.querySelectorAll('input[type="text"], input[type="url"], input[type="number"]').forEach(input => {
|
|
const propKey = input.getAttribute('data-prop-key');
|
|
if (propKey && propKey !== 'logo_file') {
|
|
let value = input.value.trim();
|
|
|
|
// Type coercion: check input type or data-prop-type attribute
|
|
const inputType = input.type;
|
|
const propType = input.getAttribute('data-prop-type');
|
|
|
|
if (inputType === 'number' || propType === 'number') {
|
|
// Use valueAsNumber if available, fallback to Number()
|
|
const numValue = input.valueAsNumber !== undefined && !isNaN(input.valueAsNumber)
|
|
? input.valueAsNumber
|
|
: Number(value);
|
|
item[propKey] = isNaN(numValue) ? value : numValue;
|
|
} else if (propType === 'array' || input.getAttribute('data-prop-is-list') === 'true') {
|
|
// Try to parse as JSON array, fallback to comma splitting
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
item[propKey] = Array.isArray(parsed) ? parsed : value;
|
|
} catch (e) {
|
|
// Fallback to comma-splitting for arrays
|
|
item[propKey] = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
}
|
|
} else {
|
|
// String value - keep as-is
|
|
item[propKey] = value;
|
|
}
|
|
}
|
|
});
|
|
// Handle checkboxes
|
|
itemEl.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
|
|
const propKey = checkbox.getAttribute('data-prop-key');
|
|
if (propKey) {
|
|
item[propKey] = checkbox.checked;
|
|
}
|
|
});
|
|
// Handle file upload data (stored in data attributes, base64-encoded)
|
|
itemEl.querySelectorAll('[data-file-data]').forEach(fileEl => {
|
|
const fileDataBase64 = fileEl.getAttribute('data-file-data');
|
|
if (fileDataBase64) {
|
|
try {
|
|
// Decode base64-encoded JSON
|
|
const fileDataJson = decodeURIComponent(escape(atob(fileDataBase64)));
|
|
const data = JSON.parse(fileDataJson);
|
|
const propKey = fileEl.getAttribute('data-prop-key');
|
|
if (propKey) {
|
|
item[propKey] = data;
|
|
}
|
|
} catch (e) {
|
|
console.error('Error parsing file data:', e);
|
|
}
|
|
}
|
|
});
|
|
items.push(item);
|
|
|
|
// Update data-item-data attribute with the merged item to keep it in sync
|
|
try {
|
|
const itemDataJson = JSON.stringify(item);
|
|
const itemDataBase64 = btoa(unescape(encodeURIComponent(itemDataJson)));
|
|
itemEl.setAttribute('data-item-data', itemDataBase64);
|
|
} catch (e) {
|
|
console.error('Error updating data-item-data attribute:', e);
|
|
}
|
|
});
|
|
|
|
hiddenInput.value = JSON.stringify(items);
|
|
};
|
|
|
|
window.handleArrayObjectFileUpload = async function(event, fieldId, itemIndex, propKey, pluginId) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
// Derive item element from event instead of constructing ID (works after reindexing)
|
|
const itemEl = event.target.closest('.array-object-item');
|
|
if (!itemEl) {
|
|
console.error('Array object item element not found');
|
|
return;
|
|
}
|
|
|
|
// Find file upload container within the item element, scoped to propKey
|
|
const fileUploadContainer = itemEl.querySelector(`.file-upload-widget-inline[data-prop-key="${propKey}"]`);
|
|
if (!fileUploadContainer) {
|
|
console.error('File upload container not found for propKey:', propKey);
|
|
return;
|
|
}
|
|
|
|
// Get upload config from data attribute
|
|
let uploadConfig = { allowed_types: ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp'], max_size_mb: 5 };
|
|
const uploadConfigBase64 = fileUploadContainer.getAttribute('data-upload-config');
|
|
if (uploadConfigBase64) {
|
|
try {
|
|
const uploadConfigJson = decodeURIComponent(escape(atob(uploadConfigBase64)));
|
|
uploadConfig = JSON.parse(uploadConfigJson);
|
|
} catch (e) {
|
|
console.error('Error parsing upload config from data attribute:', e);
|
|
}
|
|
}
|
|
|
|
// Validate file type using uploadConfig
|
|
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp'];
|
|
if (!allowedTypes.includes(file.type)) {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`File ${file.name} is not a valid image type`, 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Validate file size using uploadConfig
|
|
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
if (file.size > maxSizeMB * 1024 * 1024) {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Validate pluginId before upload (fail fast)
|
|
if (!pluginId || pluginId === 'null' || pluginId === 'undefined' || (typeof pluginId === 'string' && pluginId.trim() === '')) {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Plugin ID is required for file upload', 'error');
|
|
}
|
|
console.error('File upload failed: pluginId is required');
|
|
return;
|
|
}
|
|
|
|
// Upload file
|
|
const formData = new FormData();
|
|
formData.append('plugin_id', pluginId);
|
|
formData.append('files', file);
|
|
|
|
try {
|
|
const response = await fetch('/api/v3/plugins/assets/upload', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
// Check response.ok before parsing JSON to avoid parsing errors on HTTP errors
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
let errorMessage = `Upload failed: HTTP ${response.status}`;
|
|
try {
|
|
const errorData = JSON.parse(errorText);
|
|
errorMessage = errorData.message || errorMessage;
|
|
} catch (e) {
|
|
// If response isn't JSON, use the text or status
|
|
if (errorText) {
|
|
errorMessage = `Upload failed: ${errorText}`;
|
|
}
|
|
}
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
|
|
const uploadedFile = data.uploaded_files[0];
|
|
|
|
// Store file data in data-file-data attribute on the container (base64-encoded)
|
|
const fileDataJson = JSON.stringify(uploadedFile);
|
|
const fileDataBase64 = btoa(unescape(encodeURIComponent(fileDataJson)));
|
|
fileUploadContainer.setAttribute('data-file-data', fileDataBase64);
|
|
fileUploadContainer.setAttribute('data-prop-key', propKey);
|
|
|
|
// Update the display to show the uploaded image
|
|
const existingImage = fileUploadContainer.querySelector('.uploaded-image-container');
|
|
if (existingImage) {
|
|
existingImage.remove();
|
|
}
|
|
|
|
const imageContainer = document.createElement('div');
|
|
imageContainer.className = 'mt-2 flex items-center space-x-2 uploaded-image-container';
|
|
const escapedPath = escapeAttribute(uploadedFile.path.replace(/^\/+/, ''));
|
|
const escapedFieldId = escapeAttribute(fieldId);
|
|
const escapedPropKey = escapeAttribute(propKey);
|
|
// Get current item index from data-index attribute for remove button
|
|
const currentItemIndex = itemEl.getAttribute('data-index') || itemIndex;
|
|
imageContainer.innerHTML = `
|
|
<img src="/${escapedPath}" alt="Logo" class="w-16 h-16 object-cover rounded border">
|
|
<button type="button"
|
|
onclick="removeArrayObjectFile('${escapedFieldId}', ${currentItemIndex}, '${escapedPropKey}')"
|
|
class="text-red-600 hover:text-red-800">
|
|
<i class="fas fa-trash"></i> Remove
|
|
</button>
|
|
`;
|
|
fileUploadContainer.appendChild(imageContainer);
|
|
|
|
// Update the hidden input with the new file data
|
|
updateArrayObjectData(fieldId);
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Logo uploaded successfully', 'success');
|
|
}
|
|
} else {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`Upload failed: ${data.message || 'Unknown error'}`, 'error');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Upload error:', error);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`Upload error: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
// Clear file input
|
|
event.target.value = '';
|
|
};
|
|
|
|
window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) {
|
|
const itemId = `${fieldId}_item_${itemIndex}`;
|
|
const fileUploadContainer = document.querySelector(`#${itemId} .file-upload-widget-inline`);
|
|
if (!fileUploadContainer) {
|
|
console.error('File upload container not found');
|
|
return;
|
|
}
|
|
|
|
// Remove file data from data attribute
|
|
fileUploadContainer.removeAttribute('data-file-data');
|
|
|
|
// Remove the image display
|
|
const imageContainer = fileUploadContainer.querySelector('.uploaded-image-container');
|
|
if (imageContainer) {
|
|
imageContainer.remove();
|
|
}
|
|
|
|
// Update the hidden input to remove the file data
|
|
updateArrayObjectData(fieldId);
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Logo removed', 'success');
|
|
}
|
|
};
|
|
|
|
// Function to toggle nested sections
|
|
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) return;
|
|
|
|
// Prevent multiple simultaneous toggles
|
|
if (content.dataset.toggling === 'true') {
|
|
return;
|
|
}
|
|
|
|
// Mark as toggling
|
|
content.dataset.toggling = 'true';
|
|
|
|
// Check current state before making changes
|
|
const hasCollapsed = content.classList.contains('collapsed');
|
|
const hasExpanded = content.classList.contains('expanded');
|
|
const displayStyle = content.style.display;
|
|
const computedDisplay = window.getComputedStyle(content).display;
|
|
|
|
// Check if content is currently collapsed - prioritize class over display style
|
|
const isCollapsed = hasCollapsed || (!hasExpanded && (displayStyle === 'none' || computedDisplay === 'none'));
|
|
|
|
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');
|
|
|
|
// Allow parent section to show overflow when expanded
|
|
const sectionElement = content.closest('.nested-section');
|
|
if (sectionElement) {
|
|
sectionElement.style.overflow = 'visible';
|
|
}
|
|
|
|
// After animation completes, remove max-height constraint to allow natural expansion
|
|
// This allows parent sections to automatically expand
|
|
setTimeout(() => {
|
|
// Only set to none if still expanded (prevent race condition)
|
|
if (content.classList.contains('expanded') && !content.classList.contains('collapsed')) {
|
|
content.style.maxHeight = 'none';
|
|
content.style.overflow = '';
|
|
}
|
|
// Clear toggling flag
|
|
content.dataset.toggling = 'false';
|
|
}, 320); // Slightly longer than transition duration
|
|
|
|
// Scroll the expanded content into view after a short delay to allow animation
|
|
setTimeout(() => {
|
|
if (sectionElement) {
|
|
// Find the modal container
|
|
const modalContent = sectionElement.closest('.modal-content');
|
|
if (modalContent) {
|
|
// Scroll the section header into view within the modal
|
|
const headerButton = sectionElement.querySelector('button');
|
|
if (headerButton) {
|
|
headerButton.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
|
|
}
|
|
} else {
|
|
// If not in a modal, just scroll the section
|
|
sectionElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
}
|
|
}, 350); // Wait for animation to complete
|
|
} 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);
|
|
|
|
// Restore parent section overflow when collapsed
|
|
const sectionElement = content.closest('.nested-section');
|
|
if (sectionElement) {
|
|
sectionElement.style.overflow = 'hidden';
|
|
}
|
|
|
|
// Use setTimeout to set display:none after transition completes
|
|
setTimeout(() => {
|
|
if (content.classList.contains('collapsed')) {
|
|
content.style.display = 'none';
|
|
content.style.overflow = '';
|
|
}
|
|
// Clear toggling flag
|
|
content.dataset.toggling = 'false';
|
|
}, 320); // Match the CSS transition duration + small buffer
|
|
icon.classList.remove('fa-chevron-down');
|
|
icon.classList.add('fa-chevron-right');
|
|
}
|
|
}
|
|
|
|
function generateSimpleConfigForm(config, webUiActions = []) {
|
|
console.log('[DEBUG] generateSimpleConfigForm - webUiActions:', webUiActions, 'length:', webUiActions ? webUiActions.length : 0);
|
|
let actionsHtml = '';
|
|
if (webUiActions && webUiActions.length > 0) {
|
|
console.log('[DEBUG] Rendering', webUiActions.length, 'actions in simple form');
|
|
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">
|
|
`;
|
|
|
|
// 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' }
|
|
};
|
|
|
|
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'];
|
|
|
|
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}, '${window.currentPluginConfig?.pluginId || ''}')"
|
|
data-plugin-id="${window.currentPluginConfig?.pluginId || ''}"
|
|
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 `
|
|
<form id="plugin-config-form" class="space-y-4" novalidate>
|
|
<div class="form-group">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Configuration</label>
|
|
<textarea name="config" class="form-control h-32" placeholder="Plugin configuration JSON">${JSON.stringify(config, null, 2)}</textarea>
|
|
</div>
|
|
${actionsHtml}
|
|
<div class="flex justify-end space-x-2">
|
|
<button type="button" onclick="closePluginConfigModal()" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2">
|
|
Save Configuration
|
|
</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
}
|
|
|
|
// Plugin config modal state
|
|
let currentPluginConfigState = {
|
|
pluginId: null,
|
|
config: {},
|
|
schema: null,
|
|
jsonEditor: null,
|
|
formData: {}
|
|
};
|
|
|
|
// Initialize JSON editor
|
|
async function initJsonEditor() {
|
|
const textarea = document.getElementById('plugin-config-json-editor');
|
|
if (!textarea) return null;
|
|
|
|
// Lazy load CodeMirror if needed
|
|
if (typeof CodeMirror === 'undefined') {
|
|
if (typeof window.loadCodeMirror === 'function') {
|
|
try {
|
|
await window.loadCodeMirror();
|
|
} catch (error) {
|
|
console.error('Failed to load CodeMirror:', error);
|
|
showNotification('JSON editor not available. Please refresh the page.', 'error');
|
|
return null;
|
|
}
|
|
} else {
|
|
console.error('CodeMirror not loaded and loadCodeMirror not available. Please refresh the page.');
|
|
showNotification('JSON editor not available. Please refresh the page.', 'error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (currentPluginConfigState.jsonEditor) {
|
|
currentPluginConfigState.jsonEditor.toTextArea();
|
|
currentPluginConfigState.jsonEditor = null;
|
|
}
|
|
|
|
const editor = CodeMirror.fromTextArea(textarea, {
|
|
mode: 'application/json',
|
|
theme: 'monokai',
|
|
lineNumbers: true,
|
|
lineWrapping: true,
|
|
indentUnit: 2,
|
|
tabSize: 2,
|
|
autoCloseBrackets: true,
|
|
matchBrackets: true,
|
|
foldGutter: true,
|
|
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter']
|
|
});
|
|
|
|
// Validate JSON on change
|
|
editor.on('change', function() {
|
|
const value = editor.getValue();
|
|
try {
|
|
JSON.parse(value);
|
|
editor.setOption('class', '');
|
|
} catch (e) {
|
|
editor.setOption('class', 'cm-error');
|
|
}
|
|
});
|
|
|
|
return editor;
|
|
}
|
|
|
|
// Switch between form and JSON views
|
|
function switchPluginConfigView(view) {
|
|
const formView = document.getElementById('plugin-config-form-view');
|
|
const jsonView = document.getElementById('plugin-config-json-view');
|
|
const formBtn = document.getElementById('view-toggle-form');
|
|
const jsonBtn = document.getElementById('view-toggle-json');
|
|
|
|
if (view === 'json') {
|
|
formView.classList.add('hidden');
|
|
jsonView.classList.remove('hidden');
|
|
formBtn.classList.remove('active', 'bg-blue-600', 'text-white');
|
|
formBtn.classList.add('text-gray-700', 'hover:bg-gray-200');
|
|
jsonBtn.classList.add('active', 'bg-blue-600', 'text-white');
|
|
jsonBtn.classList.remove('text-gray-700', 'hover:bg-gray-200');
|
|
|
|
// Sync form data to JSON editor
|
|
syncFormToJson();
|
|
|
|
// Initialize editor if not already done
|
|
if (!currentPluginConfigState.jsonEditor) {
|
|
// Small delay to ensure textarea is visible, then load CodeMirror and initialize
|
|
setTimeout(async () => {
|
|
currentPluginConfigState.jsonEditor = await initJsonEditor();
|
|
if (currentPluginConfigState.jsonEditor) {
|
|
const jsonText = JSON.stringify(currentPluginConfigState.config, null, 2);
|
|
currentPluginConfigState.jsonEditor.setValue(jsonText);
|
|
currentPluginConfigState.jsonEditor.refresh();
|
|
}
|
|
}, 50);
|
|
} else {
|
|
// Update editor content if already initialized
|
|
const jsonText = JSON.stringify(currentPluginConfigState.config, null, 2);
|
|
currentPluginConfigState.jsonEditor.setValue(jsonText);
|
|
currentPluginConfigState.jsonEditor.refresh();
|
|
}
|
|
} else {
|
|
jsonView.classList.add('hidden');
|
|
formView.classList.remove('hidden');
|
|
jsonBtn.classList.remove('active', 'bg-blue-600', 'text-white');
|
|
jsonBtn.classList.add('text-gray-700', 'hover:bg-gray-200');
|
|
formBtn.classList.add('active', 'bg-blue-600', 'text-white');
|
|
formBtn.classList.remove('text-gray-700', 'hover:bg-gray-200');
|
|
|
|
// Sync JSON to form if JSON was edited
|
|
syncJsonToForm();
|
|
}
|
|
}
|
|
|
|
// Sync form data to JSON config
|
|
function syncFormToJson() {
|
|
const form = document.getElementById('plugin-config-form');
|
|
if (!form) return;
|
|
|
|
const formData = new FormData(form);
|
|
const config = {};
|
|
|
|
// Get schema for type conversion
|
|
const schema = currentPluginConfigState.schema;
|
|
|
|
for (let [key, value] of formData.entries()) {
|
|
if (key === 'enabled') continue; // Skip enabled, managed separately
|
|
|
|
// Handle nested keys (dot notation)
|
|
const keys = key.split('.');
|
|
let current = config;
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
if (!current[keys[i]]) {
|
|
current[keys[i]] = {};
|
|
}
|
|
current = current[keys[i]];
|
|
}
|
|
|
|
const finalKey = keys[keys.length - 1];
|
|
const prop = schema?.properties?.[finalKey] || (keys.length > 1 ? null : schema?.properties?.[key]);
|
|
|
|
// Type conversion based on schema
|
|
if (prop?.type === 'array') {
|
|
current[finalKey] = value.split(',').map(item => item.trim()).filter(item => item.length > 0);
|
|
} else if (prop?.type === 'integer' || key === 'display_duration') {
|
|
current[finalKey] = parseInt(value) || 0;
|
|
} else if (prop?.type === 'number') {
|
|
current[finalKey] = parseFloat(value) || 0;
|
|
} else if (prop?.type === 'boolean') {
|
|
current[finalKey] = value === 'true' || value === true;
|
|
} else {
|
|
current[finalKey] = value;
|
|
}
|
|
}
|
|
|
|
// Deep merge with existing config to preserve nested structures
|
|
function deepMerge(target, source) {
|
|
for (const key in source) {
|
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) {
|
|
target[key] = {};
|
|
}
|
|
deepMerge(target[key], source[key]);
|
|
} else {
|
|
target[key] = source[key];
|
|
}
|
|
}
|
|
return target;
|
|
}
|
|
|
|
// Deep merge new form data into existing config
|
|
currentPluginConfigState.config = deepMerge(
|
|
JSON.parse(JSON.stringify(currentPluginConfigState.config)), // Deep clone
|
|
config
|
|
);
|
|
}
|
|
|
|
// Sync JSON editor content to form
|
|
function syncJsonToForm() {
|
|
if (!currentPluginConfigState.jsonEditor) return;
|
|
|
|
try {
|
|
const jsonText = currentPluginConfigState.jsonEditor.getValue();
|
|
const config = JSON.parse(jsonText);
|
|
currentPluginConfigState.config = config;
|
|
|
|
// Update form fields (this is complex, so we'll reload the form)
|
|
// For now, just update the config state - form will be regenerated on next open
|
|
console.log('JSON synced to config state');
|
|
} catch (e) {
|
|
console.error('Invalid JSON in editor:', e);
|
|
showNotification('Invalid JSON in editor. Please fix errors before switching views.', 'error');
|
|
}
|
|
}
|
|
|
|
// Reset plugin config to defaults
|
|
async function resetPluginConfigToDefaults() {
|
|
if (!currentPluginConfigState.pluginId) {
|
|
showNotification('No plugin selected', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!confirm('Are you sure you want to reset this plugin configuration to defaults? This will replace all current settings.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/v3/plugins/config/reset', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
plugin_id: currentPluginConfigState.pluginId,
|
|
preserve_secrets: true
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
showNotification(data.message, 'success');
|
|
|
|
// Reload the config form with defaults
|
|
const newConfig = data.data?.config || {};
|
|
currentPluginConfigState.config = newConfig;
|
|
|
|
// Regenerate form
|
|
const content = document.getElementById('plugin-config-content');
|
|
if (content) {
|
|
content.innerHTML = '<div class="flex items-center justify-center py-8"><i class="fas fa-spinner fa-spin text-2xl text-blue-600"></i></div>';
|
|
generatePluginConfigForm(currentPluginConfigState.pluginId, newConfig)
|
|
.then(formHtml => {
|
|
content.innerHTML = formHtml;
|
|
const form = document.getElementById('plugin-config-form');
|
|
if (form) {
|
|
form.addEventListener('submit', handlePluginConfigSubmit);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update JSON editor if it's visible
|
|
if (currentPluginConfigState.jsonEditor) {
|
|
const jsonText = JSON.stringify(newConfig, null, 2);
|
|
currentPluginConfigState.jsonEditor.setValue(jsonText);
|
|
}
|
|
} else {
|
|
showNotification(data.message || 'Failed to reset configuration', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error resetting config:', error);
|
|
showNotification('Error resetting configuration: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Display validation errors
|
|
function displayValidationErrors(errors) {
|
|
const errorContainer = document.getElementById('plugin-config-validation-errors');
|
|
const errorList = document.getElementById('validation-errors-list');
|
|
|
|
if (!errorContainer || !errorList) return;
|
|
|
|
if (errors && errors.length > 0) {
|
|
errorContainer.classList.remove('hidden');
|
|
errorList.innerHTML = errors.map(error => `<li>${escapeHtml(error)}</li>`).join('');
|
|
} else {
|
|
errorContainer.classList.add('hidden');
|
|
errorList.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
// Save configuration from JSON editor
|
|
async function saveConfigFromJsonEditor() {
|
|
if (!currentPluginConfigState.jsonEditor || !currentPluginConfigState.pluginId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const jsonText = currentPluginConfigState.jsonEditor.getValue();
|
|
const config = JSON.parse(jsonText);
|
|
|
|
// Update state
|
|
currentPluginConfigState.config = config;
|
|
|
|
// Save the configuration (will handle validation errors)
|
|
savePluginConfiguration(currentPluginConfigState.pluginId, config);
|
|
} catch (e) {
|
|
console.error('Error saving JSON config:', e);
|
|
if (e instanceof SyntaxError) {
|
|
showNotification('Invalid JSON. Please fix syntax errors before saving.', 'error');
|
|
displayValidationErrors([`JSON Syntax Error: ${e.message}`]);
|
|
} else {
|
|
showNotification('Error saving configuration: ' + e.message, 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
window.closePluginConfigModal = function() {
|
|
const modal = document.getElementById('plugin-config-modal');
|
|
modal.style.display = 'none';
|
|
|
|
// Clean up JSON editor
|
|
if (currentPluginConfigState.jsonEditor) {
|
|
currentPluginConfigState.jsonEditor.toTextArea();
|
|
currentPluginConfigState.jsonEditor = null;
|
|
}
|
|
|
|
// Reset state
|
|
currentPluginConfig = null;
|
|
currentPluginConfigState.pluginId = null;
|
|
currentPluginConfigState.config = {};
|
|
currentPluginConfigState.schema = null;
|
|
|
|
// Hide validation errors
|
|
displayValidationErrors([]);
|
|
|
|
console.log('Modal closed');
|
|
}
|
|
|
|
// Generic Plugin Action Handler
|
|
window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) {
|
|
console.log('[DEBUG] executePluginAction called - actionId:', actionId, 'actionIndex:', actionIndex, 'pluginIdParam:', pluginIdParam);
|
|
|
|
// Construct button ID first (we have actionId and actionIndex)
|
|
const actionIdFull = `action-${actionId}-${actionIndex}`;
|
|
const statusId = `action-status-${actionId}-${actionIndex}`;
|
|
const btn = document.getElementById(actionIdFull);
|
|
const statusDiv = document.getElementById(statusId);
|
|
|
|
// Get plugin ID from multiple sources with comprehensive fallback logic
|
|
let pluginId = pluginIdParam;
|
|
|
|
// Fallback 1: Try to get from button's data-plugin-id attribute
|
|
if (!pluginId && btn) {
|
|
pluginId = btn.getAttribute('data-plugin-id');
|
|
if (pluginId) {
|
|
console.log('[DEBUG] Got pluginId from button data attribute:', pluginId);
|
|
}
|
|
}
|
|
|
|
// Fallback 2: Try to get from closest parent with data-plugin-id
|
|
if (!pluginId && btn) {
|
|
const parentWithPluginId = btn.closest('[data-plugin-id]');
|
|
if (parentWithPluginId) {
|
|
pluginId = parentWithPluginId.getAttribute('data-plugin-id');
|
|
if (pluginId) {
|
|
console.log('[DEBUG] Got pluginId from parent element:', pluginId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback 3: Try to get from plugin-config-container or plugin-config-tab
|
|
if (!pluginId && btn) {
|
|
const container = btn.closest('.plugin-config-container, .plugin-config-tab, [id^="plugin-config-"]');
|
|
if (container) {
|
|
// Try data-plugin-id first
|
|
pluginId = container.getAttribute('data-plugin-id');
|
|
if (!pluginId) {
|
|
// Try to extract from ID like "plugin-config-{pluginId}"
|
|
const idMatch = container.id.match(/plugin-config-(.+)/);
|
|
if (idMatch) {
|
|
pluginId = idMatch[1];
|
|
}
|
|
}
|
|
if (pluginId) {
|
|
console.log('[DEBUG] Got pluginId from container:', pluginId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback 4: Try to get from currentPluginConfig
|
|
if (!pluginId) {
|
|
pluginId = currentPluginConfig?.pluginId;
|
|
if (pluginId) {
|
|
console.log('[DEBUG] Got pluginId from currentPluginConfig:', pluginId);
|
|
}
|
|
}
|
|
|
|
// Fallback 5: Try to get from Alpine.js context (activeTab)
|
|
if (!pluginId && window.Alpine) {
|
|
try {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
const appData = appElement._x_dataStack[0];
|
|
if (appData.activeTab && appData.activeTab !== 'overview' && appData.activeTab !== 'plugins' && appData.activeTab !== 'wifi') {
|
|
pluginId = appData.activeTab;
|
|
console.log('[DEBUG] Got pluginId from Alpine activeTab:', pluginId);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[DEBUG] Error accessing Alpine context:', e);
|
|
}
|
|
}
|
|
|
|
// Fallback 6: Try to find from plugin tab elements (scoped to button context)
|
|
if (!pluginId && btn) {
|
|
try {
|
|
// Search within the button's Alpine.js context (closest x-data element)
|
|
const buttonContext = btn.closest('[x-data]');
|
|
if (buttonContext) {
|
|
const pluginTab = buttonContext.querySelector('[x-show*="activeTab === plugin.id"]');
|
|
if (pluginTab && window.Alpine) {
|
|
try {
|
|
const pluginData = Alpine.$data(buttonContext);
|
|
if (pluginData && pluginData.plugin) {
|
|
pluginId = pluginData.plugin.id;
|
|
if (pluginId) {
|
|
console.log('[DEBUG] Got pluginId from Alpine plugin data (scoped to button context):', pluginId);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[DEBUG] Error accessing Alpine plugin data:', e);
|
|
}
|
|
}
|
|
}
|
|
// If not found in button context, try container element
|
|
if (!pluginId) {
|
|
const container = btn.closest('.plugin-config-container, .plugin-config-tab, [id^="plugin-config-"]');
|
|
if (container) {
|
|
const containerContext = container.querySelector('[x-show*="activeTab === plugin.id"]');
|
|
if (containerContext && window.Alpine) {
|
|
try {
|
|
const containerData = Alpine.$data(container.closest('[x-data]'));
|
|
if (containerData && containerData.plugin) {
|
|
pluginId = containerData.plugin.id;
|
|
if (pluginId) {
|
|
console.log('[DEBUG] Got pluginId from Alpine plugin data (scoped to container):', pluginId);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[DEBUG] Error accessing Alpine plugin data from container:', e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[DEBUG] Error in fallback 6 DOM lookup:', e);
|
|
}
|
|
}
|
|
|
|
// Final check - if still no pluginId, show error
|
|
if (!pluginId) {
|
|
console.error('No plugin ID available after all fallbacks. actionId:', actionId, 'actionIndex:', actionIndex);
|
|
console.error('[DEBUG] Button found:', !!btn);
|
|
console.error('[DEBUG] currentPluginConfig:', currentPluginConfig);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Unable to determine plugin ID. Please refresh the page.', 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
console.log('[DEBUG] executePluginAction - Final pluginId:', pluginId, 'actionId:', actionId, 'actionIndex:', actionIndex);
|
|
|
|
if (!btn || !statusDiv) {
|
|
console.error(`Action elements not found: ${actionIdFull}`);
|
|
return;
|
|
}
|
|
|
|
// Get action definition - try currentPluginConfig first, then fetch from API
|
|
let action = currentPluginConfig?.webUiActions?.[actionIndex];
|
|
|
|
if (!action) {
|
|
// Try to get from installed plugins
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin && plugin.web_ui_actions) {
|
|
action = plugin.web_ui_actions[actionIndex];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!action) {
|
|
console.error(`Action not found: ${actionId} for plugin ${pluginId}`);
|
|
console.log('[DEBUG] currentPluginConfig:', currentPluginConfig);
|
|
console.log('[DEBUG] installedPlugins:', window.installedPlugins);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`Action ${actionId} not found. Please refresh the page.`, 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
console.log('[DEBUG] Found action:', action);
|
|
|
|
// Check if we're in step 2 (completing OAuth flow)
|
|
if (btn.dataset.step === '2') {
|
|
const redirectUrl = prompt(action.step2_prompt || 'Please paste the full redirect URL:');
|
|
if (!redirectUrl || !redirectUrl.trim()) {
|
|
return;
|
|
}
|
|
|
|
// Complete authentication
|
|
btn.disabled = true;
|
|
const originalText = btn.innerHTML;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Completing...';
|
|
statusDiv.classList.remove('hidden');
|
|
statusDiv.innerHTML = '<div class="text-blue-600"><i class="fas fa-spinner fa-spin mr-2"></i>Completing authentication...</div>';
|
|
|
|
fetch('/api/v3/plugins/action', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
plugin_id: pluginId,
|
|
action_id: actionId,
|
|
params: {step: '2', redirect_url: redirectUrl.trim()}
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
statusDiv.innerHTML = `<div class="text-green-600"><i class="fas fa-check-circle mr-2"></i>${data.message}</div>`;
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
delete btn.dataset.step;
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message || 'Action completed successfully!', 'success');
|
|
}
|
|
} else {
|
|
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>${data.message}</div>`;
|
|
if (data.output) {
|
|
statusDiv.innerHTML += `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${data.output}</pre>`;
|
|
}
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
delete btn.dataset.step;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>Error: ${error.message}</div>`;
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
delete btn.dataset.step;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Step 1: Execute action
|
|
btn.disabled = true;
|
|
const originalText = btn.innerHTML;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Executing...';
|
|
statusDiv.classList.remove('hidden');
|
|
statusDiv.innerHTML = '<div class="text-blue-600"><i class="fas fa-spinner fa-spin mr-2"></i>Executing action...</div>';
|
|
|
|
fetch('/api/v3/plugins/action', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
plugin_id: pluginId,
|
|
action_id: actionId,
|
|
params: {}
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
if (data.requires_step2 && data.auth_url) {
|
|
// OAuth flow - show auth URL
|
|
statusDiv.innerHTML = `
|
|
<div class="bg-blue-50 border border-blue-200 rounded p-3">
|
|
<div class="text-blue-900 font-medium mb-2">
|
|
<i class="fas fa-link mr-2"></i>${data.message || 'Authorization URL Generated'}
|
|
</div>
|
|
<div class="mb-3">
|
|
<p class="text-sm text-blue-700 mb-2">1. Click the link below to authorize:</p>
|
|
<a href="${data.auth_url}" target="_blank" class="text-blue-600 hover:text-blue-800 underline break-all">
|
|
${data.auth_url}
|
|
</a>
|
|
</div>
|
|
<div class="mb-2">
|
|
<p class="text-sm text-blue-700 mb-2">2. After authorization, copy the FULL redirect URL from your browser.</p>
|
|
<p class="text-sm text-blue-600">3. Click the button again and paste the redirect URL when prompted.</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
btn.innerHTML = action.step2_button_text || 'Complete Authentication';
|
|
btn.dataset.step = '2';
|
|
btn.disabled = false;
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message || 'Authorization URL generated. Please authorize and paste the redirect URL.', 'info');
|
|
}
|
|
} else {
|
|
// Simple success
|
|
statusDiv.innerHTML = `
|
|
<div class="bg-green-50 border border-green-200 rounded p-3">
|
|
<div class="text-green-900 font-medium mb-2">
|
|
<i class="fas fa-check-circle mr-2"></i>${data.message || 'Action completed successfully'}
|
|
</div>
|
|
${data.output ? `<pre class="mt-2 text-xs bg-green-50 p-2 rounded overflow-auto max-h-32">${data.output}</pre>` : ''}
|
|
</div>
|
|
`;
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message || 'Action completed successfully!', 'success');
|
|
}
|
|
}
|
|
} else {
|
|
statusDiv.innerHTML = `
|
|
<div class="bg-red-50 border border-red-200 rounded p-3">
|
|
<div class="text-red-900 font-medium mb-2">
|
|
<i class="fas fa-exclamation-circle mr-2"></i>${data.message || 'Action failed'}
|
|
</div>
|
|
${data.output ? `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${data.output}</pre>` : ''}
|
|
</div>
|
|
`;
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>Error: ${error.message}</div>`;
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
|
|
// togglePlugin is already defined at the top of the script - no need to redefine
|
|
|
|
// Only override updatePlugin if it doesn't already have improved error handling
|
|
if (!window.updatePlugin || window.updatePlugin.toString().includes('[UPDATE]')) {
|
|
window.updatePlugin = function(pluginId) {
|
|
// Validate pluginId
|
|
if (!pluginId || typeof pluginId !== 'string') {
|
|
console.error('[UPDATE] Invalid pluginId:', pluginId);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Invalid plugin ID', 'error');
|
|
}
|
|
return Promise.reject(new Error('Invalid plugin ID'));
|
|
}
|
|
|
|
showNotification(`Updating ${pluginId}...`, 'info');
|
|
|
|
// Prepare request body
|
|
const requestBody = { plugin_id: pluginId };
|
|
const requestBodyJson = JSON.stringify(requestBody);
|
|
|
|
console.log('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson });
|
|
|
|
return fetch('/api/v3/plugins/update', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: requestBodyJson
|
|
})
|
|
.then(async response => {
|
|
// Check if response is OK before parsing
|
|
if (!response.ok) {
|
|
// Try to parse error response
|
|
let errorData;
|
|
try {
|
|
const text = await response.text();
|
|
console.error('[UPDATE] Error response:', { status: response.status, statusText: response.statusText, body: text });
|
|
errorData = JSON.parse(text);
|
|
} catch (e) {
|
|
errorData = { message: `Server error: ${response.status} ${response.statusText}` };
|
|
}
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(errorData.message || `Update failed: ${response.status}`, 'error');
|
|
}
|
|
throw new Error(errorData.message || `Update failed: ${response.status}`);
|
|
}
|
|
|
|
// Parse successful response
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
showNotification(data.message || 'Update initiated', data.status || 'info');
|
|
if (data.status === 'success') {
|
|
// Refresh the list
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
|
|
window.pluginManager.loadInstalledPlugins();
|
|
}
|
|
}
|
|
return data;
|
|
})
|
|
.catch(error => {
|
|
console.error('[UPDATE] Error updating plugin:', error);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error updating plugin: ' + error.message, 'error');
|
|
}
|
|
throw error;
|
|
});
|
|
};
|
|
}
|
|
|
|
window.uninstallPlugin = function(pluginId) {
|
|
const plugin = (window.installedPlugins || installedPlugins || []).find(p => p.id === pluginId);
|
|
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
|
|
|
|
if (!confirm(`Are you sure you want to uninstall ${pluginName}?`)) {
|
|
return;
|
|
}
|
|
|
|
showNotification(`Uninstalling ${pluginName}...`, 'info');
|
|
|
|
fetch('/api/v3/plugins/uninstall', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
console.log('Uninstall response:', data);
|
|
|
|
// 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');
|
|
pollOperationStatus(operationId, pluginId, pluginName);
|
|
} else if (data.status === 'success') {
|
|
// Direct uninstall completed immediately
|
|
handleUninstallSuccess(pluginId);
|
|
} else {
|
|
// Error response
|
|
showNotification(data.message || 'Failed to uninstall plugin', data.status || 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error uninstalling plugin:', error);
|
|
showNotification('Error uninstalling plugin: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
function pollOperationStatus(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
|
|
setTimeout(() => {
|
|
loadInstalledPlugins();
|
|
}, 1000);
|
|
return;
|
|
}
|
|
|
|
fetch(`/api/v3/plugins/operation/${operationId}`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success' && data.data) {
|
|
const operation = data.data;
|
|
const status = operation.status;
|
|
|
|
if (status === 'completed') {
|
|
// Operation completed successfully
|
|
handleUninstallSuccess(pluginId);
|
|
} 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
|
|
setTimeout(() => {
|
|
loadInstalledPlugins();
|
|
}, 1000);
|
|
} else if (status === 'pending' || status === 'in_progress') {
|
|
// Still in progress, poll again
|
|
setTimeout(() => {
|
|
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}, 1000); // Poll every second
|
|
} else {
|
|
// Unknown status, poll again
|
|
setTimeout(() => {
|
|
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}, 1000);
|
|
}
|
|
} else {
|
|
// Error getting operation status, try again
|
|
setTimeout(() => {
|
|
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}, 1000);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error polling operation status:', error);
|
|
// On error, refresh plugin list to see actual state
|
|
setTimeout(() => {
|
|
loadInstalledPlugins();
|
|
}, 1000);
|
|
});
|
|
}
|
|
|
|
function handleUninstallSuccess(pluginId) {
|
|
// Remove from local array immediately for better UX
|
|
const currentPlugins = window.installedPlugins || installedPlugins || [];
|
|
const updatedPlugins = currentPlugins.filter(p => p.id !== pluginId);
|
|
// Only update if list actually changed (setter will check, but we know it changed here)
|
|
window.installedPlugins = updatedPlugins;
|
|
if (typeof installedPlugins !== 'undefined') {
|
|
installedPlugins = updatedPlugins;
|
|
}
|
|
renderInstalledPlugins(updatedPlugins);
|
|
showNotification(`Plugin uninstalled successfully`, 'success');
|
|
|
|
// Also refresh from server to ensure consistency
|
|
setTimeout(() => {
|
|
loadInstalledPlugins();
|
|
}, 1000);
|
|
}
|
|
|
|
function refreshPlugins() {
|
|
console.log('[refreshPlugins] Button clicked, refreshing plugins...');
|
|
// Clear cache to force fresh data
|
|
pluginStoreCache = null;
|
|
cacheTimestamp = null;
|
|
|
|
loadInstalledPlugins();
|
|
// Fetch latest metadata from GitHub when refreshing
|
|
searchPluginStore(true);
|
|
showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
|
|
}
|
|
|
|
function restartDisplay() {
|
|
console.log('[restartDisplay] Button clicked, restarting display service...');
|
|
showNotification('Restarting display service...', 'info');
|
|
|
|
fetch('/api/v3/system/action', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action: 'restart_display_service' })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
showNotification(data.message, data.status);
|
|
})
|
|
.catch(error => {
|
|
showNotification('Error restarting display: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
function searchPluginStore(fetchCommitInfo = true) {
|
|
pluginLog('[STORE] Searching plugin store...', { fetchCommitInfo });
|
|
|
|
const now = Date.now();
|
|
const isCacheValid = pluginStoreCache && cacheTimestamp && (now - cacheTimestamp < CACHE_DURATION);
|
|
|
|
// If cache is valid and we don't need fresh commit info, just re-filter
|
|
if (isCacheValid && !fetchCommitInfo) {
|
|
console.log('Using cached plugin store data');
|
|
const storeGrid = document.getElementById('plugin-store-grid');
|
|
if (storeGrid) {
|
|
applyStoreFiltersAndSort();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Show loading state
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) countEl.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Loading...';
|
|
} catch (e) { /* ignore */ }
|
|
showStoreLoading(true);
|
|
|
|
let url = '/api/v3/plugins/store/list';
|
|
if (!fetchCommitInfo) {
|
|
url += '?fetch_commit_info=false';
|
|
}
|
|
|
|
console.log('Store URL:', url);
|
|
|
|
fetch(url)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
showStoreLoading(false);
|
|
|
|
if (data.status === 'success') {
|
|
const plugins = data.data.plugins || [];
|
|
console.log('Store plugins count:', plugins.length);
|
|
|
|
pluginStoreCache = plugins;
|
|
cacheTimestamp = Date.now();
|
|
|
|
const storeGrid = document.getElementById('plugin-store-grid');
|
|
if (!storeGrid) {
|
|
pluginLog('[STORE] plugin-store-grid not ready, deferring render');
|
|
window.__pendingStorePlugins = plugins;
|
|
return;
|
|
}
|
|
|
|
// Update total count
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) countEl.innerHTML = `${plugins.length} available`;
|
|
} catch (e) { /* ignore */ }
|
|
|
|
applyStoreFiltersAndSort();
|
|
|
|
// Re-attach GitHub token collapse handler after store render
|
|
if (window.attachGithubTokenCollapseHandler) {
|
|
requestAnimationFrame(() => {
|
|
try { window.attachGithubTokenCollapseHandler(); } catch (e) { /* ignore */ }
|
|
if (window.checkGitHubAuthStatus) {
|
|
try { window.checkGitHubAuthStatus(); } catch (e) { /* ignore */ }
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
showError('Failed to search plugin store: ' + data.message);
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) countEl.innerHTML = 'Error loading';
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error searching plugin store:', error);
|
|
showStoreLoading(false);
|
|
showError('Error searching plugin store: ' + error.message);
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) countEl.innerHTML = 'Error loading';
|
|
} catch (e) { /* ignore */ }
|
|
});
|
|
}
|
|
|
|
function showStoreLoading(show) {
|
|
const loading = document.querySelector('.store-loading');
|
|
if (loading) {
|
|
loading.style.display = show ? 'block' : 'none';
|
|
}
|
|
}
|
|
|
|
// ── Plugin Store: Client-Side Filter/Sort/Pagination ────────────────────────
|
|
|
|
function isStorePluginInstalled(pluginId) {
|
|
return (window.installedPlugins || installedPlugins || []).some(p => p.id === pluginId);
|
|
}
|
|
|
|
function applyStoreFiltersAndSort(skipPageReset) {
|
|
if (!pluginStoreCache) return;
|
|
const st = storeFilterState;
|
|
|
|
let list = pluginStoreCache.slice();
|
|
|
|
// Text search
|
|
if (st.searchQuery) {
|
|
const q = st.searchQuery.toLowerCase();
|
|
list = list.filter(plugin => {
|
|
const hay = [
|
|
plugin.name, plugin.description, plugin.author,
|
|
plugin.id, plugin.category,
|
|
...(plugin.tags || [])
|
|
].filter(Boolean).join(' ').toLowerCase();
|
|
return hay.includes(q);
|
|
});
|
|
}
|
|
|
|
// Category filter
|
|
if (st.filterCategory) {
|
|
const cat = st.filterCategory.toLowerCase();
|
|
list = list.filter(plugin => (plugin.category || '').toLowerCase() === cat);
|
|
}
|
|
|
|
// Installed filter
|
|
if (st.filterInstalled === true) {
|
|
list = list.filter(plugin => isStorePluginInstalled(plugin.id));
|
|
} else if (st.filterInstalled === false) {
|
|
list = list.filter(plugin => !isStorePluginInstalled(plugin.id));
|
|
}
|
|
|
|
// Sort
|
|
list.sort((a, b) => {
|
|
const nameA = (a.name || a.id || '').toLowerCase();
|
|
const nameB = (b.name || b.id || '').toLowerCase();
|
|
switch (st.sort) {
|
|
case 'z-a': return nameB.localeCompare(nameA);
|
|
case 'category': {
|
|
const catCmp = (a.category || '').localeCompare(b.category || '');
|
|
return catCmp !== 0 ? catCmp : nameA.localeCompare(nameB);
|
|
}
|
|
case 'author': {
|
|
const authCmp = (a.author || '').localeCompare(b.author || '');
|
|
return authCmp !== 0 ? authCmp : nameA.localeCompare(nameB);
|
|
}
|
|
case 'newest': {
|
|
const dateA = a.last_updated ? new Date(a.last_updated).getTime() : 0;
|
|
const dateB = b.last_updated ? new Date(b.last_updated).getTime() : 0;
|
|
return dateB - dateA; // newest first
|
|
}
|
|
default: return nameA.localeCompare(nameB);
|
|
}
|
|
});
|
|
|
|
storeFilteredList = list;
|
|
if (!skipPageReset) st.page = 1;
|
|
|
|
renderStorePage();
|
|
updateStoreFilterUI();
|
|
}
|
|
|
|
function renderStorePage() {
|
|
const st = storeFilterState;
|
|
const total = storeFilteredList.length;
|
|
const totalPages = Math.max(1, Math.ceil(total / st.perPage));
|
|
if (st.page > totalPages) st.page = totalPages;
|
|
|
|
const start = (st.page - 1) * st.perPage;
|
|
const end = Math.min(start + st.perPage, total);
|
|
const pagePlugins = storeFilteredList.slice(start, end);
|
|
|
|
// Results info
|
|
const info = total > 0
|
|
? `Showing ${start + 1}\u2013${end} of ${total} plugins`
|
|
: 'No plugins match your filters';
|
|
const infoEl = document.getElementById('store-results-info');
|
|
const infoElBot = document.getElementById('store-results-info-bottom');
|
|
if (infoEl) infoEl.textContent = info;
|
|
if (infoElBot) infoElBot.textContent = info;
|
|
|
|
// Pagination
|
|
renderStorePagination('store-pagination-top', totalPages, st.page);
|
|
renderStorePagination('store-pagination-bottom', totalPages, st.page);
|
|
|
|
// Grid
|
|
renderPluginStore(pagePlugins);
|
|
}
|
|
|
|
function renderStorePagination(containerId, totalPages, currentPage) {
|
|
const container = document.getElementById(containerId);
|
|
if (!container) return;
|
|
|
|
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
|
|
|
const btnClass = 'px-3 py-1 text-sm rounded-md border transition-colors';
|
|
const activeClass = 'bg-blue-600 text-white border-blue-600';
|
|
const normalClass = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer';
|
|
const disabledClass = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed';
|
|
|
|
let html = '';
|
|
html += `<button class="${btnClass} ${currentPage <= 1 ? disabledClass : normalClass}" data-store-page="${currentPage - 1}" ${currentPage <= 1 ? 'disabled' : ''}>«</button>`;
|
|
|
|
const pages = [];
|
|
pages.push(1);
|
|
if (currentPage > 3) pages.push('...');
|
|
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
|
|
pages.push(i);
|
|
}
|
|
if (currentPage < totalPages - 2) pages.push('...');
|
|
if (totalPages > 1) pages.push(totalPages);
|
|
|
|
pages.forEach(p => {
|
|
if (p === '...') {
|
|
html += `<span class="px-2 py-1 text-sm text-gray-400">…</span>`;
|
|
} else {
|
|
html += `<button class="${btnClass} ${p === currentPage ? activeClass : normalClass}" data-store-page="${p}">${p}</button>`;
|
|
}
|
|
});
|
|
|
|
html += `<button class="${btnClass} ${currentPage >= totalPages ? disabledClass : normalClass}" data-store-page="${currentPage + 1}" ${currentPage >= totalPages ? 'disabled' : ''}>»</button>`;
|
|
|
|
container.innerHTML = html;
|
|
|
|
container.querySelectorAll('[data-store-page]').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
const p = parseInt(this.getAttribute('data-store-page'));
|
|
if (p >= 1 && p <= totalPages && p !== currentPage) {
|
|
storeFilterState.page = p;
|
|
renderStorePage();
|
|
const grid = document.getElementById('plugin-store-grid');
|
|
if (grid) grid.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function updateStoreFilterUI() {
|
|
const st = storeFilterState;
|
|
const count = st.activeCount();
|
|
|
|
const badge = document.getElementById('store-active-filters');
|
|
const clearBtn = document.getElementById('store-clear-filters');
|
|
if (badge) {
|
|
badge.classList.toggle('hidden', count === 0);
|
|
badge.textContent = count + ' filter' + (count !== 1 ? 's' : '') + ' active';
|
|
}
|
|
if (clearBtn) clearBtn.classList.toggle('hidden', count === 0);
|
|
|
|
const instBtn = document.getElementById('store-filter-installed');
|
|
if (instBtn) {
|
|
if (st.filterInstalled === true) {
|
|
instBtn.innerHTML = '<i class="fas fa-check-circle mr-1 text-green-500"></i>Installed';
|
|
instBtn.classList.add('border-green-400', 'bg-green-50');
|
|
instBtn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50');
|
|
} else if (st.filterInstalled === false) {
|
|
instBtn.innerHTML = '<i class="fas fa-times-circle mr-1 text-red-500"></i>Not Installed';
|
|
instBtn.classList.add('border-red-400', 'bg-red-50');
|
|
instBtn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50');
|
|
} else {
|
|
instBtn.innerHTML = '<i class="fas fa-filter mr-1 text-gray-400"></i>All';
|
|
instBtn.classList.add('border-gray-300', 'bg-white');
|
|
instBtn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50');
|
|
}
|
|
}
|
|
}
|
|
|
|
function setupStoreFilterListeners() {
|
|
// Search with debounce
|
|
const searchEl = document.getElementById('plugin-search');
|
|
if (searchEl && !searchEl._storeFilterInit) {
|
|
searchEl._storeFilterInit = true;
|
|
let debounce = null;
|
|
searchEl.addEventListener('input', function() {
|
|
clearTimeout(debounce);
|
|
debounce = setTimeout(() => {
|
|
storeFilterState.searchQuery = this.value.trim();
|
|
applyStoreFiltersAndSort();
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
// Category dropdown
|
|
const catEl = document.getElementById('plugin-category');
|
|
if (catEl && !catEl._storeFilterInit) {
|
|
catEl._storeFilterInit = true;
|
|
catEl.addEventListener('change', function() {
|
|
storeFilterState.filterCategory = this.value;
|
|
applyStoreFiltersAndSort();
|
|
});
|
|
}
|
|
|
|
// Sort dropdown
|
|
const sortEl = document.getElementById('store-sort');
|
|
if (sortEl && !sortEl._storeFilterInit) {
|
|
sortEl._storeFilterInit = true;
|
|
sortEl.addEventListener('change', function() {
|
|
storeFilterState.sort = this.value;
|
|
storeFilterState.persist();
|
|
applyStoreFiltersAndSort();
|
|
});
|
|
}
|
|
|
|
// Installed toggle (cycle: all → installed → not-installed → all)
|
|
const instBtn = document.getElementById('store-filter-installed');
|
|
if (instBtn && !instBtn._storeFilterInit) {
|
|
instBtn._storeFilterInit = true;
|
|
instBtn.addEventListener('click', function() {
|
|
const st = storeFilterState;
|
|
if (st.filterInstalled === null) st.filterInstalled = true;
|
|
else if (st.filterInstalled === true) st.filterInstalled = false;
|
|
else st.filterInstalled = null;
|
|
applyStoreFiltersAndSort();
|
|
});
|
|
}
|
|
|
|
// Clear filters
|
|
const clearBtn = document.getElementById('store-clear-filters');
|
|
if (clearBtn && !clearBtn._storeFilterInit) {
|
|
clearBtn._storeFilterInit = true;
|
|
clearBtn.addEventListener('click', function() {
|
|
storeFilterState.reset();
|
|
const searchEl = document.getElementById('plugin-search');
|
|
if (searchEl) searchEl.value = '';
|
|
const catEl = document.getElementById('plugin-category');
|
|
if (catEl) catEl.value = '';
|
|
const sortEl = document.getElementById('store-sort');
|
|
if (sortEl) sortEl.value = 'a-z';
|
|
storeFilterState.persist();
|
|
applyStoreFiltersAndSort();
|
|
});
|
|
}
|
|
|
|
// Per-page selector
|
|
const ppEl = document.getElementById('store-per-page');
|
|
if (ppEl && !ppEl._storeFilterInit) {
|
|
ppEl._storeFilterInit = true;
|
|
ppEl.addEventListener('change', function() {
|
|
storeFilterState.perPage = parseInt(this.value) || 12;
|
|
storeFilterState.persist();
|
|
applyStoreFiltersAndSort();
|
|
});
|
|
}
|
|
}
|
|
|
|
// Expose searchPluginStore on window.pluginManager for Alpine.js integration
|
|
window.searchPluginStore = searchPluginStore;
|
|
window.pluginManager.searchPluginStore = searchPluginStore;
|
|
|
|
function renderPluginStore(plugins) {
|
|
const container = document.getElementById('plugin-store-grid');
|
|
if (!container) {
|
|
pluginLog('[RENDER] plugin-store-grid not yet available, deferring render');
|
|
window.__pendingStorePlugins = plugins;
|
|
return;
|
|
}
|
|
|
|
if (plugins.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="col-span-full empty-state">
|
|
<div class="empty-state-icon">
|
|
<i class="fas fa-store"></i>
|
|
</div>
|
|
<p class="text-lg font-medium text-gray-700 mb-1">No plugins found</p>
|
|
<p class="text-sm text-gray-500">Try adjusting your search criteria</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Helper function to escape for JavaScript strings
|
|
const escapeJs = (text) => {
|
|
return JSON.stringify(text || '');
|
|
};
|
|
|
|
container.innerHTML = plugins.map(plugin => {
|
|
const installed = isStorePluginInstalled(plugin.id);
|
|
return `
|
|
<div class="plugin-card">
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center flex-wrap gap-1.5 mb-2">
|
|
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(plugin.name || plugin.id)}</h4>
|
|
${plugin.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : ''}
|
|
${installed ? '<span class="badge badge-success"><i class="fas fa-check mr-1"></i>Installed</span>' : ''}
|
|
${isNewPlugin(plugin.last_updated) ? '<span class="badge badge-info"><i class="fas fa-sparkles mr-1"></i>New</span>' : ''}
|
|
${plugin._source === 'custom_repository' ? `<span class="badge badge-accent" title="From: ${escapeHtml(plugin._repository_name || plugin._repository_url || 'Custom Repository')}"><i class="fas fa-bookmark mr-1"></i>Custom</span>` : ''}
|
|
</div>
|
|
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
|
|
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p>
|
|
${plugin.version ? `<p class="flex items-center"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}</p>` : ''}
|
|
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
|
|
</div>
|
|
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plugin Tags -->
|
|
${plugin.tags && plugin.tags.length > 0 ? `
|
|
<div class="flex flex-wrap gap-1.5 mb-4">
|
|
${plugin.tags.map(tag => `<span class="badge badge-info">${escapeHtml(tag)}</span>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Store Actions -->
|
|
<div class="mt-auto pt-4 border-t border-gray-200 space-y-2">
|
|
<div class="flex items-center gap-2">
|
|
<label for="branch-input-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}" class="text-xs text-gray-600 whitespace-nowrap">
|
|
<i class="fas fa-code-branch mr-1"></i>Branch:
|
|
</label>
|
|
<input type="text" id="branch-input-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}"
|
|
placeholder="main (default)"
|
|
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button onclick='if(window.installPlugin){const branchInput = document.getElementById("branch-input-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}"); window.installPlugin(${escapeJs(plugin.id)}, branchInput?.value?.trim() || null)}else{console.error("installPlugin not available")}' class="btn ${installed ? 'bg-gray-500 hover:bg-gray-600' : 'bg-green-600 hover:bg-green-700'} text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold">
|
|
<i class="fas ${installed ? 'fa-redo' : 'fa-download'} mr-2"></i>${installed ? 'Reinstall' : 'Install'}
|
|
</button>
|
|
<button onclick='${plugin.repo ? `window.open(${escapeJs(plugin.plugin_path ? plugin.repo + "/tree/" + encodeURIComponent(plugin.default_branch || plugin.branch || "main") + "/" + plugin.plugin_path.split("/").map(encodeURIComponent).join("/") : plugin.repo)}, "_blank")` : `void(0)`}' ${plugin.repo ? '' : 'disabled'} class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold${plugin.repo ? '' : ' opacity-50 cursor-not-allowed'}">
|
|
<i class="fas fa-external-link-alt mr-2"></i>View
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
// Expose functions to window for onclick handlers
|
|
window.installPlugin = function(pluginId, branch = null) {
|
|
showNotification(`Installing ${pluginId}${branch ? ` (branch: ${branch})` : ''}...`, 'info');
|
|
|
|
const requestBody = { plugin_id: pluginId };
|
|
if (branch) {
|
|
requestBody.branch = branch;
|
|
}
|
|
|
|
fetch('/api/v3/plugins/install', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(requestBody)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
showNotification(data.message, data.status);
|
|
if (data.status === 'success') {
|
|
// Refresh installed plugins list, then re-render store to update badges
|
|
loadInstalledPlugins();
|
|
setTimeout(() => applyStoreFiltersAndSort(true), 500);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showNotification('Error installing plugin: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
window.installFromCustomRegistry = function(pluginId, registryUrl, pluginPath, branch = null) {
|
|
const repoUrl = registryUrl;
|
|
const requestBody = {
|
|
repo_url: repoUrl,
|
|
plugin_id: pluginId,
|
|
plugin_path: pluginPath
|
|
};
|
|
if (branch) {
|
|
requestBody.branch = branch;
|
|
}
|
|
|
|
fetch('/api/v3/plugins/install-from-url', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showSuccess(`Plugin ${data.plugin_id} installed successfully`);
|
|
// Refresh installed plugins and re-render custom registry
|
|
loadInstalledPlugins();
|
|
// Re-render custom registry to update install buttons
|
|
const registryUrlInput = document.getElementById('github-registry-url');
|
|
if (registryUrlInput && registryUrlInput.value.trim()) {
|
|
document.getElementById('load-registry-from-url').click();
|
|
}
|
|
} else {
|
|
showError(data.message || 'Installation failed');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
let errorMsg = 'Error installing plugin: ' + error.message;
|
|
if (error.message && error.message.includes('Failed to Fetch')) {
|
|
errorMsg += ' - Please try refreshing your browser.';
|
|
}
|
|
showError(errorMsg);
|
|
});
|
|
}
|
|
|
|
function setupCollapsibleSections() {
|
|
console.log('[setupCollapsibleSections] Setting up collapsible sections...');
|
|
|
|
// Installed Plugins and Plugin Store sections no longer have collapse buttons
|
|
// They are always visible
|
|
|
|
// Functions are now defined outside IIFE, just attach the handler
|
|
if (window.attachGithubTokenCollapseHandler) {
|
|
window.attachGithubTokenCollapseHandler();
|
|
}
|
|
|
|
console.log('[setupCollapsibleSections] Collapsible sections setup complete');
|
|
}
|
|
|
|
function loadSavedRepositories() {
|
|
fetch('/api/v3/plugins/saved-repositories')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
renderSavedRepositories(data.data.repositories || []);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading saved repositories:', error);
|
|
});
|
|
}
|
|
|
|
function renderSavedRepositories(repositories) {
|
|
const container = document.getElementById('saved-repositories-list');
|
|
const countEl = document.getElementById('saved-repos-count');
|
|
|
|
if (!container) return;
|
|
|
|
if (countEl) {
|
|
countEl.textContent = `${repositories.length} saved`;
|
|
}
|
|
|
|
if (repositories.length === 0) {
|
|
container.innerHTML = '<p class="text-xs text-gray-500 italic">No saved repositories yet. Save a repository URL to see it here.</p>';
|
|
return;
|
|
}
|
|
|
|
// Helper function to escape for JavaScript strings
|
|
const escapeJs = (text) => {
|
|
return JSON.stringify(text || '');
|
|
};
|
|
|
|
container.innerHTML = repositories.map(repo => {
|
|
const repoUrl = repo.url || '';
|
|
const repoName = repo.name || repoUrl;
|
|
const repoType = repo.type || 'single';
|
|
|
|
return `
|
|
<div class="bg-white border border-gray-200 rounded p-2 flex items-center justify-between">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<i class="fas ${repoType === 'registry' ? 'fa-folder-open' : 'fa-code-branch'} text-gray-400 text-xs"></i>
|
|
<span class="text-sm font-medium text-gray-900 truncate" title="${repoUrl}">${escapeHtml(repoName)}</span>
|
|
</div>
|
|
<p class="text-xs text-gray-500 truncate" title="${repoUrl}">${escapeHtml(repoUrl)}</p>
|
|
</div>
|
|
<button onclick='if(window.removeSavedRepository){window.removeSavedRepository(${escapeJs(repoUrl)})}else{console.error("removeSavedRepository not available")}' class="ml-2 text-red-600 hover:text-red-800 text-xs px-2 py-1" title="Remove repository">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
window.removeSavedRepository = function(repoUrl) {
|
|
if (!confirm('Remove this saved repository? Its plugins will no longer appear in the store.')) {
|
|
return;
|
|
}
|
|
|
|
fetch('/api/v3/plugins/saved-repositories', {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ repo_url: repoUrl })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showSuccess('Repository removed successfully');
|
|
renderSavedRepositories(data.data.repositories || []);
|
|
// Refresh plugin store to remove plugins from deleted repo
|
|
searchPluginStore();
|
|
} else {
|
|
showError(data.message || 'Failed to remove repository');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showError('Error removing repository: ' + error.message);
|
|
});
|
|
}
|
|
|
|
// Separate function to attach install button handler (can be called multiple times)
|
|
function attachInstallButtonHandler() {
|
|
console.log('[attachInstallButtonHandler] ===== FUNCTION CALLED =====');
|
|
const installBtn = document.getElementById('install-plugin-from-url');
|
|
const pluginUrlInput = document.getElementById('github-plugin-url');
|
|
const pluginStatusDiv = document.getElementById('github-plugin-status');
|
|
|
|
console.log('[attachInstallButtonHandler] Looking for install button elements:', {
|
|
installBtn: !!installBtn,
|
|
pluginUrlInput: !!pluginUrlInput,
|
|
pluginStatusDiv: !!pluginStatusDiv
|
|
});
|
|
|
|
if (installBtn && pluginUrlInput) {
|
|
// Check if handler already attached (prevent duplicates)
|
|
if (installBtn.hasAttribute('data-handler-attached')) {
|
|
console.log('[attachInstallButtonHandler] Handler already attached, skipping');
|
|
return;
|
|
}
|
|
|
|
// Clone button to remove any existing listeners (prevents duplicate handlers)
|
|
const parent = installBtn.parentNode;
|
|
if (parent) {
|
|
const newBtn = installBtn.cloneNode(true);
|
|
// Ensure button type is set to prevent form submission
|
|
newBtn.type = 'button';
|
|
// Mark as having handler attached
|
|
newBtn.setAttribute('data-handler-attached', 'true');
|
|
parent.replaceChild(newBtn, installBtn);
|
|
|
|
console.log('[attachInstallButtonHandler] Install button cloned and replaced, type:', newBtn.type);
|
|
|
|
newBtn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
console.log('[attachInstallButtonHandler] Install button clicked!');
|
|
|
|
const repoUrl = pluginUrlInput.value.trim();
|
|
if (!repoUrl) {
|
|
if (pluginStatusDiv) {
|
|
pluginStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a GitHub URL</span>';
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!repoUrl.includes('github.com')) {
|
|
if (pluginStatusDiv) {
|
|
pluginStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
|
|
}
|
|
return;
|
|
}
|
|
|
|
newBtn.disabled = true;
|
|
newBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Installing...';
|
|
if (pluginStatusDiv) {
|
|
pluginStatusDiv.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Installing plugin...</span>';
|
|
}
|
|
|
|
const branch = document.getElementById('plugin-branch-input')?.value?.trim() || null;
|
|
const requestBody = { repo_url: repoUrl };
|
|
if (branch) {
|
|
requestBody.branch = branch;
|
|
}
|
|
|
|
console.log('[attachInstallButtonHandler] Sending install request:', requestBody);
|
|
|
|
fetch('/api/v3/plugins/install-from-url', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
})
|
|
.then(response => {
|
|
console.log('[attachInstallButtonHandler] Response status:', response.status);
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
console.log('[attachInstallButtonHandler] Response data:', data);
|
|
if (data.status === 'success') {
|
|
if (pluginStatusDiv) {
|
|
pluginStatusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Successfully installed: ${data.plugin_id}</span>`;
|
|
}
|
|
pluginUrlInput.value = '';
|
|
|
|
// Refresh installed plugins list
|
|
setTimeout(() => {
|
|
loadInstalledPlugins();
|
|
}, 1000);
|
|
} else {
|
|
if (pluginStatusDiv) {
|
|
pluginStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>${data.message || 'Installation failed'}</span>`;
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('[attachInstallButtonHandler] Error:', error);
|
|
if (pluginStatusDiv) {
|
|
pluginStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${error.message}</span>`;
|
|
}
|
|
})
|
|
.finally(() => {
|
|
newBtn.disabled = false;
|
|
newBtn.innerHTML = '<i class="fas fa-download mr-2"></i>Install';
|
|
});
|
|
});
|
|
|
|
// Allow Enter key to trigger install
|
|
pluginUrlInput.addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
console.log('[attachInstallButtonHandler] Enter key pressed, triggering install');
|
|
newBtn.click();
|
|
}
|
|
});
|
|
|
|
console.log('[attachInstallButtonHandler] Install button handler attached successfully');
|
|
} else {
|
|
console.error('[attachInstallButtonHandler] Install button parent not found!');
|
|
}
|
|
} else {
|
|
console.warn('[attachInstallButtonHandler] Install button or URL input not found:', {
|
|
installBtn: !!installBtn,
|
|
pluginUrlInput: !!pluginUrlInput
|
|
});
|
|
}
|
|
}
|
|
|
|
function setupGitHubInstallHandlers() {
|
|
console.log('[setupGitHubInstallHandlers] ===== FUNCTION CALLED ===== Setting up GitHub install handlers...');
|
|
|
|
// Toggle GitHub install section visibility
|
|
const toggleBtn = document.getElementById('toggle-github-install');
|
|
const installSection = document.getElementById('github-install-section');
|
|
const icon = document.getElementById('github-install-icon');
|
|
|
|
console.log('[setupGitHubInstallHandlers] Elements found:', {
|
|
button: !!toggleBtn,
|
|
section: !!installSection,
|
|
icon: !!icon
|
|
});
|
|
|
|
if (toggleBtn && installSection) {
|
|
// Clone button to remove any existing listeners
|
|
const parent = toggleBtn.parentNode;
|
|
if (parent) {
|
|
const newBtn = toggleBtn.cloneNode(true);
|
|
parent.replaceChild(newBtn, toggleBtn);
|
|
|
|
newBtn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
console.log('[setupGitHubInstallHandlers] GitHub install toggle clicked');
|
|
|
|
const section = document.getElementById('github-install-section');
|
|
const iconEl = document.getElementById('github-install-icon');
|
|
const btn = document.getElementById('toggle-github-install');
|
|
|
|
if (!section || !btn) return;
|
|
|
|
const hasHiddenClass = section.classList.contains('hidden');
|
|
const computedDisplay = window.getComputedStyle(section).display;
|
|
|
|
if (hasHiddenClass || computedDisplay === 'none') {
|
|
// Show section - remove hidden, ensure visible
|
|
section.classList.remove('hidden');
|
|
section.style.removeProperty('display');
|
|
if (iconEl) {
|
|
iconEl.classList.remove('fa-chevron-down');
|
|
iconEl.classList.add('fa-chevron-up');
|
|
}
|
|
const span = btn.querySelector('span');
|
|
if (span) span.textContent = 'Hide';
|
|
|
|
// Re-attach install button handler when section is shown (in case elements weren't ready before)
|
|
console.log('[setupGitHubInstallHandlers] Section shown, will re-attach install button handler in 100ms');
|
|
setTimeout(() => {
|
|
console.log('[setupGitHubInstallHandlers] Re-attaching install button handler now');
|
|
attachInstallButtonHandler();
|
|
}, 100);
|
|
} else {
|
|
// Hide section - add hidden, set display none
|
|
section.classList.add('hidden');
|
|
section.style.display = 'none';
|
|
if (iconEl) {
|
|
iconEl.classList.remove('fa-chevron-up');
|
|
iconEl.classList.add('fa-chevron-down');
|
|
}
|
|
const span = btn.querySelector('span');
|
|
if (span) span.textContent = 'Show';
|
|
}
|
|
});
|
|
console.log('[setupGitHubInstallHandlers] Handler attached');
|
|
}
|
|
} else {
|
|
console.warn('[setupGitHubInstallHandlers] Required elements not found');
|
|
}
|
|
|
|
// Install single plugin from URL - use separate function so we can re-call it
|
|
console.log('[setupGitHubInstallHandlers] About to call attachInstallButtonHandler...');
|
|
attachInstallButtonHandler();
|
|
console.log('[setupGitHubInstallHandlers] Called attachInstallButtonHandler');
|
|
|
|
// Load registry from URL
|
|
const loadRegistryBtn = document.getElementById('load-registry-from-url');
|
|
const registryUrlInput = document.getElementById('github-registry-url');
|
|
const registryStatusDiv = document.getElementById('registry-status');
|
|
const customRegistryPlugins = document.getElementById('custom-registry-plugins');
|
|
const customRegistryGrid = document.getElementById('custom-registry-grid');
|
|
|
|
if (loadRegistryBtn && registryUrlInput) {
|
|
loadRegistryBtn.addEventListener('click', function() {
|
|
const repoUrl = registryUrlInput.value.trim();
|
|
if (!repoUrl) {
|
|
registryStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a GitHub URL</span>';
|
|
return;
|
|
}
|
|
|
|
if (!repoUrl.includes('github.com')) {
|
|
registryStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
|
|
return;
|
|
}
|
|
|
|
loadRegistryBtn.disabled = true;
|
|
loadRegistryBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Loading...';
|
|
registryStatusDiv.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Loading registry...</span>';
|
|
customRegistryPlugins.classList.add('hidden');
|
|
|
|
fetch('/api/v3/plugins/registry-from-url', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ repo_url: repoUrl })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success' && data.plugins && data.plugins.length > 0) {
|
|
registryStatusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Found ${data.plugins.length} plugins</span>`;
|
|
renderCustomRegistryPlugins(data.plugins, repoUrl);
|
|
customRegistryPlugins.classList.remove('hidden');
|
|
} else {
|
|
registryStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>No valid registry found or registry is empty</span>';
|
|
customRegistryPlugins.classList.add('hidden');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
registryStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${error.message}</span>`;
|
|
customRegistryPlugins.classList.add('hidden');
|
|
})
|
|
.finally(() => {
|
|
loadRegistryBtn.disabled = false;
|
|
loadRegistryBtn.innerHTML = '<i class="fas fa-search mr-2"></i>Load Registry';
|
|
});
|
|
});
|
|
|
|
// Allow Enter key to trigger load
|
|
registryUrlInput.addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
loadRegistryBtn.click();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Save registry URL button
|
|
const saveRegistryBtn = document.getElementById('save-registry-url');
|
|
if (saveRegistryBtn && registryUrlInput) {
|
|
saveRegistryBtn.addEventListener('click', function() {
|
|
const repoUrl = registryUrlInput.value.trim();
|
|
if (!repoUrl) {
|
|
showError('Please enter a repository URL first');
|
|
return;
|
|
}
|
|
|
|
if (!repoUrl.includes('github.com')) {
|
|
showError('Please enter a valid GitHub URL');
|
|
return;
|
|
}
|
|
|
|
saveRegistryBtn.disabled = true;
|
|
saveRegistryBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Saving...';
|
|
|
|
fetch('/api/v3/plugins/saved-repositories', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ repo_url: repoUrl })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showSuccess('Repository saved successfully! Its plugins will appear in the Plugin Store.');
|
|
renderSavedRepositories(data.data.repositories || []);
|
|
// Refresh plugin store to include new repo
|
|
searchPluginStore();
|
|
} else {
|
|
showError(data.message || 'Failed to save repository');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showError('Error saving repository: ' + error.message);
|
|
})
|
|
.finally(() => {
|
|
saveRegistryBtn.disabled = false;
|
|
saveRegistryBtn.innerHTML = '<i class="fas fa-bookmark mr-2"></i>Save Repository';
|
|
});
|
|
});
|
|
}
|
|
|
|
// Refresh saved repos button
|
|
const refreshSavedReposBtn = document.getElementById('refresh-saved-repos');
|
|
if (refreshSavedReposBtn) {
|
|
refreshSavedReposBtn.addEventListener('click', function() {
|
|
loadSavedRepositories();
|
|
searchPluginStore(); // Also refresh plugin store
|
|
showSuccess('Repositories refreshed');
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderCustomRegistryPlugins(plugins, registryUrl) {
|
|
const container = document.getElementById('custom-registry-grid');
|
|
if (!container) return;
|
|
|
|
if (plugins.length === 0) {
|
|
container.innerHTML = '<p class="text-sm text-gray-500 col-span-full">No plugins found in this registry</p>';
|
|
return;
|
|
}
|
|
|
|
// Escape HTML helper
|
|
const escapeHtml = (text) => {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
};
|
|
|
|
// Helper function to escape for JavaScript strings
|
|
const escapeJs = (text) => {
|
|
return JSON.stringify(text || '');
|
|
};
|
|
|
|
container.innerHTML = plugins.map(plugin => {
|
|
const isInstalled = installedPlugins.some(p => p.id === plugin.id);
|
|
const pluginIdJs = escapeJs(plugin.id);
|
|
const escapedUrlJs = escapeJs(registryUrl);
|
|
const pluginPathJs = escapeJs(plugin.plugin_path || '');
|
|
const branchInputId = `branch-input-custom-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
|
|
|
const installBtn = isInstalled
|
|
? '<button class="px-3 py-1 text-xs bg-gray-400 text-white rounded cursor-not-allowed" disabled><i class="fas fa-check mr-1"></i>Installed</button>'
|
|
: `<button onclick='if(window.installFromCustomRegistry){const branchInput = document.getElementById("${branchInputId}"); window.installFromCustomRegistry(${pluginIdJs}, ${escapedUrlJs}, ${pluginPathJs}, branchInput?.value?.trim() || null)}else{console.error("installFromCustomRegistry not available")}' class="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded"><i class="fas fa-download mr-1"></i>Install</button>`;
|
|
|
|
return `
|
|
<div class="bg-white border border-gray-200 rounded-lg p-3">
|
|
<div class="flex items-start justify-between mb-2">
|
|
<div class="flex-1">
|
|
<h5 class="font-semibold text-sm text-gray-900">${escapeHtml(plugin.name || plugin.id)}</h5>
|
|
<p class="text-xs text-gray-600 mt-1 line-clamp-2">${escapeHtml(plugin.description || 'No description')}</p>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-2 mt-2 pt-2 border-t border-gray-100">
|
|
<div class="flex items-center gap-2">
|
|
<label for="${branchInputId}" class="text-xs text-gray-600 whitespace-nowrap">
|
|
<i class="fas fa-code-branch mr-1"></i>Branch:
|
|
</label>
|
|
<input type="text" id="${branchInputId}"
|
|
placeholder="main (default)"
|
|
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500">Last updated ${formatDate(plugin.last_updated)}</span>
|
|
${installBtn}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function showSuccess(message) {
|
|
// Try to use notification system if available, otherwise use alert
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(message, 'success');
|
|
} else {
|
|
console.log('Success: ' + message);
|
|
// Show a temporary success message
|
|
const statusDiv = document.getElementById('github-plugin-status') || document.getElementById('registry-status');
|
|
if (statusDiv) {
|
|
statusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>${message}</span>`;
|
|
setTimeout(() => {
|
|
if (statusDiv) statusDiv.innerHTML = '';
|
|
}, 5000);
|
|
}
|
|
}
|
|
}
|
|
|
|
function showError(message) {
|
|
const content = document.getElementById('plugins-content');
|
|
if (!content) {
|
|
console.error('plugins-content element not found');
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(message, 'error');
|
|
} else {
|
|
console.error('Error: ' + message);
|
|
}
|
|
return;
|
|
}
|
|
content.innerHTML = `
|
|
<div class="text-center py-8">
|
|
<i class="fas fa-exclamation-triangle text-4xl text-red-400 mb-2"></i>
|
|
<p class="text-red-600">${escapeHtml(message)}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Plugin configuration form submission is handled by handlePluginConfigSubmit
|
|
// which is attached directly to the form. The document-level listener has been removed
|
|
// to avoid duplicate submissions and to ensure proper handling of _data fields.
|
|
|
|
function savePluginConfiguration(pluginId, config) {
|
|
// Update the plugin configuration in the backend
|
|
fetch('/api/v3/plugins/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId, config })
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
// Try to parse error response
|
|
return response.json().then(data => {
|
|
// Return error data with status
|
|
return { error: true, status: response.status, ...data };
|
|
}).catch(() => {
|
|
// If JSON parsing fails, return generic error
|
|
return {
|
|
error: true,
|
|
status: response.status,
|
|
message: `Server error: ${response.status} ${response.statusText}`
|
|
};
|
|
});
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.error || data.status !== 'success') {
|
|
// Display validation errors if present
|
|
if (data.validation_errors && Array.isArray(data.validation_errors)) {
|
|
displayValidationErrors(data.validation_errors);
|
|
}
|
|
let errorMessage = data.message || 'Error saving configuration';
|
|
if (data.validation_errors && Array.isArray(data.validation_errors) && data.validation_errors.length > 0) {
|
|
errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n');
|
|
}
|
|
showNotification(errorMessage, 'error');
|
|
console.error('Config save failed:', data);
|
|
} else {
|
|
// Hide validation errors on success
|
|
displayValidationErrors([]);
|
|
showNotification(data.message || 'Configuration saved successfully', data.status);
|
|
closePluginConfigModal();
|
|
// Refresh the installed plugins to update the UI
|
|
loadInstalledPlugins();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error saving plugin config:', error);
|
|
showNotification('Error saving plugin configuration: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
// Utility function to escape HTML
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Utility function to escape text for use in HTML attributes
|
|
// Escapes quotes, ampersands, and other special characters that could break attributes
|
|
function escapeAttribute(text) {
|
|
if (text == null) {
|
|
return '';
|
|
}
|
|
const str = String(text);
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
// Format date for display
|
|
function formatDate(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;
|
|
}
|
|
}
|
|
|
|
function formatCommit(commit, branch) {
|
|
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';
|
|
}
|
|
|
|
// Check if plugin is new (updated within last 7 days)
|
|
function isNewPlugin(lastUpdated) {
|
|
if (!lastUpdated) return false;
|
|
|
|
try {
|
|
const date = new Date(lastUpdated);
|
|
const now = new Date();
|
|
const diffTime = Math.abs(now - date);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
return diffDays <= 7;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Debounce utility
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
// Toggle password visibility for secret fields
|
|
function togglePasswordVisibility(fieldId) {
|
|
const input = document.getElementById(fieldId);
|
|
const icon = document.getElementById(fieldId + '-icon');
|
|
|
|
if (input && icon) {
|
|
if (input.type === 'password') {
|
|
input.type = 'text';
|
|
icon.classList.remove('fa-eye');
|
|
icon.classList.add('fa-eye-slash');
|
|
} else {
|
|
input.type = 'password';
|
|
icon.classList.remove('fa-eye-slash');
|
|
icon.classList.add('fa-eye');
|
|
}
|
|
}
|
|
}
|
|
|
|
// GitHub Token Configuration Functions
|
|
// Open GitHub Token Settings panel (only opens, doesn't close)
|
|
// Used when user clicks "Configure Token" link
|
|
window.openGithubTokenSettings = function() {
|
|
const settings = document.getElementById('github-token-settings');
|
|
const warning = document.getElementById('github-auth-warning');
|
|
const tokenContent = document.getElementById('github-token-content');
|
|
|
|
if (settings) {
|
|
// Show settings panel using both methods
|
|
settings.classList.remove('hidden');
|
|
settings.style.display = '';
|
|
|
|
// Expand the content when opening
|
|
if (tokenContent) {
|
|
tokenContent.style.removeProperty('display');
|
|
tokenContent.classList.remove('hidden');
|
|
|
|
// Update collapse button state
|
|
const tokenIconCollapse = document.getElementById('github-token-icon-collapse');
|
|
const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse');
|
|
if (tokenIconCollapse) {
|
|
tokenIconCollapse.classList.remove('fa-chevron-down');
|
|
tokenIconCollapse.classList.add('fa-chevron-up');
|
|
}
|
|
if (toggleTokenCollapseBtn) {
|
|
const span = toggleTokenCollapseBtn.querySelector('span');
|
|
if (span) span.textContent = 'Collapse';
|
|
}
|
|
}
|
|
|
|
// When opening settings, hide the warning banner
|
|
if (warning) {
|
|
warning.classList.add('hidden');
|
|
warning.style.display = 'none';
|
|
// Clear any dismissal state since user is actively configuring
|
|
sessionStorage.removeItem('github-auth-warning-dismissed');
|
|
}
|
|
|
|
// Load token when opening the panel
|
|
loadGithubToken();
|
|
}
|
|
}
|
|
|
|
window.toggleGithubTokenVisibility = function() {
|
|
const input = document.getElementById('github-token-input');
|
|
const icon = document.getElementById('github-token-icon');
|
|
|
|
if (input && icon) {
|
|
if (input.type === 'password') {
|
|
input.type = 'text';
|
|
icon.classList.remove('fa-eye');
|
|
icon.classList.add('fa-eye-slash');
|
|
} else {
|
|
input.type = 'password';
|
|
icon.classList.remove('fa-eye-slash');
|
|
icon.classList.add('fa-eye');
|
|
}
|
|
}
|
|
}
|
|
|
|
window.loadGithubToken = function() {
|
|
const input = document.getElementById('github-token-input');
|
|
const loadButton = document.querySelector('button[onclick="loadGithubToken()"]');
|
|
|
|
if (!input) return;
|
|
|
|
// Set loading state on load button
|
|
const originalButtonContent = loadButton ? loadButton.innerHTML : '';
|
|
if (loadButton) {
|
|
loadButton.disabled = true;
|
|
loadButton.classList.add('opacity-50', 'cursor-not-allowed');
|
|
loadButton.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Loading...';
|
|
}
|
|
|
|
fetch('/api/v3/config/secrets')
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
// Handle empty data (secrets file doesn't exist) - API returns {} in this case
|
|
const secrets = data.data || {};
|
|
const token = secrets.github?.api_token || '';
|
|
|
|
if (input) {
|
|
if (token && token !== 'YOUR_GITHUB_PERSONAL_ACCESS_TOKEN') {
|
|
// Token exists and is valid
|
|
input.value = token;
|
|
showNotification('GitHub token loaded successfully', 'success');
|
|
} else {
|
|
// No token configured or placeholder value
|
|
input.value = '';
|
|
showNotification('No GitHub token configured. Enter a new token to save.', 'info');
|
|
}
|
|
}
|
|
} else {
|
|
throw new Error(data.message || 'Failed to load secrets configuration');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading GitHub token:', error);
|
|
if (input) {
|
|
input.value = '';
|
|
}
|
|
// If it's a 404 or file doesn't exist, that's okay - just inform the user
|
|
if (error.message.includes('404') || error.message.includes('not found')) {
|
|
showNotification('No secrets file found. You can create one by saving a token.', 'info');
|
|
} else {
|
|
showNotification('Error loading GitHub token: ' + error.message, 'error');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
// Restore button state
|
|
if (loadButton) {
|
|
loadButton.disabled = false;
|
|
loadButton.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
loadButton.innerHTML = originalButtonContent;
|
|
}
|
|
});
|
|
}
|
|
|
|
window.saveGithubToken = function() {
|
|
const input = document.getElementById('github-token-input');
|
|
const saveButton = document.querySelector('button[onclick="saveGithubToken()"]');
|
|
if (!input) return;
|
|
|
|
const token = input.value.trim();
|
|
|
|
if (!token) {
|
|
showNotification('Please enter a GitHub token', 'error');
|
|
return;
|
|
}
|
|
|
|
// Client-side token validation
|
|
if (!token.startsWith('ghp_') && !token.startsWith('github_pat_')) {
|
|
if (!confirm('Token format looks invalid. GitHub tokens should start with "ghp_" or "github_pat_". Continue anyway?')) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Set loading state on save button
|
|
const originalButtonContent = saveButton ? saveButton.innerHTML : '';
|
|
if (saveButton) {
|
|
saveButton.disabled = true;
|
|
saveButton.classList.add('opacity-50', 'cursor-not-allowed');
|
|
saveButton.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Saving...';
|
|
}
|
|
|
|
// Load current secrets config
|
|
fetch('/api/v3/config/secrets')
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
const secrets = data.data || {};
|
|
|
|
// Update GitHub token
|
|
if (!secrets.github) {
|
|
secrets.github = {};
|
|
}
|
|
secrets.github.api_token = token;
|
|
|
|
// Save updated secrets
|
|
return fetch('/api/v3/config/raw/secrets', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(secrets)
|
|
});
|
|
} else {
|
|
throw new Error(data.message || 'Failed to load current secrets');
|
|
}
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showNotification('GitHub token saved successfully! Rate limit increased to 5,000/hour', 'success');
|
|
|
|
// Clear input field for security (user can reload if needed)
|
|
input.value = '';
|
|
|
|
// Clear the dismissal flag so warning can properly hide/show based on token status
|
|
sessionStorage.removeItem('github-auth-warning-dismissed');
|
|
|
|
// Small delay to ensure backend has reloaded the token, then refresh status
|
|
// checkGitHubAuthStatus() will handle collapsing the panel automatically
|
|
// Reduced delay from 300ms to 100ms - backend should reload quickly
|
|
setTimeout(() => {
|
|
if (window.checkGitHubAuthStatus) {
|
|
window.checkGitHubAuthStatus();
|
|
}
|
|
}, 100);
|
|
} else {
|
|
throw new Error(data.message || 'Failed to save token');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error saving GitHub token:', error);
|
|
showNotification('Error saving GitHub token: ' + error.message, 'error');
|
|
})
|
|
.finally(() => {
|
|
// Restore button state
|
|
if (saveButton) {
|
|
saveButton.disabled = false;
|
|
saveButton.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
saveButton.innerHTML = originalButtonContent;
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
window.dismissGithubWarning = function() {
|
|
const warning = document.getElementById('github-auth-warning');
|
|
const settings = document.getElementById('github-token-settings');
|
|
if (warning) {
|
|
// Hide warning using both classList and style.display
|
|
warning.classList.add('hidden');
|
|
warning.style.display = 'none';
|
|
// Also hide settings if it's open (since they're combined now)
|
|
if (settings && !settings.classList.contains('hidden')) {
|
|
settings.classList.add('hidden');
|
|
settings.style.display = 'none';
|
|
}
|
|
// Remember dismissal for this session
|
|
sessionStorage.setItem('github-auth-warning-dismissed', 'true');
|
|
}
|
|
}
|
|
|
|
window.showGithubTokenInstructions = function() {
|
|
const instructions = `
|
|
<div class="space-y-4">
|
|
<h4 class="font-semibold text-lg">How to Add a GitHub Token</h4>
|
|
|
|
<div class="space-y-3">
|
|
<div class="bg-gray-50 p-3 rounded">
|
|
<h5 class="font-medium mb-2">Step 1: Create a GitHub Token</h5>
|
|
<ol class="list-decimal list-inside space-y-1 text-sm">
|
|
<li>Click the "Create a GitHub Token" link above (or <a href="https://github.com/settings/tokens/new?description=LEDMatrix%20Plugin%20Manager&scopes=" target="_blank" class="text-blue-600 underline">click here</a>)</li>
|
|
<li>Give it a name like "LEDMatrix Plugin Manager"</li>
|
|
<li>No special scopes/permissions are needed for public repositories</li>
|
|
<li>Click "Generate token" at the bottom</li>
|
|
<li>Copy the generated token (it starts with "ghp_")</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div class="bg-gray-50 p-3 rounded">
|
|
<h5 class="font-medium mb-2">Step 2: Add Token to LEDMatrix</h5>
|
|
<ol class="list-decimal list-inside space-y-1 text-sm">
|
|
<li>SSH into your Raspberry Pi</li>
|
|
<li>Edit the secrets file: <code class="bg-gray-200 px-1 rounded">nano ~/LEDMatrix/config/config_secrets.json</code></li>
|
|
<li>Find the "github" section and add your token:
|
|
<pre class="bg-gray-800 text-white p-2 rounded mt-2 text-xs overflow-x-auto">"github": {
|
|
"api_token": "ghp_your_token_here"
|
|
}</pre>
|
|
</li>
|
|
<li>Save the file (Ctrl+O, Enter, Ctrl+X)</li>
|
|
<li>Restart the web service: <code class="bg-gray-200 px-1 rounded">sudo systemctl restart ledmatrix-web</code></li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div class="bg-blue-50 p-3 rounded border border-blue-200">
|
|
<p class="text-sm text-blue-800">
|
|
<i class="fas fa-info-circle mr-2"></i>
|
|
<strong>Note:</strong> Your token is stored locally and never shared. It's only used to authenticate API requests to GitHub.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-end">
|
|
<button onclick="closeInstructionsModal()" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
|
|
Got it!
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Use the existing plugin config modal for instructions
|
|
const modal = document.getElementById('plugin-config-modal');
|
|
const title = document.getElementById('plugin-config-title');
|
|
const content = document.getElementById('plugin-config-content');
|
|
|
|
title.textContent = 'GitHub Token Setup';
|
|
content.innerHTML = instructions;
|
|
modal.style.display = 'flex';
|
|
console.log('GitHub instructions modal opened');
|
|
}
|
|
|
|
window.closeInstructionsModal = function() {
|
|
const modal = document.getElementById('plugin-config-modal');
|
|
modal.style.display = 'none';
|
|
console.log('Instructions modal closed');
|
|
}
|
|
|
|
// ==================== File Upload Functions ====================
|
|
// Make these globally accessible for use in base.html
|
|
|
|
window.handleFileDrop = function(event, fieldId) {
|
|
event.preventDefault();
|
|
const files = event.dataTransfer.files;
|
|
if (files.length > 0) {
|
|
window.handleFiles(fieldId, Array.from(files));
|
|
}
|
|
}
|
|
|
|
window.handleFileSelect = function(event, fieldId) {
|
|
const files = event.target.files;
|
|
if (files.length > 0) {
|
|
window.handleFiles(fieldId, Array.from(files));
|
|
}
|
|
}
|
|
|
|
window.handleCredentialsUpload = async function(event, fieldId, uploadEndpoint, targetFilename) {
|
|
const file = event.target.files[0];
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
// Validate file extension
|
|
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
|
|
if (!fileExt || fileExt === '.') {
|
|
showNotification('Please select a valid file', 'error');
|
|
return;
|
|
}
|
|
|
|
// Validate file size (1MB max)
|
|
if (file.size > 1024 * 1024) {
|
|
showNotification('File exceeds 1MB limit', 'error');
|
|
return;
|
|
}
|
|
|
|
// Show upload status
|
|
const statusEl = document.getElementById(fieldId + '_status');
|
|
if (statusEl) {
|
|
statusEl.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Uploading...';
|
|
}
|
|
|
|
// Create form data
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
try {
|
|
const response = await fetch(uploadEndpoint, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
// Update hidden input with filename
|
|
const hiddenInput = document.getElementById(fieldId + '_hidden');
|
|
if (hiddenInput) {
|
|
hiddenInput.value = targetFilename || file.name;
|
|
}
|
|
|
|
// Update status
|
|
if (statusEl) {
|
|
statusEl.innerHTML = `✓ Uploaded: ${targetFilename || file.name}`;
|
|
statusEl.className = 'text-sm text-green-600';
|
|
}
|
|
|
|
showNotification('Credentials file uploaded successfully', 'success');
|
|
} else {
|
|
if (statusEl) {
|
|
statusEl.innerHTML = 'Upload failed - click to try again';
|
|
statusEl.className = 'text-sm text-gray-600';
|
|
}
|
|
showNotification(data.message || 'Upload failed', 'error');
|
|
}
|
|
} catch (error) {
|
|
if (statusEl) {
|
|
statusEl.innerHTML = 'Upload failed - click to try again';
|
|
statusEl.className = 'text-sm text-gray-600';
|
|
}
|
|
showNotification('Error uploading file: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
window.handleFiles = async function(fieldId, files) {
|
|
const uploadConfig = window.getUploadConfig(fieldId);
|
|
const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image';
|
|
const maxFiles = uploadConfig.max_files || 10;
|
|
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
const fileType = uploadConfig.file_type || 'image';
|
|
const customUploadEndpoint = uploadConfig.endpoint || '/api/v3/plugins/assets/upload';
|
|
|
|
// Get current files list (works for both images and JSON)
|
|
const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : [];
|
|
if (currentFiles.length + files.length > maxFiles) {
|
|
showNotification(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${files.length}.`, 'error');
|
|
return;
|
|
}
|
|
|
|
// Validate file types and sizes
|
|
const validFiles = [];
|
|
for (const file of files) {
|
|
if (file.size > maxSizeMB * 1024 * 1024) {
|
|
showNotification(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
|
|
continue;
|
|
}
|
|
|
|
if (fileType === 'json') {
|
|
// Validate JSON files
|
|
if (!file.name.toLowerCase().endsWith('.json')) {
|
|
showNotification(`File ${file.name} must be a JSON file (.json)`, 'error');
|
|
continue;
|
|
}
|
|
} else {
|
|
// Validate image files
|
|
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp', 'image/gif'];
|
|
if (!allowedTypes.includes(file.type)) {
|
|
showNotification(`File ${file.name} is not a valid image type`, 'error');
|
|
continue;
|
|
}
|
|
}
|
|
|
|
validFiles.push(file);
|
|
}
|
|
|
|
if (validFiles.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Show upload progress
|
|
window.showUploadProgress(fieldId, validFiles.length);
|
|
|
|
// Upload files
|
|
const formData = new FormData();
|
|
if (fileType !== 'json') {
|
|
formData.append('plugin_id', pluginId);
|
|
}
|
|
validFiles.forEach(file => formData.append('files', file));
|
|
|
|
try {
|
|
const response = await fetch(customUploadEndpoint, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
// Add uploaded files to current list
|
|
const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : [];
|
|
const newFiles = [...currentFiles, ...data.uploaded_files];
|
|
window.updateImageList(fieldId, newFiles);
|
|
|
|
showNotification(`Successfully uploaded ${data.uploaded_files.length} ${fileType === 'json' ? 'file(s)' : 'image(s)'}`, 'success');
|
|
} else {
|
|
showNotification(`Upload failed: ${data.message}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Upload error:', error);
|
|
showNotification(`Upload error: ${error.message}`, 'error');
|
|
} finally {
|
|
window.hideUploadProgress(fieldId);
|
|
// Clear file input
|
|
const fileInput = document.getElementById(`${fieldId}_file_input`);
|
|
if (fileInput) {
|
|
fileInput.value = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
window.deleteUploadedImage = async function(fieldId, imageId, pluginId) {
|
|
return window.deleteUploadedFile(fieldId, imageId, pluginId, 'image', null);
|
|
}
|
|
|
|
window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType, customDeleteEndpoint) {
|
|
const fileTypeLabel = fileType === 'json' ? 'file' : 'image';
|
|
if (!confirm(`Are you sure you want to delete this ${fileTypeLabel}?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const deleteEndpoint = customDeleteEndpoint || (fileType === 'json' ? '/api/v3/plugins/of-the-day/json/delete' : '/api/v3/plugins/assets/delete');
|
|
const requestBody = fileType === 'json'
|
|
? { file_id: fileId }
|
|
: { plugin_id: pluginId, image_id: fileId };
|
|
|
|
const response = await fetch(deleteEndpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(requestBody)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
// Remove from current list
|
|
const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : [];
|
|
const newFiles = currentFiles.filter(file => (file.id || file.category_name) !== fileId);
|
|
window.updateImageList(fieldId, newFiles);
|
|
|
|
showNotification(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success');
|
|
} else {
|
|
showNotification(`Delete failed: ${data.message}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Delete error:', error);
|
|
showNotification(`Delete error: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
window.getUploadConfig = function(fieldId) {
|
|
// Extract config from schema
|
|
const schema = window.currentPluginConfig?.schema;
|
|
if (!schema || !schema.properties) return {};
|
|
|
|
// Find the property that matches this fieldId
|
|
// FieldId is like "image_config_images" for "image_config.images"
|
|
const key = fieldId.replace(/_/g, '.');
|
|
const keys = key.split('.');
|
|
let prop = schema.properties;
|
|
|
|
for (const k of keys) {
|
|
if (prop && prop[k]) {
|
|
prop = prop[k];
|
|
if (prop.properties && prop.type === 'object') {
|
|
prop = prop.properties;
|
|
} else if (prop.type === 'array' && prop['x-widget'] === 'file-upload') {
|
|
break;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we found an array with x-widget, get its config
|
|
if (prop && prop.type === 'array' && prop['x-widget'] === 'file-upload') {
|
|
return prop['x-upload-config'] || {};
|
|
}
|
|
|
|
// Try to find nested images array
|
|
if (schema.properties && schema.properties.image_config &&
|
|
schema.properties.image_config.properties &&
|
|
schema.properties.image_config.properties.images) {
|
|
const imagesProp = schema.properties.image_config.properties.images;
|
|
if (imagesProp['x-widget'] === 'file-upload') {
|
|
return imagesProp['x-upload-config'] || {};
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
window.getCurrentImages = function(fieldId) {
|
|
const hiddenInput = document.getElementById(`${fieldId}_images_data`);
|
|
if (hiddenInput && hiddenInput.value) {
|
|
try {
|
|
return JSON.parse(hiddenInput.value);
|
|
} catch (e) {
|
|
console.error('Error parsing images data:', e);
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
window.updateImageList = function(fieldId, images) {
|
|
const hiddenInput = document.getElementById(`${fieldId}_images_data`);
|
|
if (hiddenInput) {
|
|
hiddenInput.value = JSON.stringify(images);
|
|
}
|
|
|
|
// Update the display
|
|
const imageList = document.getElementById(`${fieldId}_image_list`);
|
|
if (imageList) {
|
|
const uploadConfig = window.getUploadConfig(fieldId);
|
|
const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image';
|
|
|
|
imageList.innerHTML = images.map((img, idx) => {
|
|
const imgSchedule = img.schedule || {};
|
|
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
|
|
const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown';
|
|
|
|
return `
|
|
<div id="img_${img.id || idx}" 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 || ''}"
|
|
alt="${img.filename || ''}"
|
|
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">${img.original_filename || img.filename || 'Image'}</p>
|
|
<p class="text-xs text-gray-500">${window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB')} • ${window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || '')}</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"
|
|
onclick="window.openImageSchedule('${fieldId}', '${img.id}', ${idx})"
|
|
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"
|
|
onclick="window.deleteUploadedImage('${fieldId}', '${img.id}', '${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}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
window.showUploadProgress = function(fieldId, totalFiles) {
|
|
const dropZone = document.getElementById(`${fieldId}_drop_zone`);
|
|
if (dropZone) {
|
|
dropZone.innerHTML = `
|
|
<i class="fas fa-spinner fa-spin text-3xl text-blue-500 mb-2"></i>
|
|
<p class="text-sm text-gray-600">Uploading ${totalFiles} file(s)...</p>
|
|
`;
|
|
dropZone.style.pointerEvents = 'none';
|
|
}
|
|
}
|
|
|
|
window.hideUploadProgress = function(fieldId) {
|
|
const uploadConfig = window.getUploadConfig(fieldId);
|
|
const maxFiles = uploadConfig.max_files || 10;
|
|
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
|
|
|
|
const dropZone = document.getElementById(`${fieldId}_drop_zone`);
|
|
if (dropZone) {
|
|
dropZone.innerHTML = `
|
|
<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>
|
|
`;
|
|
dropZone.style.pointerEvents = 'auto';
|
|
}
|
|
}
|
|
|
|
window.formatFileSize = function(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
}
|
|
|
|
function formatDate(dateString) {
|
|
if (!dateString) return 'Unknown date';
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
} catch (e) {
|
|
return dateString;
|
|
}
|
|
}
|
|
|
|
window.getScheduleSummary = function(schedule) {
|
|
if (!schedule || !schedule.enabled || schedule.mode === 'always') {
|
|
return 'Always shown';
|
|
}
|
|
|
|
if (schedule.mode === 'time_range') {
|
|
return `${schedule.start_time || '08:00'} - ${schedule.end_time || '18:00'} (daily)`;
|
|
}
|
|
|
|
if (schedule.mode === 'per_day' && schedule.days) {
|
|
const enabledDays = Object.entries(schedule.days)
|
|
.filter(([day, config]) => config && config.enabled)
|
|
.map(([day]) => day.charAt(0).toUpperCase() + day.slice(1, 3));
|
|
|
|
if (enabledDays.length === 0) {
|
|
return 'Never shown';
|
|
}
|
|
|
|
return enabledDays.join(', ') + ' only';
|
|
}
|
|
|
|
return 'Scheduled';
|
|
}
|
|
|
|
window.openImageSchedule = function(fieldId, imageId, imageIdx) {
|
|
const currentImages = getCurrentImages(fieldId);
|
|
const image = currentImages[imageIdx];
|
|
if (!image) return;
|
|
|
|
const scheduleContainer = document.getElementById(`schedule_${imageId || imageIdx}`);
|
|
if (!scheduleContainer) return;
|
|
|
|
// Toggle visibility
|
|
const isVisible = !scheduleContainer.classList.contains('hidden');
|
|
|
|
if (isVisible) {
|
|
scheduleContainer.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
scheduleContainer.classList.remove('hidden');
|
|
|
|
const schedule = image.schedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
|
|
|
scheduleContainer.innerHTML = `
|
|
<div class="bg-white rounded-lg border border-blue-200 p-4">
|
|
<h4 class="text-sm font-semibold text-gray-900 mb-3">
|
|
<i class="fas fa-clock mr-2"></i>Schedule Settings
|
|
</h4>
|
|
|
|
<!-- Enable Schedule -->
|
|
<div class="mb-4">
|
|
<label class="flex items-center">
|
|
<input type="checkbox"
|
|
id="schedule_enabled_${imageId}"
|
|
${schedule.enabled ? 'checked' : ''}
|
|
onchange="window.toggleImageScheduleEnabled('${fieldId}', '${imageId}', ${imageIdx})"
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<span class="ml-2 text-sm font-medium text-gray-700">Enable schedule for this image</span>
|
|
</label>
|
|
<p class="ml-6 text-xs text-gray-500 mt-1">When enabled, this image will only display during scheduled times</p>
|
|
</div>
|
|
|
|
<!-- Schedule Mode -->
|
|
<div id="schedule_options_${imageId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
|
|
<select id="schedule_mode_${imageId}"
|
|
onchange="window.updateImageScheduleMode('${fieldId}', '${imageId}', ${imageIdx})"
|
|
class="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">
|
|
<option value="always" ${schedule.mode === 'always' ? 'selected' : ''}>Always Show (No Schedule)</option>
|
|
<option value="time_range" ${schedule.mode === 'time_range' ? 'selected' : ''}>Same Time Every Day</option>
|
|
<option value="per_day" ${schedule.mode === 'per_day' ? 'selected' : ''}>Different Times Per Day</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Time Range Mode -->
|
|
<div id="time_range_${imageId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
|
|
<input type="time"
|
|
id="schedule_start_${imageId}"
|
|
value="${schedule.start_time || '08:00'}"
|
|
onchange="window.updateImageScheduleTime('${fieldId}', '${imageId}', ${imageIdx})"
|
|
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
|
|
<input type="time"
|
|
id="schedule_end_${imageId}"
|
|
value="${schedule.end_time || '18:00'}"
|
|
onchange="window.updateImageScheduleTime('${fieldId}', '${imageId}', ${imageIdx})"
|
|
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Per-Day Mode -->
|
|
<div id="per_day_${imageId}" style="display: ${schedule.mode === 'per_day' ? 'block' : 'none'};">
|
|
<label class="block text-xs font-medium text-gray-700 mb-2">Day-Specific Times</label>
|
|
<div class="bg-gray-50 rounded p-3 space-y-2 max-h-64 overflow-y-auto">
|
|
${['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].map(day => {
|
|
const dayConfig = (schedule.days && schedule.days[day]) || { enabled: true, start_time: '08:00', end_time: '18:00' };
|
|
return `
|
|
<div class="bg-white rounded p-2 border border-gray-200">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<label class="flex items-center">
|
|
<input type="checkbox"
|
|
id="day_${day}_${imageId}"
|
|
${dayConfig.enabled ? 'checked' : ''}
|
|
onchange="window.updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')"
|
|
class="h-3 w-3 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<span class="ml-2 text-xs font-medium text-gray-700 capitalize">${day}</span>
|
|
</label>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${imageId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
|
|
<input type="time"
|
|
id="day_${day}_start_${imageId}"
|
|
value="${dayConfig.start_time || '08:00'}"
|
|
onchange="updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')"
|
|
class="text-xs px-2 py-1 border border-gray-300 rounded"
|
|
${!dayConfig.enabled ? 'disabled' : ''}>
|
|
<input type="time"
|
|
id="day_${day}_end_${imageId}"
|
|
value="${dayConfig.end_time || '18:00'}"
|
|
onchange="updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')"
|
|
class="text-xs px-2 py-1 border border-gray-300 rounded"
|
|
${!dayConfig.enabled ? 'disabled' : ''}>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
window.toggleImageScheduleEnabled = function(fieldId, imageId, imageIdx) {
|
|
const currentImages = window.getCurrentImages(fieldId);
|
|
const image = currentImages[imageIdx];
|
|
if (!image) return;
|
|
|
|
const checkbox = document.getElementById(`schedule_enabled_${imageId}`);
|
|
const enabled = checkbox.checked;
|
|
|
|
if (!image.schedule) {
|
|
image.schedule = { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
|
}
|
|
|
|
image.schedule.enabled = enabled;
|
|
|
|
const optionsDiv = document.getElementById(`schedule_options_${imageId}`);
|
|
if (optionsDiv) {
|
|
optionsDiv.style.display = enabled ? 'block' : 'none';
|
|
}
|
|
|
|
window.updateImageList(fieldId, currentImages);
|
|
}
|
|
|
|
window.updateImageScheduleMode = function(fieldId, imageId, imageIdx) {
|
|
const currentImages = window.getCurrentImages(fieldId);
|
|
const image = currentImages[imageIdx];
|
|
if (!image) return;
|
|
|
|
if (!image.schedule) {
|
|
image.schedule = { enabled: true, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
|
}
|
|
|
|
const modeSelect = document.getElementById(`schedule_mode_${imageId}`);
|
|
const mode = modeSelect.value;
|
|
|
|
image.schedule.mode = mode;
|
|
|
|
const timeRangeDiv = document.getElementById(`time_range_${imageId}`);
|
|
const perDayDiv = document.getElementById(`per_day_${imageId}`);
|
|
|
|
if (timeRangeDiv) timeRangeDiv.style.display = mode === 'time_range' ? 'grid' : 'none';
|
|
if (perDayDiv) perDayDiv.style.display = mode === 'per_day' ? 'block' : 'none';
|
|
|
|
window.updateImageList(fieldId, currentImages);
|
|
}
|
|
|
|
window.updateImageScheduleTime = function(fieldId, imageId, imageIdx) {
|
|
const currentImages = window.getCurrentImages(fieldId);
|
|
const image = currentImages[imageIdx];
|
|
if (!image) return;
|
|
|
|
if (!image.schedule) {
|
|
image.schedule = { enabled: true, mode: 'time_range', start_time: '08:00', end_time: '18:00' };
|
|
}
|
|
|
|
const startInput = document.getElementById(`schedule_start_${imageId}`);
|
|
const endInput = document.getElementById(`schedule_end_${imageId}`);
|
|
|
|
if (startInput) image.schedule.start_time = startInput.value || '08:00';
|
|
if (endInput) image.schedule.end_time = endInput.value || '18:00';
|
|
|
|
window.updateImageList(fieldId, currentImages);
|
|
}
|
|
|
|
window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) {
|
|
const currentImages = window.getCurrentImages(fieldId);
|
|
const image = currentImages[imageIdx];
|
|
if (!image) return;
|
|
|
|
if (!image.schedule) {
|
|
image.schedule = { enabled: true, mode: 'per_day', days: {} };
|
|
}
|
|
|
|
if (!image.schedule.days) {
|
|
image.schedule.days = {};
|
|
}
|
|
|
|
const checkbox = document.getElementById(`day_${day}_${imageId}`);
|
|
const startInput = document.getElementById(`day_${day}_start_${imageId}`);
|
|
const endInput = document.getElementById(`day_${day}_end_${imageId}`);
|
|
|
|
const enabled = checkbox ? checkbox.checked : true;
|
|
|
|
if (!image.schedule.days[day]) {
|
|
image.schedule.days[day] = { enabled: true, start_time: '08:00', end_time: '18:00' };
|
|
}
|
|
|
|
image.schedule.days[day].enabled = enabled;
|
|
|
|
if (startInput) image.schedule.days[day].start_time = startInput.value || '08:00';
|
|
if (endInput) image.schedule.days[day].end_time = endInput.value || '18:00';
|
|
|
|
const timesDiv = document.getElementById(`day_times_${day}_${imageId}`);
|
|
if (timesDiv) {
|
|
timesDiv.style.display = enabled ? 'grid' : 'none';
|
|
if (startInput) startInput.disabled = !enabled;
|
|
if (endInput) endInput.disabled = !enabled;
|
|
}
|
|
|
|
window.updateImageList(fieldId, currentImages);
|
|
}
|
|
|
|
// Expose renderArrayObjectItem, getSchemaProperty, and escapeHtml to window for use by global functions
|
|
window.renderArrayObjectItem = renderArrayObjectItem;
|
|
window.getSchemaProperty = getSchemaProperty;
|
|
window.escapeHtml = escapeHtml;
|
|
window.escapeAttribute = escapeAttribute;
|
|
|
|
})(); // End IIFE
|
|
|
|
// Functions to handle array-of-objects
|
|
// Define these at the top level (outside any IIFE) to ensure they're always available
|
|
if (typeof window !== 'undefined') {
|
|
window.addArrayObjectItem = function(fieldId, fullKey, maxItems) {
|
|
const itemsContainer = document.getElementById(fieldId + '_items');
|
|
const hiddenInput = document.getElementById(fieldId + '_data');
|
|
if (!itemsContainer || !hiddenInput) return;
|
|
|
|
const currentItems = itemsContainer.querySelectorAll('.array-object-item');
|
|
if (currentItems.length >= maxItems) {
|
|
alert(`Maximum ${maxItems} items allowed`);
|
|
return;
|
|
}
|
|
|
|
// Get schema for item properties - ensure currentPluginConfig is available
|
|
// Try window.currentPluginConfig first (most reliable), then currentPluginConfig
|
|
const schema = (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema) ||
|
|
(typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema);
|
|
if (!schema) {
|
|
console.error('addArrayObjectItem: Schema not available. currentPluginConfig may not be set.');
|
|
return;
|
|
}
|
|
|
|
// Use getSchemaProperty to properly handle nested schemas (e.g., news.custom_feeds)
|
|
const arraySchema = window.getSchemaProperty(schema, fullKey);
|
|
if (!arraySchema || arraySchema.type !== 'array' || !arraySchema.items) {
|
|
return;
|
|
}
|
|
|
|
const itemsSchema = arraySchema.items;
|
|
if (!itemsSchema || !itemsSchema.properties) return;
|
|
|
|
const newIndex = currentItems.length;
|
|
// Use renderArrayObjectItem if available, otherwise create basic HTML
|
|
let itemHtml = '';
|
|
if (typeof window.renderArrayObjectItem === 'function') {
|
|
itemHtml = window.renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema);
|
|
} else {
|
|
// Fallback: create basic HTML structure
|
|
// Note: newItem is {} for newly added items, so this will use schema defaults
|
|
const newItem = {};
|
|
itemHtml = `<div class="border border-gray-300 rounded-lg p-4 bg-gray-50 array-object-item" data-index="${newIndex}">`;
|
|
Object.keys(itemsSchema.properties || {}).forEach(propKey => {
|
|
const propSchema = itemsSchema.properties[propKey];
|
|
const propValue = newItem[propKey] !== undefined ? newItem[propKey] : propSchema.default;
|
|
const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
itemHtml += `<div class="mb-3"><label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(propLabel)}</label>`;
|
|
if (propSchema.type === 'boolean') {
|
|
const checked = propValue ? 'checked' : '';
|
|
// No name attribute - rely solely on _data field to prevent key leakage
|
|
itemHtml += `<input type="checkbox" data-prop-key="${propKey}" ${checked} class="h-4 w-4 text-blue-600" onchange="window.updateArrayObjectData('${fieldId}')">`;
|
|
} else {
|
|
// Escape HTML to prevent XSS
|
|
// No name attribute - rely solely on _data field to prevent key leakage
|
|
const escapedValue = typeof propValue === 'string' ? propValue.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''') : (propValue || '');
|
|
itemHtml += `<input type="text" data-prop-key="${propKey}" value="${escapedValue}" class="block w-full px-3 py-2 border border-gray-300 rounded-md" onchange="window.updateArrayObjectData('${fieldId}')">`;
|
|
}
|
|
itemHtml += `</div>`;
|
|
});
|
|
itemHtml += `<button type="button" onclick="window.removeArrayObjectItem('${fieldId}', ${newIndex})" class="mt-2 px-3 py-2 text-red-600 hover:text-red-800">Remove</button></div>`;
|
|
}
|
|
itemsContainer.insertAdjacentHTML('beforeend', itemHtml);
|
|
window.updateArrayObjectData(fieldId);
|
|
|
|
// Update add button state
|
|
const addButton = itemsContainer.nextElementSibling;
|
|
if (addButton && currentItems.length + 1 >= maxItems) {
|
|
addButton.disabled = true;
|
|
addButton.style.opacity = '0.5';
|
|
addButton.style.cursor = 'not-allowed';
|
|
}
|
|
};
|
|
|
|
window.removeArrayObjectItem = function(fieldId, index) {
|
|
const itemsContainer = document.getElementById(fieldId + '_items');
|
|
if (!itemsContainer) return;
|
|
|
|
const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`);
|
|
if (item) {
|
|
item.remove();
|
|
// Re-index remaining items
|
|
// Use data-index for index storage - no need to encode index in onclick strings or IDs
|
|
const remainingItems = itemsContainer.querySelectorAll('.array-object-item');
|
|
remainingItems.forEach((itemEl, newIndex) => {
|
|
itemEl.setAttribute('data-index', newIndex);
|
|
// Update all inputs within this item - only update index in array bracket notation
|
|
itemEl.querySelectorAll('input, select, textarea').forEach(input => {
|
|
const name = input.getAttribute('name');
|
|
const id = input.id;
|
|
if (name) {
|
|
// Only replace index in bracket notation like [0], [1], etc.
|
|
// Match pattern: field_name[index] but not field_name123
|
|
const newName = name.replace(/\[(\d+)\]/, `[${newIndex}]`);
|
|
input.setAttribute('name', newName);
|
|
}
|
|
if (id) {
|
|
// Only update index in specific patterns like _item_0, _item_1
|
|
// Match pattern: _item_<digits> but be careful not to break other numeric IDs
|
|
const newId = id.replace(/_item_(\d+)/, `_item_${newIndex}`);
|
|
input.id = newId;
|
|
}
|
|
});
|
|
// Update button onclick attributes - only update the index parameter
|
|
// Since we use data-index for tracking, we can compute index from closest('.array-object-item')
|
|
// For now, update onclick strings but be more careful with the regex
|
|
itemEl.querySelectorAll('button[onclick]').forEach(button => {
|
|
const onclick = button.getAttribute('onclick');
|
|
if (onclick) {
|
|
// Match patterns like:
|
|
// removeArrayObjectItem('fieldId', 0)
|
|
// handleArrayObjectFileUpload(event, 'fieldId', 0, 'propKey', 'pluginId')
|
|
// removeArrayObjectFile('fieldId', 0, 'propKey')
|
|
// Only replace the numeric index parameter (second or third argument depending on function)
|
|
let newOnclick = onclick;
|
|
// For removeArrayObjectItem('fieldId', index) - second param
|
|
newOnclick = newOnclick.replace(
|
|
/removeArrayObjectItem\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*\)/g,
|
|
`removeArrayObjectItem('$1', ${newIndex})`
|
|
);
|
|
// For handleArrayObjectFileUpload(event, 'fieldId', index, ...) - third param
|
|
newOnclick = newOnclick.replace(
|
|
/handleArrayObjectFileUpload\s*\(\s*event\s*,\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
|
|
`handleArrayObjectFileUpload(event, '$1', ${newIndex},`
|
|
);
|
|
// For removeArrayObjectFile('fieldId', index, ...) - second param
|
|
newOnclick = newOnclick.replace(
|
|
/removeArrayObjectFile\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
|
|
`removeArrayObjectFile('$1', ${newIndex},`
|
|
);
|
|
button.setAttribute('onclick', newOnclick);
|
|
}
|
|
});
|
|
});
|
|
window.updateArrayObjectData(fieldId);
|
|
|
|
// Update add button state
|
|
const addButton = itemsContainer.nextElementSibling;
|
|
if (addButton && addButton.getAttribute('onclick')) {
|
|
// Extract maxItems from onclick attribute more safely
|
|
// Pattern: addArrayObjectItem('fieldId', 'fullKey', maxItems)
|
|
const onclickMatch = addButton.getAttribute('onclick').match(/addArrayObjectItem\s*\([^,]+,\s*[^,]+,\s*(\d+)\)/);
|
|
if (onclickMatch && onclickMatch[1]) {
|
|
const maxItems = parseInt(onclickMatch[1]);
|
|
if (remainingItems.length < maxItems) {
|
|
addButton.disabled = false;
|
|
addButton.style.opacity = '1';
|
|
addButton.style.cursor = 'pointer';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// updateArrayObjectData is defined earlier in the file (line ~3596)
|
|
// Only define stub if it doesn't already exist (defensive fallback)
|
|
if (typeof window.updateArrayObjectData === 'undefined') {
|
|
window.updateArrayObjectData = function(fieldId) {
|
|
console.warn('updateArrayObjectData stub called - implementation should be defined earlier');
|
|
};
|
|
}
|
|
|
|
window.updateCheckboxGroupData = function(fieldId) {
|
|
// Update hidden _data input with currently checked values
|
|
const hiddenInput = document.getElementById(fieldId + '_data');
|
|
if (!hiddenInput) return;
|
|
|
|
const checkboxes = document.querySelectorAll(`input[type="checkbox"][data-checkbox-group="${fieldId}"]`);
|
|
const selectedValues = [];
|
|
|
|
checkboxes.forEach(checkbox => {
|
|
if (checkbox.checked) {
|
|
const optionValue = checkbox.getAttribute('data-option-value') || checkbox.value;
|
|
selectedValues.push(optionValue);
|
|
}
|
|
});
|
|
|
|
hiddenInput.value = JSON.stringify(selectedValues);
|
|
};
|
|
|
|
// handleArrayObjectFileUpload and removeArrayObjectFile are defined earlier in the file
|
|
// Only define stubs if they don't already exist (defensive fallback)
|
|
if (typeof window.handleArrayObjectFileUpload === 'undefined') {
|
|
window.handleArrayObjectFileUpload = function(event, fieldId, itemIndex, propKey, pluginId) {
|
|
console.warn('handleArrayObjectFileUpload stub called - implementation should be defined earlier');
|
|
window.updateArrayObjectData(fieldId);
|
|
};
|
|
}
|
|
|
|
if (typeof window.removeArrayObjectFile === 'undefined') {
|
|
window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) {
|
|
console.warn('removeArrayObjectFile stub called - implementation should be defined earlier');
|
|
window.updateArrayObjectData(fieldId);
|
|
};
|
|
}
|
|
|
|
// Debug logging (only if pluginDebug is enabled)
|
|
if (_PLUGIN_DEBUG_EARLY) {
|
|
console.log('[ARRAY-OBJECTS] Functions defined on window:', {
|
|
addArrayObjectItem: typeof window.addArrayObjectItem,
|
|
removeArrayObjectItem: typeof window.removeArrayObjectItem,
|
|
updateArrayObjectData: typeof window.updateArrayObjectData,
|
|
handleArrayObjectFileUpload: typeof window.handleArrayObjectFileUpload,
|
|
removeArrayObjectFile: typeof window.removeArrayObjectFile
|
|
});
|
|
}
|
|
}
|
|
|
|
// Make currentPluginConfig globally accessible (outside IIFE)
|
|
window.currentPluginConfig = null;
|
|
|
|
// Force initialization immediately when script loads (for HTMX swapped content)
|
|
console.log('Plugins script loaded, checking for elements...');
|
|
|
|
// Ensure all functions are globally available (in case IIFE didn't expose them properly)
|
|
// These should already be set inside the IIFE, but this ensures they're available
|
|
if (typeof initializePluginPageWhenReady !== 'undefined') {
|
|
window.initializePluginPageWhenReady = initializePluginPageWhenReady;
|
|
}
|
|
if (typeof initializePlugins !== 'undefined') {
|
|
window.initializePlugins = initializePlugins;
|
|
}
|
|
if (typeof loadInstalledPlugins !== 'undefined') {
|
|
window.loadInstalledPlugins = loadInstalledPlugins;
|
|
}
|
|
if (typeof renderInstalledPlugins !== 'undefined') {
|
|
window.renderInstalledPlugins = renderInstalledPlugins;
|
|
}
|
|
// Expose GitHub install handlers for debugging and manual testing
|
|
if (typeof setupGitHubInstallHandlers !== 'undefined') {
|
|
window.setupGitHubInstallHandlers = setupGitHubInstallHandlers;
|
|
console.log('[GLOBAL] setupGitHubInstallHandlers exposed to window');
|
|
}
|
|
if (typeof attachInstallButtonHandler !== 'undefined') {
|
|
window.attachInstallButtonHandler = attachInstallButtonHandler;
|
|
console.log('[GLOBAL] attachInstallButtonHandler exposed to window');
|
|
}
|
|
// searchPluginStore is now exposed inside the IIFE after its definition
|
|
|
|
// Verify critical functions are available
|
|
if (_PLUGIN_DEBUG_EARLY) {
|
|
console.log('Plugin functions available:', {
|
|
configurePlugin: typeof window.configurePlugin,
|
|
togglePlugin: typeof window.togglePlugin,
|
|
initializePlugins: typeof window.initializePlugins,
|
|
loadInstalledPlugins: typeof window.loadInstalledPlugins,
|
|
searchPluginStore: typeof window.searchPluginStore
|
|
});
|
|
}
|
|
|
|
// Check GitHub auth status immediately if elements exist (don't wait for full initialization)
|
|
if (window.checkGitHubAuthStatus && document.getElementById('github-auth-warning')) {
|
|
console.log('[EARLY] Checking GitHub auth status immediately on script load...');
|
|
window.checkGitHubAuthStatus();
|
|
}
|
|
|
|
// Initialize on-demand modal immediately since it's in base.html
|
|
if (typeof initializeOnDemandModal === 'function') {
|
|
// Run immediately and also after DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initializeOnDemandModal);
|
|
} else {
|
|
initializeOnDemandModal();
|
|
}
|
|
// Also try after a short delay to ensure elements are available
|
|
setTimeout(initializeOnDemandModal, 100);
|
|
}
|
|
|
|
setTimeout(function() {
|
|
const installedGrid = document.getElementById('installed-plugins-grid');
|
|
if (installedGrid) {
|
|
console.log('Found installed-plugins-grid, forcing initialization...');
|
|
window.pluginManager.initialized = false;
|
|
if (typeof initializePluginPageWhenReady === 'function') {
|
|
initializePluginPageWhenReady();
|
|
} else if (typeof window.initPluginsPage === 'function') {
|
|
window.initPluginsPage();
|
|
}
|
|
} else {
|
|
console.log('installed-plugins-grid not found yet, will retry via event listeners');
|
|
}
|
|
|
|
// Also try to attach install button handler after a delay (fallback)
|
|
setTimeout(() => {
|
|
if (typeof window.attachInstallButtonHandler === 'function') {
|
|
console.log('[FALLBACK] Attempting to attach install button handler...');
|
|
window.attachInstallButtonHandler();
|
|
} else {
|
|
console.warn('[FALLBACK] attachInstallButtonHandler not available on window');
|
|
}
|
|
}, 500);
|
|
}, 200);
|
|
|
|
// ─── Starlark Apps Integration ──────────────────────────────────────────────
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
let starlarkSectionVisible = false;
|
|
let starlarkFullCache = null; // All apps from server
|
|
let starlarkFilteredList = []; // After filters applied
|
|
let starlarkDataLoaded = false;
|
|
|
|
// ── Filter State ────────────────────────────────────────────────────────
|
|
const starlarkFilterState = {
|
|
sort: safeLocalStorage.getItem('starlarkSort') || 'a-z',
|
|
filterInstalled: null, // null=all, true=installed, false=not-installed
|
|
filterAuthor: '',
|
|
filterCategory: '',
|
|
searchQuery: '',
|
|
page: 1,
|
|
perPage: parseInt(safeLocalStorage.getItem('starlarkPerPage')) || 24,
|
|
persist() {
|
|
safeLocalStorage.setItem('starlarkSort', this.sort);
|
|
safeLocalStorage.setItem('starlarkPerPage', this.perPage);
|
|
},
|
|
reset() {
|
|
this.sort = 'a-z';
|
|
this.filterInstalled = null;
|
|
this.filterAuthor = '';
|
|
this.filterCategory = '';
|
|
this.searchQuery = '';
|
|
this.page = 1;
|
|
},
|
|
activeCount() {
|
|
let n = 0;
|
|
if (this.searchQuery) n++;
|
|
if (this.filterInstalled !== null) n++;
|
|
if (this.filterAuthor) n++;
|
|
if (this.filterCategory) n++;
|
|
if (this.sort !== 'a-z') n++;
|
|
return n;
|
|
}
|
|
};
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function isStarlarkInstalled(appId) {
|
|
// Check window.installedPlugins (populated by loadInstalledPlugins)
|
|
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
|
|
return window.installedPlugins.some(p => p.id === 'starlark:' + appId);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Section Toggle + Init ───────────────────────────────────────────────
|
|
function initStarlarkSection() {
|
|
const toggleBtn = document.getElementById('toggle-starlark-section');
|
|
if (toggleBtn && !toggleBtn._starlarkInit) {
|
|
toggleBtn._starlarkInit = true;
|
|
toggleBtn.addEventListener('click', function() {
|
|
starlarkSectionVisible = !starlarkSectionVisible;
|
|
const content = document.getElementById('starlark-section-content');
|
|
const icon = document.getElementById('starlark-section-icon');
|
|
if (content) content.classList.toggle('hidden', !starlarkSectionVisible);
|
|
if (icon) {
|
|
icon.classList.toggle('fa-chevron-down', !starlarkSectionVisible);
|
|
icon.classList.toggle('fa-chevron-up', starlarkSectionVisible);
|
|
}
|
|
this.querySelector('span').textContent = starlarkSectionVisible ? 'Hide' : 'Show';
|
|
if (starlarkSectionVisible) {
|
|
loadStarlarkStatus();
|
|
if (!starlarkDataLoaded) fetchStarlarkApps();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Restore persisted sort/perPage
|
|
const sortEl = document.getElementById('starlark-sort');
|
|
if (sortEl) sortEl.value = starlarkFilterState.sort;
|
|
const ppEl = document.getElementById('starlark-per-page');
|
|
if (ppEl) ppEl.value = starlarkFilterState.perPage;
|
|
|
|
setupStarlarkFilterListeners();
|
|
|
|
const uploadBtn = document.getElementById('starlark-upload-btn');
|
|
if (uploadBtn && !uploadBtn._starlarkInit) {
|
|
uploadBtn._starlarkInit = true;
|
|
uploadBtn.addEventListener('click', function() {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.star';
|
|
input.onchange = function(e) {
|
|
if (e.target.files.length > 0) uploadStarlarkFile(e.target.files[0]);
|
|
};
|
|
input.click();
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Status ──────────────────────────────────────────────────────────────
|
|
function loadStarlarkStatus() {
|
|
fetch('/api/v3/starlark/status')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const banner = document.getElementById('starlark-pixlet-status');
|
|
if (!banner) return;
|
|
if (data.pixlet_available) {
|
|
banner.innerHTML = `<div class="bg-green-50 border border-green-200 rounded-lg p-3 text-sm text-green-800">
|
|
<i class="fas fa-check-circle mr-2"></i>Pixlet available${data.pixlet_version ? ' (' + escapeHtml(data.pixlet_version) + ')' : ''} — ${data.installed_apps || 0} app(s) installed
|
|
</div>`;
|
|
} else {
|
|
banner.innerHTML = `<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800">
|
|
<i class="fas fa-exclamation-triangle mr-2"></i>Pixlet not installed.
|
|
<button onclick="window.installPixlet()" class="ml-2 px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs font-semibold">Install Pixlet</button>
|
|
</div>`;
|
|
}
|
|
})
|
|
.catch(err => console.error('Starlark status error:', err));
|
|
}
|
|
|
|
// ── Bulk Fetch All Apps ─────────────────────────────────────────────────
|
|
function fetchStarlarkApps() {
|
|
const grid = document.getElementById('starlark-apps-grid');
|
|
if (grid) {
|
|
grid.innerHTML = `<div class="col-span-full">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
|
${Array(10).fill('<div class="bg-gray-200 rounded-lg p-4 h-48 animate-pulse"></div>').join('')}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
fetch('/api/v3/starlark/repository/browse')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status !== 'success') {
|
|
if (grid) grid.innerHTML = `<div class="col-span-full text-center py-8 text-red-500"><i class="fas fa-exclamation-circle mr-2"></i>${escapeHtml(data.message || 'Failed to load')}</div>`;
|
|
return;
|
|
}
|
|
|
|
starlarkFullCache = data.apps || [];
|
|
starlarkDataLoaded = true;
|
|
|
|
// Populate category dropdown
|
|
const catSelect = document.getElementById('starlark-category');
|
|
if (catSelect) {
|
|
catSelect.innerHTML = '<option value="">All Categories</option>';
|
|
(data.categories || []).forEach(cat => {
|
|
const opt = document.createElement('option');
|
|
opt.value = cat;
|
|
opt.textContent = cat;
|
|
catSelect.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
// Populate author dropdown
|
|
const authSelect = document.getElementById('starlark-filter-author');
|
|
if (authSelect) {
|
|
authSelect.innerHTML = '<option value="">All Authors</option>';
|
|
(data.authors || []).forEach(author => {
|
|
const opt = document.createElement('option');
|
|
opt.value = author;
|
|
opt.textContent = author;
|
|
authSelect.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
const countEl = document.getElementById('starlark-apps-count');
|
|
if (countEl) countEl.textContent = `${data.count} apps`;
|
|
|
|
if (data.rate_limit) {
|
|
console.log(`[Starlark] GitHub rate limit: ${data.rate_limit.remaining}/${data.rate_limit.limit} remaining` + (data.cached ? ' (cached)' : ''));
|
|
}
|
|
|
|
applyStarlarkFiltersAndSort();
|
|
})
|
|
.catch(err => {
|
|
console.error('Starlark browse error:', err);
|
|
if (grid) grid.innerHTML = '<div class="col-span-full text-center py-8 text-red-500"><i class="fas fa-exclamation-circle mr-2"></i>Error loading apps</div>';
|
|
});
|
|
}
|
|
|
|
// ── Apply Filters + Sort ────────────────────────────────────────────────
|
|
function applyStarlarkFiltersAndSort(skipPageReset) {
|
|
if (!starlarkFullCache) return;
|
|
const st = starlarkFilterState;
|
|
|
|
let list = starlarkFullCache.slice();
|
|
|
|
// Text search
|
|
if (st.searchQuery) {
|
|
const q = st.searchQuery.toLowerCase();
|
|
list = list.filter(app => {
|
|
const hay = [app.name, app.summary, app.desc, app.author, app.id, app.category]
|
|
.filter(Boolean).join(' ').toLowerCase();
|
|
return hay.includes(q);
|
|
});
|
|
}
|
|
|
|
// Category filter
|
|
if (st.filterCategory) {
|
|
const cat = st.filterCategory.toLowerCase();
|
|
list = list.filter(app => (app.category || '').toLowerCase() === cat);
|
|
}
|
|
|
|
// Author filter
|
|
if (st.filterAuthor) {
|
|
list = list.filter(app => app.author === st.filterAuthor);
|
|
}
|
|
|
|
// Installed filter
|
|
if (st.filterInstalled === true) {
|
|
list = list.filter(app => isStarlarkInstalled(app.id));
|
|
} else if (st.filterInstalled === false) {
|
|
list = list.filter(app => !isStarlarkInstalled(app.id));
|
|
}
|
|
|
|
// Sort
|
|
list.sort((a, b) => {
|
|
const nameA = (a.name || a.id || '').toLowerCase();
|
|
const nameB = (b.name || b.id || '').toLowerCase();
|
|
switch (st.sort) {
|
|
case 'z-a': return nameB.localeCompare(nameA);
|
|
case 'category': {
|
|
const catCmp = (a.category || '').localeCompare(b.category || '');
|
|
return catCmp !== 0 ? catCmp : nameA.localeCompare(nameB);
|
|
}
|
|
case 'author': {
|
|
const authCmp = (a.author || '').localeCompare(b.author || '');
|
|
return authCmp !== 0 ? authCmp : nameA.localeCompare(nameB);
|
|
}
|
|
default: return nameA.localeCompare(nameB); // a-z
|
|
}
|
|
});
|
|
|
|
starlarkFilteredList = list;
|
|
if (!skipPageReset) st.page = 1;
|
|
|
|
renderStarlarkPage();
|
|
updateStarlarkFilterUI();
|
|
}
|
|
|
|
// ── Render Current Page ─────────────────────────────────────────────────
|
|
function renderStarlarkPage() {
|
|
const st = starlarkFilterState;
|
|
const total = starlarkFilteredList.length;
|
|
const totalPages = Math.max(1, Math.ceil(total / st.perPage));
|
|
if (st.page > totalPages) st.page = totalPages;
|
|
|
|
const start = (st.page - 1) * st.perPage;
|
|
const end = Math.min(start + st.perPage, total);
|
|
const pageApps = starlarkFilteredList.slice(start, end);
|
|
|
|
// Results info
|
|
const info = total > 0
|
|
? `Showing ${start + 1}\u2013${end} of ${total} apps`
|
|
: 'No apps match your filters';
|
|
const infoEl = document.getElementById('starlark-results-info');
|
|
const infoElBot = document.getElementById('starlark-results-info-bottom');
|
|
if (infoEl) infoEl.textContent = info;
|
|
if (infoElBot) infoElBot.textContent = info;
|
|
|
|
// Pagination
|
|
renderStarlarkPagination('starlark-pagination-top', totalPages, st.page);
|
|
renderStarlarkPagination('starlark-pagination-bottom', totalPages, st.page);
|
|
|
|
// Grid
|
|
const grid = document.getElementById('starlark-apps-grid');
|
|
renderStarlarkApps(pageApps, grid);
|
|
}
|
|
|
|
// ── Pagination Controls ─────────────────────────────────────────────────
|
|
function renderStarlarkPagination(containerId, totalPages, currentPage) {
|
|
const container = document.getElementById(containerId);
|
|
if (!container) return;
|
|
|
|
if (totalPages <= 1) { container.innerHTML = ''; return; }
|
|
|
|
const btnClass = 'px-3 py-1 text-sm rounded-md border transition-colors';
|
|
const activeClass = 'bg-blue-600 text-white border-blue-600';
|
|
const normalClass = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer';
|
|
const disabledClass = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed';
|
|
|
|
let html = '';
|
|
|
|
// Prev
|
|
html += `<button class="${btnClass} ${currentPage <= 1 ? disabledClass : normalClass}" data-starlark-page="${currentPage - 1}" ${currentPage <= 1 ? 'disabled' : ''}>«</button>`;
|
|
|
|
// Page numbers with ellipsis
|
|
const pages = [];
|
|
pages.push(1);
|
|
if (currentPage > 3) pages.push('...');
|
|
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
|
|
pages.push(i);
|
|
}
|
|
if (currentPage < totalPages - 2) pages.push('...');
|
|
if (totalPages > 1) pages.push(totalPages);
|
|
|
|
pages.forEach(p => {
|
|
if (p === '...') {
|
|
html += `<span class="px-2 py-1 text-sm text-gray-400">…</span>`;
|
|
} else {
|
|
html += `<button class="${btnClass} ${p === currentPage ? activeClass : normalClass}" data-starlark-page="${p}">${p}</button>`;
|
|
}
|
|
});
|
|
|
|
// Next
|
|
html += `<button class="${btnClass} ${currentPage >= totalPages ? disabledClass : normalClass}" data-starlark-page="${currentPage + 1}" ${currentPage >= totalPages ? 'disabled' : ''}>»</button>`;
|
|
|
|
container.innerHTML = html;
|
|
|
|
// Event delegation for page buttons
|
|
container.querySelectorAll('[data-starlark-page]').forEach(btn => {
|
|
btn.addEventListener('click', function() {
|
|
const p = parseInt(this.getAttribute('data-starlark-page'));
|
|
if (p >= 1 && p <= totalPages && p !== currentPage) {
|
|
starlarkFilterState.page = p;
|
|
renderStarlarkPage();
|
|
// Scroll to top of grid
|
|
const grid = document.getElementById('starlark-apps-grid');
|
|
if (grid) grid.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Card Rendering ──────────────────────────────────────────────────────
|
|
function renderStarlarkApps(apps, grid) {
|
|
if (!grid) return;
|
|
if (!apps || apps.length === 0) {
|
|
grid.innerHTML = '<div class="col-span-full empty-state"><div class="empty-state-icon"><i class="fas fa-star"></i></div><p>No Starlark apps found</p></div>';
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = apps.map(app => {
|
|
const installed = isStarlarkInstalled(app.id);
|
|
return `
|
|
<div class="plugin-card" data-app-id="${escapeHtml(app.id)}">
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center flex-wrap gap-1.5 mb-2">
|
|
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(app.name || app.id)}</h4>
|
|
<span class="badge badge-warning"><i class="fas fa-star mr-1"></i>Starlark</span>
|
|
${installed ? '<span class="badge badge-success"><i class="fas fa-check mr-1"></i>Installed</span>' : ''}
|
|
</div>
|
|
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
|
|
${app.author ? `<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(app.author)}</p>` : ''}
|
|
${app.category ? `<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(app.category)}</p>` : ''}
|
|
</div>
|
|
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(app.summary || app.desc || 'No description')}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 mt-auto pt-3 border-t border-gray-200">
|
|
<button data-action="install" class="btn ${installed ? 'bg-gray-500 hover:bg-gray-600' : 'bg-green-600 hover:bg-green-700'} text-white px-4 py-2 rounded-md text-sm font-semibold flex-1 flex justify-center items-center">
|
|
<i class="fas ${installed ? 'fa-redo' : 'fa-download'} mr-2"></i>${installed ? 'Reinstall' : 'Install'}
|
|
</button>
|
|
<button data-action="view" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-semibold flex justify-center items-center">
|
|
<i class="fas fa-external-link-alt mr-1"></i>View
|
|
</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Add delegated event listener only once (prevent duplicate handlers)
|
|
if (!grid.dataset.starlarkHandlerAttached) {
|
|
grid.addEventListener('click', function handleStarlarkGridClick(e) {
|
|
const button = e.target.closest('button[data-action]');
|
|
if (!button) return;
|
|
|
|
const card = button.closest('.plugin-card');
|
|
if (!card) return;
|
|
|
|
const appId = card.dataset.appId;
|
|
if (!appId) return;
|
|
|
|
const action = button.dataset.action;
|
|
if (action === 'install') {
|
|
window.installStarlarkApp(appId);
|
|
} else if (action === 'view') {
|
|
window.open('https://github.com/tronbyt/apps/tree/main/apps/' + encodeURIComponent(appId), '_blank');
|
|
}
|
|
});
|
|
grid.dataset.starlarkHandlerAttached = 'true';
|
|
}
|
|
}
|
|
|
|
// ── Filter UI Updates ───────────────────────────────────────────────────
|
|
function updateStarlarkFilterUI() {
|
|
const st = starlarkFilterState;
|
|
const count = st.activeCount();
|
|
|
|
const badge = document.getElementById('starlark-active-filters');
|
|
const clearBtn = document.getElementById('starlark-clear-filters');
|
|
if (badge) {
|
|
badge.classList.toggle('hidden', count === 0);
|
|
badge.textContent = count + ' filter' + (count !== 1 ? 's' : '') + ' active';
|
|
}
|
|
if (clearBtn) clearBtn.classList.toggle('hidden', count === 0);
|
|
|
|
// Update installed toggle button text
|
|
const instBtn = document.getElementById('starlark-filter-installed');
|
|
if (instBtn) {
|
|
if (st.filterInstalled === true) {
|
|
instBtn.innerHTML = '<i class="fas fa-check-circle mr-1 text-green-500"></i>Installed';
|
|
instBtn.classList.add('border-green-400', 'bg-green-50');
|
|
instBtn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50');
|
|
} else if (st.filterInstalled === false) {
|
|
instBtn.innerHTML = '<i class="fas fa-times-circle mr-1 text-red-500"></i>Not Installed';
|
|
instBtn.classList.add('border-red-400', 'bg-red-50');
|
|
instBtn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50');
|
|
} else {
|
|
instBtn.innerHTML = '<i class="fas fa-filter mr-1 text-gray-400"></i>All';
|
|
instBtn.classList.add('border-gray-300', 'bg-white');
|
|
instBtn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Event Listeners ─────────────────────────────────────────────────────
|
|
function setupStarlarkFilterListeners() {
|
|
// Search with debounce
|
|
const searchEl = document.getElementById('starlark-search');
|
|
if (searchEl && !searchEl._starlarkInit) {
|
|
searchEl._starlarkInit = true;
|
|
let debounce = null;
|
|
searchEl.addEventListener('input', function() {
|
|
clearTimeout(debounce);
|
|
debounce = setTimeout(() => {
|
|
starlarkFilterState.searchQuery = this.value.trim();
|
|
applyStarlarkFiltersAndSort();
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
// Category dropdown
|
|
const catEl = document.getElementById('starlark-category');
|
|
if (catEl && !catEl._starlarkInit) {
|
|
catEl._starlarkInit = true;
|
|
catEl.addEventListener('change', function() {
|
|
starlarkFilterState.filterCategory = this.value;
|
|
applyStarlarkFiltersAndSort();
|
|
});
|
|
}
|
|
|
|
// Sort dropdown
|
|
const sortEl = document.getElementById('starlark-sort');
|
|
if (sortEl && !sortEl._starlarkInit) {
|
|
sortEl._starlarkInit = true;
|
|
sortEl.addEventListener('change', function() {
|
|
starlarkFilterState.sort = this.value;
|
|
starlarkFilterState.persist();
|
|
applyStarlarkFiltersAndSort();
|
|
});
|
|
}
|
|
|
|
// Author dropdown
|
|
const authEl = document.getElementById('starlark-filter-author');
|
|
if (authEl && !authEl._starlarkInit) {
|
|
authEl._starlarkInit = true;
|
|
authEl.addEventListener('change', function() {
|
|
starlarkFilterState.filterAuthor = this.value;
|
|
applyStarlarkFiltersAndSort();
|
|
});
|
|
}
|
|
|
|
// Installed toggle (cycle: all → installed → not-installed → all)
|
|
const instBtn = document.getElementById('starlark-filter-installed');
|
|
if (instBtn && !instBtn._starlarkInit) {
|
|
instBtn._starlarkInit = true;
|
|
instBtn.addEventListener('click', function() {
|
|
const st = starlarkFilterState;
|
|
if (st.filterInstalled === null) st.filterInstalled = true;
|
|
else if (st.filterInstalled === true) st.filterInstalled = false;
|
|
else st.filterInstalled = null;
|
|
applyStarlarkFiltersAndSort();
|
|
});
|
|
}
|
|
|
|
// Clear filters
|
|
const clearBtn = document.getElementById('starlark-clear-filters');
|
|
if (clearBtn && !clearBtn._starlarkInit) {
|
|
clearBtn._starlarkInit = true;
|
|
clearBtn.addEventListener('click', function() {
|
|
starlarkFilterState.reset();
|
|
// Reset UI elements
|
|
const searchEl = document.getElementById('starlark-search');
|
|
if (searchEl) searchEl.value = '';
|
|
const catEl = document.getElementById('starlark-category');
|
|
if (catEl) catEl.value = '';
|
|
const sortEl = document.getElementById('starlark-sort');
|
|
if (sortEl) sortEl.value = 'a-z';
|
|
const authEl = document.getElementById('starlark-filter-author');
|
|
if (authEl) authEl.value = '';
|
|
starlarkFilterState.persist();
|
|
applyStarlarkFiltersAndSort();
|
|
});
|
|
}
|
|
|
|
// Per-page selector
|
|
const ppEl = document.getElementById('starlark-per-page');
|
|
if (ppEl && !ppEl._starlarkInit) {
|
|
ppEl._starlarkInit = true;
|
|
ppEl.addEventListener('change', function() {
|
|
starlarkFilterState.perPage = parseInt(this.value) || 24;
|
|
starlarkFilterState.persist();
|
|
applyStarlarkFiltersAndSort();
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Install / Upload / Pixlet ───────────────────────────────────────────
|
|
window.installStarlarkApp = function(appId) {
|
|
if (!confirm(`Install Starlark app "${appId}" from Tronbyte repository?`)) return;
|
|
|
|
fetch('/api/v3/starlark/repository/install', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({app_id: appId})
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
alert(`Installed: ${data.message || appId}`);
|
|
// Refresh installed plugins list
|
|
if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
|
|
else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins();
|
|
// Re-render current page to update installed badges
|
|
setTimeout(() => applyStarlarkFiltersAndSort(true), 500);
|
|
} else {
|
|
alert(`Install failed: ${data.message || 'Unknown error'}`);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Install error:', err);
|
|
alert('Install failed: ' + err.message);
|
|
});
|
|
};
|
|
|
|
window.installPixlet = function() {
|
|
if (!confirm('Download and install Pixlet binary? This may take a few minutes.')) return;
|
|
|
|
fetch('/api/v3/starlark/install-pixlet', {method: 'POST'})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
alert(data.message || 'Pixlet installed!');
|
|
loadStarlarkStatus();
|
|
} else {
|
|
alert('Pixlet install failed: ' + (data.message || 'Unknown error'));
|
|
}
|
|
})
|
|
.catch(err => alert('Pixlet install failed: ' + err.message));
|
|
};
|
|
|
|
function uploadStarlarkFile(file) {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const appId = file.name.replace('.star', '');
|
|
formData.append('app_id', appId);
|
|
formData.append('name', appId.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()));
|
|
|
|
fetch('/api/v3/starlark/upload', {method: 'POST', body: formData})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
alert(`Uploaded: ${data.app_id}`);
|
|
if (typeof loadInstalledPlugins === 'function') loadInstalledPlugins();
|
|
else if (typeof window.loadInstalledPlugins === 'function') window.loadInstalledPlugins();
|
|
setTimeout(() => applyStarlarkFiltersAndSort(true), 500);
|
|
} else {
|
|
alert('Upload failed: ' + (data.message || 'Unknown error'));
|
|
}
|
|
})
|
|
.catch(err => alert('Upload failed: ' + err.message));
|
|
}
|
|
|
|
// ── Bootstrap ───────────────────────────────────────────────────────────
|
|
const origInit = window.initializePlugins;
|
|
window.initializePlugins = function() {
|
|
if (origInit) origInit();
|
|
initStarlarkSection();
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', initStarlarkSection);
|
|
document.addEventListener('htmx:afterSwap', function(e) {
|
|
if (e.detail && e.detail.target && e.detail.target.id === 'plugins-content') {
|
|
initStarlarkSection();
|
|
}
|
|
});
|
|
})();
|
|
|