mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
* fix(web): Resolve font display and config API error handling issues
- Fix font catalog display error where path.startsWith fails
(path is object, not string)
- Update save_main_config to use error_response() helper
- Improve save_raw_main_config error handling consistency
- Add proper error codes and traceback details to API responses
* fix(web): Prevent fontCatalog redeclaration error on HTMX reload
- Use window object to store global font variables
- Check if script has already loaded before declaring variables
- Update both window properties and local references on assignment
- Fixes 'Identifier fontCatalog has already been declared' error
* fix(web): Wrap fonts script in IIFE to prevent all redeclaration errors
- Wrap entire script in IIFE that only runs once
- Check if script already loaded before declaring variables/functions
- Expose initializeFontsTab to window for re-initialization
- Prevents 'Identifier has already been declared' errors on HTMX reload
* fix(web): Exempt config save API endpoints from CSRF protection
- Exempt save_raw_main_config, save_raw_secrets_config, and save_main_config from CSRF
- These endpoints are called via fetch from JavaScript and don't include CSRF tokens
- Fixes 500 error when saving config via raw JSON editor
* fix(web): Exempt system action endpoint from CSRF protection
- Exempt execute_system_action from CSRF
- Fixes 500 error when using system action buttons (restart display, restart Pi, etc.)
- These endpoints are called via HTMX and don't include CSRF tokens
* fix(web): Exempt all API v3 endpoints from CSRF protection
- Add before_request handler to exempt all api_v3.* endpoints
- All API endpoints are programmatic (HTMX/fetch) and don't include CSRF tokens
- Prevents future CSRF errors on any API endpoint
- Cleaner than exempting individual endpoints
* refactor(web): Remove CSRF protection for local-only application
- CSRF is designed for internet-facing apps to prevent cross-site attacks
- For local-only Raspberry Pi app, threat model is different
- All endpoints were exempted anyway, so it wasn't protecting anything
- Forms use HTMX without CSRF tokens
- If exposing to internet later, can re-enable with proper token implementation
* fix(web): Fix font path double-prefixing in font catalog display
- Only prefix with 'assets/fonts/' if path is a bare filename
- If path starts with '/' (absolute) or 'assets/' (already prefixed), use as-is
- Fixes double-prefixing when get_fonts_catalog returns relative paths like 'assets/fonts/press_start.ttf'
* fix(web): Remove fontsTabInitialized guard to allow re-initialization on HTMX reload
- Remove fontsTabInitialized check that prevented re-initialization on HTMX content swap
- The window._fontsScriptLoaded guard is sufficient to prevent function redeclaration
- Allow initializeFontsTab() to run on each HTMX swap to attach listeners to new DOM elements
- Fixes fonts UI breaking after HTMX reload (buttons, upload dropzone, etc. not working)
* fix(api): Preserve empty strings for optional string fields in plugin config
- Add _is_field_required() helper to check if fields are required in schema
- Update _parse_form_value_with_schema() to preserve empty strings for optional string fields
- Fixes 400 error when saving MQTT plugin config with empty username/password
- Resolves validation error: 'Expected type string, got NoneType'
* fix(config): Add defaults to schemas and fix None value handling
- Updated merge_with_defaults to replace None values with defaults
- Fixed form processing to skip empty optional fields without defaults
- Added script to automatically add defaults to all plugin config schemas
- Added defaults to 89 fields across 10 plugin schemas
- Prevents validation errors from None values in configs
Changes:
- schema_manager.py: Enhanced merge_with_defaults to replace None with defaults
- api_v3.py: Added _SKIP_FIELD sentinel to skip optional fields without defaults
- add_defaults_to_schemas.py: Script to add sensible defaults to schemas
- Plugin schemas: Added defaults for number, boolean, and array fields
* fix(config): Fix save button spinner by checking HTTP status code
- Fixed handleConfigSave to check xhr.status instead of event.detail.successful
- With hx-swap="none", HTMX doesn't set event.detail.successful
- Now properly detects successful saves (status 200-299) and stops spinner
- Improved error message extraction from API responses
- Also fixed handleToggleResponse for consistency
* fix(web-ui): Resolve GitHub token warning persistence after save
- Made checkGitHubAuthStatus() return Promise for proper async handling
- Clear sessionStorage dismissal flag when token is saved
- Add delay before status check to ensure backend token reload
- Wait for status check completion before hiding settings panel
Fixes issue where GitHub token warnings and pop-ups would not
disappear after successfully saving a token in the web UI.
* fix(web-ui): Add token validation and improve GitHub token warning behavior
- Add token validation to backend API endpoint to check if token is valid/expired
- Implement _validate_github_token() method in PluginStoreManager with caching
- Update frontend to show warning only when token is missing or invalid
- Keep settings panel accessible (collapsible) when token is configured
- Collapse settings panel content after successful token save instead of hiding
- Display specific error messages for invalid/expired tokens
- Clear sessionStorage dismissal flag when token becomes valid
Fixes issue where GitHub token warnings and settings panel would not
properly hide/show based on token status. Now validates token validity
and provides better UX with collapsible settings panel.
* fix(web-ui): Fix CSS/display issue for GitHub token warning and settings
- Update all hide/show operations to use both classList and style.display
- Fix checkGitHubAuthStatus() to properly hide/show warning and settings
- Fix dismissGithubWarning() to use both methods
- Fix toggleGithubTokenSettings() with improved state checking
- Fix collapse button handler with improved state checking
- Fix saveGithubToken() to properly show/collapse settings panel
This ensures elements actually hide/show when status changes, matching
the pattern used elsewhere in the codebase (like toggleSection). All
buttons (dismiss, close, collapse) should now work correctly.
* fix(web-ui): Fix GitHub token expand button functionality
- Convert collapse button handler to named function (toggleGithubTokenContent)
- Improve state checking using class, inline style, and computed style
- Re-attach event listener after saving token to ensure it works
- Add console logging for debugging
- Make function globally accessible for better reliability
Fixes issue where expand button didn't work after saving token.
* fix(web-ui): Remove X button and improve GitHub token panel behavior
- Remove X (close) button from GitHub token configuration panel
- Replace toggleGithubTokenSettings() with openGithubTokenSettings() that only opens
- Auto-collapse panel when token is valid (user must click expand to edit)
- Auto-detect token status on page load (no need to click save)
- Simplify saveGithubToken() to rely on checkGitHubAuthStatus() for UI updates
- Ensure expand button works correctly with proper event listener attachment
The panel now remains visible but collapsed when a token is configured,
allowing users to expand it when needed without the ability to completely hide it.
* refactor(web-ui): Improve GitHub token collapse button code quality
- Update comment to reflect actual behavior (prevent parent click handlers)
- Use empty string for display to defer to CSS instead of hard-coding block/none
- Extract duplicate clone-and-attach logic into attachGithubTokenCollapseHandler() helper
- Make helper function globally accessible for reuse in checkGitHubAuthStatus()
Improves maintainability and makes code more future-proof for layout changes.
* fix(web-ui): Fix collapse/expand button by using removeProperty for display
- Use style.removeProperty('display') instead of style.display = ''
- This properly removes inline styles and defers to CSS classes
- Fixes issue where collapse/expand button stopped working after refactor
* fix(web-ui): Make display handling consistent for token collapse
- Use removeProperty('display') consistently in all places
- Fix checkGitHubAuthStatus() to use removeProperty instead of inline style
- Simplify state checking to rely on hidden class with computed style fallback
- Ensures collapse/expand button works correctly by deferring to CSS classes
* fix(web-ui): Fix token collapse button and simplify state detection
- Simplify state checking to rely on hidden class only (element has class='block')
- Only remove inline display style if it exists (check before removing)
- Add console logging to debug handler attachment
- Ensure collapse/expand works by relying on CSS classes
Fixes issues where:
- Collapse button did nothing
- Auto-detection of token status wasn't working
* debug(web-ui): Add extensive debugging for token collapse button
- Add console logs to track function calls and element detection
- Improve state detection to use computed style as fallback
- Add wrapper function for click handler to ensure it's called
- Better error messages to identify why handler might not attach
This will help identify why the collapse button isn't working.
* debug(web-ui): Add comprehensive debugging for GitHub token features
- Add console logs to checkGitHubAuthStatus() to track execution
- Re-attach collapse handler after plugin store is rendered
- Add error stack traces for better debugging
- Ensure handler is attached when content is dynamically loaded
This will help identify why:
- Auto-detection of token status isn't working
- Collapse button isn't functioning
* fix(web-ui): Move checkGitHubAuthStatus before IIFE to fix scope issue
- Move checkGitHubAuthStatus function definition before IIFE starts
- Function was defined after IIFE but called inside it, causing it to be undefined
- Now function is available when called during initialization
- This should fix auto-detection of token status on page load
* debug(web-ui): Add extensive logging to GitHub token functions
- Add logging when checkGitHubAuthStatus is defined
- Add logging when function is called during initialization
- Add logging in attachGithubTokenCollapseHandler
- Add logging in store render callback
- This will help identify why functions aren't executing
* fix(web-ui): Move GitHub token functions outside IIFE for availability
- Move attachGithubTokenCollapseHandler and toggleGithubTokenContent outside IIFE
- These functions need to be available when store renders, before IIFE completes
- Add logging to initializePlugins to track when it's called
- This should fix the 'undefined' error when store tries to attach handlers
* fix(web-ui): Fix GitHub token content collapse/expand functionality
- Element has 'block' class in HTML which conflicts with 'hidden' class
- When hiding: add 'hidden', remove 'block', set display:none inline
- When showing: remove 'hidden', add 'block', remove inline display
- This ensures proper visibility toggle for the GitHub API Configuration section
* feat(display): Implement on-demand plugin filtering with restart
- Add on-demand plugin filtering to DisplayController initialization
- Filters available_modes to only include on-demand plugin's modes
- Allows plugin internal rotation (e.g., NFL upcoming, NCAA FB Recent)
- Prevents rotation to other plugins
- Implement restart mechanism for on-demand activation/clear
- _restart_with_on_demand_filter() saves state and restarts with filter
- _restart_without_on_demand_filter() restores normal operation
- Supports both systemd service and direct process execution
- Add state preservation across restarts
- Saves/restores rotation position from cache
- Restores on-demand config from cache after restart
- Add service detection method
- Detects if running as systemd service
- Uses file-based approach for environment variable passing
- Update API endpoints with restart flow comments
- Update systemd service file with on-demand support notes
- Add comprehensive error handling for edge cases
* perf(web-ui): Optimize GitHub token detection speed
- Call checkGitHubAuthStatus immediately when script loads (if elements exist)
- Call it early in initPluginsPage (before full initialization completes)
- Use requestAnimationFrame instead of setTimeout(100ms) for store render callback
- Reduce save token delay from 300ms to 100ms
- Token detection now happens in parallel with other initialization tasks
- This makes token status visible much faster on page load
* fix(ui): Move on-demand modal to base.html for always-available access
- Move on-demand modal from plugins.html to base.html
- Ensures modal is always in DOM when Run On-Demand button is clicked
- Fixes issue where button in plugin_config.html couldn't find modal
- Modal is now available regardless of which tab is active
* fix(ui): Initialize on-demand modal unconditionally on page load
- Create initializeOnDemandModal() function that runs regardless of plugins tab
- Modal is in base.html so it should always be available
- Call initialization on DOMContentLoaded and with timeout
- Fixes 'On-demand modal elements not found' error when clicking button
- Modal setup now happens even if plugins tab hasn't been loaded yet
* fix(ui): Add safety check for updatePluginTabStates function
- Check if updatePluginTabStates exists before calling
- Prevents TypeError when function is not available
- Fixes error when clicking plugin tabs
* fix(ui): Add safety checks for all updatePluginTabStates calls
- Add safety check in Alpine component tab button handler
- Add safety check in Alpine callback
- Prevents TypeError when function is not available in all contexts
* fix(ui): Add safety check in Alpine callback for updatePluginTabStates
* debug(ui): Add console logging to trace on-demand modal opening
- Add logging to runPluginOnDemand function
- Add logging to __openOnDemandModalImpl function
- Log plugin lookup, modal element checks, and display changes
- Helps diagnose why modal doesn't open when button is clicked
* debug(ui): Add logging for modal display change
* debug(ui): Add more explicit modal visibility settings and computed style logging
- Set visibility and opacity explicitly when showing modal
- Force reflow to ensure styles are applied
- Log computed styles to diagnose CSS issues
- Helps identify if modal is hidden by CSS rules
* debug(ui): Increase modal z-index and add bounding rect check
- Set z-index to 9999 to ensure modal is above all other elements
- Add bounding rect check to verify modal is in viewport
- Helps diagnose if modal is positioned off-screen or behind other elements
* debug(display): Add detailed logging for on-demand restart flow
- Log when polling finds requests
- Log service detection result
- Log file writing and systemctl commands
- Log restart command execution and results
- Helps diagnose why on-demand restart isn't working
* debug(display): Add logging for on-demand request polling
- Log request_id comparison to diagnose why requests aren't being processed
- Helps identify if request_id matching is preventing processing
* fix(ui): Force modal positioning with !important to override any conflicting styles
- Use cssText with !important flags to ensure modal is always visible
- Remove all inline styles first to start fresh
- Ensure modal is positioned at top:0, left:0 with fixed positioning
- Fixes issue where modal was still positioned off-screen (top: 2422px)
* debug(ui): Add logging to on-demand form submission
- Log form submission events
- Log payload being sent
- Log response status and data
- Helps diagnose why on-demand requests aren't being processed
* fix(display): Remove restart-based on-demand activation
- Replace restart-based activation with immediate mode switch
- On-demand now activates without restarting the service
- Saves rotation state for restoration when on-demand ends
- Fixes infinite restart loop issue
- On-demand now works when display is already running
* docs: Add comprehensive guide for on-demand cache management
- Document all on-demand cache keys and their purposes
- Explain when manual clearing is needed
- Clarify what clearing from cache management tab does/doesn't do
- Provide troubleshooting steps and best practices
* fix(display): Ensure on-demand takes priority over live priority
- Move on-demand check BEFORE live priority check
- Add explicit logging when on-demand overrides live priority
- Improve request_id checking with both instance and persisted checks
- Add debug logging to trace why requests aren't being processed
- Fixes issue where on-demand didn't interrupt live NHL game
* fix(display): Ensure on-demand takes priority over live priority
- Move on-demand check BEFORE live priority check in main loop
- Add explicit logging when on-demand overrides live priority
- Fixes issue where on-demand didn't interrupt live NHL game
* fix(display): Improve on-demand request processing and priority
- Add persistent processed_id check to prevent duplicate processing
- Mark request as processed BEFORE processing to prevent race conditions
- Improve logging to trace request processing
- Ensure on-demand takes priority over live priority (already fixed in previous commit)
* fix(display): Remove duplicate action line
* fix(display): Fix live priority and ensure on-demand overrides it
- Fix live priority to properly set active_mode when live content is detected
- Ensure on-demand check happens before live priority check
- Add debug logging to trace on-demand vs live priority
- Fix live priority to stay on live mode instead of rotating
* fix(display): Add debug logging for on-demand priority check
* fix(display): Add better logging for on-demand request processing
- Add logging to show when requests are blocked by processed_id check
- Add logging to show on-demand state after activation
- Helps debug why on-demand requests aren't being processed
* fix(display): Add detailed logging for on-demand activation and checking
- Log on-demand state after activation to verify it's set correctly
- Add debug logging in main loop to trace on-demand check
- Helps identify why on-demand isn't overriding live priority
* fix(display): Add debug logging for on-demand check in main loop
* fix(display): Remove restart logic from _clear_on_demand and fix cache delete
- Replace cache_manager.delete() with cache_manager.clear_cache()
- Remove restart logic from _clear_on_demand - now clears immediately
- Restore rotation state immediately without restarting
- Fixes AttributeError: 'CacheManager' object has no attribute 'delete'
* fix(display): Remove restart logic from _clear_on_demand
- Remove restart logic - now clears on-demand state immediately
- Restore rotation state immediately without restarting
- Use clear_cache instead of delete (already fixed in previous commit)
- Fixes error when stopping on-demand mode
* feat(display): Clear display before activating on-demand mode
- Clear display and reset state before activating on-demand
- Reset dynamic mode state to ensure clean transition
- Mimics the behavior of manually stopping display first
- Should fix issue where on-demand only works after manual stop
* feat(display): Stop display service before starting on-demand mode
- Stop the display service first if it's running
- Wait 1.5 seconds for clean shutdown
- Then start the service with on-demand request in cache
- Mimics the manual workflow of stopping display first
- Should fix issue where on-demand only works after manual stop
* feat(display): Filter plugins during initialization for on-demand mode
- Check cache for on-demand requests during initialization
- Only load the on-demand plugin if on-demand request is found
- Prevents loading background services for other plugins
- Fixes issue where Hockey/Football data loads even when only Clock is requested
* fix(display): Use filtered enabled_plugins list instead of discovered_plugins
- Use enabled_plugins list which is already filtered for on-demand mode
- Prevents loading all plugins when on-demand mode is active
- Fixes issue where all plugins were loaded even in on-demand mode
* fix(display): Fix on-demand stop request processing and expiration check
- Always process stop requests, even if request_id was seen before
- Fix expiration check to handle cases where on-demand is not active
- Add better logging for stop requests and expiration
- Fixes issue where stop button does nothing and timer doesn't expire
* fix(display): Fix on-demand stop processing, expiration, and plugin filtering
- Fix stop request processing to always process stop requests, bypassing request_id checks
- Fix expiration check logic to properly check on_demand_active and expires_at separately
- Store display_on_demand_config cache key in _activate_on_demand for plugin filtering
- Clear display before switching to on-demand mode to prevent visual artifacts
- Clear display_on_demand_config cache key in _clear_on_demand to prevent stale data
- Implement plugin filtering during initialization based on display_on_demand_config
Fixes issues where:
- Stop button did nothing (stop requests were blocked by request_id check)
- Expiration timer didn't work (logic issue with or condition)
- Plugin filtering didn't work on restart (config cache key never set)
- Display showed artifacts when switching to on-demand (display not cleared)
- All plugins loaded even in on-demand mode (filtering not implemented)
* fix(web): Allow on-demand to work with disabled plugins
- Remove frontend checks that blocked disabled plugins from on-demand
- Backend already supports temporarily enabling disabled plugins during on-demand
- Update UI messages to indicate plugin will be temporarily enabled
- Remove disabled attribute from Run On-Demand button
Fixes issue where disabled plugins couldn't use on-demand feature even
though the backend implementation supports it.
* fix(display): Resolve plugin_id when sent as mode in on-demand requests
- Detect when mode parameter is actually a plugin_id and resolve to first display mode
- Handle case where frontend sends plugin_id as mode (e.g., 'football-scoreboard')
- Add fallback to use first available display mode if provided mode is invalid
- Add logging for mode resolution debugging
Fixes issue where on-demand requests with mode=plugin_id failed with 'invalid-mode' error
* feat(display): Rotate through all plugin modes in on-demand mode
- Store all modes for on-demand plugin instead of locking to single mode
- Rotate through available modes (live, recent, upcoming) when on-demand active
- Skip modes that return False (no content) and move to next mode
- Prioritize live modes if they have content, otherwise skip them
- Add on_demand_modes list and on_demand_mode_index for rotation tracking
Fixes issue where on-demand mode stayed on one mode (e.g., football_recent)
and didn't rotate through other available modes (football_live, football_upcoming).
Now properly rotates through all modes, skipping empty ones.
* fix(display): Improve on-demand stop request handling
- Always process stop requests if on-demand is active, even if same request_id
- Add better logging when stop is requested but on-demand is not active
- Improve logging in _clear_on_demand to show which mode rotation resumes to
- Ensure stop requests are properly acknowledged
Fixes issue where stop button shows as completed but display doesn't resume
normal rotation. Stop requests now properly clear on-demand state and resume.
* security(web): Fix XSS vulnerability in GitHub auth error display
Replace innerHTML usage with safe DOM manipulation:
- Use textContent to clear element and create text nodes
- Create <strong> element via createElement instead of string HTML
- Add safe fallback ('Unknown error') for error messages
- Ensure authData.error/authData.message are treated as plain text
- Avoid trusting backend-provided data as HTML
Fixes XSS vulnerability where malicious HTML in error messages could
be injected into the DOM.
* style(api): Remove unnecessary str() in f-string for error message
Remove explicit str(e) call in error_response f-string since f-strings
automatically convert exceptions to strings. This matches the style used
elsewhere in the file.
Changed: f"Error saving configuration: {str(e)}"
To: f"Error saving configuration: {e}"
* fix(store): Skip caching for rate-limited 403 responses
When a 403 response indicates a rate limit (detected by checking if
'rate limit' is in response.text.lower()), return the error result but
do NOT cache it in _token_validation_cache. Rate limits are temporary
and should be retried, so caching would incorrectly mark the token as
invalid.
Continue to cache 403 responses that indicate missing token permissions,
as these are persistent issues that should be cached.
This prevents rate-limited responses from being incorrectly cached as
invalid tokens, allowing the system to retry after the rate limit
resets.
* fix(display): Prevent ZeroDivisionError when on_demand_modes is empty
Add guards to check if on_demand_modes is non-empty before performing
any rotation/index math operations. When on_demand_active is True but
on_demand_modes is empty, clear on-demand mode instead of attempting
division by zero.
Fixed in three locations:
1. Mode selection logic (line ~1081): Check before accessing modes
2. Skip to next mode when no content (line ~1190): Guard before modulo
3. Rotate to next mode (line ~1561): Guard before modulo
This prevents ZeroDivisionError when a plugin has no available display
modes or when on_demand_modes becomes empty unexpectedly.
* fix(display): Improve guard for empty on_demand_modes in rotation skip
Refine the guard around lines 1195-1209 to:
- Check if on_demand_modes is empty before any modulo/index operations
- Log warning and debug trace when no modes are configured
- Skip rotation (continue) instead of clearing on-demand mode
- Only perform modulo and index operations when modes are available
- Only log rotation message when next_mode is valid
This prevents ZeroDivisionError and ensures all logging only occurs
when next_mode is valid, providing better traceability.
* fix(display): Populate on_demand_modes when restoring on-demand state from cache
When restoring on-demand state from cache during initialization (around
lines 163-197), the code sets on_demand_active, on_demand_plugin_id and
related fields but does not populate self.on_demand_modes, causing the
run loop to see an empty modes list after restart.
Fix by:
1. Adding _populate_on_demand_modes_from_plugin() method that retrieves
the plugin's display modes from plugin_display_modes and builds the
ordered modes list (prioritizing live modes with content, same logic
as _activate_on_demand)
2. Calling this method after plugin loading completes (around line 296)
when on_demand_active and on_demand_plugin_id are set
3. Setting on_demand_mode_index to match the restored mode if available,
otherwise starting at index 0
This ensures on_demand_modes is populated after restart, preventing
empty modes list errors in the run loop.
* docs: Update on-demand documentation to reflect current implementation
Replace obsolete log message reference with current log messages:
- Old: 'Activating on-demand mode... restarting display controller'
- New: 'Processing on-demand start request for plugin' and 'Activated on-demand for plugin'
Update Scenario 2 to reflect immediate mode switching:
- Changed title from 'Infinite Restart Loop' to 'On-Demand Mode Switching Issues'
- Updated symptoms to describe mode switching issues instead of restart loops
- Added note that on-demand now switches modes immediately without restarting
- Updated solution to include display_on_demand_state key
This reflects the current implementation where on-demand activates
immediately without restarting the service.
* fix(api): Fix undefined logger and service stop logic in start_on_demand_display
- Add module-level logger to avoid NameError when logging disabled plugin
- Only stop display service when start_service is True (prevents stopping
service without restarting when start_service is False)
- Remove unused stop_result variable
- Clean up f-strings that don't need formatting
- Improve code formatting for logger.info call
Fixes issue where logger.info() would raise NameError and where the
service would be stopped even when start_service=False, leaving the
service stopped without restarting it.
---------
Signed-off-by: Chuck <33324927+ChuckBuilds@users.noreply.github.com>
Co-authored-by: Chuck <chuck@example.com>
6130 lines
268 KiB
JavaScript
6130 lines
268 KiB
JavaScript
// Define critical functions immediately so they're available before any HTML is rendered
|
|
// Debug logging controlled by localStorage.setItem('pluginDebug', 'true')
|
|
const _PLUGIN_DEBUG_EARLY = typeof localStorage !== 'undefined' && localStorage.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;
|
|
});
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 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');
|
|
})();
|
|
|
|
window.configurePlugin = window.configurePlugin || async function(pluginId) {
|
|
console.log('[DEBUG] ===== configurePlugin called =====');
|
|
console.log('[DEBUG] Plugin ID:', 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;
|
|
console.log('[DEBUG] 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');
|
|
}
|
|
}
|
|
};
|
|
|
|
window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
|
|
console.log('[DEBUG] ===== togglePlugin called =====');
|
|
console.log('[DEBUG] Plugin ID:', pluginId, 'Enabled:', enabled);
|
|
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
|
|
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
|
|
const action = enabled ? 'enabling' : 'disabling';
|
|
|
|
// 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');
|
|
|
|
if (toggleCheckbox) toggleCheckbox.checked = enabled;
|
|
|
|
// 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 => {
|
|
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();
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
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();
|
|
}
|
|
});
|
|
};
|
|
|
|
// 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');
|
|
}
|
|
};
|
|
|
|
// 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 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');
|
|
renderPluginStore(window.__pendingStorePlugins);
|
|
window.__pendingStorePlugins = null;
|
|
}
|
|
|
|
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 searchBtn = document.getElementById('search-plugins-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');
|
|
|
|
if (refreshBtn) {
|
|
refreshBtn.replaceWith(refreshBtn.cloneNode(true));
|
|
document.getElementById('refresh-plugins-btn').addEventListener('click', refreshPlugins);
|
|
}
|
|
if (updateAllBtn) {
|
|
updateAllBtn.replaceWith(updateAllBtn.cloneNode(true));
|
|
document.getElementById('update-all-plugins-btn').addEventListener('click', runUpdateAllPlugins);
|
|
}
|
|
if (restartBtn) {
|
|
restartBtn.replaceWith(restartBtn.cloneNode(true));
|
|
document.getElementById('restart-display-btn').addEventListener('click', restartDisplay);
|
|
}
|
|
if (searchBtn) {
|
|
searchBtn.replaceWith(searchBtn.cloneNode(true));
|
|
document.getElementById('search-plugins-btn').addEventListener('click', searchPluginStore);
|
|
}
|
|
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;
|
|
}
|
|
|
|
loadOnDemandStatus(true);
|
|
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] Called, pluginsInitialized:', pluginsInitialized);
|
|
// Guard against multiple initializations
|
|
if (pluginsInitialized) {
|
|
console.log('[initializePlugins] Already initialized, skipping');
|
|
pluginLog('[INIT] Plugins already initialized, skipping');
|
|
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
|
|
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 localStorage.setItem('pluginDebug', 'true')
|
|
const PLUGIN_DEBUG = typeof localStorage !== 'undefined' && localStorage.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.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>
|
|
<p class="flex items-center"><i class="fas fa-code-branch mr-2 text-gray-400 w-4"></i>${formatCommit(plugin.last_commit, plugin.branch)}</p>
|
|
<p class="flex items-center"><i class="fas fa-calendar mr-2 text-gray-400 w-4"></i>${formatDate(plugin.last_updated)}</p>
|
|
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
|
|
${plugin.stars ? `<p class="flex items-center"><i class="fas fa-star mr-2 text-gray-400 w-4"></i>${plugin.stars} stars</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 class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-200">
|
|
<button class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold"
|
|
data-plugin-id="${escapedPluginId}"
|
|
data-action="configure">
|
|
<i class="fas fa-cog mr-2"></i>Configure
|
|
</button>
|
|
<button class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold"
|
|
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 flex-1 font-semibold"
|
|
data-plugin-id="${escapedPluginId}"
|
|
data-action="uninstall">
|
|
<i class="fas fa-trash mr-2"></i>Uninstall
|
|
</button>
|
|
</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':
|
|
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;
|
|
|
|
function runUpdateAllPlugins() {
|
|
const button = document.getElementById('update-all-plugins-btn');
|
|
|
|
if (!button) {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Unable to locate bulk update controls. Refresh the Plugin Manager tab.', 'error');
|
|
} else {
|
|
console.error('update-all-plugins-btn element not found');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (button.dataset.running === 'true') {
|
|
return;
|
|
}
|
|
|
|
if (typeof window.updateAllPlugins !== 'function') {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Bulk update handler unavailable. Refresh the Plugin Manager tab.', 'error');
|
|
} else {
|
|
console.error('window.updateAllPlugins is not defined');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const originalContent = button.innerHTML;
|
|
button.dataset.running = 'true';
|
|
button.disabled = true;
|
|
button.classList.add('opacity-60', 'cursor-wait');
|
|
button.innerHTML = '<i class="fas fa-sync fa-spin mr-2"></i>Updating...';
|
|
|
|
Promise.resolve(window.updateAllPlugins())
|
|
.catch(error => {
|
|
console.error('Error updating all plugins:', error);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error updating 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 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 hidden input (contains JSON data)
|
|
if (key.endsWith('_data') || key.includes('_data')) {
|
|
try {
|
|
const baseKey = key.replace(/_data$/, '');
|
|
const jsonValue = JSON.parse(value);
|
|
if (typeof jsonValue === 'object' && !Array.isArray(jsonValue)) {
|
|
flatConfig[baseKey] = jsonValue;
|
|
console.log(`PatternProperties field ${baseKey}: parsed JSON object`, jsonValue);
|
|
continue; // Skip normal processing for patternProperties
|
|
}
|
|
} catch (e) {
|
|
// Not valid JSON, continue with normal processing
|
|
}
|
|
}
|
|
|
|
// Skip key_value pair inputs (they're handled by the hidden _data input)
|
|
if (key.includes('[key_') || key.includes('[value_')) {
|
|
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') {
|
|
const formElement = form.elements[actualKey] || form.elements[key];
|
|
flatConfig[actualKey] = formElement ? formElement.checked : (actualValue === 'true' || actualValue === true);
|
|
} 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 {
|
|
const formElement = form.elements[actualKey] || form.elements[key];
|
|
if (formElement && formElement.type === 'checkbox') {
|
|
flatConfig[actualKey] = formElement.checked;
|
|
} else {
|
|
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)
|
|
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}">
|
|
<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}">
|
|
<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 a file upload widget - try multiple ways to access x-widget
|
|
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,
|
|
hasXWidget: hasXWidget,
|
|
'x-widget': xWidgetValue,
|
|
'x-widget (alt)': xWidgetValue2,
|
|
'x-upload-config': prop['x-upload-config'],
|
|
propKeys: Object.keys(prop),
|
|
propString: JSON.stringify(prop),
|
|
value: value
|
|
});
|
|
|
|
// Check for file-upload widget - be more defensive
|
|
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
|
|
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 || {};
|
|
|
|
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 = `${fullKey.replace(/\./g, '_')}_${option}`;
|
|
html += `
|
|
<label class="flex items-center">
|
|
<input type="checkbox"
|
|
id="${checkboxId}"
|
|
name="${fullKey}[]"
|
|
value="${option}"
|
|
${isChecked ? 'checked' : ''}
|
|
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">${label}</span>
|
|
</label>
|
|
`;
|
|
});
|
|
html += `</div>`;
|
|
} else {
|
|
// Regular array input
|
|
console.log(`[DEBUG] ❌ NOT a file upload widget 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);
|
|
};
|
|
|
|
// 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() {
|
|
// 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() {
|
|
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 });
|
|
|
|
// Safely get search values (elements may not exist yet)
|
|
const searchInput = document.getElementById('plugin-search');
|
|
const categorySelect = document.getElementById('plugin-category');
|
|
const query = searchInput ? searchInput.value : '';
|
|
const category = categorySelect ? categorySelect.value : '';
|
|
|
|
// For filtered searches (user typing), we can use cache to avoid excessive API calls
|
|
// For initial load or refresh, always fetch fresh metadata
|
|
const isFilteredSearch = query || category;
|
|
const now = Date.now();
|
|
const isCacheValid = pluginStoreCache && cacheTimestamp && (now - cacheTimestamp < CACHE_DURATION);
|
|
|
|
// Only use cache for filtered searches that don't explicitly request fresh metadata
|
|
if (isFilteredSearch && isCacheValid && !fetchCommitInfo) {
|
|
console.log('Using cached plugin store data for filtered search');
|
|
// Ensure plugin store grid exists before rendering
|
|
const storeGrid = document.getElementById('plugin-store-grid');
|
|
if (!storeGrid) {
|
|
console.error('plugin-store-grid element not found, cannot render cached plugins');
|
|
// Don't return, let it fetch fresh data
|
|
} else {
|
|
renderPluginStore(pluginStoreCache);
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) {
|
|
countEl.innerHTML = `${pluginStoreCache.length} available`;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not update store count:', e);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Show loading state - safely check element exists
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) {
|
|
countEl.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Loading...';
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not update store count:', e);
|
|
}
|
|
showStoreLoading(true);
|
|
|
|
let url = '/api/v3/plugins/store/list';
|
|
const params = new URLSearchParams();
|
|
if (query) params.append('query', query);
|
|
if (category) params.append('category', category);
|
|
// Always fetch fresh commit metadata unless explicitly disabled (for performance on repeated filtered searches)
|
|
if (!fetchCommitInfo) {
|
|
params.append('fetch_commit_info', 'false');
|
|
}
|
|
// Note: fetch_commit_info defaults to true on the server side to keep metadata fresh
|
|
|
|
if (params.toString()) {
|
|
url += '?' + params.toString();
|
|
}
|
|
|
|
console.log('Store URL:', url);
|
|
|
|
fetch(url)
|
|
.then(response => {
|
|
console.log('Store response:', response.status);
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
console.log('Store data:', data);
|
|
showStoreLoading(false);
|
|
|
|
if (data.status === 'success') {
|
|
const plugins = data.data.plugins || [];
|
|
console.log('Store plugins count:', plugins.length);
|
|
|
|
// Cache the results if no filters
|
|
if (!query && !category) {
|
|
pluginStoreCache = plugins;
|
|
cacheTimestamp = Date.now();
|
|
console.log('Cached plugin store data');
|
|
}
|
|
|
|
// Ensure plugin store grid exists before rendering
|
|
const storeGrid = document.getElementById('plugin-store-grid');
|
|
if (!storeGrid) {
|
|
// Defer rendering until plugin tab loads
|
|
pluginLog('[STORE] plugin-store-grid not ready, deferring render');
|
|
window.__pendingStorePlugins = plugins;
|
|
return;
|
|
}
|
|
|
|
renderPluginStore(plugins);
|
|
|
|
// Update count - safely check element exists
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) {
|
|
countEl.innerHTML = `${plugins.length} available`;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not update store count:', e);
|
|
}
|
|
|
|
// Ensure GitHub token collapse handler is attached after store is rendered
|
|
// The button might not exist until the store content is loaded
|
|
console.log('[STORE] Checking for attachGithubTokenCollapseHandler...', {
|
|
exists: typeof window.attachGithubTokenCollapseHandler,
|
|
checkGitHubAuthStatus: typeof window.checkGitHubAuthStatus
|
|
});
|
|
if (window.attachGithubTokenCollapseHandler) {
|
|
// Use requestAnimationFrame for faster execution (runs on next frame, ~16ms)
|
|
requestAnimationFrame(() => {
|
|
console.log('[STORE] Re-attaching GitHub token collapse handler after store render');
|
|
try {
|
|
window.attachGithubTokenCollapseHandler();
|
|
} catch (error) {
|
|
console.error('[STORE] Error attaching collapse handler:', error);
|
|
}
|
|
// Also check auth status to update UI (already checked earlier, but refresh to be sure)
|
|
if (window.checkGitHubAuthStatus) {
|
|
console.log('[STORE] Refreshing GitHub auth status after store render...');
|
|
try {
|
|
window.checkGitHubAuthStatus();
|
|
} catch (error) {
|
|
console.error('[STORE] Error calling checkGitHubAuthStatus:', error);
|
|
}
|
|
} else {
|
|
console.warn('[STORE] checkGitHubAuthStatus not available');
|
|
}
|
|
});
|
|
} else {
|
|
console.warn('[STORE] attachGithubTokenCollapseHandler not available');
|
|
}
|
|
} else {
|
|
showError('Failed to search plugin store: ' + data.message);
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) {
|
|
countEl.innerHTML = 'Error loading';
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not update store count:', e);
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error searching plugin store:', error);
|
|
showStoreLoading(false);
|
|
let errorMsg = 'Error searching plugin store: ' + error.message;
|
|
if (error.message && error.message.includes('Failed to Fetch')) {
|
|
errorMsg += ' - Please try refreshing your browser.';
|
|
}
|
|
showError(errorMsg);
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) {
|
|
countEl.innerHTML = 'Error loading';
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not update store count:', e);
|
|
}
|
|
});
|
|
}
|
|
|
|
function showStoreLoading(show) {
|
|
const loading = document.querySelector('.store-loading');
|
|
if (loading) {
|
|
loading.style.display = show ? 'block' : 'none';
|
|
}
|
|
}
|
|
|
|
// 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 => `
|
|
<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.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</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>
|
|
<p class="flex items-center"><i class="fas fa-code-branch mr-2 text-gray-400 w-4"></i>${formatCommit(plugin.last_commit, plugin.branch)}</p>
|
|
<p class="flex items-center"><i class="fas fa-calendar mr-2 text-gray-400 w-4"></i>${formatDate(plugin.last_updated)}</p>
|
|
<p class="flex items-center"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
|
|
<p class="flex items-center"><i class="fas fa-star mr-2 text-gray-400 w-4"></i>${plugin.stars || 0} stars</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-4 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 bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold">
|
|
<i class="fas fa-download mr-2"></i>Install
|
|
</button>
|
|
<button onclick='window.open(${escapeJs(plugin.repo || '#')}, "_blank")' class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold">
|
|
<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 both installed plugins and store
|
|
loadInstalledPlugins();
|
|
// Delay store refresh slightly to ensure DOM is ready
|
|
setTimeout(() => {
|
|
searchPluginStore();
|
|
}, 100);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showNotification('Error installing plugin: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
function setupGitHubInstallHandlers() {
|
|
console.log('[setupGitHubInstallHandlers] 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';
|
|
} 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
|
|
const installBtn = document.getElementById('install-plugin-from-url');
|
|
const pluginUrlInput = document.getElementById('github-plugin-url');
|
|
const pluginStatusDiv = document.getElementById('github-plugin-status');
|
|
|
|
if (installBtn && pluginUrlInput) {
|
|
installBtn.addEventListener('click', function() {
|
|
const repoUrl = pluginUrlInput.value.trim();
|
|
if (!repoUrl) {
|
|
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')) {
|
|
pluginStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
|
|
return;
|
|
}
|
|
|
|
installBtn.disabled = true;
|
|
installBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Installing...';
|
|
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;
|
|
}
|
|
|
|
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') {
|
|
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 {
|
|
pluginStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>${data.message || 'Installation failed'}</span>`;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
pluginStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${error.message}</span>`;
|
|
})
|
|
.finally(() => {
|
|
installBtn.disabled = false;
|
|
installBtn.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') {
|
|
installBtn.click();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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('');
|
|
}
|
|
|
|
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 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
|
|
document.addEventListener('submit', function(e) {
|
|
if (e.target.id === 'plugin-config-form') {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(e.target);
|
|
const config = {};
|
|
const schema = currentPluginConfig?.schema;
|
|
|
|
// Convert form data to config object
|
|
// Note: 'enabled' is managed separately via the header toggle, not through this form
|
|
for (let [key, value] of formData.entries()) {
|
|
// Skip enabled - it's managed separately via the header toggle
|
|
if (key === 'enabled') continue;
|
|
|
|
// Check if this field is an array type in the schema
|
|
if (schema?.properties?.[key]?.type === 'array') {
|
|
// Convert comma-separated string to array
|
|
const arrayValue = value.split(',').map(item => item.trim()).filter(item => item.length > 0);
|
|
config[key] = arrayValue;
|
|
console.log(`Array field ${key}: "${value}" -> `, arrayValue);
|
|
} else if (key === 'display_duration' || schema?.properties?.[key]?.type === 'integer') {
|
|
config[key] = parseInt(value);
|
|
} else if (schema?.properties?.[key]?.type === 'number') {
|
|
config[key] = parseFloat(value);
|
|
} else if (schema?.properties?.[key]?.type === 'boolean') {
|
|
config[key] = value === 'true' || value === true;
|
|
} else {
|
|
config[key] = value;
|
|
}
|
|
}
|
|
|
|
console.log('Final config to save:', config);
|
|
console.log('Schema loaded:', schema ? 'Yes' : 'No');
|
|
|
|
// Save the configuration
|
|
savePluginConfiguration(currentPluginConfig.pluginId, config);
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
})(); // End IIFE
|
|
|
|
// 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;
|
|
}
|
|
// 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');
|
|
}
|
|
}, 200);
|
|
|