Files
LEDMatrix/web_interface/templates/v3/base.html
Chuck 0f4dbb6c1a Feature/one shot installer (#178)
* fix(plugins): Remove compatible_versions requirement from single plugin install

Remove compatible_versions from required fields in install_from_url method
to match install_plugin behavior. This allows installing plugins from URLs
without manifest version requirements, consistent with store plugin installation.

* fix(7-segment-clock): Update submodule with separator and spacing fixes

* fix(plugins): Add onchange handlers to existing custom feed inputs

- Add onchange handlers to key and value inputs for existing patternProperties fields
- Fixes bug where editing existing custom RSS feeds didn't save changes
- Ensures hidden JSON input field is updated when users edit feed entries
- Affects all plugins using patternProperties (custom_feeds, feed_logo_map, etc.)

* Add array-of-objects widget support to web UI

- Add support for rendering arrays of objects in web UI (for custom_feeds)
- Implement add/remove/update functions for array-of-objects widgets
- Support file-upload widgets within array items
- Update form data handling to support array JSON data fields

* Update plugins_manager.js cache-busting version

Update version parameter to force browser to load new JavaScript with array-of-objects widget support.

* Fix: Move array-of-objects detection before file-upload/checkbox checks

Move the array-of-objects widget detection to the top of the array handler so it's checked before file-upload and checkbox-group widgets. This ensures custom_feeds is properly detected as an array of objects.

* Update cache-busting version for array-of-objects fix

* Remove duplicate array-of-objects check

* Update cache version again

* Add array-of-objects widget support to server-side template

Add detection and rendering for array-of-objects in the Jinja2 template (plugin_config.html).
This enables the custom_feeds widget to display properly with name, URL, enabled checkbox, and logo upload fields.

The widget is detected by checking if prop.items.type == 'object' && prop.items.properties,
and is rendered before the file-upload widget check.

* Use window. prefix for array-of-objects JavaScript functions

Explicitly use window.addArrayObjectItem, window.removeArrayObjectItem, etc.
in the template to ensure the functions are accessible from inline event handlers.
Also add safety checks to prevent errors if functions aren't loaded yet.

* Fix syntax error: Missing indentation for html += in array else block

The html += statement was outside the else block, causing a syntax error.
Fixed by properly indenting it inside the else block.

* Update cache version for syntax fix

* Add debug logging to diagnose addArrayObjectItem availability

* Fix: Wrap array-of-objects functions in window check and move outside IIFE

Ensure functions are available globally by wrapping them in a window check
and ensuring they're defined outside any IIFE scope. Also fix internal
function calls to use window.updateArrayObjectData for consistency.

* Update cache version for array-of-objects fix

* Move array-of-objects functions outside IIFE to make them globally available

The functions were inside the IIFE scope, making them inaccessible from
inline event handlers. Moving them outside the IIFE ensures they're
available on window when the script loads.

* Update cache version for IIFE fix

* Fix: Add array-of-objects functions after IIFE ends

The functions were removed from inside the IIFE but never added after it.
Also removed orphaned code that was causing syntax errors.

* Update cache version for array-of-objects fix

* Fix: Remove all orphaned code and properly add array-of-objects functions after IIFE

* Add array-of-objects functions after IIFE ends

These functions must be outside the IIFE to be accessible from inline
event handlers in the server-rendered template.

* Update cache version for syntax fix

* Fix syntax error: Add missing closing brace for else block

* Update cache version for syntax fix

* Replace complex array-of-objects widget with simple table interface

- Replace nested array-of-objects widget with clean table interface
- Table shows: Name, URL, Logo (with upload), Enabled checkbox, Delete button
- Fix file-upload widget detection order to prevent breaking static-image plugin
- Add simple JavaScript functions for add/remove rows and logo upload
- Much more intuitive and easier to use

* Add simple table interface for custom feeds

- Replace complex array-of-objects widget with clean table
- Table columns: Name, URL, Logo (upload), Enabled checkbox, Delete
- Use dot notation for form field names (feeds.custom_feeds.0.name)
- Add JavaScript functions for add/remove rows and logo upload
- Fix file-upload detection order to prevent breaking static-image plugin

* Fix custom feeds table issues

- Fix JavaScript error in removeCustomFeedRow (get tbody before removing row)
- Improve array conversion logic to handle nested paths like feeds.custom_feeds
- Add better error handling and debug logging for array conversion
- Ensure dicts with numeric keys are properly converted to arrays before validation

* Add fallback fix for feeds.custom_feeds dict-to-array conversion

- Add explicit fallback conversion for feeds.custom_feeds if fix_array_structures misses it
- This ensures the dict with numeric keys is converted to an array before validation
- Logo field is already optional in schema (not in required array)

* feat(web): Add checkbox-group widget support for plugin config arrays

Add server-side rendering support for checkbox-group widget in plugin
configuration forms. This allows plugins to use checkboxes for multi-select
array fields instead of comma-separated text inputs.

The implementation:
- Checks for x-widget: 'checkbox-group' in schema
- Renders checkboxes for each enum item in items.enum
- Supports custom labels via x-options.labels
- Works with any plugin that follows the pattern

Already used by:
- ledmatrix-news plugin (enabled_feeds)
- odds-ticker plugin (enabled_leagues)

* feat(install): Add one-shot installation script

- Create comprehensive one-shot installer with robust error handling
- Includes network checks, disk space validation, and retry logic
- Handles existing installations gracefully (idempotent)
- Updates README with quick install command prominently featured
- Manual installation instructions moved to collapsible section

The script provides explicit error messages and never fails silently.
All prerequisites are validated before starting installation.

* fix: Remove accidental plugins/7-segment-clock submodule entry

Remove uninitialized submodule 'plugins/7-segment-clock' that was
accidentally included. This submodule is not related to the one-shot
installer feature and should not be part of this PR.

- Remove submodule entry from .gitmodules
- Remove submodule from git index
- Clean up submodule configuration

* fix(array-objects): Fix schema lookup, reindexing, and disable file upload

Address PR review feedback for array-of-objects helpers:

1. Schema resolution: Use getSchemaProperty() instead of manual traversal
   - Fixes nested array-of-objects schema lookup (e.g., news.custom_feeds)
   - Now properly descends through .properties for nested objects

2. Reindexing: Replace brittle regex with targeted patterns
   - Only replace index in bracket notation [0], [1], etc. for names
   - Only replace _item_<digits> pattern for IDs (not arbitrary digits)
   - Use specific function parameter patterns for onclick handlers
   - Prevents corruption of fieldId, pluginId, or other numeric values

3. File upload: Disable widget until properly implemented
   - Hide/disable upload button with clear message
   - Show existing logos if present but disable upload functionality
   - Prevents silent failures when users attempt to upload files
   - Added TODO comments for future implementation

Also fixes exit code handling in one-shot-install.sh to properly capture
first_time_install.sh exit status before error trap fires.

* fix(security): Fix XSS vulnerability in handleCustomFeedLogoUpload

Replace innerHTML usage with safe DOM manipulation using createElement
and setAttribute to prevent XSS when injecting uploadedFile.path and
uploadedFile.id values.

- Clear logoCell using textContent instead of innerHTML
- Create all DOM elements using document.createElement
- Set uploadedFile.path and uploadedFile.id via setAttribute (automatically escaped)
- Properly structure DOM tree by appending elements in order
- Prevents malicious HTML/script injection through file path or ID values

* fix: Update upload button onclick when reindexing custom feed rows

Fix removeCustomFeedRow to update button onclick handlers that reference
file input IDs with _logo_<index> when rows are reindexed after deletion.

Previously, after deleting a row, the upload button's onclick still referenced
the old file input ID, causing the upload functionality to fail.

Now properly updates:
- getElementById('..._logo_<num>') patterns in onclick handlers
- Other _logo_<num> patterns in button onclick strings
- Function parameter indices in onclick handlers

This ensures upload buttons continue to work correctly after row deletion.

* fix: Make custom feeds table widget-specific instead of generic fallback

Replace generic array-of-objects check with widget-specific check for
'custom-feeds' widget to prevent hardcoded schema from breaking other
plugins with different array-of-objects structures.

Changes:
- Check for x-widget == 'custom-feeds' before rendering custom feeds table
- Add schema validation to ensure required fields (name, url) exist
- Show warning message if schema doesn't match expected structure
- Fall back to generic array input for other array-of-objects schemas
- Add comments for future generic array-of-objects support

This ensures the hardcoded custom feeds table (name, url, logo, enabled)
only renders when explicitly requested via widget type, preventing
breakage for other plugins with different array-of-objects schemas.

* fix: Add image/gif to custom feed logo upload accept attribute

Update file input accept attributes for custom feed logo uploads to include
image/gif, making it consistent with the file-upload widget which also
allows GIF images.

Updated in three places:
- Template file input (plugin_config.html)
- JavaScript addCustomFeedRow function (base.html)
- Dynamic file input creation in handleCustomFeedLogoUpload (base.html)

All custom feed logo upload inputs now accept: image/png, image/jpeg,
image/bmp, image/gif

* fix: Add hidden input for enabled checkbox to ensure false is submitted

Add hidden input with value='false' before enabled checkbox in custom feeds
table to ensure an explicit false value is sent when checkbox is unchecked.

Pattern implemented:
- Hidden input: name='enabled', value='false' (always submitted)
- Checkbox: name='enabled', value='true' (only submitted when checked)
- When unchecked: only hidden input submits (false)
- When checked: both submit, checkbox value (true) overwrites hidden

Updated in two places:
- Template checkbox in plugin_config.html (existing rows)
- JavaScript addCustomFeedRow function in base.html (new rows)

Backend verification:
- Backend (api_v3.py) handles string boolean values and converts properly
- JavaScript form processing explicitly checks element.checked, independent of this pattern
- Standard form submission uses last value when multiple values share same name

* fix: Expose renderArrayObjectItem to window for addArrayObjectItem

Fix scope issue where renderArrayObjectItem is defined inside IIFE but
window.addArrayObjectItem is defined outside, causing the function check
to always fail and fallback to degraded HTML rendering.

Problem:
- renderArrayObjectItem (line 2469) is inside IIFE (lines 796-6417)
- window.addArrayObjectItem (line 6422) is outside IIFE
- Check 'typeof renderArrayObjectItem === function' at line 6454 always fails
- Fallback code lacks file upload widgets, URL input types, descriptions, styling

Solution:
- Expose renderArrayObjectItem to window object before IIFE closes
- Function maintains closure access to escapeHtml and other IIFE-scoped functions
- Newly added items now have full functionality matching initially rendered items

* fix: Reorder array type checks to match template order

Fix inconsistent rendering where JavaScript and Jinja template had opposite
ordering for array type checks, causing schemas with both x-widget: file-upload
AND items.type: object (like static-image) to render differently.

Problem:
- Template checks file-upload FIRST (to avoid breaking static-image plugin)
- JavaScript checked array-of-objects FIRST
- Server-rendered forms showed file-upload widget correctly
- JS-rendered forms incorrectly displayed array-of-objects table widget

Solution:
- Reorder JavaScript checks to match template order:
  1. Check file-upload widget FIRST
  2. Check checkbox-group widget
  3. Check custom-feeds widget
  4. Check array-of-objects as fallback
  5. Regular array input (comma-separated)

This ensures consistent rendering between server-rendered and JS-rendered forms
for schemas that have both x-widget: file-upload AND items.type: object.

* fix: Handle None value for feeds config to prevent TypeError

Fix crash when plugin_config['feeds'] exists but is None, causing
TypeError when checking 'custom_feeds' in feeds_config.

Problem:
- When plugin_config['feeds'] exists but is None, dict.get('feeds', {})
  returns None (not the default {}) because dict.get() only uses default
  when key doesn't exist, not when value is None
- Line 3642's 'custom_feeds' in feeds_config raises TypeError because
  None is not iterable
- This can crash the API endpoint if a plugin config has feeds: null

Solution:
- Change plugin_config.get('feeds', {}) to plugin_config.get('feeds') or {}
  to ensure feeds_config is always a dict (never None)
- Add feeds_config check before 'in' operator for extra safety

This ensures the code gracefully handles feeds: null in plugin configuration.

* fix: Add default value for AVAILABLE_SPACE to prevent TypeError

Fix crash when df produces unexpected output that results in empty
AVAILABLE_SPACE variable, causing 'integer expression expected' error.

Problem:
- df may produce unexpected output format (different locale, unusual
  filesystem name spanning lines, or non-standard df implementation)
- While '|| echo "0"' handles pipeline failures, it doesn't trigger if
  awk succeeds but produces no output (empty string)
- When AVAILABLE_SPACE is empty, comparison [ "$AVAILABLE_SPACE" -lt 500 ]
  fails with 'integer expression expected' error
- With set -e, this causes script to exit unexpectedly

Solution:
- Add AVAILABLE_SPACE=${AVAILABLE_SPACE:-0} before comparison to ensure
  variable always has a numeric value (defaults to 0 if empty)
- This gracefully handles edge cases where df/awk produces unexpected output

* fix: Wrap debug console.log in debug flag check

Fix unconditional debug logging that outputs internal implementation
details to browser console for all users.

Problem:
- console.log('[ARRAY-OBJECTS] Functions defined on window:', ...)
  executes unconditionally when page loads
- Outputs debug information about function availability to all users
- Appears to be development/debugging code inadvertently included
- Noisy console output in production

Solution:
- Wrap console.log statement in _PLUGIN_DEBUG_EARLY check to only
  output when pluginDebug localStorage flag is enabled
- Matches pattern used elsewhere in the file for debug logging
- Debug info now only visible when explicitly enabled via
  localStorage.setItem('pluginDebug', 'true')

* fix: Expose getSchemaProperty, disable upload widget, handle bracket notation arrays

Multiple fixes for array-of-objects and form processing:

1. Expose getSchemaProperty to window (plugins_manager.js):
   - getSchemaProperty was defined inside IIFE but needed by global functions
   - Added window.getSchemaProperty = getSchemaProperty before IIFE closes
   - Updated window.addArrayObjectItem to use window.getSchemaProperty
   - Fixes ReferenceError when dynamically adding array items

2. Disable upload widget for custom feeds (plugin_config.html):
   - File input and Upload button were still active but should be disabled
   - Removed onchange/onclick handlers, added disabled and aria-disabled
   - Added visible disabled styling and tooltip
   - Existing logos continue to display but uploads are prevented
   - Matches PR objectives to disable upload until fully implemented

3. Handle bracket notation array fields (api_v3.py):
   - checkbox-group uses name="field_name[]" which sends multiple values
   - request.form.to_dict() collapses duplicate keys (only keeps last value)
   - Added handling to detect fields ending with "[]" before to_dict()
   - Use request.form.getlist() to get all values, combine as comma-separated
   - Processed before existing array index field handling
   - Fixes checkbox-group losing all but last selected value

* fix: Remove duplicate submit handler to prevent double POSTs

Remove document-level submit listener that conflicts with handlePluginConfigSubmit,
causing duplicate form submissions with divergent payloads.

Problem:
- handlePluginConfigSubmit correctly parses JSON from _data fields and maps to
  flatConfig[baseKey] for patternProperties and array-of-objects
- Document-level listener (line 5368) builds its own config without understanding
  _data convention and posts independently via savePluginConfiguration
- Every submit now sends two POSTs with divergent payloads:
  - First POST: Correct structure with parsed _data fields
  - Second POST: Incorrect structure with raw _data fields, missing structure
- Arrays-of-objects and patternProperties saved incorrectly in second request

Solution:
- Remove document-level submit listener for #plugin-config-form
- Rely solely on handlePluginConfigSubmit which is already attached to the form
- handlePluginConfigSubmit properly handles all form-to-config conversion including:
  - _data field parsing (JSON from hidden fields)
  - Type-aware conversion using schema
  - Dot notation to nested object conversion
  - PatternProperties and array-of-objects support

Note: savePluginConfiguration function remains for use by JSON editor saves

* fix: Use indexed names for checkbox-group to work with existing parser

Change checkbox-group widget to use indexed field names instead of bracket
notation, so the existing indexed field parser correctly handles multiple
selected values.

Problem:
- checkbox-group uses name="{{ full_key }}[]" which requires bracket
  notation handling in backend
- While bracket notation handler exists, using indexed names is more robust
  and leverages existing well-tested indexed field parser
- Indexed field parser already handles fields like "field_name.0",
  "field_name.1" correctly

Solution:
- Template: Change name="{{ full_key }}[]" to name="{{ full_key }}.{{
  loop.index0 }}"
- JavaScript: Update checkbox-group rendering to use name="."
- Backend indexed field parser (lines 3364-3388) already handles this pattern:
  - Detects fields ending with numeric indices (e.g., ".0", ".1")
  - Groups them by base_path and sorts by index
  - Combines into array correctly

This ensures checkbox-group values are properly preserved when multiple
options are selected, working with the existing schema-based parsing system.

* fix: Set values from item data in fallback array-of-objects rendering

Fix fallback code path for rendering array-of-objects items to properly
set input values from existing item data, matching behavior of proper
renderArrayObjectItem function.

Problem:
- Fallback code at lines 3078-3091 and 6471-6486 creates input elements
  without setting values from existing item data
- Text inputs have no value attribute set
- Checkboxes have no checked attribute computed from item properties
- Users would see empty form fields instead of existing configuration data
- Proper renderArrayObjectItem function correctly sets values (line 2556)

Solution:
- Extract propValue from item data: item[propKey] with schema default fallback
- For text inputs: Set value attribute with HTML-escaped propValue
- For checkboxes: Set checked attribute based on propValue truthiness
- Add inline HTML escaping for XSS prevention (since fallback code may
  run outside IIFE scope where escapeHtml function may not be available)

This ensures fallback rendering displays existing data correctly when
window.renderArrayObjectItem is not available.

* fix: Remove extra closing brace breaking if/else chain

Remove stray closing brace at line 3127 that was breaking the if/else chain
before the 'else if (prop.enum)' branch, causing 'Unexpected token else'
syntax error.

Problem:
- Extra '}' at line 3127 closed the prop.type === 'array' block prematurely
- This broke the if/else chain, causing syntax error when parser reached
  'else if (prop.enum)' at line 3128
- Structure was: } else if (array) { ... } } } else if (enum) - extra brace

Solution:
- Removed the extra closing brace at line 3127
- Structure now correctly: } else if (array) { ... } } else if (enum)
- Verified with Node.js syntax checker - no errors

* fix: Remove local logger assignments to prevent UnboundLocalError

Remove all local logger assignments inside save_plugin_config function that
were shadowing the module-level logger, causing UnboundLocalError when nested
helpers like normalize_config_values() or debug checks reference logger before
those assignments run.

Problem:
- Module-level logger exists at line 13: logger = logging.getLogger(__name__)
- Multiple local assignments inside save_plugin_config (lines 3361, 3401, 3421,
  3540, 3660, 3977, 4093, 4118) make logger a local variable for entire function
- Python treats logger as local for entire function scope when any assignment
  exists, causing UnboundLocalError if logger is used before assignments
- Nested helpers like normalize_config_values() or debug checks that reference
  logger before local assignments would fail

Solution:
- Removed all local logger = logging.getLogger(__name__) assignments in
  save_plugin_config function
- Use module-level logger directly throughout the function
- Removed redundant import logging statements that were only used for logger
- This ensures logger is always available and references the module-level logger

All logger references now use the module-level logger without shadowing.

* fix: Fix checkbox-group serialization and array-of-objects key leakage

Multiple fixes for array-of-objects and checkbox-group widgets:

1. Fix checkbox-group serialization (JS and template):
   - Changed from indexed names (categories.0, categories.1) to _data pattern
   - Added updateCheckboxGroupData() function to sync selected values
   - Hidden input stores JSON array of selected enum values
   - Checkboxes use data-checkbox-group and data-option-value attributes
   - Fixes issue where config.categories became {0: true, 1: true} instead of ['nfl', 'nba']
   - Now correctly serializes to array using existing _data handling logic

2. Prevent array-of-objects per-item key leakage:
   - Added skip pattern in handlePluginConfigSubmit for _item_<n>_ names
   - Removed name attributes from per-item inputs in renderArrayObjectItem
   - Per-item inputs now rely solely on hidden _data field
   - Prevents feeds_item_0_name from leaking into flatConfig

3. Add type coercion to updateArrayObjectData:
   - Consults itemsSchema.properties[propKey].type for coercion
   - Handles integer and number types correctly
   - Preserves string values as-is
   - Ensures numeric fields in array items are stored as numbers

4. Ensure currentPluginConfig is always available:
   - Updated addArrayObjectItem to check window.currentPluginConfig first
   - Added error logging if schema not available
   - Prevents ReferenceError when global helpers need schema

This ensures checkbox-group arrays serialize correctly and array-of-objects
per-item fields don't leak extra keys into the configuration.

* fix: Make _data field matching more specific to prevent false positives

Fix overly broad condition that matched any field containing '_data',
causing false positives and inconsistent key transformation.

Problem:
- Condition 'key.endsWith('_data') || key.includes('_data')' matches any
  field containing '_data' anywhere (e.g., 'meta_data_field', 'custom_data_config')
- key.replace(/_data$/, '') only removes '_data' from end, making logic inconsistent
- Fields with '_data' in middle get matched but key isn't transformed
- If their value happens to be valid JSON, it gets incorrectly parsed

Solution:
- Remove 'key.includes('_data')' clause
- Only check 'key.endsWith('_data')' to match actual _data suffix pattern
- Ensures consistent matching: only fields ending with '_data' are treated
  as JSON data fields, and only those get the suffix removed
- Prevents false positives on fields like 'meta_data_field' that happen to
  contain '_data' in their name

* fix: Add HTML escaping to prevent XSS in fallback code and checkbox-group

Add proper HTML escaping for schema-derived values to prevent XSS vulnerabilities
in fallback rendering code and checkbox-group widget.

Problem:
- Fallback code in generateFieldHtml (line 3094) doesn't escape propLabel
  when building HTML strings, while main renderArrayObjectItem uses escapeHtml()
- Checkbox-group widget (lines 3012-3025) doesn't escape option or label values
- While risk is limited (values come from plugin schemas), malicious plugin
  schemas or untrusted schema sources could inject XSS
- Inconsistent with main renderArrayObjectItem which properly escapes

Solution:
- Added escapeHtml() calls for propLabel in fallback array-of-objects rendering
  (both locations: generateFieldHtml and addArrayObjectItem fallback)
- Added escapeHtml() calls for option values in checkbox-group widget:
  - checkboxId (contains option)
  - data-option-value attribute
  - value attribute
  - label text in span
- Ensures consistent XSS protection across all rendering paths

This prevents potential XSS if plugin schemas contain malicious HTML/script
content in enum values or property titles.

* fix: Recreate one-shot install script with APT permission and non-interactive fixes

Recreate one-shot install script that was deleted, with fixes for:
1. APT permission denied errors on /tmp
2. Non-interactive mode support

Fixes:
1. Fix /tmp permissions before running first_time_install.sh:
   - chmod 1777 /tmp to ensure APT can write temp files
   - Set TMPDIR=/tmp explicitly
   - Preserve TMPDIR when using sudo -E

2. Enable non-interactive mode:
   - Pass -y flag or LEDMATRIX_ASSUME_YES=1 to first_time_install.sh
   - Prevents read prompt failure at line 242 when run via curl | bash

3. Better error handling:
   - Temporarily disable errexit to capture exit code
   - Re-enable errexit after capturing
   - Added fix_tmp_permissions() function

This resolves the 'Permission denied' errors for APT temp files and the
interactive prompt failure when running via pipe.

* fix: Pass both -y flag and env var to first_time_install.sh for non-interactive mode

Ensure first_time_install.sh runs in non-interactive mode by passing both:
1. The -y command-line flag
2. The LEDMATRIX_ASSUME_YES=1 environment variable

This is necessary because first_time_install.sh re-executes itself with sudo
if not running as root (line 131), and we need to ensure the non-interactive
flag is preserved through the re-execution.

Also added debug_install.sh diagnostic script to help troubleshoot
installation failures on the Pi.

* fix: Improve /tmp permission handling and non-interactive mode detection

Improve handling of /tmp permissions and non-interactive mode:

1. /tmp permissions fix:
   - Check current permissions before attempting to fix
   - Display warning when fixing incorrect permissions (2775 -> 1777)
   - Verify /tmp has permissions 1777 (sticky bit + world writable)

2. Non-interactive mode detection:
   - Redirect stdin from /dev/null when running via sudo to prevent
     read commands from hanging when stdin is not a TTY
   - Add better error message in first_time_install.sh when non-interactive
     mode is detected but ASSUME_YES is not set
   - Check if stdin is a TTY before attempting interactive read

This fixes the issues identified in diagnostic output:
- /tmp permissions 2775 causing APT write failures
- read -p failing when stdin is not a TTY (curl | bash)

Fixes installation failures when running one-shot install via curl | bash.

* refactor: Simplify /tmp permission handling - only fix if actually wrong

Simplify /tmp permission handling:
- Only check and fix /tmp permissions if they're actually incorrect (not preemptively)
- Remove redundant fix_tmp_permissions() call from prerequisites check
- Keep the fix inline where first_time_install.sh is executed
- When running manually, /tmp usually has correct permissions (1777) so no fix needed

This makes the script less aggressive and avoids unnecessary permission changes
when running manually, while still fixing the issue in automated scenarios.

* fix: Remove user confirmation prompts in install_wifi_monitor.sh for non-interactive mode

Make install_wifi_monitor.sh respect non-interactive mode:

1. Package installation prompt (line 48):
   - Check for ASSUME_YES or LEDMATRIX_ASSUME_YES environment variable
   - If set, automatically install required packages without prompting
   - If stdin is not a TTY (non-interactive), also auto-install packages
   - Only prompt user in true interactive mode (TTY available)

2. Continue installation prompt (line 145):
   - Already checks for ASSUME_YES, but now also checks LEDMATRIX_ASSUME_YES
   - Skip prompt if stdin is not a TTY
   - Proceed automatically in non-interactive mode

This fixes installation failures at step 8.5 when running via one-shot
installer or with -y flag, as the script was hanging on user prompts.

* fix: Explicitly pass ASSUME_YES to install_wifi_monitor.sh and simplify package installation

Fix WiFi monitor installation failing at step 8.5:

1. Explicitly pass ASSUME_YES environment variable when calling
   install_wifi_monitor.sh from first_time_install.sh to ensure
   non-interactive mode is respected

2. Simplify package installation logic in install_wifi_monitor.sh:
   - Use apt directly when running as root (from first_time_install.sh)
   - Use sudo when running as regular user (direct script execution)
   - Always install packages automatically in non-interactive mode
   - Only prompt in true interactive mode (TTY available and ASSUME_YES not set)

This ensures packages are installed automatically when running via
one-shot installer or with -y flag, preventing installation failures
at step 8.5.

* refactor: Remove all prompts from install_wifi_monitor.sh - install packages automatically

Simplify WiFi monitor installation by removing all user prompts:

1. Package installation: Always install required packages automatically
   - No prompt for missing packages (hostapd, dnsmasq, network-manager)
   - Just install them if missing

2. Network connection warning: Remove prompt to continue
   - Just display informational message and proceed
   - WiFi monitor will handle AP mode automatically if no network

3. Remove ASSUME_YES environment variable passing from first_time_install.sh
   - No longer needed since script has no prompts

This makes the installation completely non-interactive and simpler,
preventing any hangs or failures at step 8.5.

* fix: Address multiple issues in debug script, array rendering, and custom feeds

1. debug_install.sh: Make log path dynamic instead of hardcoded
   - Compute project root from script location
   - Use dynamic LOG_DIR instead of hardcoded /home/ledpi/LEDMatrix/logs/
   - Works from any clone location and user

2. plugins_manager.js renderArrayObjectItem: Fix XSS and metadata issues
   - HTML-escape logoValue.path in img src attribute (XSS prevention)
   - Add data-file-data attribute to preserve file metadata for serialization
   - Add data-prop-key attribute for proper property tracking
   - Use schema-driven remove button label (x-removeLabel) with fallback to 'Remove item'

3. base.html addCustomFeedRow: Fix duplicate enabled field and hardcoded pluginId
   - Remove duplicate hidden input for enabled field (checkbox alone is sufficient)
   - Add pluginId parameter to function signature
   - Pass pluginId to handleCustomFeedLogoUpload instead of hardcoded 'ledmatrix-news'
   - Update caller in plugin_config.html to pass plugin_id

These fixes improve security (XSS prevention), functionality (metadata
preservation), and maintainability (no hardcoded values).

* fix: Make install_wifi_monitor.sh more resilient to failures

Make install_wifi_monitor.sh handle errors more gracefully:

1. Remove unnecessary sudo when running as root:
   - Check EUID before using sudo for systemctl commands
   - Use systemctl directly when running as root
   - Use sudo only when running as regular user

2. Add error handling for package installation:
   - Continue even if apt update fails (just warn)
   - Continue even if apt install fails (warn and provide manual install command)
   - Allow installation to continue even if packages fail

3. Make service operations more resilient:
   - Remove sudo when running as root
   - Allow service start to fail without exiting script
   - Print warning if service fails to start
   - Service will still be enabled and may start on reboot

Note: Script still uses 'set -e' but errors in critical paths are handled
with || operators to prevent exit. This prevents the script from exiting
with code 1 when called from first_time_install.sh, allowing the
installation to continue even if some WiFi-related operations fail.

* fix: Make WiFi monitor installation failure non-fatal in first_time_install.sh

Make the WiFi monitor service installation optional/non-fatal:

1. Capture exit code from install_wifi_monitor.sh but don't fail installation
2. Continue installation even if WiFi monitor installation fails
3. Provide clear messages about the failure but allow installation to proceed
4. Check for service file creation and provide helpful messages

WiFi monitor is optional functionality - the main LED Matrix installation
should succeed even if WiFi monitor setup fails (e.g., package installation
issues, service start failures, etc.). Users can install it later if needed.

This prevents the entire installation from failing at step 8.5 due to
WiFi monitor installation issues.

* fix: Use JSON encoding for bracket-notation arrays and add sentinel for clearing

Fix bracket-notation array handling to prevent data loss:

1. Use JSON encoding instead of comma-join (lines 3358-3359):
   - Comma-join breaks if option values contain commas
   - Switch to json.dumps() to encode array values as JSON strings
   - _parse_form_value_with_schema() already handles JSON arrays correctly
   - Preserves values with commas, special characters, etc.

2. Add sentinel hidden input for clearing arrays:
   - Add hidden input with name="field[]" value="" in checkbox-group template
   - Ensures field is always submitted, even when all checkboxes unchecked
   - Backend filters out sentinel empty strings to detect empty array
   - Allows users to clear array to [] by unchecking all options

3. Update backend to handle sentinel:
   - Filter out sentinel empty strings from bracket notation values
   - Empty array (all unchecked) is represented as "[]" JSON string
   - Properly handles both sentinel-only (empty array) and sentinel+values cases

This fixes data loss when:
- Option values contain commas (comma-join corruption)
- All checkboxes are unchecked (field omitted from form, can't clear to [])

* fix: Harden upload flow - HTTP status check, path normalization, property assignment

Fix three security and reliability issues in upload flow:

1. Check HTTP status before calling response.json():
   - Prevents JSON parsing errors on non-2xx responses
   - Properly handles error responses with status codes
   - Returns error text if available for better debugging
   - Prevents masking of HTTP errors

2. Normalize uploadedFile.path before using in img src:
   - Remove leading slashes with replace(/^\/+/, '')
   - Add single leading slash for image src
   - Prevents //host/odd paths that could cause security issues
   - Ensures consistent path format

3. Replace string-based handlers with property assignment:
   - Replace setAttribute('onchange', ...) with addEventListener('change', ...)
   - Replace setAttribute('onclick', ...) with addEventListener('click', ...)
   - Refactor addCustomFeedRow to use DOM manipulation instead of innerHTML
   - Prevents injection vulnerabilities from string interpolation
   - Uses property assignment (img.src, input.name, input.value) instead of setAttribute where appropriate

These changes improve security by eliminating XSS injection surfaces
and improve reliability by properly handling HTTP errors and path formats.

* fix: Add bracket notation to checkbox-group input names

The backend expects checkbox groups to submit with bracket notation
(request.form.getlist("<field>[]")), but the templates were rendering
checkboxes without the "[]" suffix in the name attribute.

Changes:
1. Add name="{{ full_key }}[]" to checkbox inputs in plugin_config.html
2. Add name="${fullKey}[]" to checkbox inputs in plugins_manager.js

This ensures:
- Checked checkboxes submit their values with the bracket notation
- Backend can use request.form.getlist("<field>[]") to collect all values
- Sentinel hidden input (already using bracket notation) works correctly
- Backend bracket_array_fields logic receives and processes the array values

The sentinel hidden input ensures the field is always submitted (even
when all checkboxes are unchecked), allowing the backend to detect and
set empty arrays correctly.

* fix: Swap order of enabled checkbox and hidden input in custom-feeds

The hidden input with value="false" was rendered before the checkbox,
causing request.form.to_dict() to use the hidden input's value instead
of the checkbox's "true" value when checked.

Fix by rendering the checkbox first, then the hidden fallback input.
This ensures that when the checkbox is checked, its "true" value
overwrites the hidden input's "false" value in request.form.to_dict().

The hidden input still serves as a fallback to ensure "false" is
submitted when the checkbox is unchecked (since unchecked checkboxes
don't submit a value).

* fix: Enable upload buttons for existing custom feed rows in template

The template was rendering disabled upload buttons for existing custom
feed rows with the message "Logo upload for custom feeds is not yet
implemented", while the JavaScript addCustomFeedRow function creates
working upload buttons for newly added rows. This created confusing UX
where users saw disabled buttons on existing feeds but working buttons
on newly added feeds.

Since handleCustomFeedLogoUpload is fully implemented and functional,
enable the upload buttons in the template to match the JavaScript
behavior:

1. Remove disabled and aria-disabled attributes from file input
2. Remove disabled, aria-disabled, misleading title, and update button
   styling to match working buttons (remove cursor-not-allowed and
   opacity-50, add hover:bg-gray-300)
3. Add onchange handler to file input calling handleCustomFeedLogoUpload
4. Add onclick handler to button to trigger file input click

This ensures consistent UX across existing and newly added custom feed
rows, with all upload buttons functional.

* fix: Expose escapeHtml to window object for use by global functions

The escapeHtml function is defined inside the IIFE (at line 5445) but is
called at line 6508 from within window.addArrayObjectItem, which is
defined outside the IIFE (starting at line 6465). Since escapeHtml is
not exposed to the window object (unlike renderArrayObjectItem and
getSchemaProperty which are exposed at lines 6457-6458), the fallback
code path throws a ReferenceError: escapeHtml is not defined when
window.renderArrayObjectItem is unavailable.

Fix by exposing escapeHtml to the window object alongside
renderArrayObjectItem and getSchemaProperty, ensuring the fallback code
in window.addArrayObjectItem can safely call escapeHtml when the primary
rendering function fails to load.

This prevents users from being unable to add new items to array-of-objects
fields when the primary rendering function is unavailable.

* fix: Escape single quotes in checkbox-group JSON value attribute

The hidden input for checkbox-group uses a single-quoted value attribute
with {{ array_value|tojson|safe }}, but the tojson filter doesn't escape
single quotes for HTML attributes. While JSON uses double quotes for
strings, if array_value contains strings with single quotes (like
"Tom's Choice"), the resulting HTML value='["Tom's Choice"]' could
have parsing issues in some browsers when the single quote appears inside
the JSON string content.

The JavaScript equivalent at line 3037 correctly escapes single quotes
with .replace(/'/g, "&#39;"), but the Jinja2 template lacked this
escaping.

Fix by applying the replace filter to escape single quotes:
{{ (array_value|tojson|safe)|replace("'", "&#39;") }}

This ensures consistent behavior between server-side template rendering
and client-side JavaScript rendering, and prevents potential HTML attribute
parsing issues.

* fix: Move hidden input before checkbox for enabled field in custom-feeds

The hidden input and checkbox share the same name, causing duplicate form
values. When request.form.to_dict() processes multiple fields with the same
name, it uses the LAST value.

The previous fix (a315693b) had the checkbox first and hidden input second,
which meant the hidden input's "false" value would override the checkbox's
"true" value when checked.

Fix by moving the hidden input BEFORE the checkbox, so:
- When checkbox is checked: checkbox value ("true") overrides hidden ("false")
- When checkbox is unchecked: hidden input value ("false") is used (checkbox
  doesn't submit a value)

This ensures the correct boolean value is submitted in both cases.

* fix: Use dataset-driven indices for custom feed row reindexing

After removeCustomFeedRow() reindexes data-index/id/name, the existing
file-input change handlers still used stale closure indices, causing
querySelector to fail and preventing logo uploads from working.

Fix by using dataset-driven indices instead of closure-captured values:

1. In addCustomFeedRow:
   - Store index in fileInput.dataset.index
   - Read index from e.target.dataset.index in event handler
   - Use fileInput.click() directly instead of getElementById

2. In removeCustomFeedRow:
   - Update dataset.index for all inputs during reindexing
   - Remove onclick/onchange attribute rewriting (handlers use addEventListener)
   - Simplify ID updating to handle both _logo_<n> and _logo_preview_<n>

3. In handleCustomFeedLogoUpload:
   - Store index in fileInput.dataset.index
   - Read index from e.target.dataset.index in event handler
   - Use fileInput.click() directly
   - Set pathInput.value to imageSrc (normalized path)
   - Reset event.target.value to allow re-uploading the same file

This ensures event handlers always use the current index from the DOM,
preventing stale closure issues after row removal and reindexing.

* fix: Reset file input value to allow re-uploading same file

Add event.target.value = '' after successful upload to allow re-uploading
the same file (change event won't fire otherwise if the same file is
selected again).

* fix: Add proper attribute escaping for renderArrayObjectItem

The renderArrayObjectItem function was vulnerable because escapeHtml does
not properly escape attribute contexts (quotes). This could lead to XSS
if user-provided data contains quotes or other special characters in
attribute values.

Changes:
1. Create escapeAttribute function for proper attribute escaping
   - Escapes quotes, ampersands, and other special characters
   - Handles null/undefined values safely

2. Update renderArrayObjectItem to use escapeAttribute for all attribute values:
   - id attributes (itemId, propKey)
   - data-* attributes (data-prop-key, data-file-data)
   - value attributes (input values)
   - placeholder attributes
   - title attributes
   - src attributes (img src)
   - onclick/onchange handler parameters (fieldId)

3. Safely encode JSON in data-file-data attribute:
   - Use base64 encoding (btoa) instead of manual quote escaping
   - Decode with atob when reading the attribute
   - This safely handles all characters including quotes, newlines, etc.

4. Remove hardcoded 'ledmatrix-news' pluginId fallback:
   - Change fallback from 'ledmatrix-news' to null
   - Prevents surprising defaults when uploads are enabled later
   - Requires explicit pluginId configuration

This ensures all attribute values are properly escaped and prevents
XSS vulnerabilities from unescaped quotes or special characters.

* fix: Expose escapeAttribute to window object

The escapeAttribute function was not exposed to the window object, which
could cause issues if other code needs to use it. Expose it alongside
escapeHtml for consistency.

---------

Co-authored-by: Chuck <chuck@example.com>
2026-01-11 16:38:55 -05:00

5159 lines
304 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LED Matrix Control Panel - v3</title>
<!-- Resource hints for CDN resources -->
<link rel="preconnect" href="https://unpkg.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="dns-prefetch" href="https://unpkg.com">
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<!-- HTMX for dynamic content loading -->
<!-- Use local files when in AP mode (192.168.4.x) to avoid CDN dependency -->
<script>
(function() {
// Detect AP mode by IP address
const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.');
// In AP mode, use local files; otherwise use CDN
const htmxSrc = isAPMode ? '/static/v3/js/htmx.min.js' : 'https://unpkg.com/htmx.org@1.9.10';
const sseSrc = isAPMode ? '/static/v3/js/htmx-sse.js' : 'https://unpkg.com/htmx.org/dist/ext/sse.js';
const jsonEncSrc = isAPMode ? '/static/v3/js/htmx-json-enc.js' : 'https://unpkg.com/htmx.org/dist/ext/json-enc.js';
// Load HTMX with fallback
function loadScript(src, fallback, onLoad) {
const script = document.createElement('script');
script.src = src;
script.onload = onLoad || (() => {});
script.onerror = function() {
if (fallback && src !== fallback) {
console.warn(`Failed to load ${src}, trying fallback ${fallback}`);
const fallbackScript = document.createElement('script');
fallbackScript.src = fallback;
fallbackScript.onload = onLoad || (() => {});
document.head.appendChild(fallbackScript);
} else {
console.error(`Failed to load script: ${src}`);
}
};
document.head.appendChild(script);
}
// Load HTMX core
loadScript(htmxSrc, isAPMode ? 'https://unpkg.com/htmx.org@1.9.10' : '/static/v3/js/htmx.min.js', function() {
// Wait a moment for HTMX to initialize, then verify
setTimeout(function() {
// Verify HTMX loaded
if (typeof htmx === 'undefined') {
console.error('HTMX failed to load, trying fallback...');
const fallbackSrc = isAPMode ? 'https://unpkg.com/htmx.org@1.9.10' : '/static/v3/js/htmx.min.js';
if (fallbackSrc !== htmxSrc) {
loadScript(fallbackSrc, null, function() {
setTimeout(function() {
if (typeof htmx !== 'undefined') {
console.log('HTMX loaded from fallback');
// Load extensions after core loads
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
} else {
console.error('HTMX failed to load from both primary and fallback sources');
// Trigger fallback content loading
window.dispatchEvent(new Event('htmx-load-failed'));
}
}, 100);
});
} else {
console.error('HTMX failed to load and no fallback available');
window.dispatchEvent(new Event('htmx-load-failed'));
}
} else {
console.log('HTMX loaded successfully');
// Load extensions after core loads
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
}
}, 100);
});
})();
</script>
<script>
// Configure HTMX to evaluate scripts in swapped content and fix insertBefore errors
(function() {
function setupScriptExecution() {
if (document.body) {
// Fix HTMX insertBefore errors by validating targets before swap
document.body.addEventListener('htmx:beforeSwap', function(event) {
try {
const target = event.detail.target;
if (!target) {
console.warn('[HTMX] Target is null, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Check if target is a valid DOM element
if (!(target instanceof Element)) {
console.warn('[HTMX] Target is not a valid Element, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Check if target has a parent node (required for insertBefore)
if (!target.parentNode) {
console.warn('[HTMX] Target has no parent node, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Ensure target is in the DOM
if (!document.body.contains(target) && !document.head.contains(target)) {
console.warn('[HTMX] Target is not in DOM, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// Additional check: ensure parent is also in DOM
if (target.parentNode && !document.body.contains(target.parentNode) && !document.head.contains(target.parentNode)) {
console.warn('[HTMX] Target parent is not in DOM, skipping swap');
event.detail.shouldSwap = false;
return false;
}
// All checks passed, allow swap
return true;
} catch (e) {
// If validation fails, cancel swap
console.warn('[HTMX] Error validating target:', e);
event.detail.shouldSwap = false;
return false;
}
});
// Suppress HTMX insertBefore errors and other noisy errors - they're harmless but noisy
const originalError = console.error;
const originalWarn = console.warn;
console.error = function(...args) {
const errorStr = args.join(' ');
const errorStack = args.find(arg => arg && typeof arg === 'string' && arg.includes('htmx')) || '';
// Suppress HTMX insertBefore errors (comprehensive check)
// These occur when HTMX tries to swap content but the target element is null
// Usually happens due to timing/race conditions and is harmless
if (errorStr.includes("insertBefore") ||
errorStr.includes("Cannot read properties of null") ||
errorStr.includes("reading 'insertBefore'")) {
// Check if it's from HTMX by looking at stack trace or error string
// Also check the call stack if available
const isHtmxError = errorStr.includes('htmx.org') ||
errorStr.includes('htmx') ||
errorStack.includes('htmx') ||
args.some(arg => {
if (typeof arg === 'string') {
return arg.includes('htmx.org') || arg.includes('htmx');
}
// Check error objects for stack traces
if (arg && typeof arg === 'object' && arg.stack) {
return arg.stack.includes('htmx');
}
return false;
});
if (isHtmxError) {
return; // Suppress - this is a harmless HTMX timing/race condition issue
}
}
// Suppress script execution errors from malformed HTML
if (errorStr.includes("Failed to execute 'appendChild' on 'Node'") ||
errorStr.includes("Failed to execute 'insertBefore' on 'Node'")) {
if (errorStr.includes('Unexpected token')) {
return; // Suppress malformed HTML errors
}
}
originalError.apply(console, args);
};
console.warn = function(...args) {
const warnStr = args.join(' ');
// Suppress Permissions-Policy warnings (harmless browser warnings)
if (warnStr.includes('Permissions-Policy header') ||
warnStr.includes('Unrecognized feature') ||
warnStr.includes('Origin trial controlled feature') ||
warnStr.includes('browsing-topics') ||
warnStr.includes('run-ad-auction') ||
warnStr.includes('join-ad-interest-group') ||
warnStr.includes('private-state-token') ||
warnStr.includes('private-aggregation') ||
warnStr.includes('attribution-reporting')) {
return; // Suppress - these are harmless browser feature warnings
}
originalWarn.apply(console, args);
};
// Handle HTMX errors gracefully with detailed logging
document.body.addEventListener('htmx:responseError', function(event) {
const detail = event.detail;
const xhr = detail.xhr;
const target = detail.target;
// Enhanced error logging
console.error('HTMX response error:', {
status: xhr?.status,
statusText: xhr?.statusText,
url: xhr?.responseURL,
target: target?.id || target?.tagName,
responseText: xhr?.responseText
});
// For form submissions, log the form data
if (target && target.tagName === 'FORM') {
const formData = new FormData(target);
const formPayload = {};
for (const [key, value] of formData.entries()) {
formPayload[key] = value;
}
console.error('Form payload:', formPayload);
// Try to parse error response for validation details
if (xhr?.responseText) {
try {
const errorData = JSON.parse(xhr.responseText);
console.error('Error details:', {
message: errorData.message,
details: errorData.details,
validation_errors: errorData.validation_errors,
context: errorData.context
});
} catch (e) {
console.error('Error response (non-JSON):', xhr.responseText.substring(0, 500));
}
}
}
});
document.body.addEventListener('htmx:swapError', function(event) {
// Log but don't break the app
console.warn('HTMX swap error:', event.detail);
});
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail && event.detail.target) {
try {
const scripts = event.detail.target.querySelectorAll('script');
scripts.forEach(function(oldScript) {
try {
if (oldScript.innerHTML.trim() || oldScript.src) {
const newScript = document.createElement('script');
if (oldScript.src) newScript.src = oldScript.src;
if (oldScript.type) newScript.type = oldScript.type;
if (oldScript.innerHTML) newScript.textContent = oldScript.innerHTML;
if (oldScript.parentNode) {
oldScript.parentNode.insertBefore(newScript, oldScript);
oldScript.parentNode.removeChild(oldScript);
} else {
// If no parent, append to head or body
(document.head || document.body).appendChild(newScript);
}
}
} catch (e) {
// Silently ignore script execution errors
}
});
} catch (e) {
// Silently ignore errors in script processing
}
}
});
} else {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupScriptExecution);
} else {
setTimeout(setupScriptExecution, 100);
}
}
}
setupScriptExecution();
// Section toggle function - define early so it's available for HTMX-loaded content
window.toggleSection = function(sectionId) {
const section = document.getElementById(sectionId);
const icon = document.getElementById(sectionId + '-icon');
if (!section) {
console.warn('toggleSection: Could not find section for', sectionId);
return;
}
if (!icon) {
console.warn('toggleSection: Could not find icon for', sectionId);
return;
}
// Check if currently hidden by checking both class and computed display
const hasHiddenClass = section.classList.contains('hidden');
const computedDisplay = window.getComputedStyle(section).display;
const isHidden = hasHiddenClass || computedDisplay === 'none';
if (isHidden) {
// Show the section - remove hidden class and explicitly set display to block
section.classList.remove('hidden');
section.style.display = 'block';
icon.classList.remove('fa-chevron-right');
icon.classList.add('fa-chevron-down');
} else {
// Hide the section - add hidden class and set display to none
section.classList.add('hidden');
section.style.display = 'none';
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-right');
}
};
// Function to load plugins tab
window.loadPluginsTab = function() {
const content = document.getElementById('plugins-content');
if (content && !content.hasAttribute('data-loaded')) {
content.setAttribute('data-loaded', 'true');
console.log('Loading plugins directly via fetch...');
fetch('/v3/partials/plugins')
.then(r => r.text())
.then(html => {
// Parse HTML into a temporary container to extract scripts
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
// Extract scripts BEFORE inserting into DOM (browser may remove them)
const scripts = Array.from(tempDiv.querySelectorAll('script'));
console.log('Found', scripts.length, 'scripts to execute');
// Insert content WITHOUT scripts first
const scriptsToExecute = [];
scripts.forEach(script => {
scriptsToExecute.push({
content: script.textContent || script.innerHTML,
src: script.src,
type: script.type
});
script.remove(); // Remove from temp div
});
// Now insert the HTML (without scripts)
content.innerHTML = tempDiv.innerHTML;
console.log('Plugins HTML loaded, executing', scriptsToExecute.length, 'scripts...');
// Execute scripts manually - ensure they run properly
if (scriptsToExecute.length > 0) {
try {
scriptsToExecute.forEach((scriptData, index) => {
try {
// Skip if script has no content and no src
const scriptContent = scriptData.content ? scriptData.content.trim() : '';
if (!scriptContent && !scriptData.src) {
return;
}
// Log script info for debugging
if (scriptContent) {
const preview = scriptContent.substring(0, 100).replace(/\n/g, ' ');
console.log(`[SCRIPT ${index + 1}] Content preview: ${preview}... (${scriptContent.length} chars)`);
// Check if this script defines our critical functions
if (scriptContent.includes('window.configurePlugin') || scriptContent.includes('window.togglePlugin')) {
console.log(`[SCRIPT ${index + 1}] ⚠️ This script should define configurePlugin/togglePlugin!`);
}
}
// Only execute if we have valid content
if (scriptContent || scriptData.src) {
// For inline scripts, use appendChild for reliable execution
if (scriptContent && !scriptData.src) {
// For very large scripts (>100KB), try fallback methods first
// as appendChild can sometimes have issues with large scripts
const isLargeScript = scriptContent.length > 100000;
if (isLargeScript) {
console.log(`[SCRIPT ${index + 1}] Large script detected (${scriptContent.length} chars), trying fallback methods first...`);
// Try Function constructor first for large scripts
let executed = false;
try {
const func = new Function('window', scriptContent);
func(window);
console.log(`[SCRIPT ${index + 1}] ✓ Executed large script via Function constructor`);
executed = true;
} catch (funcError) {
console.warn(`[SCRIPT ${index + 1}] Function constructor failed:`, funcError.message);
}
// If Function constructor failed, try indirect eval
if (!executed) {
try {
(0, eval)(scriptContent);
console.log(`[SCRIPT ${index + 1}] ✓ Executed large script via indirect eval`);
executed = true;
} catch (evalError) {
console.warn(`[SCRIPT ${index + 1}] Indirect eval failed:`, evalError.message);
}
}
// If both fallbacks worked, skip appendChild
if (executed) {
// Verify functions were defined
setTimeout(() => {
console.log(`[SCRIPT ${index + 1}] After fallback execution:`, {
configurePlugin: typeof window.configurePlugin,
togglePlugin: typeof window.togglePlugin
});
}, 50);
return; // Skip to next script (use return, not continue, in forEach)
}
}
try {
// Create new script element and append to head/body
// This ensures proper execution context and window attachment
const newScript = document.createElement('script');
if (scriptData.type) {
newScript.type = scriptData.type;
}
// Wrap in a promise to wait for execution
const scriptPromise = new Promise((resolve, reject) => {
// Set up error handler
newScript.onerror = (error) => {
reject(error);
};
// For inline scripts, execution happens synchronously when appended
// But we'll use a small delay to ensure it completes
try {
// Set textContent (not innerHTML) to avoid execution issues
// Note: We can't wrap in try-catch here as it would interfere with the script
// Instead, we rely on the script's own error handling
newScript.textContent = scriptContent;
// Append to head for better execution context
const target = document.head || document.body;
if (target) {
// Set up error handler to catch execution errors
newScript.onerror = (error) => {
console.error(`[SCRIPT ${index + 1}] Execution error:`, error);
reject(error);
};
// Check before execution
const beforeConfigurePlugin = typeof window.configurePlugin === 'function';
const beforeTogglePlugin = typeof window.togglePlugin === 'function';
// Declare variables in outer scope so setTimeout can access them
let afterConfigurePlugin = beforeConfigurePlugin;
let afterTogglePlugin = beforeTogglePlugin;
// Append and execute (execution is synchronous for inline scripts)
// Wrap in try-catch to catch any execution errors
try {
target.appendChild(newScript);
// Check immediately after append (inline scripts execute synchronously)
afterConfigurePlugin = typeof window.configurePlugin === 'function';
afterTogglePlugin = typeof window.togglePlugin === 'function';
console.log(`[SCRIPT ${index + 1}] Immediate check after appendChild:`, {
configurePlugin: { before: beforeConfigurePlugin, after: afterConfigurePlugin },
togglePlugin: { before: beforeTogglePlugin, after: afterTogglePlugin }
});
} catch (appendError) {
console.error(`[SCRIPT ${index + 1}] Error during appendChild:`, appendError);
console.error(`[SCRIPT ${index + 1}] Error message:`, appendError.message);
console.error(`[SCRIPT ${index + 1}] Error stack:`, appendError.stack);
// Try fallback execution methods immediately
console.warn(`[SCRIPT ${index + 1}] Attempting fallback execution methods...`);
let executed = false;
// Method 1: Function constructor
try {
const func = new Function('window', scriptContent);
func(window);
console.log(`[SCRIPT ${index + 1}] ✓ Executed via Function constructor (fallback)`);
executed = true;
} catch (funcError) {
console.warn(`[SCRIPT ${index + 1}] Function constructor failed:`, funcError.message);
if (funcError.stack) {
console.warn(`[SCRIPT ${index + 1}] Function constructor stack:`, funcError.stack);
}
// Try to find the line number if available
if (funcError.message.includes('line')) {
const lineMatch = funcError.message.match(/line (\d+)/);
if (lineMatch) {
const lineNum = parseInt(lineMatch[1]);
const lines = scriptContent.split('\n');
const start = Math.max(0, lineNum - 5);
const end = Math.min(lines.length, lineNum + 5);
console.warn(`[SCRIPT ${index + 1}] Context around error (lines ${start}-${end}):`,
lines.slice(start, end).join('\n'));
}
}
}
// Method 2: Indirect eval
if (!executed) {
try {
(0, eval)(scriptContent);
console.log(`[SCRIPT ${index + 1}] ✓ Executed via indirect eval (fallback)`);
executed = true;
} catch (evalError) {
console.warn(`[SCRIPT ${index + 1}] Indirect eval failed:`, evalError.message);
if (evalError.stack) {
console.warn(`[SCRIPT ${index + 1}] Indirect eval stack:`, evalError.stack);
}
}
}
// Check if functions are now defined
const fallbackConfigurePlugin = typeof window.configurePlugin === 'function';
const fallbackTogglePlugin = typeof window.togglePlugin === 'function';
console.log(`[SCRIPT ${index + 1}] After fallback attempts:`, {
configurePlugin: fallbackConfigurePlugin,
togglePlugin: fallbackTogglePlugin,
executed: executed
});
if (!executed) {
reject(appendError);
} else {
resolve();
}
}
// Also check after a small delay to catch any async definitions
setTimeout(() => {
const delayedConfigurePlugin = typeof window.configurePlugin === 'function';
const delayedTogglePlugin = typeof window.togglePlugin === 'function';
// Use the variables from the outer scope
if (delayedConfigurePlugin !== afterConfigurePlugin || delayedTogglePlugin !== afterTogglePlugin) {
console.log(`[SCRIPT ${index + 1}] Functions appeared after delay:`, {
configurePlugin: { immediate: afterConfigurePlugin, delayed: delayedConfigurePlugin },
togglePlugin: { immediate: afterTogglePlugin, delayed: delayedTogglePlugin }
});
}
resolve();
}, 100); // Small delay to catch any async definitions
} else {
reject(new Error('No target found for script execution'));
}
} catch (appendError) {
reject(appendError);
}
});
// Wait for script to execute (with timeout)
Promise.race([
scriptPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Script execution timeout')), 1000))
]).catch(error => {
console.warn(`[SCRIPT ${index + 1}] Script execution issue, trying fallback:`, error);
// Fallback: try multiple execution methods
let executed = false;
// Method 1: Function constructor with window in scope
try {
const func = new Function('window', scriptContent);
func(window);
console.log(`[SCRIPT ${index + 1}] Executed via Function constructor (fallback method 1)`);
executed = true;
} catch (funcError) {
console.warn(`[SCRIPT ${index + 1}] Function constructor failed:`, funcError);
}
// Method 2: Direct eval in global scope (if method 1 failed)
if (!executed) {
try {
// Use indirect eval to execute in global scope
(0, eval)(scriptContent);
console.log(`[SCRIPT ${index + 1}] Executed via indirect eval (fallback method 2)`);
executed = true;
} catch (evalError) {
console.warn(`[SCRIPT ${index + 1}] Indirect eval failed:`, evalError);
}
}
// Verify functions after fallback
setTimeout(() => {
console.log(`[SCRIPT ${index + 1}] After fallback execution:`, {
configurePlugin: typeof window.configurePlugin,
togglePlugin: typeof window.togglePlugin,
executed: executed
});
}, 10);
if (!executed) {
console.error(`[SCRIPT ${index + 1}] All script execution methods failed`);
console.error(`[SCRIPT ${index + 1}] Script content (first 500 chars):`, scriptContent.substring(0, 500));
}
});
} catch (appendError) {
console.error('Failed to execute script:', appendError);
}
} else if (scriptData.src) {
// For external scripts, use appendChild
const newScript = document.createElement('script');
newScript.src = scriptData.src;
if (scriptData.type) {
newScript.type = scriptData.type;
}
const target = document.head || document.body;
if (target) {
target.appendChild(newScript);
}
console.log('Loaded external script', index + 1, 'of', scriptsToExecute.length);
}
}
} catch (scriptError) {
console.warn('Error executing script', index + 1, ':', scriptError);
}
});
// Wait a moment for scripts to execute, then verify functions are available
// Use multiple checks to ensure scripts have time to execute
let checkCount = 0;
const maxChecks = 10;
const checkInterval = setInterval(() => {
checkCount++;
const funcs = {
configurePlugin: typeof window.configurePlugin,
togglePlugin: typeof window.togglePlugin,
updatePlugin: typeof window.updatePlugin,
uninstallPlugin: typeof window.uninstallPlugin,
initializePlugins: typeof window.initializePlugins,
loadInstalledPlugins: typeof window.loadInstalledPlugins,
renderInstalledPlugins: typeof window.renderInstalledPlugins
};
if (checkCount === 1 || checkCount === maxChecks) {
console.log('Verifying plugin functions after script execution (check', checkCount, '):', funcs);
}
// Stop checking once critical functions are available or max checks reached
if ((funcs.configurePlugin === 'function' && funcs.togglePlugin === 'function') || checkCount >= maxChecks) {
clearInterval(checkInterval);
if (funcs.configurePlugin !== 'function' || funcs.togglePlugin !== 'function') {
console.error('Critical plugin functions not available after', checkCount, 'checks');
}
}
}, 100);
} catch (executionError) {
console.error('Script execution error:', executionError);
}
} else {
console.log('No scripts found in loaded HTML');
}
// Wait for scripts to execute, then load plugins
// CRITICAL: Wait for configurePlugin and togglePlugin to be defined before proceeding
let attempts = 0;
const maxAttempts = 20; // Increased to give more time
const checkInterval = setInterval(() => {
attempts++;
// First, ensure critical functions are available
const criticalFunctionsReady =
window.configurePlugin && typeof window.configurePlugin === 'function' &&
window.togglePlugin && typeof window.togglePlugin === 'function';
if (!criticalFunctionsReady && attempts < maxAttempts) {
if (attempts % 5 === 0) { // Log every 5th attempt
console.log(`Waiting for critical functions... (attempt ${attempts}/${maxAttempts})`, {
configurePlugin: typeof window.configurePlugin,
togglePlugin: typeof window.togglePlugin
});
}
return; // Keep waiting
}
if (!criticalFunctionsReady) {
console.error('Critical functions (configurePlugin, togglePlugin) not available after', maxAttempts, 'attempts');
clearInterval(checkInterval);
return;
}
console.log('Critical functions ready, proceeding with plugin initialization...');
clearInterval(checkInterval);
// Now try to call initializePlugins first (loads both installed and store)
if (window.initializePlugins && typeof window.initializePlugins === 'function') {
console.log('Found initializePlugins, calling it...');
window.initializePlugins();
} else if (window.loadInstalledPlugins && typeof window.loadInstalledPlugins === 'function') {
console.log('Found loadInstalledPlugins, calling it...');
window.loadInstalledPlugins();
// Also try to load plugin store
if (window.searchPluginStore && typeof window.searchPluginStore === 'function') {
setTimeout(() => window.searchPluginStore(true), 500);
}
} else if (window.pluginManager && window.pluginManager.loadInstalledPlugins) {
console.log('Found pluginManager.loadInstalledPlugins, calling it...');
window.pluginManager.loadInstalledPlugins();
// Also try to load plugin store
setTimeout(() => {
const searchFn = window.searchPluginStore ||
(window.pluginManager && window.pluginManager.searchPluginStore);
if (searchFn && typeof searchFn === 'function') {
console.log('Loading plugin store...');
searchFn(true);
} else {
console.warn('searchPluginStore not available');
}
}, 500);
} else if (attempts >= maxAttempts) {
console.log('loadInstalledPlugins not found after', maxAttempts, 'attempts, fetching and rendering directly...');
clearInterval(checkInterval);
// Load both installed plugins and plugin store
Promise.all([
// Use batched API requests for better performance
window.PluginAPI && window.PluginAPI.batch ?
window.PluginAPI.batch([
{endpoint: '/plugins/installed', method: 'GET'},
{endpoint: '/plugins/store/list?fetch_commit_info=true', method: 'GET'}
]).then(([installedRes, storeRes]) => {
return [installedRes, storeRes];
}) :
Promise.all([
getInstalledPluginsSafe(),
fetch('/api/v3/plugins/store/list?fetch_commit_info=true').then(r => r.json())
])
]).then(([installedData, storeData]) => {
console.log('Fetched plugins:', installedData);
console.log('Fetched store:', storeData);
// Render installed plugins
if (installedData.status === 'success') {
const plugins = installedData.data.plugins || [];
const container = document.getElementById('installed-plugins-grid');
const countEl = document.getElementById('installed-count');
// Try renderInstalledPlugins one more time
if (window.renderInstalledPlugins && typeof window.renderInstalledPlugins === 'function') {
console.log('Using renderInstalledPlugins...');
window.renderInstalledPlugins(plugins);
} else if (container) {
console.log('renderInstalledPlugins not available, rendering full plugin cards manually...');
// Render full plugin cards with all information
const escapeHtml = function(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
const escapeAttr = function(text) {
return (text || '').replace(/'/g, "\\'").replace(/"/g, '&quot;');
};
const escapeJs = function(text) {
return JSON.stringify(text || '');
};
const formatCommit = function(commit, branch) {
if (!commit && !branch) return 'Unknown';
const shortCommit = commit ? String(commit).substring(0, 7) : '';
const branchText = branch ? String(branch) : '';
if (branchText && shortCommit) return branchText + ' · ' + shortCommit;
if (branchText) return branchText;
if (shortCommit) return shortCommit;
return 'Unknown';
};
const formatDate = function(dateString) {
if (!dateString) return 'Unknown';
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return 'Unknown';
const now = new Date();
const diffDays = Math.ceil(Math.abs(now - date) / (1000 * 60 * 60 * 24));
if (diffDays < 1) return 'Today';
if (diffDays < 2) return 'Yesterday';
if (diffDays < 7) return diffDays + ' days ago';
if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return weeks + (weeks === 1 ? ' week' : ' weeks') + ' ago';
}
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
} catch (e) {
return 'Unknown';
}
};
container.innerHTML = plugins.map(function(p) {
const name = escapeHtml(p.name || p.id);
const desc = escapeHtml(p.description || 'No description available');
const author = escapeHtml(p.author || 'Unknown');
const category = escapeHtml(p.category || 'General');
const enabled = p.enabled ? 'checked' : '';
const enabledBool = Boolean(p.enabled);
const escapedId = escapeAttr(p.id);
const verified = p.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : '';
const tags = (p.tags && p.tags.length > 0) ? '<div class="flex flex-wrap gap-1.5 mb-4">' + p.tags.map(function(tag) { return '<span class="badge badge-info">' + escapeHtml(tag) + '</span>'; }).join('') + '</div>' : '';
const escapedJsId = escapeJs(p.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">' + name + '</h4>' + verified + '</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>' + author + '</p><p class="flex items-center"><i class="fas fa-code-branch mr-2 text-gray-400 w-4"></i>' + formatCommit(p.last_commit, p.branch) + '</p><p class="flex items-center"><i class="fas fa-calendar mr-2 text-gray-400 w-4"></i>' + formatDate(p.last_updated) + '</p><p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>' + category + '</p></div><p class="text-sm text-gray-700 leading-relaxed">' + desc + '</p></div><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-' + escapedId + '" ' + enabled + ' data-plugin-id="' + escapedId + '" data-action="toggle" onchange=\'if(window.togglePlugin){window.togglePlugin(' + escapedJsId + ', this.checked)}else{console.error("togglePlugin not available")}\'><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"><div class="relative w-14 h-7 ' + (enabledBool ? 'bg-green-500' : 'bg-gray-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><span class="text-sm font-semibold ' + (enabledBool ? 'text-green-700' : 'text-gray-600') + ' flex items-center gap-1.5"><i class="fas ' + (enabledBool ? 'fa-toggle-on text-green-600' : 'fa-toggle-off text-gray-400') + '"></i><span>' + (enabledBool ? 'Enabled' : 'Disabled') + '</span></span></div></label></div></div>' + tags + '<div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-200"><button onclick=\'if(window.configurePlugin){window.configurePlugin(' + escapedJsId + ')}else{console.error("configurePlugin not available")}\' 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="' + escapedId + '" data-action="configure"><i class="fas fa-cog mr-2"></i>Configure</button><button onclick=\'if(window.updatePlugin){window.updatePlugin(' + escapedJsId + ')}else{console.error("updatePlugin not available")}\' 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="' + escapedId + '" data-action="update"><i class="fas fa-sync mr-2"></i>Update</button><button onclick=\'if(window.uninstallPlugin){window.uninstallPlugin(' + escapedJsId + ')}else{console.error("uninstallPlugin not available")}\' 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="' + escapedId + '" data-action="uninstall"><i class="fas fa-trash mr-2"></i>Uninstall</button></div></div>';
}).join('');
if (countEl) countEl.textContent = plugins.length + ' installed';
window.installedPlugins = plugins;
console.log('Rendered', plugins.length, 'plugins with full cards');
} else {
console.error('installed-plugins-grid container not found');
}
}
// Render plugin store
if (storeData.status === 'success') {
const storePlugins = storeData.data.plugins || [];
const storeContainer = document.getElementById('plugin-store-grid');
const storeCountEl = document.getElementById('store-count');
if (storeContainer) {
// Try renderPluginStore if available
if (window.renderPluginStore && typeof window.renderPluginStore === 'function') {
console.log('Using renderPluginStore...');
window.renderPluginStore(storePlugins);
} else {
// Manual rendering fallback
console.log('renderPluginStore not available, rendering manually...');
const escapeHtml = function(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
const escapeJs = function(text) {
return JSON.stringify(text || '');
};
storeContainer.innerHTML = storePlugins.map(function(p) {
const name = escapeHtml(p.name || p.id);
const desc = escapeHtml(p.description || 'No description available');
const author = escapeHtml(p.author || 'Unknown');
const category = escapeHtml(p.category || 'General');
const stars = p.stars || 0;
const verified = p.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : '';
const escapedJsId = escapeJs(p.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">' + name + '</h4>' + verified + '</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>' + author + '</p><p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>' + category + '</p>' + (stars > 0 ? '<p class="flex items-center"><i class="fas fa-star mr-2 text-gray-400 w-4"></i>' + stars + ' stars</p>' : '') + '</div><p class="text-sm text-gray-700 leading-relaxed">' + desc + '</p></div></div><div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-200"><button onclick=\'if(window.installPlugin){window.installPlugin(' + escapedJsId + ')}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></div></div>';
}).join('');
}
if (storeCountEl) {
storeCountEl.innerHTML = storePlugins.length + ' available';
}
console.log('Rendered', storePlugins.length, 'store plugins');
} else {
console.error('plugin-store-grid container not found');
}
} else {
console.error('Failed to load plugin store:', storeData.message);
const storeCountEl = document.getElementById('store-count');
if (storeCountEl) {
storeCountEl.innerHTML = '<span class="text-red-600">Error loading store</span>';
}
}
})
.catch(err => {
console.error('Error fetching plugins/store:', err);
// Still try to render installed plugins if store fails
});
}
}, 100); // Reduced from 200ms to 100ms for faster retries
})
.catch(err => console.error('Error loading plugins:', err));
}
};
})();
</script>
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script>
// Helper function to get installed plugins with fallback
// Must be defined before app() function that uses it
async function getInstalledPluginsSafe() {
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
try {
const plugins = await window.PluginAPI.getInstalledPlugins();
// Ensure plugins is always an array
const pluginsArray = Array.isArray(plugins) ? plugins : [];
return { status: 'success', data: { plugins: pluginsArray } };
} catch (error) {
console.error('Error using PluginAPI.getInstalledPlugins, falling back to direct fetch:', error);
// Fall through to direct fetch
}
}
// Fallback to direct fetch if PluginAPI not loaded
const response = await fetch('/api/v3/plugins/installed');
return await response.json();
}
// Global event listener for pluginsUpdated - works even if Alpine isn't ready yet
// This ensures tabs update when plugins_manager.js loads plugins
document.addEventListener('pluginsUpdated', function(event) {
console.log('[GLOBAL] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
const plugins = event.detail?.plugins || [];
// Update window.installedPlugins
window.installedPlugins = plugins;
// Try to update Alpine component if it exists (only if using full implementation)
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
const appComponent = appElement._x_dataStack[0];
appComponent.installedPlugins = plugins;
// Only call updatePluginTabs if it's the full implementation (has _doUpdatePluginTabs)
if (typeof appComponent.updatePluginTabs === 'function' &&
appComponent.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
console.log('[GLOBAL] Updating plugin tabs via Alpine component (full implementation)');
appComponent.updatePluginTabs();
return; // Full implementation handles it, don't do direct update
}
}
}
// Only do direct DOM update if full implementation isn't available yet
const pluginTabsRow = document.getElementById('plugin-tabs-row');
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
if (pluginTabsRow && pluginTabsNav && plugins.length > 0) {
// Clear existing plugin tabs (except Plugin Manager)
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
existingTabs.forEach(tab => tab.remove());
// Add tabs for each installed plugin
plugins.forEach(plugin => {
const tabButton = document.createElement('button');
tabButton.type = 'button';
tabButton.setAttribute('data-plugin-id', plugin.id);
tabButton.className = `plugin-tab nav-tab`;
tabButton.onclick = function() {
// Try to set activeTab via Alpine if available
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
appElement._x_dataStack[0].activeTab = plugin.id;
// Only call updatePluginTabStates if it exists
if (typeof appElement._x_dataStack[0].updatePluginTabStates === 'function') {
appElement._x_dataStack[0].updatePluginTabStates();
}
}
}
};
tabButton.innerHTML = `<i class="fas fa-puzzle-piece"></i>${(plugin.name || plugin.id).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}`;
pluginTabsNav.appendChild(tabButton);
});
console.log('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
}
});
// Define app() function early so Alpine can find it when it initializes
// This is a complete implementation that will work immediately
(function() {
const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.');
// Create the app function - will be enhanced by full implementation later
window.app = function() {
return {
activeTab: isAPMode ? 'wifi' : 'overview',
installedPlugins: [],
init() {
// Try to enhance immediately with full implementation
const tryEnhance = () => {
if (typeof window.app === 'function') {
const fullApp = window.app();
// Check if this is the full implementation (has updatePluginTabs with proper implementation)
if (fullApp && typeof fullApp.updatePluginTabs === 'function' && fullApp.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
// Full implementation is available, copy all methods
// But preserve _initialized flag to prevent double init
const wasInitialized = this._initialized;
Object.assign(this, fullApp);
// Restore _initialized flag if it was set
if (wasInitialized) {
this._initialized = wasInitialized;
}
// Only call init if not already initialized
if (typeof this.init === 'function' && !this._initialized) {
this.init();
}
return true;
}
}
return false;
};
// Set up event listener for pluginsUpdated in stub (only if not already enhanced)
// The full implementation will have its own listener, so we only need this for the stub
if (!this._pluginsUpdatedListenerSet) {
const handlePluginsUpdated = (event) => {
console.log('[STUB] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
const plugins = event.detail?.plugins || [];
// Only update if we're still in stub mode (not enhanced yet)
if (typeof this.updatePluginTabs === 'function' && !this.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
this.installedPlugins = plugins;
if (this.$nextTick && typeof this.$nextTick === 'function') {
this.$nextTick(() => {
this.updatePluginTabs();
});
} else {
setTimeout(() => {
this.updatePluginTabs();
}, 100);
}
}
};
document.addEventListener('pluginsUpdated', handlePluginsUpdated);
this._pluginsUpdatedListenerSet = true;
console.log('[STUB] init: Set up pluginsUpdated event listener');
}
// Try immediately - if full implementation is already loaded, use it right away
if (!tryEnhance()) {
// Full implementation not ready yet, load plugins directly while waiting
this.loadInstalledPluginsDirectly();
// Try again very soon to enhance with full implementation
setTimeout(tryEnhance, 10);
// Also set up a periodic check to update tabs if plugins get loaded by plugins_manager.js
let retryCount = 0;
const maxRetries = 20; // Check for 2 seconds (20 * 100ms)
const checkAndUpdateTabs = () => {
if (retryCount >= maxRetries) {
// Fallback: if plugins_manager.js hasn't loaded after 2 seconds, fetch directly
if (!window.installedPlugins || window.installedPlugins.length === 0) {
console.log('[STUB] checkAndUpdateTabs: Fallback - fetching plugins directly after timeout');
this.loadInstalledPluginsDirectly();
}
return;
}
// Check if plugins are available (either from window or component)
const plugins = window.installedPlugins || this.installedPlugins || [];
if (plugins.length > 0) {
console.log('[STUB] checkAndUpdateTabs: Found', plugins.length, 'plugins, updating tabs');
this.installedPlugins = plugins;
if (typeof this.updatePluginTabs === 'function') {
this.updatePluginTabs();
}
} else {
retryCount++;
setTimeout(checkAndUpdateTabs, 100);
}
};
// Start checking after a short delay
setTimeout(checkAndUpdateTabs, 200);
} else {
// Full implementation loaded, but still set up fallback timer
setTimeout(() => {
if (!window.installedPlugins || window.installedPlugins.length === 0) {
console.log('[STUB] init: Fallback timer - fetching plugins directly');
this.loadInstalledPluginsDirectly();
}
}, 2000);
}
},
// Direct plugin loading for stub (before full implementation loads)
async loadInstalledPluginsDirectly() {
try {
console.log('[STUB] loadInstalledPluginsDirectly: Starting...');
// Ensure DOM is ready
const ensureDOMReady = () => {
return new Promise((resolve) => {
if (document.readyState === 'complete' || document.readyState === 'interactive') {
// Use requestAnimationFrame to ensure DOM is painted
requestAnimationFrame(() => {
setTimeout(resolve, 50); // Small delay to ensure rendering
});
} else {
document.addEventListener('DOMContentLoaded', () => {
requestAnimationFrame(() => {
setTimeout(resolve, 50);
});
});
}
});
};
await ensureDOMReady();
const data = await getInstalledPluginsSafe();
if (data.status === 'success') {
const plugins = data.data.plugins || [];
console.log('[STUB] loadInstalledPluginsDirectly: Loaded', plugins.length, 'plugins');
// Update both component and window
this.installedPlugins = plugins;
window.installedPlugins = plugins;
// Dispatch event so global listener can update tabs
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
detail: { plugins: plugins }
}));
console.log('[STUB] loadInstalledPluginsDirectly: Dispatched pluginsUpdated event');
// Update tabs if we have the method - use $nextTick if available
if (typeof this.updatePluginTabs === 'function') {
if (this.$nextTick && typeof this.$nextTick === 'function') {
this.$nextTick(() => {
this.updatePluginTabs();
});
} else {
// Fallback: wait a bit for DOM
setTimeout(() => {
this.updatePluginTabs();
}, 100);
}
}
} else {
console.warn('[STUB] loadInstalledPluginsDirectly: Failed to load plugins:', data.message);
}
} catch (error) {
console.error('[STUB] loadInstalledPluginsDirectly: Error loading plugins:', error);
}
},
// Stub methods that will be replaced by full implementation
loadTabContent: function(tab) {},
loadInstalledPlugins: async function() {
// Try to use global function if available, otherwise use direct loading
if (typeof window.loadInstalledPlugins === 'function') {
await window.loadInstalledPlugins();
// Update tabs after loading (window.installedPlugins should be set by the global function)
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
this.installedPlugins = window.installedPlugins;
this.updatePluginTabs();
}
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
await window.pluginManager.loadInstalledPlugins();
// Update tabs after loading
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
this.installedPlugins = window.installedPlugins;
this.updatePluginTabs();
}
} else {
// Fallback to direct loading (which already calls updatePluginTabs)
await this.loadInstalledPluginsDirectly();
}
},
updatePluginTabs: function() {
// Basic implementation for stub - will be replaced by full implementation
// Debounce to prevent multiple rapid calls
if (this._updatePluginTabsTimeout) {
clearTimeout(this._updatePluginTabsTimeout);
}
this._updatePluginTabsTimeout = setTimeout(() => {
console.log('[STUB] updatePluginTabs: Executing with', this.installedPlugins?.length || 0, 'plugins');
const pluginTabsRow = document.getElementById('plugin-tabs-row');
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
if (!pluginTabsRow || !pluginTabsNav) {
console.warn('[STUB] updatePluginTabs: Plugin tabs container not found');
return;
}
if (!this.installedPlugins || this.installedPlugins.length === 0) {
console.log('[STUB] updatePluginTabs: No plugins to display');
return;
}
// Check if tabs are already correct by comparing plugin IDs
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
const existingIds = Array.from(existingTabs).map(tab => tab.getAttribute('data-plugin-id')).sort().join(',');
const currentIds = this.installedPlugins.map(p => p.id).sort().join(',');
if (existingIds === currentIds && existingTabs.length === this.installedPlugins.length) {
console.log('[STUB] updatePluginTabs: Tabs already match, skipping update');
return;
}
// Clear existing plugin tabs (except Plugin Manager)
existingTabs.forEach(tab => tab.remove());
console.log('[STUB] updatePluginTabs: Cleared', existingTabs.length, 'existing tabs');
// Add tabs for each installed plugin
this.installedPlugins.forEach(plugin => {
const tabButton = document.createElement('button');
tabButton.type = 'button';
tabButton.setAttribute('data-plugin-id', plugin.id);
tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`;
tabButton.onclick = () => {
this.activeTab = plugin.id;
if (typeof this.updatePluginTabStates === 'function') {
this.updatePluginTabStates();
}
};
const div = document.createElement('div');
div.textContent = plugin.name || plugin.id;
tabButton.innerHTML = `<i class="fas fa-puzzle-piece"></i>${div.innerHTML}`;
pluginTabsNav.appendChild(tabButton);
});
console.log('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
}, 100);
},
showNotification: function(message, type) {},
escapeHtml: function(text) { return String(text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
};
};
})();
</script>
<!-- Alpine.js for reactive components -->
<!-- Use local file when in AP mode (192.168.4.x) to avoid CDN dependency -->
<script>
(function() {
// Prevent Alpine from auto-initializing by setting deferLoadingAlpine before it loads
window.deferLoadingAlpine = function(callback) {
// Wait for DOM to be ready
function waitForReady() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', waitForReady);
return;
}
// app() is already defined in head, so we can initialize Alpine
if (callback && typeof callback === 'function') {
callback();
} else if (window.Alpine && typeof window.Alpine.start === 'function') {
// If callback not provided but Alpine is available, start it
try {
window.Alpine.start();
} catch (e) {
// Alpine may already be initialized, ignore
console.warn('Alpine start error (may already be initialized):', e);
}
}
}
waitForReady();
};
// Detect AP mode by IP address
const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.');
const alpineSrc = isAPMode ? '/static/v3/js/alpinejs.min.js' : 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js';
const alpineFallback = isAPMode ? 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js' : '/static/v3/js/alpinejs.min.js';
const script = document.createElement('script');
script.defer = true;
script.src = alpineSrc;
script.onerror = function() {
if (alpineSrc !== alpineFallback) {
const fallback = document.createElement('script');
fallback.defer = true;
fallback.src = alpineFallback;
document.head.appendChild(fallback);
}
};
document.head.appendChild(script);
})();
</script>
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
<link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css"></noscript>
<link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/monokai.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/monokai.min.css"></noscript>
<!-- CodeMirror scripts loaded on demand when JSON editor is opened -->
<script>
// Lazy load CodeMirror when needed
window.loadCodeMirror = function() {
if (window.CodeMirror) return Promise.resolve();
return new Promise((resolve, reject) => {
const scripts = [
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/javascript/javascript.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/json/json.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/edit/closebrackets.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/edit/matchbrackets.min.js'
];
let loaded = 0;
scripts.forEach((src, index) => {
const script = document.createElement('script');
script.src = src;
script.defer = true;
script.onload = () => {
loaded++;
if (loaded === scripts.length) resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
});
};
</script>
<!-- Font Awesome icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- Custom v3 styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
</head>
<body x-data="app()" class="bg-gray-50 min-h-screen">
<!-- Header -->
<header class="bg-white shadow-md border-b border-gray-200">
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16" style="max-width: 100%;">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<h1 class="text-xl font-bold text-gray-900">
<i class="fas fa-tv text-blue-600 mr-2"></i>
LED Matrix Control - v3
</h1>
</div>
<!-- Connection status -->
<div class="flex items-center space-x-4">
<div id="connection-status" class="flex items-center space-x-2 text-sm">
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
<span class="text-gray-600">Disconnected</span>
</div>
<!-- System stats (populated via SSE) -->
<div class="hidden lg:flex items-center space-x-4 text-sm text-gray-600 xl:space-x-6 2xl:space-x-8">
<span id="cpu-stat" class="flex items-center space-x-1">
<i class="fas fa-microchip"></i>
<span>--%</span>
</span>
<span id="memory-stat" class="flex items-center space-x-1">
<i class="fas fa-memory"></i>
<span>--%</span>
</span>
<span id="temp-stat" class="flex items-center space-x-1">
<i class="fas fa-thermometer-half"></i>
<span>--°C</span>
</span>
</div>
</div>
</div>
</div>
</header>
<!-- Main content -->
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
<!-- Navigation tabs -->
<nav class="mb-8">
<!-- First row - System tabs -->
<div class="border-b border-gray-200 mb-4">
<nav class="-mb-px flex space-x-4 lg:space-x-6 xl:space-x-8 overflow-x-auto">
<button @click="activeTab = 'overview'"
:class="activeTab === 'overview' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-tachometer-alt"></i>Overview
</button>
<button @click="activeTab = 'general'"
:class="activeTab === 'general' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-sliders-h"></i>General
</button>
<button @click="activeTab = 'wifi'"
:class="activeTab === 'wifi' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-wifi"></i>WiFi
</button>
<button @click="activeTab = 'schedule'"
:class="activeTab === 'schedule' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-clock"></i>Schedule
</button>
<button @click="activeTab = 'display'"
:class="activeTab === 'display' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-desktop"></i>Display
</button>
<button @click="activeTab = 'config-editor'"
:class="activeTab === 'config-editor' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-file-code"></i>Config Editor
</button>
<button @click="activeTab = 'fonts'"
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-font"></i>Fonts
</button>
<button @click="activeTab = 'logs'"
:class="activeTab === 'logs' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-file-alt"></i>Logs
</button>
<button @click="activeTab = 'cache'"
:class="activeTab === 'cache' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-database"></i>Cache
</button>
<button @click="activeTab = 'operation-history'"
:class="activeTab === 'operation-history' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-history"></i>Operation History
</button>
</nav>
</div>
<!-- Second row - Plugin tabs (populated dynamically) -->
<div id="plugin-tabs-row" class="border-b border-gray-200">
<nav class="-mb-px flex space-x-4 lg:space-x-6 xl:space-x-8 overflow-x-auto">
<button @click="activeTab = 'plugins'; $nextTick(() => { if (typeof htmx !== 'undefined' && !document.getElementById('plugins-content').hasAttribute('data-loaded')) { htmx.trigger('#plugins-content', 'load'); } })"
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-plug"></i>Plugin Manager
</button>
<!-- Installed plugin tabs will be added here dynamically -->
</nav>
</div>
</nav>
<!-- Tab content -->
<div id="tab-content" class="space-y-6">
<!-- Overview tab -->
<div x-show="activeTab === 'overview'" x-transition>
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="revealed" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-32 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
<script>
// Fallback: Load overview content directly if HTMX fails
function loadOverviewDirect() {
const overviewContent = document.getElementById('overview-content');
if (overviewContent && !overviewContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/overview')
.then(response => response.text())
.then(html => {
overviewContent.innerHTML = html;
overviewContent.setAttribute('data-loaded', 'true');
// Re-initialize Alpine.js for the new content
if (window.Alpine) {
window.Alpine.initTree(overviewContent);
}
})
.catch(err => {
console.error('Failed to load overview content:', err);
overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load overview page. Please refresh the page.</p></div>';
});
}
}
// Listen for HTMX load failure
window.addEventListener('htmx-load-failed', function() {
console.warn('HTMX failed to load, setting up direct content loading fallbacks');
// Try to load content directly after a delay
setTimeout(() => {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement.__x) {
const activeTab = appElement.__x.$data.activeTab || 'overview';
if (activeTab === 'overview') {
loadOverviewDirect();
}
}
}, 2000);
});
// Also try direct load if HTMX doesn't load within 5 seconds
setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for content');
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement.__x) {
const activeTab = appElement.__x.$data.activeTab || 'overview';
if (activeTab === 'overview') {
loadOverviewDirect();
}
}
}
}, 5000);
</script>
<!-- General tab -->
<div x-show="activeTab === 'general'" x-transition>
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="revealed" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="space-y-4">
<div class="h-10 bg-gray-200 rounded"></div>
<div class="h-10 bg-gray-200 rounded"></div>
<div class="h-10 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- WiFi tab -->
<div x-show="activeTab === 'wifi'" x-transition>
<div id="wifi-content"
hx-get="/v3/partials/wifi"
hx-trigger="revealed"
hx-swap="innerHTML"
hx-on::htmx:response-error="loadWifiDirect()">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="space-y-4">
<div class="h-10 bg-gray-200 rounded"></div>
<div class="h-10 bg-gray-200 rounded"></div>
<div class="h-10 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<script>
// Fallback: Load WiFi content directly if HTMX fails
function loadWifiDirect() {
const wifiContent = document.getElementById('wifi-content');
if (wifiContent && !wifiContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/wifi')
.then(response => response.text())
.then(html => {
wifiContent.innerHTML = html;
wifiContent.setAttribute('data-loaded', 'true');
// Re-initialize Alpine.js for the new content
if (window.Alpine) {
window.Alpine.initTree(wifiContent);
}
})
.catch(err => {
console.error('Failed to load WiFi content:', err);
wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load WiFi setup page. Please refresh the page.</p></div>';
});
}
}
// Also try direct load if HTMX doesn't load within 3 seconds (AP mode detection)
setTimeout(() => {
const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.');
if (isAPMode && typeof htmx === 'undefined') {
console.warn('HTMX not loaded, using direct fetch for WiFi content');
loadWifiDirect();
}
}, 3000);
</script>
<!-- Schedule tab -->
<div x-show="activeTab === 'schedule'" x-transition>
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="revealed" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="space-y-4">
<div class="h-10 bg-gray-200 rounded"></div>
<div class="h-10 bg-gray-200 rounded"></div>
<div class="h-10 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Display tab -->
<div x-show="activeTab === 'display'" x-transition>
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="revealed" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="h-32 bg-gray-200 rounded"></div>
<div class="h-32 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Config Editor tab -->
<div x-show="activeTab === 'config-editor'" x-transition>
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="revealed" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="space-y-4">
<div class="h-64 bg-gray-200 rounded"></div>
<div class="h-64 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Plugins tab -->
<div x-show="activeTab === 'plugins'"
x-transition
x-effect="if (activeTab === 'plugins') { window.loadPluginsTab && window.loadPluginsTab(); }">
<div id="plugins-content">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="h-24 bg-gray-200 rounded"></div>
<div class="h-24 bg-gray-200 rounded"></div>
<div class="h-24 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Fonts tab -->
<div x-show="activeTab === 'fonts'" x-transition>
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="revealed" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="space-y-4">
<div class="h-20 bg-gray-200 rounded"></div>
<div class="h-20 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Logs tab -->
<div x-show="activeTab === 'logs'" x-transition>
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="revealed" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-96 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
<!-- Cache tab -->
<div x-show="activeTab === 'cache'" x-transition>
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="revealed" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-64 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
<!-- Operation History tab -->
<div x-show="activeTab === 'operation-history'" x-transition>
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="revealed" hx-swap="innerHTML">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-64 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</div>
<!-- Dynamic Plugin Tabs - HTMX Lazy Loading -->
<!--
Architecture: Server-side rendered plugin configuration forms
- Each plugin tab loads its config via HTMX when first viewed
- Forms are generated server-side using Jinja2 macros
- Reduces client-side complexity and improves performance
- Uses x-init to trigger HTMX after Alpine renders the element
-->
<template x-for="plugin in installedPlugins" :key="plugin.id">
<div x-show="activeTab === plugin.id" x-transition>
<!-- Only load content when tab is active (lazy loading) -->
<template x-if="activeTab === plugin.id">
<div class="bg-white rounded-lg shadow p-6 plugin-config-tab"
:id="'plugin-config-' + plugin.id"
x-init="$nextTick(() => {
if (window.htmx && !$el.dataset.htmxLoaded) {
$el.dataset.htmxLoaded = 'true';
htmx.ajax('GET', '/v3/partials/plugin-config/' + plugin.id, {target: $el, swap: 'innerHTML'});
}
})">
<!-- Loading skeleton shown until HTMX loads server-rendered content -->
<div class="animate-pulse space-y-6">
<div class="border-b border-gray-200 pb-4">
<div class="flex items-center justify-between">
<div class="space-y-2">
<div class="h-6 bg-gray-200 rounded w-48"></div>
<div class="h-4 bg-gray-200 rounded w-96"></div>
</div>
<div class="h-6 bg-gray-200 rounded w-24"></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-gray-50 rounded-lg p-4 space-y-4">
<div class="h-5 bg-gray-200 rounded w-32"></div>
<div class="space-y-3">
<div class="h-4 bg-gray-200 rounded w-full"></div>
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4 space-y-4">
<div class="h-5 bg-gray-200 rounded w-32"></div>
<div class="space-y-3">
<div class="h-4 bg-gray-200 rounded w-full"></div>
<div class="h-10 bg-gray-200 rounded w-full"></div>
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-10 bg-gray-200 rounded w-full"></div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
</main>
<!-- Notifications -->
<div id="notifications" class="fixed top-4 right-4 z-50 space-y-2"></div>
<!-- SSE connection for real-time updates -->
<script>
// Connect to SSE streams
const statsSource = new EventSource('/api/v3/stream/stats');
const displaySource = new EventSource('/api/v3/stream/display');
statsSource.onmessage = function(event) {
const data = JSON.parse(event.data);
updateSystemStats(data);
};
displaySource.onmessage = function(event) {
const data = JSON.parse(event.data);
updateDisplayPreview(data);
};
// Connection status
statsSource.addEventListener('open', function() {
document.getElementById('connection-status').innerHTML = `
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-gray-600">Connected</span>
`;
});
statsSource.addEventListener('error', function() {
document.getElementById('connection-status').innerHTML = `
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
<span class="text-gray-600">Disconnected</span>
`;
});
function updateSystemStats(data) {
// Update CPU in header
const cpuEl = document.getElementById('cpu-stat');
if (cpuEl && data.cpu_percent !== undefined) {
const spans = cpuEl.querySelectorAll('span');
if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_percent + '%';
}
// Update Memory in header
const memEl = document.getElementById('memory-stat');
if (memEl && data.memory_used_percent !== undefined) {
const spans = memEl.querySelectorAll('span');
if (spans.length > 0) spans[spans.length - 1].textContent = data.memory_used_percent + '%';
}
// Update Temperature in header
const tempEl = document.getElementById('temp-stat');
if (tempEl && data.cpu_temp !== undefined) {
const spans = tempEl.querySelectorAll('span');
if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_temp + '°C';
}
// Update Overview tab stats (if visible)
const cpuUsageEl = document.getElementById('cpu-usage');
if (cpuUsageEl && data.cpu_percent !== undefined) {
cpuUsageEl.textContent = data.cpu_percent + '%';
}
const memUsageEl = document.getElementById('memory-usage');
if (memUsageEl && data.memory_used_percent !== undefined) {
memUsageEl.textContent = data.memory_used_percent + '%';
}
const cpuTempEl = document.getElementById('cpu-temp');
if (cpuTempEl && data.cpu_temp !== undefined) {
cpuTempEl.textContent = data.cpu_temp + '°C';
}
const displayStatusEl = document.getElementById('display-status');
if (displayStatusEl) {
displayStatusEl.textContent = data.service_active ? 'Active' : 'Inactive';
displayStatusEl.className = data.service_active ?
'text-lg font-medium text-green-600' :
'text-lg font-medium text-red-600';
}
}
window.__onDemandStore = window.__onDemandStore || {
loading: true,
state: {},
service: {},
error: null,
lastUpdated: null
};
document.addEventListener('alpine:init', () => {
// On-Demand state store
if (window.Alpine && !window.Alpine.store('onDemand')) {
window.Alpine.store('onDemand', {
loading: window.__onDemandStore.loading,
state: window.__onDemandStore.state,
service: window.__onDemandStore.service,
error: window.__onDemandStore.error,
lastUpdated: window.__onDemandStore.lastUpdated
});
}
if (window.Alpine) {
window.__onDemandStore = window.Alpine.store('onDemand');
}
// Plugin state store - centralized state management for plugins
// Used primarily by HTMX-loaded plugin config partials
if (window.Alpine && !window.Alpine.store('plugins')) {
window.Alpine.store('plugins', {
// Track which plugin configs have been loaded
loadedConfigs: {},
// Mark a plugin config as loaded
markLoaded(pluginId) {
this.loadedConfigs[pluginId] = true;
},
// Check if a plugin config is loaded
isLoaded(pluginId) {
return !!this.loadedConfigs[pluginId];
},
// Refresh a plugin config tab via HTMX
refreshConfig(pluginId) {
const container = document.querySelector(`#plugin-config-${pluginId}`);
if (container && window.htmx) {
htmx.ajax('GET', `/v3/partials/plugin-config/${pluginId}`, {
target: container,
swap: 'innerHTML'
});
}
}
});
}
});
// ===== DEPRECATED: pluginConfigData =====
// This function is no longer used - plugin configuration forms are now
// rendered server-side and loaded via HTMX. Kept for backwards compatibility.
// See: /v3/partials/plugin-config/<plugin_id> for the new implementation.
function pluginConfigData(plugin) {
if (!plugin) {
console.error('pluginConfigData called with undefined plugin');
return {
plugin: { id: 'unknown', name: 'Unknown Plugin', enabled: false },
loading: false,
config: {},
schema: {},
webUiActions: [],
onDemandRefreshing: false,
onDemandStopping: false
};
}
return {
plugin: plugin,
loading: true,
config: {},
schema: {},
webUiActions: [],
onDemandRefreshing: false,
onDemandStopping: false,
get onDemandStore() {
if (window.Alpine && typeof Alpine.store === 'function' && Alpine.store('onDemand')) {
return Alpine.store('onDemand');
}
return window.__onDemandStore || { loading: true, state: {}, service: {}, error: null, lastUpdated: null };
},
get isOnDemandLoading() {
const store = this.onDemandStore || {};
return !!store.loading;
},
get onDemandState() {
const store = this.onDemandStore || {};
return store.state || {};
},
get onDemandService() {
const store = this.onDemandStore || {};
return store.service || {};
},
get onDemandError() {
const store = this.onDemandStore || {};
return store.error || null;
},
get onDemandActive() {
const state = this.onDemandState;
return !!(state.active && state.plugin_id === plugin.id);
},
resolvePluginName() {
return plugin.name || plugin.id;
},
resolvePluginDisplayName(id) {
if (!id) {
return 'Another plugin';
}
const list = window.installedPlugins || [];
const match = Array.isArray(list) ? list.find(p => p.id === id) : null;
return match ? (match.name || match.id) : id;
},
formatDuration(value) {
if (value === undefined || value === null) {
return '';
}
const total = Number(value);
if (Number.isNaN(total)) {
return '';
}
const seconds = Math.max(0, Math.round(total));
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes > 0) {
return `${minutes}m${remainingSeconds > 0 ? ` ${remainingSeconds}s` : ''}`;
}
return `${remainingSeconds}s`;
},
get onDemandStatusText() {
if (this.isOnDemandLoading) {
return 'Loading on-demand status...';
}
if (this.onDemandError) {
return `On-demand error: ${this.onDemandError}`;
}
const state = this.onDemandState;
if (state.active) {
const activeName = this.resolvePluginDisplayName(state.plugin_id);
if (state.plugin_id !== plugin.id) {
return `${activeName} is running on-demand.`;
}
const modeLabel = state.mode ? ` (${state.mode})` : '';
const remaining = this.formatDuration(state.remaining);
const duration = this.formatDuration(state.duration);
let message = `${this.resolvePluginName()}${modeLabel} is running on-demand`;
if (remaining) {
message += `${remaining} remaining`;
} else if (duration) {
message += ` — duration ${duration}`;
} else {
message += ' — until stopped';
}
return message;
}
const lastEvent = state.last_event ? state.last_event.replace(/-/g, ' ') : null;
if (lastEvent && lastEvent !== 'cleared') {
return `No on-demand session active (last event: ${lastEvent})`;
}
return 'No on-demand session active.';
},
get onDemandStatusClass() {
if (this.isOnDemandLoading) return 'text-blue-600';
if (this.onDemandError) return 'text-red-600';
if (this.onDemandActive) return 'text-green-600';
return 'text-blue-600';
},
get onDemandServiceText() {
if (this.isOnDemandLoading) {
return 'Checking display service status...';
}
if (this.onDemandError) {
return 'Display service status unavailable.';
}
if (this.onDemandService.active) {
return 'Display service is running.';
}
const serviceError = this.onDemandService.stderr || this.onDemandService.error;
return serviceError ? `Display service inactive (${serviceError})` : 'Display service is not running.';
},
get onDemandServiceClass() {
if (this.isOnDemandLoading) return 'text-blue-500';
if (this.onDemandError) return 'text-red-500';
return this.onDemandService.active ? 'text-blue-500' : 'text-red-500';
},
get onDemandLastUpdated() {
const store = this.onDemandStore || {};
if (!store.lastUpdated) {
return '';
}
const deltaSeconds = Math.round((Date.now() - store.lastUpdated) / 1000);
if (deltaSeconds < 5) return 'Just now';
if (deltaSeconds < 60) return `${deltaSeconds}s ago`;
const minutes = Math.round(deltaSeconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
return `${hours}h ago`;
},
get canStopOnDemand() {
if (this.isOnDemandLoading) return false;
if (this.onDemandError) return true;
return this.onDemandActive;
},
get disableRunButton() {
return !plugin.enabled;
},
get showEnableHint() {
return !plugin.enabled;
},
notify(message, type = 'info') {
if (typeof showNotification === 'function') {
showNotification(message, type);
}
},
refreshOnDemandStatus() {
if (typeof window.loadOnDemandStatus !== 'function') {
this.notify('On-demand status controls unavailable. Refresh the Plugin Manager tab.', 'error');
return;
}
this.onDemandRefreshing = true;
Promise.resolve(window.loadOnDemandStatus(true))
.finally(() => {
this.onDemandRefreshing = false;
});
},
runOnDemand() {
// Note: On-demand can work with disabled plugins - the backend will temporarily enable them
if (typeof window.openOnDemandModal === 'function') {
window.openOnDemandModal(plugin.id);
} else {
this.notify('On-demand modal unavailable. Refresh the Plugin Manager tab.', 'error');
}
},
stopOnDemandWithEvent(stopService = false) {
if (typeof window.requestOnDemandStop !== 'function') {
this.notify('Unable to stop on-demand mode. Refresh the Plugin Manager tab.', 'error');
return;
}
this.onDemandStopping = true;
Promise.resolve(window.requestOnDemandStop({ stopService }))
.finally(() => {
this.onDemandStopping = false;
});
},
async loadPluginConfig(pluginId) {
// Use PluginConfigHelpers to load config directly into this component
if (window.PluginConfigHelpers) {
await window.PluginConfigHelpers.loadPluginConfig(pluginId, this);
this.loading = false;
return;
}
console.error('loadPluginConfig not available');
this.loading = false;
}
// Note: generateConfigForm and savePluginConfig are now called via window.PluginConfigHelpers
// to avoid delegation recursion and ensure proper access to app component.
// See template usage:
// x-html="window.PluginConfigHelpers.generateConfigForm(...)" and
// x-on:submit.prevent="window.PluginConfigHelpers.savePluginConfig(...)"
};
}
// Alpine.js app function - full implementation
function app() {
// If Alpine is already initialized, get the current component and enhance it
let baseComponent = {};
if (window.Alpine) {
const appElement = document.querySelector('[x-data]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
baseComponent = appElement._x_dataStack[0];
}
}
const fullImplementation = {
activeTab: (function() {
// Auto-open WiFi tab when in AP mode (192.168.4.x)
const isAPMode = window.location.hostname === '192.168.4.1' ||
window.location.hostname.startsWith('192.168.4.');
return isAPMode ? 'wifi' : 'overview';
})(),
installedPlugins: [],
init() {
// Prevent multiple initializations
if (this._initialized) {
return;
}
this._initialized = true;
// Load plugins on page load so tabs are available on any page, regardless of active tab
// First check if plugins are already in window.installedPlugins (from plugins_manager.js)
if (typeof window.installedPlugins !== 'undefined' && Array.isArray(window.installedPlugins) && window.installedPlugins.length > 0) {
this.installedPlugins = window.installedPlugins;
console.log('Initialized installedPlugins from global:', this.installedPlugins.length);
// Ensure tabs are updated immediately
this.$nextTick(() => {
this.updatePluginTabs();
});
} else if (!this.installedPlugins || this.installedPlugins.length === 0) {
// Load plugins asynchronously, but ensure tabs update when done
this.loadInstalledPlugins().then(() => {
// Ensure tabs are updated after loading
this.$nextTick(() => {
this.updatePluginTabs();
});
}).catch(err => {
console.error('Error loading plugins in init:', err);
// Still try to update tabs in case some plugins are available
this.$nextTick(() => {
this.updatePluginTabs();
});
});
} else {
// Plugins already loaded, just update tabs
this.$nextTick(() => {
this.updatePluginTabs();
});
}
// Ensure content loads for the active tab
this.$watch('activeTab', (newTab, oldTab) => {
// Update plugin tab states when activeTab changes
if (typeof this.updatePluginTabStates === 'function') {
this.updatePluginTabStates();
}
// Trigger content load when tab changes
this.$nextTick(() => {
this.loadTabContent(newTab);
});
});
// Load initial tab content
this.$nextTick(() => {
this.loadTabContent(this.activeTab);
});
// Listen for plugin updates from pluginManager
document.addEventListener('pluginsUpdated', (event) => {
console.log('Received pluginsUpdated event:', event.detail.plugins.length, 'plugins');
this.installedPlugins = event.detail.plugins;
this.updatePluginTabs();
});
// Also listen for direct window.installedPlugins changes
// Store the actual value in a private property to avoid infinite loops
let _installedPluginsValue = this.installedPlugins || [];
// Only define the property if it doesn't already exist or if it's configurable
const existingDescriptor = Object.getOwnPropertyDescriptor(window, 'installedPlugins');
if (!existingDescriptor || existingDescriptor.configurable) {
// Delete existing property if it exists and is configurable
if (existingDescriptor) {
delete window.installedPlugins;
}
Object.defineProperty(window, 'installedPlugins', {
set: (value) => {
const newPlugins = value || [];
const oldIds = (_installedPluginsValue || []).map(p => p.id).sort().join(',');
const newIds = newPlugins.map(p => p.id).sort().join(',');
// Only update if plugin list actually changed
if (oldIds !== newIds) {
console.log('window.installedPlugins changed:', newPlugins.length, 'plugins');
_installedPluginsValue = newPlugins;
this.installedPlugins = newPlugins;
this.updatePluginTabs();
}
},
get: () => _installedPluginsValue,
configurable: true // Allow redefinition if needed
});
} else {
// Property already exists and is not configurable, just update the value
if (typeof window.installedPlugins !== 'undefined') {
_installedPluginsValue = window.installedPlugins;
}
}
},
loadTabContent(tab) {
// Try to load content for the active tab
if (typeof htmx !== 'undefined') {
const contentId = tab + '-content';
const contentEl = document.getElementById(contentId);
if (contentEl && !contentEl.hasAttribute('data-loaded')) {
// Trigger HTMX load
htmx.trigger(contentEl, 'revealed');
}
} else {
// HTMX not available, use direct fetch
console.warn('HTMX not available, using direct fetch for tab:', tab);
if (tab === 'overview' && typeof loadOverviewDirect === 'function') {
loadOverviewDirect();
} else if (tab === 'wifi' && typeof loadWifiDirect === 'function') {
loadWifiDirect();
}
}
},
async loadInstalledPlugins() {
// If pluginManager exists (plugins.html is loaded), delegate to it
if (window.pluginManager) {
console.log('[FULL] Delegating plugin loading to pluginManager...');
await window.pluginManager.loadInstalledPlugins();
// pluginManager should set window.installedPlugins, so update our component
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
this.installedPlugins = window.installedPlugins;
console.log('[FULL] Updated component plugins from window.installedPlugins:', this.installedPlugins.length);
}
this.updatePluginTabs();
return;
}
// Otherwise, load plugins directly (fallback for when plugins.html isn't loaded)
try {
console.log('[FULL] Loading installed plugins directly...');
const data = await getInstalledPluginsSafe();
if (data.status === 'success') {
this.installedPlugins = data.data.plugins || [];
// Also update window.installedPlugins for consistency
window.installedPlugins = this.installedPlugins;
console.log(`[FULL] Loaded ${this.installedPlugins.length} plugins:`, this.installedPlugins.map(p => p.id));
// Debug: Log enabled status for each plugin
this.installedPlugins.forEach(plugin => {
console.log(`[DEBUG Alpine] Plugin ${plugin.id}: enabled=${plugin.enabled} (type: ${typeof plugin.enabled})`);
});
this.updatePluginTabs();
} else {
console.error('[FULL] Failed to load plugins:', data.message);
}
} catch (error) {
console.error('[FULL] Error loading installed plugins:', error);
}
},
updatePluginTabs(retryCount = 0) {
console.log('[FULL] updatePluginTabs called (retryCount:', retryCount, ')');
const maxRetries = 5;
// Debounce: Clear any pending update
if (this._updatePluginTabsTimeout) {
clearTimeout(this._updatePluginTabsTimeout);
}
// For first call or retries, execute immediately to ensure tabs appear quickly
if (retryCount === 0) {
// First call - execute immediately, then debounce subsequent calls
this._doUpdatePluginTabs(retryCount);
} else {
// Retry - execute immediately
this._doUpdatePluginTabs(retryCount);
}
},
_doUpdatePluginTabs(retryCount = 0) {
const maxRetries = 5;
// Use component's installedPlugins first (most up-to-date), then global, then empty array
const pluginsToShow = (this.installedPlugins && this.installedPlugins.length > 0)
? this.installedPlugins
: (window.installedPlugins || []);
console.log('[FULL] _doUpdatePluginTabs called with:', pluginsToShow.length, 'plugins (attempt', retryCount + 1, ')');
console.log('[FULL] Plugin sources:', {
componentPlugins: this.installedPlugins?.length || 0,
windowPlugins: window.installedPlugins?.length || 0,
using: pluginsToShow.length > 0 ? (this.installedPlugins?.length > 0 ? 'component' : 'window') : 'none'
});
// Check if plugin list actually changed by comparing IDs
const currentPluginIds = pluginsToShow.map(p => p.id).sort().join(',');
const lastRenderedIds = (this._lastRenderedPluginIds || '');
// Only skip if we have plugins and they match (don't skip if both are empty)
if (currentPluginIds === lastRenderedIds && retryCount === 0 && currentPluginIds.length > 0) {
// Plugin list hasn't changed, skip update
console.log('[FULL] Plugin list unchanged, skipping update');
return;
}
// If we have no plugins and haven't rendered anything yet, still try to render (might be first load)
if (pluginsToShow.length === 0 && retryCount === 0) {
console.log('[FULL] No plugins to show, but will retry in case they load...');
if (retryCount < maxRetries) {
setTimeout(() => {
this._doUpdatePluginTabs(retryCount + 1);
}, 500);
}
return;
}
// Store the current plugin IDs for next comparison
this._lastRenderedPluginIds = currentPluginIds;
const pluginTabsRow = document.getElementById('plugin-tabs-row');
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
console.log('[FULL] Plugin tabs elements:', {
pluginTabsRow: !!pluginTabsRow,
pluginTabsNav: !!pluginTabsNav,
bodyExists: !!document.body,
installedPlugins: pluginsToShow.length,
pluginIds: pluginsToShow.map(p => p.id)
});
if (!pluginTabsRow || !pluginTabsNav) {
if (retryCount < maxRetries) {
console.warn('[FULL] Plugin tabs container not found, retrying in 500ms... (attempt', retryCount + 1, 'of', maxRetries, ')');
setTimeout(() => {
this._doUpdatePluginTabs(retryCount + 1);
}, 500);
} else {
console.error('[FULL] Plugin tabs container not found after maximum retries. Elements:', {
pluginTabsRow: document.getElementById('plugin-tabs-row'),
pluginTabsNav: document.getElementById('plugin-tabs-row')?.querySelector('nav'),
allNavs: document.querySelectorAll('nav').length
});
}
return;
}
console.log(`[FULL] Updating plugin tabs for ${pluginsToShow.length} plugins`);
// Always show the plugin tabs row (Plugin Manager should always be available)
console.log('[FULL] Ensuring plugin tabs row is visible');
pluginTabsRow.style.display = 'block';
// Clear existing plugin tabs (except the Plugin Manager tab)
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
console.log(`[FULL] Removing ${existingTabs.length} existing plugin tabs`);
existingTabs.forEach(tab => tab.remove());
// Add tabs for each installed plugin
console.log('[FULL] Adding tabs for plugins:', pluginsToShow.map(p => p.id));
pluginsToShow.forEach(plugin => {
const tabButton = document.createElement('button');
tabButton.type = 'button';
tabButton.setAttribute('data-plugin-id', plugin.id);
tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`;
tabButton.onclick = () => {
this.activeTab = plugin.id;
if (typeof this.updatePluginTabStates === 'function') {
this.updatePluginTabStates();
}
};
tabButton.innerHTML = `
<i class="fas fa-puzzle-piece"></i>${this.escapeHtml(plugin.name || plugin.id)}
`;
// Insert before the closing </nav> tag
pluginTabsNav.appendChild(tabButton);
console.log('[FULL] Added tab for plugin:', plugin.id);
});
console.log('[FULL] Plugin tabs update completed. Total tabs:', pluginTabsNav.querySelectorAll('.plugin-tab').length);
},
updatePluginTabStates() {
// Update active state of all plugin tabs when activeTab changes
const pluginTabsNav = document.getElementById('plugin-tabs-row')?.querySelector('nav');
if (!pluginTabsNav) return;
const pluginTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
pluginTabs.forEach(tab => {
const pluginId = tab.getAttribute('data-plugin-id');
if (pluginId && this.activeTab === pluginId) {
tab.classList.add('nav-tab-active');
} else {
tab.classList.remove('nav-tab-active');
}
});
},
showNotification(message, type = 'info') {
const notifications = document.getElementById('notifications');
const notification = document.createElement('div');
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500'
};
notification.className = `px-4 py-3 rounded-md text-white text-sm ${colors[type] || colors.info}`;
notification.textContent = message;
notifications.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 5000);
},
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
async refreshPlugins() {
await this.loadInstalledPlugins();
await this.searchPluginStore();
this.showNotification('Plugin list refreshed', 'success');
},
async loadPluginConfig(pluginId) {
console.log('Loading config for plugin:', pluginId);
this.loading = true;
try {
// Load config, schema, and installed plugins (for web_ui_actions) in parallel
// Use batched API if available for better performance
let configData, schemaData, pluginsData;
if (window.PluginAPI && window.PluginAPI.batch) {
// PluginAPI.batch returns already-parsed JSON objects
try {
const results = await window.PluginAPI.batch([
{endpoint: `/plugins/config?plugin_id=${pluginId}`, method: 'GET'},
{endpoint: `/plugins/schema?plugin_id=${pluginId}`, method: 'GET'},
{endpoint: '/plugins/installed', method: 'GET'}
]);
[configData, schemaData, pluginsData] = results;
} catch (batchError) {
console.error('Batch API request failed, falling back to individual requests:', batchError);
// Fall back to individual requests
const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
]);
configData = configResponse;
schemaData = schemaResponse;
pluginsData = pluginsResponse;
}
} else {
// Direct fetch returns Response objects that need parsing
const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
]);
configData = configResponse;
schemaData = schemaResponse;
pluginsData = pluginsResponse;
}
if (configData && configData.status === 'success') {
this.config = configData.data;
} else {
console.warn('Config API returned non-success status:', configData);
// Set defaults if config failed to load
this.config = { enabled: true, display_duration: 30 };
}
if (schemaData && schemaData.status === 'success') {
this.schema = schemaData.data.schema || {};
} else {
console.warn('Schema API returned non-success status:', schemaData);
// Set empty schema as fallback
this.schema = {};
}
// Extract web_ui_actions from installed plugins and update plugin data
if (pluginsData && pluginsData.status === 'success' && pluginsData.data && pluginsData.data.plugins) {
// Update window.installedPlugins with fresh data (includes commit info)
// The setter will check if data actually changed before updating tabs
window.installedPlugins = pluginsData.data.plugins;
// Update Alpine.js app data
this.installedPlugins = pluginsData.data.plugins;
const pluginInfo = pluginsData.data.plugins.find(p => p.id === pluginId);
this.webUiActions = pluginInfo ? (pluginInfo.web_ui_actions || []) : [];
console.log('[DEBUG] Loaded web_ui_actions for', pluginId, ':', this.webUiActions.length, 'actions');
console.log('[DEBUG] Updated plugin data with commit info:', pluginInfo ? {
last_commit: pluginInfo.last_commit,
branch: pluginInfo.branch,
last_updated: pluginInfo.last_updated
} : 'plugin not found');
} else {
console.warn('Plugins API returned non-success status:', pluginsData);
this.webUiActions = [];
}
console.log('Loaded config, schema, and actions for', pluginId);
} catch (error) {
console.error('Error loading plugin config:', error);
this.config = { enabled: true, display_duration: 30 };
this.schema = {};
this.webUiActions = [];
} finally {
this.loading = false;
}
},
generateConfigForm(pluginId, config, schema, webUiActions = []) {
// Safety check - if schema/config not ready, return empty
if (!pluginId || !config) {
return '<div class="text-gray-500">Loading configuration...</div>';
}
// Only log once per plugin to avoid spam (Alpine.js may call this multiple times during rendering)
if (!this._configFormLogged || this._configFormLogged !== pluginId) {
console.log('[DEBUG] generateConfigForm called for', pluginId, 'with', webUiActions?.length || 0, 'actions');
// Debug: Check if image_config.images has x-widget in schema
if (schema && schema.properties && schema.properties.image_config) {
const imgConfig = schema.properties.image_config;
if (imgConfig.properties && imgConfig.properties.images) {
const imagesProp = imgConfig.properties.images;
console.log('[DEBUG] Schema check - image_config.images:', {
type: imagesProp.type,
'x-widget': imagesProp['x-widget'],
'has x-widget': 'x-widget' in imagesProp,
keys: Object.keys(imagesProp)
});
}
}
this._configFormLogged = pluginId;
}
if (!schema || !schema.properties) {
return this.generateSimpleConfigForm(config, webUiActions, pluginId);
}
// Helper function to get schema property by full key path
const getSchemaProperty = (schemaObj, keyPath) => {
if (!schemaObj || !schemaObj.properties) return null;
const keys = keyPath.split('.');
let current = schemaObj.properties;
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (!current || !current[k]) {
return null;
}
const prop = current[k];
// If this is the last key, return the property
if (i === keys.length - 1) {
return prop;
}
// If this property has nested properties, navigate deeper
if (prop && typeof prop === 'object' && prop.properties) {
current = prop.properties;
} else {
// Can't navigate deeper
return null;
}
}
return null;
};
const generateFieldHtml = (key, prop, value, prefix = '') => {
const fullKey = prefix ? `${prefix}.${key}` : key;
const label = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
const description = prop.description || '';
let html = '';
// Debug: Log property structure for arrays to help diagnose file-upload widget issues
if (prop.type === 'array') {
// Also check schema directly as fallback
const schemaProp = getSchemaProperty(schema, fullKey);
const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
console.log('[DEBUG generateFieldHtml] Array property:', fullKey, {
'prop.x-widget': prop['x-widget'],
'prop.x_widget': prop['x_widget'],
'schema.x-widget': xWidgetFromSchema,
'hasOwnProperty(x-widget)': prop.hasOwnProperty('x-widget'),
'x-widget in prop': 'x-widget' in prop,
'all prop keys': Object.keys(prop),
'schemaProp keys': schemaProp ? Object.keys(schemaProp) : 'null'
});
}
// Handle nested objects
if (prop.type === 'object' && prop.properties) {
const sectionId = `section-${fullKey.replace(/\./g, '-')}`;
const nestedConfig = value || {};
const sectionLabel = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
// Calculate nesting depth for better spacing
const nestingDepth = (fullKey.match(/\./g) || []).length;
const marginClass = nestingDepth > 1 ? 'mb-6' : 'mb-4';
html += `
<div class="nested-section border border-gray-300 rounded-lg ${marginClass}">
<button type="button"
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors"
onclick="toggleNestedSection('${sectionId}', event); return false;">
<div class="flex-1">
<h4 class="font-semibold text-gray-900">${sectionLabel}</h4>
${description ? `<p class="text-sm text-gray-600 mt-1">${description}</p>` : ''}
</div>
<i id="${sectionId}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
</button>
<div id="${sectionId}" class="nested-content collapsed bg-gray-50 px-4 py-4 space-y-3" style="max-height: 0; display: none;">
`;
// Recursively generate fields for nested properties
// Get ordered properties if x-propertyOrder is defined
let nestedPropertyEntries = Object.entries(prop.properties);
if (prop['x-propertyOrder'] && Array.isArray(prop['x-propertyOrder'])) {
const order = prop['x-propertyOrder'];
const orderedEntries = [];
const unorderedEntries = [];
// Separate ordered and unordered properties
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
const index = order.indexOf(nestedKey);
if (index !== -1) {
orderedEntries[index] = [nestedKey, nestedProp];
} else {
unorderedEntries.push([nestedKey, nestedProp]);
}
});
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
nestedPropertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
}
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
// Use config value if it exists and is not null (including false), otherwise use schema default
// Check if key exists in config and value is not null/undefined
const hasValue = nestedKey in nestedConfig && nestedConfig[nestedKey] !== null && nestedConfig[nestedKey] !== undefined;
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
const isNestedObject = nestedProp.type === 'object' && nestedProp.properties;
const nestedValue = hasValue ? nestedConfig[nestedKey] :
(nestedProp.default !== undefined ? nestedProp.default :
(isNestedObject ? {} : (nestedProp.type === 'array' ? [] : (nestedProp.type === 'boolean' ? false : ''))));
// Debug logging for file-upload widgets
if (nestedProp.type === 'array' && (nestedProp['x-widget'] === 'file-upload' || nestedProp['x_widget'] === 'file-upload')) {
console.log('[DEBUG] Found file-upload widget in nested property:', nestedKey, 'fullKey:', fullKey + '.' + nestedKey, 'prop:', nestedProp);
}
html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey);
});
html += `
</div>
</div>
`;
// Add extra spacing after nested sections to prevent overlap with next section
if (nestingDepth > 0) {
html += `<div class="mb-2"></div>`;
}
return html;
}
// Regular (non-nested) field
html += `<div class="form-group">`;
html += `<label class="block text-sm font-medium text-gray-700 mb-1">${label}</label>`;
if (description) {
html += `<p class="text-sm text-gray-600 mb-2">${description}</p>`;
}
// Generate appropriate input based on type
if (prop.type === 'boolean') {
html += `<label class="flex items-center">`;
html += `<input type="checkbox" name="${fullKey}" ${value ? 'checked' : ''} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">`;
html += `<span class="ml-2 text-sm">Enabled</span>`;
html += `</label>`;
} else if (prop.type === 'number' || prop.type === 'integer' ||
(Array.isArray(prop.type) && (prop.type.includes('number') || prop.type.includes('integer')))) {
// Handle union types like ["integer", "null"]
const isUnionType = Array.isArray(prop.type);
const allowsNull = isUnionType && prop.type.includes('null');
const isInteger = prop.type === 'integer' || (isUnionType && prop.type.includes('integer'));
const isNumber = prop.type === 'number' || (isUnionType && prop.type.includes('number'));
const min = prop.minimum !== undefined ? `min="${prop.minimum}"` : '';
const max = prop.maximum !== undefined ? `max="${prop.maximum}"` : '';
const step = isInteger ? 'step="1"' : 'step="any"';
// For union types with null, don't show default if value is null (leave empty)
// This allows users to explicitly set null by leaving it empty
let fieldValue = '';
if (value !== undefined && value !== null) {
fieldValue = value;
} else if (!allowsNull && prop.default !== undefined) {
// Only use default if null is not allowed
fieldValue = prop.default;
}
// Ensure value respects min/max constraints
if (fieldValue !== '' && fieldValue !== undefined && fieldValue !== null) {
const numValue = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue;
if (!isNaN(numValue)) {
// Clamp value to min/max if constraints exist
if (prop.minimum !== undefined && numValue < prop.minimum) {
fieldValue = prop.minimum;
} else if (prop.maximum !== undefined && numValue > prop.maximum) {
fieldValue = prop.maximum;
} else {
fieldValue = numValue;
}
}
}
// Add placeholder/help text for null-able fields
const placeholder = allowsNull ? 'Leave empty to use current time (random)' : '';
const helpText = allowsNull && description && description.includes('null') ?
`<p class="text-xs text-gray-500 mt-1">${description}</p>` : '';
html += `<input type="number" name="${fullKey}" value="${fieldValue}" ${min} ${max} ${step} placeholder="${placeholder}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
if (helpText) {
html += helpText;
}
} else if (prop.type === 'array') {
// AGGRESSIVE file upload widget detection
// For 'images' field in static-image plugin, always check schema directly
let isFileUpload = false;
let uploadConfig = {};
// Direct check: if this is the 'images' field and schema has it with x-widget
if (fullKey === 'images' && schema && schema.properties && schema.properties.images) {
const imagesSchema = schema.properties.images;
if (imagesSchema['x-widget'] === 'file-upload' || imagesSchema['x_widget'] === 'file-upload') {
isFileUpload = true;
uploadConfig = imagesSchema['x-upload-config'] || imagesSchema['x_upload_config'] || {};
console.log('[DEBUG] ✅ Direct detection: images field has file-upload widget', uploadConfig);
}
}
// Fallback: check prop object (should have x-widget if schema loaded correctly)
if (!isFileUpload) {
const xWidgetFromProp = prop['x-widget'] || prop['x_widget'] || prop.xWidget;
if (xWidgetFromProp === 'file-upload') {
isFileUpload = true;
uploadConfig = prop['x-upload-config'] || prop['x_upload_config'] || {};
console.log('[DEBUG] ✅ Detection via prop object');
}
}
// Fallback: schema property lookup
if (!isFileUpload) {
let schemaProp = getSchemaProperty(schema, fullKey);
if (!schemaProp && fullKey === 'images' && schema && schema.properties && schema.properties.images) {
schemaProp = schema.properties.images;
}
const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
if (xWidgetFromSchema === 'file-upload') {
isFileUpload = true;
uploadConfig = schemaProp['x-upload-config'] || schemaProp['x_upload_config'] || {};
console.log('[DEBUG] ✅ Detection via schema lookup');
}
}
// Debug logging for ALL array fields to diagnose
console.log('[DEBUG] Array field check:', fullKey, {
'isFileUpload': isFileUpload,
'prop keys': Object.keys(prop),
'prop.x-widget': prop['x-widget'],
'schema.properties.images exists': !!(schema && schema.properties && schema.properties.images),
'schema.properties.images.x-widget': (schema && schema.properties && schema.properties.images) ? schema.properties.images['x-widget'] : null,
'uploadConfig': uploadConfig
});
if (isFileUpload) {
console.log('[DEBUG] ✅ Rendering file-upload widget for', fullKey, 'with config:', uploadConfig);
// Use the file upload widget from plugins.html
// We'll need to call a function that exists in the global scope
const maxFiles = uploadConfig.max_files || 10;
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
const maxSizeMB = uploadConfig.max_size_mb || 5;
const currentImages = Array.isArray(value) ? value : [];
const fieldId = fullKey.replace(/\./g, '_');
const safePluginId = (uploadConfig.plugin_id || pluginId || 'static-image').toString().replace(/[^a-zA-Z0-9_-]/g, '_');
html += `
<div id="${fieldId}_upload_widget" class="mt-1">
<!-- File Upload Drop Zone -->
<div id="${fieldId}_drop_zone"
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors cursor-pointer"
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
ondragover="event.preventDefault()"
data-field-id="${fieldId}"
onclick="document.getElementById(this.dataset.fieldId + '_file_input').click()">
<input type="file"
id="${fieldId}_file_input"
multiple
accept="${allowedTypes.join(',')}"
style="display: none;"
data-field-id="${fieldId}"
onchange="window.handleFileSelect(event, this.dataset.fieldId)">
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
<p class="text-sm text-gray-600">Drag and drop images here or click to browse</p>
<p class="text-xs text-gray-500 mt-1">Max ${maxFiles} files, ${maxSizeMB}MB each (PNG, JPG, GIF, BMP)</p>
</div>
<!-- Uploaded Images List -->
<div id="${fieldId}_image_list" class="mt-4 space-y-2">
${currentImages.map((img, idx) => {
const imgSchedule = img.schedule || {};
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
let scheduleSummary = 'Always shown';
if (hasSchedule && window.getScheduleSummary) {
try {
scheduleSummary = window.getScheduleSummary(imgSchedule) || 'Scheduled';
} catch (e) {
scheduleSummary = 'Scheduled';
}
} else if (hasSchedule) {
scheduleSummary = 'Scheduled';
}
// Escape the summary for HTML
scheduleSummary = String(scheduleSummary).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return `
<div id="img_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-3 flex-1">
<img src="/${(img.path || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;')}"
alt="${(img.filename || '').replace(/"/g, '&quot;')}"
class="w-16 h-16 object-cover rounded"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
<div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
<i class="fas fa-image text-gray-400"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">${String(img.original_filename || img.filename || 'Image').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
<p class="text-xs text-gray-500">${img.size ? (Math.round(img.size / 1024) + ' KB') : ''} • ${(img.uploaded_at || '').replace(/&/g, '&amp;')}</p>
<p class="text-xs text-blue-600 mt-1">
<i class="fas fa-clock mr-1"></i>${scheduleSummary}
</p>
</div>
</div>
<div class="flex items-center space-x-2 ml-4">
<button type="button"
data-field-id="${fieldId}"
data-image-id="${img.id || ''}"
data-image-idx="${idx}"
onclick="window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId || null, parseInt(this.dataset.imageIdx))"
class="text-blue-600 hover:text-blue-800 p-2"
title="Schedule this image">
<i class="fas fa-calendar-alt"></i>
</button>
<button type="button"
data-field-id="${fieldId}"
data-image-id="${img.id || ''}"
data-plugin-id="${safePluginId}"
onclick="window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId)"
class="text-red-600 hover:text-red-800 p-2"
title="Delete image">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<!-- Schedule widget will be inserted here when opened -->
<div id="schedule_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
</div>
`;
}).join('')}
</div>
<!-- Hidden input to store image data -->
<input type="hidden" id="${fieldId}_images_data" name="${fullKey}" value="${JSON.stringify(currentImages).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')}">
</div>
`;
} else {
// Regular array input
const arrayValue = Array.isArray(value) ? value.join(', ') : '';
html += `<input type="text" name="${fullKey}" value="${arrayValue}" placeholder="Enter values separated by commas" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
html += `<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>`;
}
} else if (prop.enum) {
html += `<select name="${fullKey}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
prop.enum.forEach(option => {
const selected = value === option ? 'selected' : '';
html += `<option value="${option}" ${selected}>${option}</option>`;
});
html += `</select>`;
} else if (prop.type === 'string' && prop['x-widget'] === 'file-upload') {
// File upload widget for string fields (e.g., credentials.json)
const uploadConfig = prop['x-upload-config'] || {};
const uploadEndpoint = uploadConfig.upload_endpoint || '/api/v3/plugins/assets/upload';
const maxSizeMB = uploadConfig.max_size_mb || 1;
const allowedExtensions = uploadConfig.allowed_extensions || ['.json'];
const targetFilename = uploadConfig.target_filename || 'file.json';
const fieldId = fullKey.replace(/\./g, '_');
const hasFile = value && value !== '';
html += `
<div id="${fieldId}_upload_widget" class="mt-1">
<div id="${fieldId}_file_upload"
class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors cursor-pointer"
onclick="document.getElementById('${fieldId}_file_input').click()">
<input type="file"
id="${fieldId}_file_input"
accept="${allowedExtensions.join(',')}"
style="display: none;"
data-field-id="${fieldId}"
data-upload-endpoint="${uploadEndpoint}"
data-target-filename="${targetFilename}"
onchange="window.handleCredentialsUpload(event, this.dataset.fieldId, this.dataset.uploadEndpoint, this.dataset.targetFilename)">
<i class="fas fa-file-upload text-2xl text-gray-400 mb-2"></i>
<p class="text-sm text-gray-600" id="${fieldId}_status">
${hasFile ? `Current file: ${value}` : 'Click to upload ' + targetFilename}
</p>
<p class="text-xs text-gray-500 mt-1">Max ${maxSizeMB}MB (${allowedExtensions.join(', ')})</p>
</div>
<input type="hidden" name="${fullKey}" value="${value || ''}" id="${fieldId}_hidden">
</div>
`;
} else {
// Default to text input
const maxLength = prop.maxLength || '';
const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : '';
html += `<input type="text" name="${fullKey}" value="${value !== undefined ? value : ''}" ${maxLengthAttr} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
}
html += `</div>`;
return html;
};
let formHtml = '';
// Get ordered properties if x-propertyOrder is defined
let propertyEntries = Object.entries(schema.properties);
if (schema['x-propertyOrder'] && Array.isArray(schema['x-propertyOrder'])) {
const order = schema['x-propertyOrder'];
const orderedEntries = [];
const unorderedEntries = [];
// Separate ordered and unordered properties
propertyEntries.forEach(([key, prop]) => {
const index = order.indexOf(key);
if (index !== -1) {
orderedEntries[index] = [key, prop];
} else {
unorderedEntries.push([key, prop]);
}
});
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
propertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
}
propertyEntries.forEach(([key, prop]) => {
// Skip the 'enabled' property - it's managed separately via the header toggle
if (key === 'enabled') return;
// Use config value if key exists and is not null/undefined, otherwise use schema default
// Check if key exists in config and value is not null/undefined
const hasValue = key in config && config[key] !== null && config[key] !== undefined;
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
const isNestedObject = prop.type === 'object' && prop.properties;
const value = hasValue ? config[key] :
(prop.default !== undefined ? prop.default :
(isNestedObject ? {} : (prop.type === 'array' ? [] : (prop.type === 'boolean' ? false : ''))));
formHtml += generateFieldHtml(key, prop, value);
});
// Add web UI actions section if plugin defines any
if (webUiActions && webUiActions.length > 0) {
console.log('[DEBUG] Rendering', webUiActions.length, 'actions in tab form');
// Map color names to explicit Tailwind classes
const colorMap = {
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
};
formHtml += `
<div class="border-t border-gray-200 pt-4 mt-4">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
<p class="text-sm text-gray-600 mb-4">${webUiActions[0].section_description || 'Perform actions for this plugin'}</p>
<div class="space-y-3">
`;
webUiActions.forEach((action, index) => {
const actionId = `action-${action.id}-${index}`;
const statusId = `action-status-${action.id}-${index}`;
const bgColor = action.color || 'blue';
const colors = colorMap[bgColor] || colorMap['blue'];
// Ensure pluginId is valid for template interpolation
const safePluginId = pluginId || '';
formHtml += `
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<h4 class="font-medium ${colors.text} mb-1">
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
</h4>
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
</div>
<button type="button"
id="${actionId}"
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
data-plugin-id="${safePluginId}"
data-action-id="${action.id}"
class="btn ${colors.btn} text-white px-4 py-2 rounded-md whitespace-nowrap">
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
</button>
</div>
<div id="${statusId}" class="mt-3 hidden"></div>
</div>
`;
});
formHtml += `
</div>
</div>
`;
}
return formHtml;
},
generateSimpleConfigForm(config, webUiActions = [], pluginId = '') {
let actionsHtml = '';
if (webUiActions && webUiActions.length > 0) {
const colorMap = {
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
};
actionsHtml = `
<div class="border-t border-gray-200 pt-4 mt-4">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
<div class="space-y-3">
`;
webUiActions.forEach((action, index) => {
const actionId = `action-${action.id}-${index}`;
const statusId = `action-status-${action.id}-${index}`;
const bgColor = action.color || 'blue';
const colors = colorMap[bgColor] || colorMap['blue'];
// Ensure pluginId is valid for template interpolation
const safePluginId = pluginId || '';
actionsHtml += `
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<h4 class="font-medium ${colors.text} mb-1">
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
</h4>
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
</div>
<button type="button"
id="${actionId}"
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
data-plugin-id="${safePluginId}"
data-action-id="${action.id}"
class="btn ${colors.btn} text-white px-4 py-2 rounded-md">
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
</button>
</div>
<div id="${statusId}" class="mt-3 hidden"></div>
</div>
`;
});
actionsHtml += `
</div>
</div>
`;
}
return `
<div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
<input type="number" name="display_duration" value="${Math.max(5, Math.min(300, config.display_duration || 30))}" min="5" max="300" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
<p class="text-sm text-gray-600 mt-1">How long to show this plugin's content</p>
</div>
${actionsHtml}
`;
},
// Helper function to get schema property type for a field path
getSchemaPropertyType(schema, path) {
if (!schema || !schema.properties) return null;
const parts = path.split('.');
let current = schema.properties;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (current && current[part]) {
if (i === parts.length - 1) {
return current[part];
} else if (current[part].properties) {
current = current[part].properties;
} else {
return null;
}
} else {
return null;
}
}
return null;
},
async savePluginConfig(pluginId, event) {
try {
// Get the form element for this plugin
const form = event ? event.target : null;
if (!form) {
throw new Error('Form element not found');
}
const formData = new FormData(form);
const schema = this.schema || {};
// First, collect all checkbox states (including unchecked ones)
// Unchecked checkboxes don't appear in FormData, so we need to iterate form elements
const flatConfig = {};
// Process all form elements to capture all field states
for (let i = 0; i < form.elements.length; i++) {
const element = form.elements[i];
const name = element.name;
// Skip elements without names or submit buttons
if (!name || element.type === 'submit' || element.type === 'button') {
continue;
}
// Handle checkboxes explicitly (both checked and unchecked)
if (element.type === 'checkbox') {
// Check if this is a checkbox group (name ends with [])
if (name.endsWith('[]')) {
const baseName = name.slice(0, -2); // Remove '[]' suffix
if (!flatConfig[baseName]) {
flatConfig[baseName] = [];
}
if (element.checked) {
flatConfig[baseName].push(element.value);
}
} else {
// Regular checkbox (boolean)
flatConfig[name] = element.checked;
}
}
// Handle radio buttons
else if (element.type === 'radio') {
if (element.checked) {
flatConfig[name] = element.value;
}
}
// Handle select elements (including multi-select)
else if (element.tagName === 'SELECT') {
if (element.multiple) {
// Multi-select: get all selected options
const selectedValues = Array.from(element.selectedOptions).map(opt => opt.value);
flatConfig[name] = selectedValues;
} else {
// Single select: handled by FormData, but ensure it's captured
if (!(name in flatConfig)) {
flatConfig[name] = element.value;
}
}
}
// Handle textarea
else if (element.tagName === 'TEXTAREA') {
// Textarea: handled by FormData, but ensure it's captured
if (!(name in flatConfig)) {
flatConfig[name] = element.value;
}
}
}
// Now process FormData for other field types
for (const [key, value] of formData.entries()) {
// Skip checkboxes - we already handled them above
const element = form.elements[key];
if (element && element.type === 'checkbox') {
// Also skip checkbox groups (name ends with [])
if (key.endsWith('[]')) {
continue; // Already processed
}
continue; // Already processed
}
// Skip multi-select - we already handled them above
if (element && element.tagName === 'SELECT' && element.multiple) {
continue; // Already processed
}
// Get schema property type if available
const propSchema = this.getSchemaPropertyType(schema, key);
const propType = propSchema ? propSchema.type : null;
// Handle based on schema type or field name patterns
if (propType === 'array') {
// Check if this is a file upload widget (JSON array in hidden input)
if (propSchema && propSchema['x-widget'] === 'file-upload') {
try {
// Unescape HTML entities that were escaped when setting the value
let unescapedValue = value;
if (typeof value === 'string') {
// Reverse the HTML escaping: &quot; -> ", &#39; -> ', &amp; -> &
unescapedValue = value
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
// Try to parse as JSON
const jsonValue = JSON.parse(unescapedValue);
if (Array.isArray(jsonValue)) {
flatConfig[key] = jsonValue;
console.log(`File upload array field ${key}: parsed JSON array with ${jsonValue.length} items`);
} else {
// Fallback to empty array
flatConfig[key] = [];
}
} catch (e) {
console.warn(`Failed to parse JSON for file upload field ${key}:`, e, 'Value:', value);
// Not valid JSON, use empty array or try comma-separated
if (value && value.trim()) {
// Try to unescape and parse again
try {
const unescaped = value
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
const jsonValue = JSON.parse(unescaped);
if (Array.isArray(jsonValue)) {
flatConfig[key] = jsonValue;
} else {
flatConfig[key] = [];
}
} catch (e2) {
// If still fails, try comma-separated or empty array
const arrayValue = value.split(',').map(v => v.trim()).filter(v => v);
flatConfig[key] = arrayValue.length > 0 ? arrayValue : [];
}
} else {
flatConfig[key] = [];
}
}
} else {
// Regular array: convert comma-separated string to array
const arrayValue = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
flatConfig[key] = arrayValue;
}
} else if (propType === 'integer' || (Array.isArray(propType) && propType.includes('integer'))) {
// Handle union types - if null is allowed and value is empty, keep as empty string (backend will convert to null)
if (Array.isArray(propType) && propType.includes('null') && (!value || value.trim() === '')) {
flatConfig[key] = ''; // Send empty string, backend will normalize to null
} else {
const numValue = parseInt(value, 10);
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
}
} else if (propType === 'number' || (Array.isArray(propType) && propType.includes('number'))) {
// Handle union types - if null is allowed and value is empty, keep as empty string (backend will convert to null)
if (Array.isArray(propType) && propType.includes('null') && (!value || value.trim() === '')) {
flatConfig[key] = ''; // Send empty string, backend will normalize to null
} else {
const numValue = parseFloat(value);
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
}
} else if (propType === 'boolean') {
// Boolean from FormData (shouldn't happen for checkboxes, but handle it)
flatConfig[key] = value === 'on' || value === 'true' || value === true;
} else {
// String or other types
// Check if it's a number field by name pattern (fallback if no schema)
if (!propType && (key.includes('duration') || key.includes('interval') ||
key.includes('timeout') || key.includes('teams') || key.includes('fps') ||
key.includes('bits') || key.includes('nanoseconds') || key.includes('hz'))) {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
flatConfig[key] = Number.isInteger(numValue) ? parseInt(value, 10) : numValue;
} else {
flatConfig[key] = value;
}
} else {
flatConfig[key] = value;
}
}
}
// Handle unchecked checkboxes using schema (if available)
if (schema && schema.properties) {
const collectBooleanFields = (props, prefix = '') => {
const boolFields = [];
for (const [key, prop] of Object.entries(props)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (prop.type === 'boolean') {
boolFields.push(fullKey);
} else if (prop.type === 'object' && prop.properties) {
boolFields.push(...collectBooleanFields(prop.properties, fullKey));
}
}
return boolFields;
};
const allBoolFields = collectBooleanFields(schema.properties);
allBoolFields.forEach(key => {
// Only set to false if the field is completely missing from flatConfig
// Don't override existing false values - they're explicitly set by the user
if (!(key in flatConfig)) {
flatConfig[key] = false;
}
});
}
// Convert dot notation to nested object
const dotToNested = (obj) => {
const result = {};
for (const key in obj) {
const parts = key.split('.');
let current = result;
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {};
}
current = current[parts[i]];
}
current[parts[parts.length - 1]] = obj[key];
}
return result;
};
const config = dotToNested(flatConfig);
// Save to backend
const response = await fetch('/api/v3/plugins/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
plugin_id: pluginId,
config: config
})
});
let data;
try {
data = await response.json();
} catch (e) {
console.error('Failed to parse JSON response:', e);
console.error('Response status:', response.status, response.statusText);
console.error('Response text:', await response.text());
throw new Error(`Failed to parse server response: ${response.status} ${response.statusText}`);
}
console.log('Response status:', response.status, 'Response OK:', response.ok);
console.log('Response data:', JSON.stringify(data, null, 2));
if (!response.ok || data.status !== 'success') {
let errorMessage = data.message || 'Failed to save configuration';
if (data.validation_errors && Array.isArray(data.validation_errors)) {
console.error('Validation errors:', data.validation_errors);
errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n');
}
if (data.config_keys && data.schema_keys) {
console.error('Config keys sent:', data.config_keys);
console.error('Schema keys expected:', data.schema_keys);
const extraKeys = data.config_keys.filter(k => !data.schema_keys.includes(k));
const missingKeys = data.schema_keys.filter(k => !data.config_keys.includes(k));
if (extraKeys.length > 0) {
errorMessage += '\n\nExtra keys (not in schema): ' + extraKeys.join(', ');
}
if (missingKeys.length > 0) {
errorMessage += '\n\nMissing keys (in schema): ' + missingKeys.join(', ');
}
}
this.showNotification(errorMessage, 'error');
console.error('Config save failed - Full error response:', JSON.stringify(data, null, 2));
} else {
this.showNotification('Configuration saved successfully', 'success');
// Reload plugin config to reflect changes
await this.loadPluginConfig(pluginId);
}
} catch (error) {
console.error('Error saving plugin config:', error);
this.showNotification('Error saving configuration: ' + error.message, 'error');
}
},
formatCommitInfo(commit, branch) {
// Handle null, undefined, or empty string
const commitStr = (commit && String(commit).trim()) || '';
const branchStr = (branch && String(branch).trim()) || '';
if (!commitStr && !branchStr) return 'Unknown';
const shortCommit = commitStr.length >= 7 ? commitStr.substring(0, 7) : commitStr;
if (branchStr && shortCommit) {
return `${branchStr} · ${shortCommit}`;
}
if (branchStr) {
return branchStr;
}
if (shortCommit) {
return shortCommit;
}
return 'Unknown';
},
formatDateInfo(dateString) {
// Handle null, undefined, or empty string
if (!dateString || !String(dateString).trim()) return 'Unknown';
try {
const date = new Date(dateString);
// Check if date is valid
if (isNaN(date.getTime())) {
return 'Unknown';
}
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 1) {
return 'Today';
} else if (diffDays < 2) {
return 'Yesterday';
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
} else {
// Return formatted date for older items
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
} catch (e) {
console.error('Error formatting date:', e, dateString);
return 'Unknown';
}
}
};
// Update window.app to return full implementation
window.app = function() {
return fullImplementation;
};
// If Alpine is already initialized, update the existing component immediately
if (window.Alpine) {
// Use requestAnimationFrame for immediate execution without blocking
requestAnimationFrame(() => {
const appElement = document.querySelector('[x-data]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
const existingComponent = appElement._x_dataStack[0];
// Replace all properties and methods from full implementation
Object.keys(fullImplementation).forEach(key => {
existingComponent[key] = fullImplementation[key];
});
// Call init to load plugins and set up watchers (only if not already initialized)
if (typeof existingComponent.init === 'function' && !existingComponent._initialized) {
existingComponent.init();
}
}
});
}
return fullImplementation;
}
// Make app() available globally
window.app = app;
// ===== DEPRECATED: Plugin Configuration Functions (Global Access) =====
// These functions are no longer the primary method for loading plugin configs.
// Plugin configuration forms are now rendered server-side via HTMX.
// See: /v3/partials/plugin-config/<plugin_id> for the new implementation.
// Kept for backwards compatibility with any remaining client-side code.
window.PluginConfigHelpers = {
loadPluginConfig: async function(pluginId, componentContext) {
// This function can be called from inline components
// It loads config, schema, and updates the component context
if (!componentContext) {
console.error('loadPluginConfig requires component context');
return;
}
console.log('Loading config for plugin:', pluginId);
componentContext.loading = true;
try {
// Load config, schema, and installed plugins (for web_ui_actions) in parallel
let configData, schemaData, pluginsData;
if (window.PluginAPI && window.PluginAPI.batch) {
try {
const results = await window.PluginAPI.batch([
{endpoint: `/plugins/config?plugin_id=${pluginId}`, method: 'GET'},
{endpoint: `/plugins/schema?plugin_id=${pluginId}`, method: 'GET'},
{endpoint: '/plugins/installed', method: 'GET'}
]);
[configData, schemaData, pluginsData] = results;
} catch (batchError) {
console.error('Batch API request failed, falling back to individual requests:', batchError);
// Fall back to individual requests
const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
]);
configData = configResponse;
schemaData = schemaResponse;
pluginsData = pluginsResponse;
}
} else {
const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
]);
configData = configResponse;
schemaData = schemaResponse;
pluginsData = pluginsResponse;
}
if (configData && configData.status === 'success') {
componentContext.config = configData.data;
} else {
console.warn('Config API returned non-success status:', configData);
// Set defaults if config failed to load
componentContext.config = { enabled: true, display_duration: 30 };
}
if (schemaData && schemaData.status === 'success') {
componentContext.schema = schemaData.data.schema || {};
} else {
console.warn('Schema API returned non-success status:', schemaData);
// Set empty schema as fallback
componentContext.schema = {};
}
if (pluginsData && pluginsData.status === 'success' && pluginsData.data && pluginsData.data.plugins) {
const pluginInfo = pluginsData.data.plugins.find(p => p.id === pluginId);
componentContext.webUiActions = pluginInfo ? (pluginInfo.web_ui_actions || []) : [];
} else {
console.warn('Plugins API returned non-success status:', pluginsData);
componentContext.webUiActions = [];
}
console.log('Loaded config, schema, and actions for', pluginId);
} catch (error) {
console.error('Error loading plugin config:', error);
componentContext.config = { enabled: true, display_duration: 30 };
componentContext.schema = {};
componentContext.webUiActions = [];
} finally {
componentContext.loading = false;
}
},
generateConfigForm: function(pluginId, config, schema, webUiActions, componentContext) {
// Try to get the app component
let appComponent = null;
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
appComponent = appElement._x_dataStack[0];
}
}
// If we have access to the app component, use its method
if (appComponent && typeof appComponent.generateConfigForm === 'function') {
return appComponent.generateConfigForm(pluginId, config, schema, webUiActions);
}
// Fallback: return loading message if function not available
if (!pluginId || !config) {
return '<div class="text-gray-500">Loading configuration...</div>';
}
return '<div class="text-gray-500">Configuration form not available yet...</div>';
},
savePluginConfig: async function(pluginId, event, componentContext) {
// Try to get the app component
let appComponent = null;
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
appComponent = appElement._x_dataStack[0];
}
}
// If we have access to the app component, use its method
if (appComponent && typeof appComponent.savePluginConfig === 'function') {
return appComponent.savePluginConfig(pluginId, event);
}
console.error('savePluginConfig not available');
throw new Error('Save configuration method not available');
}
};
// ===== Nested Section Toggle =====
window.toggleNestedSection = function(sectionId, event) {
// Prevent event bubbling if event is provided
if (event) {
event.stopPropagation();
event.preventDefault();
}
const content = document.getElementById(sectionId);
const icon = document.getElementById(sectionId + '-icon');
if (!content || !icon) {
console.warn('[toggleNestedSection] Content or icon not found for:', sectionId);
return;
}
// Check if content is currently collapsed (has 'collapsed' class or display:none)
const isCollapsed = content.classList.contains('collapsed') ||
content.style.display === 'none' ||
(content.style.display === '' && !content.classList.contains('expanded'));
if (isCollapsed) {
// Expand the section
content.classList.remove('collapsed');
content.classList.add('expanded');
content.style.display = 'block';
content.style.overflow = 'hidden'; // Prevent content jumping during animation
// CRITICAL FIX: Use setTimeout to ensure browser has time to layout the element
// When element goes from display:none to display:block, scrollHeight might be 0
// We need to wait for the browser to calculate the layout
setTimeout(() => {
// Force reflow to ensure transition works
void content.offsetHeight;
// Now measure the actual content height after layout
const scrollHeight = content.scrollHeight;
if (scrollHeight > 0) {
content.style.maxHeight = scrollHeight + 'px';
} else {
// Fallback: if scrollHeight is still 0, try measuring again after a brief delay
setTimeout(() => {
const retryHeight = content.scrollHeight;
content.style.maxHeight = retryHeight > 0 ? retryHeight + 'px' : '500px';
}, 10);
}
}, 10);
icon.classList.remove('fa-chevron-right');
icon.classList.add('fa-chevron-down');
// After animation completes, remove max-height constraint to allow natural expansion
setTimeout(() => {
if (content.classList.contains('expanded') && !content.classList.contains('collapsed')) {
content.style.maxHeight = 'none';
content.style.overflow = '';
}
}, 320); // Slightly longer than transition duration
} else {
// Collapse the section
content.classList.add('collapsed');
content.classList.remove('expanded');
content.style.overflow = 'hidden'; // Prevent content jumping during animation
// Set max-height to current scroll height first (required for smooth animation)
const currentHeight = content.scrollHeight;
content.style.maxHeight = currentHeight + 'px';
// Force reflow to apply the height
void content.offsetHeight;
// Then animate to 0
setTimeout(() => {
content.style.maxHeight = '0';
}, 10);
// Hide after transition completes
setTimeout(() => {
if (content.classList.contains('collapsed')) {
content.style.display = 'none';
content.style.overflow = '';
}
}, 320); // Match the CSS transition duration + small buffer
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-right');
}
};
// ===== Display Preview Functions (from v2) =====
function updateDisplayPreview(data) {
const preview = document.getElementById('displayPreview');
const stage = document.getElementById('previewStage');
const img = document.getElementById('displayImage');
const canvas = document.getElementById('gridOverlay');
const ledCanvas = document.getElementById('ledCanvas');
const placeholder = document.getElementById('displayPlaceholder');
if (!stage || !img || !placeholder) return; // Not on overview page
if (data.image) {
// Show stage
placeholder.style.display = 'none';
stage.style.display = 'inline-block';
// Current scale from slider
const scale = parseInt(document.getElementById('scaleRange')?.value || '8');
// Update image and meta label
img.style.imageRendering = 'pixelated';
img.onload = () => {
renderLedDots();
};
img.src = `data:image/png;base64,${data.image}`;
const meta = document.getElementById('previewMeta');
if (meta) {
meta.textContent = `${data.width || 128} x ${data.height || 64} @ ${scale}x`;
}
// Size the canvases to match
const width = (data.width || 128) * scale;
const height = (data.height || 64) * scale;
img.style.width = width + 'px';
img.style.height = height + 'px';
ledCanvas.width = width;
ledCanvas.height = height;
canvas.width = width;
canvas.height = height;
drawGrid(canvas, data.width || 128, data.height || 64, scale);
renderLedDots();
} else {
stage.style.display = 'none';
placeholder.style.display = 'block';
placeholder.innerHTML = `<div class="text-center text-gray-400 py-8">
<i class="fas fa-exclamation-triangle text-4xl mb-3"></i>
<p>No display data available</p>
</div>`;
}
}
function renderLedDots() {
const ledCanvas = document.getElementById('ledCanvas');
const img = document.getElementById('displayImage');
const toggle = document.getElementById('toggleLedDots');
if (!ledCanvas || !img || !toggle) {
return;
}
const show = toggle.checked;
if (!show) {
// LED mode OFF: Show image, hide canvas
img.style.visibility = 'visible';
ledCanvas.style.display = 'none';
const ctx = ledCanvas.getContext('2d');
ctx.clearRect(0, 0, ledCanvas.width, ledCanvas.height);
return;
}
// LED mode ON: Hide image (but keep layout space), show only dots on canvas
img.style.visibility = 'hidden';
ledCanvas.style.display = 'block';
const scale = parseInt(document.getElementById('scaleRange')?.value || '8');
const fillPct = parseInt(document.getElementById('dotFillRange')?.value || '75');
const dotRadius = Math.max(1, Math.floor((scale * fillPct) / 200)); // radius in px
const ctx = ledCanvas.getContext('2d', { willReadFrequently: true });
ctx.clearRect(0, 0, ledCanvas.width, ledCanvas.height);
// Create an offscreen canvas to sample pixel colors
const off = document.createElement('canvas');
const logicalWidth = Math.floor(ledCanvas.width / scale);
const logicalHeight = Math.floor(ledCanvas.height / scale);
off.width = logicalWidth;
off.height = logicalHeight;
const offCtx = off.getContext('2d', { willReadFrequently: true });
// Draw the current image scaled down to logical LEDs to sample colors
try {
offCtx.drawImage(img, 0, 0, logicalWidth, logicalHeight);
} catch (e) {
console.error('Failed to draw image to offscreen canvas:', e);
return;
}
// Fill canvas with black background (LED matrix bezel)
ctx.fillStyle = 'rgb(0, 0, 0)';
ctx.fillRect(0, 0, ledCanvas.width, ledCanvas.height);
// Draw circular dots for each LED pixel
let drawn = 0;
for (let y = 0; y < logicalHeight; y++) {
for (let x = 0; x < logicalWidth; x++) {
const pixel = offCtx.getImageData(x, y, 1, 1).data;
const r = pixel[0], g = pixel[1], b = pixel[2], a = pixel[3];
// Skip fully transparent or black pixels to reduce overdraw
if (a === 0 || (r|g|b) === 0) continue;
ctx.fillStyle = `rgb(${r},${g},${b})`;
const cx = Math.floor(x * scale + scale / 2);
const cy = Math.floor(y * scale + scale / 2);
ctx.beginPath();
ctx.arc(cx, cy, dotRadius, 0, Math.PI * 2);
ctx.fill();
drawn++;
}
}
// If nothing was drawn (e.g., image not ready), hide overlay to show base image
if (drawn === 0) {
ledCanvas.style.display = 'none';
}
}
function drawGrid(canvas, pixelWidth, pixelHeight, scale) {
const toggle = document.getElementById('toggleGrid');
if (!toggle || !toggle.checked) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
return;
}
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
ctx.lineWidth = 1;
for (let x = 0; x <= pixelWidth; x++) {
ctx.beginPath();
ctx.moveTo(x * scale, 0);
ctx.lineTo(x * scale, pixelHeight * scale);
ctx.stroke();
}
for (let y = 0; y <= pixelHeight; y++) {
ctx.beginPath();
ctx.moveTo(0, y * scale);
ctx.lineTo(pixelWidth * scale, y * scale);
ctx.stroke();
}
}
function takeScreenshot() {
const img = document.getElementById('displayImage');
if (img && img.src) {
const link = document.createElement('a');
link.download = `led_matrix_${new Date().getTime()}.png`;
link.href = img.src;
link.click();
}
}
// ===== Plugin Management Functions =====
// Make togglePluginFromTab global so Alpine.js can access it
window.togglePluginFromTab = async function(pluginId, enabled) {
try {
const response = await fetch('/api/v3/plugins/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId, enabled })
});
const data = await response.json();
showNotification(data.message, data.status);
if (data.status === 'success') {
// Update the plugin in window.installedPlugins
if (window.installedPlugins) {
const plugin = window.installedPlugins.find(p => p.id === pluginId);
if (plugin) {
plugin.enabled = enabled;
}
}
// Refresh the plugin list to ensure both management page and config page stay in sync
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
}
} else {
// Revert the toggle if API call failed
if (window.installedPlugins) {
const plugin = window.installedPlugins.find(p => p.id === pluginId);
if (plugin) {
plugin.enabled = !enabled;
}
}
}
} catch (error) {
showNotification('Error toggling plugin: ' + error.message, 'error');
// Revert on error
if (window.installedPlugins) {
const plugin = window.installedPlugins.find(p => p.id === pluginId);
if (plugin) {
plugin.enabled = !enabled;
}
}
}
}
// Helper function to get schema property type for a field path
function getSchemaPropertyType(schema, path) {
if (!schema || !schema.properties) return null;
const parts = path.split('.');
let current = schema.properties;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (current && current[part]) {
if (i === parts.length - 1) {
return current[part];
} else if (current[part].properties) {
current = current[part].properties;
} else {
return null;
}
} else {
return null;
}
}
return null;
}
async function savePluginConfig(pluginId) {
try {
console.log('Saving config for plugin:', pluginId);
// Load schema for type detection
let schema = {};
try {
const schemaResponse = await fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`);
const schemaData = await schemaResponse.json();
if (schemaData.status === 'success' && schemaData.data.schema) {
schema = schemaData.data.schema;
}
} catch (e) {
console.warn('Could not load schema for type detection:', e);
}
// Find the form in the active plugin tab
// Alpine.js hides/shows elements with display:none, so we look for the currently visible one
const allForms = document.querySelectorAll('form[x-on\\:submit\\.prevent]');
console.log('Found forms:', allForms.length);
let form = null;
for (const f of allForms) {
const parent = f.closest('[x-show]');
if (parent && parent.style.display !== 'none' && parent.offsetParent !== null) {
form = f;
console.log('Found visible form');
break;
}
}
if (!form) {
throw new Error('Form not found for plugin ' + pluginId);
}
const formData = new FormData(form);
const flatConfig = {};
// First, collect all checkbox states (including unchecked ones)
// Unchecked checkboxes don't appear in FormData, so we need to iterate form elements
for (let i = 0; i < form.elements.length; i++) {
const element = form.elements[i];
const name = element.name;
// Skip elements without names or submit buttons
if (!name || element.type === 'submit' || element.type === 'button') {
continue;
}
// Handle checkboxes explicitly (both checked and unchecked)
if (element.type === 'checkbox') {
flatConfig[name] = element.checked;
}
// Handle radio buttons
else if (element.type === 'radio') {
if (element.checked) {
flatConfig[name] = element.value;
}
}
// Handle select elements (including multi-select)
else if (element.tagName === 'SELECT') {
if (element.multiple) {
// Multi-select: get all selected options
const selectedValues = Array.from(element.selectedOptions).map(opt => opt.value);
flatConfig[name] = selectedValues;
} else {
// Single select: handled by FormData, but ensure it's captured
if (!(name in flatConfig)) {
flatConfig[name] = element.value;
}
}
}
// Handle textarea
else if (element.tagName === 'TEXTAREA') {
// Textarea: handled by FormData, but ensure it's captured
if (!(name in flatConfig)) {
flatConfig[name] = element.value;
}
}
}
// Now process FormData for other field types
for (const [key, value] of formData.entries()) {
// Skip checkboxes - we already handled them above
const element = form.elements[key];
if (element && element.type === 'checkbox') {
continue; // Already processed
}
// Skip multi-select - we already handled them above
if (element && element.tagName === 'SELECT' && element.multiple) {
continue; // Already processed
}
// Get schema property type if available
const propSchema = getSchemaPropertyType(schema, key);
const propType = propSchema ? propSchema.type : null;
// Handle based on schema type or field name patterns
if (propType === 'array') {
// Check if this is a file upload widget (JSON array in hidden input)
if (propSchema && propSchema['x-widget'] === 'file-upload') {
try {
// Unescape HTML entities that were escaped when setting the value
let unescapedValue = value;
if (typeof value === 'string') {
// Reverse the HTML escaping: &quot; -> ", &#39; -> ', &amp; -> &
unescapedValue = value
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
try {
const jsonValue = JSON.parse(unescapedValue);
if (Array.isArray(jsonValue)) {
flatConfig[key] = jsonValue;
console.log(`File upload array field ${key}: parsed JSON array with ${jsonValue.length} items`);
} else {
// Fallback to empty array
flatConfig[key] = [];
}
} catch (e) {
console.warn(`Failed to parse JSON for file upload field ${key}:`, e, 'Value:', value);
// Fallback to empty array
flatConfig[key] = [];
}
} catch (e) {
// Not valid JSON, use empty array or try comma-separated
if (value && value.trim()) {
const arrayValue = value.split(',').map(v => v.trim()).filter(v => v);
flatConfig[key] = arrayValue;
} else {
flatConfig[key] = [];
}
}
} else {
// Regular array: convert comma-separated string to array
const arrayValue = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
flatConfig[key] = arrayValue;
}
} else if (propType === 'integer') {
const numValue = parseInt(value, 10);
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
} else if (propType === 'number') {
const numValue = parseFloat(value);
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
} else if (propType === 'boolean') {
// Boolean from FormData (shouldn't happen for checkboxes, but handle it)
flatConfig[key] = value === 'on' || value === 'true' || value === true;
} else {
// String or other types
// Check if it's a number field by name pattern (fallback if no schema)
if (!propType && (key.includes('duration') || key.includes('interval') ||
key.includes('timeout') || key.includes('teams') || key.includes('fps') ||
key.includes('bits') || key.includes('nanoseconds') || key.includes('hz'))) {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
flatConfig[key] = Number.isInteger(numValue) ? parseInt(value, 10) : numValue;
} else {
flatConfig[key] = value;
}
} else {
flatConfig[key] = value;
}
}
}
// Handle unchecked checkboxes using schema (if available)
if (schema && schema.properties) {
const collectBooleanFields = (props, prefix = '') => {
const boolFields = [];
for (const [key, prop] of Object.entries(props)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (prop.type === 'boolean') {
boolFields.push(fullKey);
} else if (prop.type === 'object' && prop.properties) {
boolFields.push(...collectBooleanFields(prop.properties, fullKey));
}
}
return boolFields;
};
const allBoolFields = collectBooleanFields(schema.properties);
allBoolFields.forEach(key => {
if (!(key in flatConfig)) {
flatConfig[key] = false;
}
});
}
// Convert dot notation to nested object
const dotToNested = (obj) => {
const result = {};
for (const key in obj) {
const parts = key.split('.');
let current = result;
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {};
}
current = current[parts[i]];
}
current[parts[parts.length - 1]] = obj[key];
}
return result;
};
const config = dotToNested(flatConfig);
console.log('Saving config for', pluginId, ':', config);
console.log('Flat config before nesting:', flatConfig);
// Save to backend
const response = await fetch('/api/v3/plugins/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId, config })
});
let data;
try {
data = await response.json();
} catch (e) {
throw new Error(`Failed to parse server response: ${response.status} ${response.statusText}`);
}
if (!response.ok || data.status !== 'success') {
let errorMessage = data.message || 'Failed to save configuration';
if (data.validation_errors && Array.isArray(data.validation_errors)) {
errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n');
}
throw new Error(errorMessage);
} else {
showNotification(`Configuration saved for ${pluginId}`, 'success');
}
} catch (error) {
console.error('Error saving plugin configuration:', error);
showNotification('Error saving plugin configuration: ' + error.message, 'error');
}
}
// Notification helper function
// Fix invalid number inputs before form submission
// This prevents "invalid form control is not focusable" errors
window.fixInvalidNumberInputs = function(form) {
if (!form) return;
const allInputs = form.querySelectorAll('input[type="number"]');
allInputs.forEach(input => {
const min = parseFloat(input.getAttribute('min'));
const max = parseFloat(input.getAttribute('max'));
const value = parseFloat(input.value);
if (!isNaN(value)) {
if (!isNaN(min) && value < min) {
input.value = min;
} else if (!isNaN(max) && value > max) {
input.value = max;
}
}
});
};
function showNotification(message, type = 'info') {
console.log(`[${type.toUpperCase()}]`, message);
// Create a simple toast notification
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${
type === 'success' ? 'bg-green-500' :
type === 'error' ? 'bg-red-500' :
'bg-blue-500'
} text-white`;
notification.textContent = message;
document.body.appendChild(notification);
// Fade out and remove after 3 seconds
setTimeout(() => {
notification.style.transition = 'opacity 0.5s';
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 500);
}, 3000);
}
// Section toggle function - already defined earlier, but ensure it's not overwritten
// (duplicate definition removed - function is defined in early script block above)
// Plugin config handler functions (idempotent initialization)
if (!window.__pluginConfigHandlersInitialized) {
window.__pluginConfigHandlersInitialized = true;
// Initialize state on window object
window.pluginConfigRefreshInProgress = window.pluginConfigRefreshInProgress || new Set();
// Validate plugin config form and show helpful error messages
window.validatePluginConfigForm = function(form, pluginId) {
// Check HTML5 validation
if (!form.checkValidity()) {
// Find all invalid fields
const invalidFields = Array.from(form.querySelectorAll(':invalid'));
const errors = [];
let firstInvalidField = null;
invalidFields.forEach((field, index) => {
// Build error message
let fieldName = field.name || field.id || 'field';
// Make field name more readable (remove plugin ID prefix, convert dots/underscores)
fieldName = fieldName.replace(new RegExp('^' + pluginId + '-'), '')
.replace(/\./g, ' → ')
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase()); // Capitalize words
let errorMsg = field.validationMessage || 'Invalid value';
// Get more specific error message based on validation state
if (field.validity.valueMissing) {
errorMsg = 'This field is required';
} else if (field.validity.rangeUnderflow) {
errorMsg = `Value must be at least ${field.min || 'the minimum'}`;
} else if (field.validity.rangeOverflow) {
errorMsg = `Value must be at most ${field.max || 'the maximum'}`;
} else if (field.validity.stepMismatch) {
errorMsg = `Value must be a multiple of ${field.step || 1}`;
} else if (field.validity.typeMismatch) {
errorMsg = 'Invalid format (e.g., text in number field)';
} else if (field.validity.patternMismatch) {
errorMsg = 'Value does not match required pattern';
} else if (field.validity.tooShort) {
errorMsg = `Value must be at least ${field.minLength} characters`;
} else if (field.validity.tooLong) {
errorMsg = `Value must be at most ${field.maxLength} characters`;
} else if (field.validity.badInput) {
errorMsg = 'Invalid input type';
}
errors.push(`${fieldName}: ${errorMsg}`);
// Track first invalid field for focusing
if (index === 0) {
firstInvalidField = field;
}
// If field is in a collapsed section, expand it
const nestedContent = field.closest('.nested-content');
if (nestedContent && nestedContent.classList.contains('hidden')) {
// Find the toggle button for this section
const sectionId = nestedContent.id;
if (sectionId) {
// Try multiple selectors to find the toggle button
const toggleBtn = document.querySelector(`button[aria-controls="${sectionId}"], button[onclick*="${sectionId}"], [data-toggle-section="${sectionId}"]`) ||
nestedContent.previousElementSibling?.querySelector('button');
if (toggleBtn && toggleBtn.onclick) {
toggleBtn.click(); // Expand the section
}
}
}
});
// Focus and scroll to first invalid field after a brief delay
// (allows collapsed sections to expand first)
setTimeout(() => {
if (firstInvalidField) {
firstInvalidField.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstInvalidField.focus();
}
}, 200);
// Show error notification with details
if (errors.length > 0) {
// Format error message nicely
const errorList = errors.slice(0, 5).join('\n'); // Show first 5 errors
const moreErrors = errors.length > 5 ? `\n... and ${errors.length - 5} more error(s)` : '';
const errorMessage = `Validation failed:\n${errorList}${moreErrors}`;
if (typeof showNotification === 'function') {
showNotification(errorMessage, 'error');
} else {
alert(errorMessage); // Fallback if showNotification not available
}
// Also log to console for debugging
console.error('Form validation errors:', errors);
}
// Report validation failure to browser (shows native validation tooltips)
form.reportValidity();
return false; // Prevent form submission
}
return true; // Validation passed
};
// Handle config save response with detailed error logging
window.handleConfigSave = function(event, pluginId) {
const btn = event.target.querySelector('[type=submit]');
if (btn) btn.disabled = false;
const xhr = event.detail.xhr;
const status = xhr?.status || 0;
// Check if request was successful (2xx status codes)
if (status >= 200 && status < 300) {
// Try to get message from response JSON
let message = 'Configuration saved successfully!';
try {
if (xhr?.responseJSON?.message) {
message = xhr.responseJSON.message;
} else if (xhr?.responseText) {
const responseData = JSON.parse(xhr.responseText);
message = responseData.message || message;
}
} catch (e) {
// Use default message if parsing fails
}
showNotification(message, 'success');
} else {
// Request failed - log detailed error information
console.error('Config save failed:', {
status: status,
statusText: xhr?.statusText,
responseText: xhr?.responseText
});
// Try to parse error response
let errorMessage = 'Failed to save configuration';
try {
if (xhr?.responseJSON) {
const errorData = xhr.responseJSON;
errorMessage = errorData.message || errorData.details || errorMessage;
if (errorData.validation_errors) {
errorMessage += ': ' + errorData.validation_errors.join(', ');
}
} else if (xhr?.responseText) {
const errorData = JSON.parse(xhr.responseText);
errorMessage = errorData.message || errorData.details || errorMessage;
if (errorData.validation_errors) {
errorMessage += ': ' + errorData.validation_errors.join(', ');
}
}
} catch (e) {
// If parsing fails, use status text
errorMessage = xhr?.statusText || errorMessage;
}
showNotification(errorMessage, 'error');
}
};
// Handle toggle response
window.handleToggleResponse = function(event, pluginId) {
const xhr = event.detail.xhr;
const status = xhr?.status || 0;
if (status >= 200 && status < 300) {
// Update UI in place instead of refreshing to avoid duplication
const checkbox = document.getElementById(`plugin-enabled-${pluginId}`);
const label = checkbox?.nextElementSibling;
if (checkbox && label) {
const isEnabled = checkbox.checked;
label.textContent = isEnabled ? 'Enabled' : 'Disabled';
label.className = `ml-2 text-sm ${isEnabled ? 'text-green-600' : 'text-gray-500'}`;
}
// Try to get message from response
let message = 'Plugin status updated';
try {
if (xhr?.responseJSON?.message) {
message = xhr.responseJSON.message;
} else if (xhr?.responseText) {
const responseData = JSON.parse(xhr.responseText);
message = responseData.message || message;
}
} catch (e) {
// Use default message
}
showNotification(message, 'success');
} else {
// Revert checkbox state on error
const checkbox = document.getElementById(`plugin-enabled-${pluginId}`);
if (checkbox) {
checkbox.checked = !checkbox.checked;
}
// Try to get error message from response
let errorMessage = 'Failed to update plugin status';
try {
if (xhr?.responseJSON?.message) {
errorMessage = xhr.responseJSON.message;
} else if (xhr?.responseText) {
const errorData = JSON.parse(xhr.responseText);
errorMessage = errorData.message || errorData.details || errorMessage;
}
} catch (e) {
// Use default message
}
showNotification(errorMessage, 'error');
}
};
// Handle plugin update response
window.handlePluginUpdate = function(event, pluginId) {
const xhr = event.detail.xhr;
const status = xhr?.status || 0;
// Check if request was successful (2xx status)
if (status >= 200 && status < 300) {
// Try to parse the response to get the actual message from server
let message = 'Plugin updated successfully';
if (xhr && xhr.responseText) {
try {
const data = JSON.parse(xhr.responseText);
// Use the server's message, ensuring it says "update" not "save"
message = data.message || message;
// Ensure message is about updating, not saving
if (message.toLowerCase().includes('save') && !message.toLowerCase().includes('update')) {
message = message.replace(/save/i, 'update');
}
} catch (e) {
// If parsing fails, use default message
console.warn('Could not parse update response:', e);
}
}
showNotification(message, 'success');
} else {
console.error('Plugin update failed:', {
status: status,
statusText: xhr?.statusText,
responseText: xhr?.responseText
});
// Try to parse error response for better error message
let errorMessage = 'Failed to update plugin';
if (xhr?.responseText) {
try {
const errorData = JSON.parse(xhr.responseText);
errorMessage = errorData.message || errorMessage;
} catch (e) {
// If parsing fails, use default
}
}
showNotification(errorMessage, 'error');
}
};
// Refresh plugin config (with duplicate prevention)
window.refreshPluginConfig = function(pluginId) {
// Prevent concurrent refreshes
if (window.pluginConfigRefreshInProgress.has(pluginId)) {
return;
}
const container = document.getElementById(`plugin-config-${pluginId}`);
if (container && window.htmx) {
window.pluginConfigRefreshInProgress.add(pluginId);
// Clear container first, then reload
container.innerHTML = '';
window.htmx.ajax('GET', `/v3/partials/plugin-config/${pluginId}`, {
target: container,
swap: 'innerHTML'
});
// Clear flag after delay
setTimeout(() => {
window.pluginConfigRefreshInProgress.delete(pluginId);
}, 1000);
}
};
// Plugin action handlers
window.runPluginOnDemand = function(pluginId) {
if (typeof window.openOnDemandModal === 'function') {
window.openOnDemandModal(pluginId);
} else {
showNotification('On-demand modal not available', 'error');
}
};
window.stopOnDemand = function() {
if (typeof window.requestOnDemandStop === 'function') {
window.requestOnDemandStop({});
} else {
showNotification('Stop function not available', 'error');
}
};
window.executePluginAction = function(pluginId, actionId) {
fetch(`/api/v3/plugins/action?plugin_id=${pluginId}&action_id=${actionId}`, {
method: 'POST'
})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
showNotification(data.message || 'Action executed', 'success');
} else {
showNotification(data.message || 'Action failed', 'error');
}
})
.catch(err => {
showNotification('Failed to execute action', 'error');
});
};
}
function getAppComponent() {
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
return appElement._x_dataStack[0];
}
}
return null;
}
async function updatePlugin(pluginId) {
try {
showNotification(`Updating ${pluginId}...`, 'info');
const response = await fetch('/api/v3/plugins/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId })
});
const data = await response.json();
showNotification(data.message, data.status);
if (data.status === 'success') {
// Refresh the plugin list
const appComponent = getAppComponent();
if (appComponent && typeof appComponent.loadInstalledPlugins === 'function') {
await appComponent.loadInstalledPlugins();
}
}
} catch (error) {
showNotification('Error updating plugin: ' + error.message, 'error');
}
}
async function updateAllPlugins() {
try {
const plugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : [];
if (!plugins.length) {
showNotification('No installed plugins to update.', 'warning');
return;
}
showNotification(`Checking ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} for updates...`, 'info');
let successCount = 0;
let failureCount = 0;
for (const plugin of plugins) {
const pluginId = plugin.id;
const pluginName = plugin.name || pluginId;
try {
const response = await fetch('/api/v3/plugins/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId })
});
const data = await response.json();
const status = data.status || 'info';
const message = data.message || `Checked ${pluginName}`;
showNotification(message, status);
if (status === 'success') {
successCount += 1;
} else {
failureCount += 1;
}
} catch (error) {
failureCount += 1;
showNotification(`Error updating ${pluginName}: ${error.message}`, 'error');
}
}
const appComponent = getAppComponent();
if (appComponent && typeof appComponent.loadInstalledPlugins === 'function') {
await appComponent.loadInstalledPlugins();
}
if (failureCount === 0) {
showNotification(`Finished checking ${successCount} plugin${successCount === 1 ? '' : 's'} for updates.`, 'success');
} else {
showNotification(`Updated ${successCount} plugin${successCount === 1 ? '' : 's'} with ${failureCount} failure${failureCount === 1 ? '' : 's'}. Check logs for details.`, 'error');
}
} catch (error) {
console.error('Bulk plugin update failed:', error);
showNotification('Failed to update all plugins: ' + error.message, 'error');
}
}
window.updateAllPlugins = updateAllPlugins;
async function uninstallPlugin(pluginId) {
try {
// Get plugin info from window.installedPlugins
const plugin = window.installedPlugins ? window.installedPlugins.find(p => p.id === pluginId) : null;
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
if (!confirm(`Are you sure you want to uninstall ${pluginName}?`)) {
return;
}
showNotification(`Uninstalling ${pluginName}...`, 'info');
const response = await fetch('/api/v3/plugins/uninstall', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId })
});
const data = await response.json();
// Check if operation was queued
if (data.status === 'success' && data.data && data.data.operation_id) {
// Operation was queued, poll for completion
const operationId = data.data.operation_id;
showNotification(`Uninstall queued for ${pluginName}...`, 'info');
await pollUninstallOperation(operationId, pluginId, pluginName);
} else if (data.status === 'success') {
// Direct uninstall completed immediately
showNotification(data.message || `Plugin ${pluginName} uninstalled successfully`, 'success');
// Refresh the plugin list
await app.loadInstalledPlugins();
} else {
// Error response
showNotification(data.message || 'Failed to uninstall plugin', data.status || 'error');
}
} catch (error) {
showNotification('Error uninstalling plugin: ' + error.message, 'error');
}
}
async function pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts = 60, attempt = 0) {
if (attempt >= maxAttempts) {
showNotification(`Uninstall operation timed out for ${pluginName}`, 'error');
// Refresh plugin list to see actual state
await app.loadInstalledPlugins();
return;
}
try {
const response = await fetch(`/api/v3/plugins/operation/${operationId}`);
const data = await response.json();
if (data.status === 'success' && data.data) {
const operation = data.data;
const status = operation.status;
if (status === 'completed') {
// Operation completed successfully
showNotification(`Plugin ${pluginName} uninstalled successfully`, 'success');
await app.loadInstalledPlugins();
} else if (status === 'failed') {
// Operation failed
const errorMsg = operation.error || operation.message || `Failed to uninstall ${pluginName}`;
showNotification(errorMsg, 'error');
// Refresh plugin list to see actual state
await app.loadInstalledPlugins();
} else if (status === 'pending' || status === 'in_progress') {
// Still in progress, poll again
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
} else {
// Unknown status, poll again
await new Promise(resolve => setTimeout(resolve, 1000));
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
}
} else {
// Error getting operation status, try again
await new Promise(resolve => setTimeout(resolve, 1000));
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
}
} catch (error) {
console.error('Error polling operation status:', error);
// On error, refresh plugin list to see actual state
await app.loadInstalledPlugins();
}
}
// Assign to window for global access
window.uninstallPlugin = uninstallPlugin;
async function refreshPlugin(pluginId) {
try {
// Switch to the plugin manager tab briefly to refresh
const originalTab = app.activeTab;
app.activeTab = 'plugins';
// Wait a moment then switch back
setTimeout(() => {
app.activeTab = originalTab;
app.showNotification(`Refreshed ${pluginId}`, 'success');
}, 100);
} catch (error) {
app.showNotification('Error refreshing plugin: ' + error.message, 'error');
}
}
// Format commit information for display
function formatCommitInfo(commit, branch) {
if (!commit && !branch) return 'Unknown';
const shortCommit = commit ? String(commit).substring(0, 7) : '';
const branchText = branch ? String(branch) : '';
if (branchText && shortCommit) {
return `${branchText} · ${shortCommit}`;
}
if (branchText) {
return branchText;
}
if (shortCommit) {
return shortCommit;
}
return 'Latest';
}
// Format date for display
function formatDateInfo(dateString) {
if (!dateString) return 'Unknown';
try {
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 1) {
return 'Today';
} else if (diffDays < 2) {
return 'Yesterday';
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
} else {
// Return formatted date for older items
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
} catch (e) {
return dateString;
}
}
// Make functions available to Alpine.js
window.formatCommitInfo = formatCommitInfo;
window.formatDateInfo = formatDateInfo;
</script>
<!-- Custom v3 JavaScript -->
<script src="{{ url_for('static', filename='v3/app.js') }}" defer></script>
<!-- Modular Plugin Management JavaScript -->
<!-- Load utilities first -->
<script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script>
<!-- Load core API client first (used by other modules) -->
<script src="{{ url_for('static', filename='v3/js/plugins/api_client.js') }}" defer></script>
<!-- Load plugin management modules -->
<script src="{{ url_for('static', filename='v3/js/plugins/store_manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/plugins/config_manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
<!-- Load config utilities -->
<script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script>
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20250104i" defer></script>
<!-- Custom feeds table helper functions -->
<script>
function addCustomFeedRow(fieldId, fullKey, maxItems, pluginId) {
const tbody = document.getElementById(fieldId + '_tbody');
if (!tbody) return;
const currentRows = tbody.querySelectorAll('.custom-feed-row');
if (currentRows.length >= maxItems) {
alert(`Maximum ${maxItems} feeds allowed`);
return;
}
const newIndex = currentRows.length;
const newRow = document.createElement('tr');
newRow.className = 'custom-feed-row';
newRow.setAttribute('data-index', newIndex);
// Create name cell
const nameCell = document.createElement('td');
nameCell.className = 'px-4 py-3 whitespace-nowrap';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.name = `${fullKey}.${newIndex}.name`;
nameInput.value = '';
nameInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
nameInput.placeholder = 'Feed Name';
nameInput.required = true;
nameCell.appendChild(nameInput);
// Create URL cell
const urlCell = document.createElement('td');
urlCell.className = 'px-4 py-3 whitespace-nowrap';
const urlInput = document.createElement('input');
urlInput.type = 'url';
urlInput.name = `${fullKey}.${newIndex}.url`;
urlInput.value = '';
urlInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
urlInput.placeholder = 'https://example.com/feed';
urlInput.required = true;
urlCell.appendChild(urlInput);
// Create logo cell
const logoCell = document.createElement('td');
logoCell.className = 'px-4 py-3 whitespace-nowrap';
const logoContainer = document.createElement('div');
logoContainer.className = 'flex items-center space-x-2';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = `${fieldId}_logo_${newIndex}`;
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
fileInput.style.display = 'none';
fileInput.dataset.index = String(newIndex);
// Use addEventListener with dataset index to allow reindexing
fileInput.addEventListener('change', function(e) {
const idx = parseInt(e.target.dataset.index || '0', 10);
handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey);
});
const uploadButton = document.createElement('button');
uploadButton.type = 'button';
uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded';
// Use fileInput directly instead of getElementById for reindexing compatibility
uploadButton.addEventListener('click', function() {
fileInput.click();
});
const uploadIcon = document.createElement('i');
uploadIcon.className = 'fas fa-upload mr-1';
uploadButton.appendChild(uploadIcon);
uploadButton.appendChild(document.createTextNode(' Upload'));
const noLogoSpan = document.createElement('span');
noLogoSpan.className = 'text-xs text-gray-400';
noLogoSpan.textContent = 'No logo';
logoContainer.appendChild(fileInput);
logoContainer.appendChild(uploadButton);
logoContainer.appendChild(noLogoSpan);
logoCell.appendChild(logoContainer);
// Create enabled cell
const enabledCell = document.createElement('td');
enabledCell.className = 'px-4 py-3 whitespace-nowrap text-center';
const enabledInput = document.createElement('input');
enabledInput.type = 'checkbox';
enabledInput.name = `${fullKey}.${newIndex}.enabled`;
enabledInput.checked = true;
enabledInput.value = 'true';
enabledInput.className = 'h-4 w-4 text-blue-600';
enabledCell.appendChild(enabledInput);
// Create remove cell
const removeCell = document.createElement('td');
removeCell.className = 'px-4 py-3 whitespace-nowrap text-center';
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
// Use addEventListener instead of string-based onclick to prevent injection
removeButton.addEventListener('click', function() {
removeCustomFeedRow(this);
});
const removeIcon = document.createElement('i');
removeIcon.className = 'fas fa-trash';
removeButton.appendChild(removeIcon);
removeCell.appendChild(removeButton);
// Append all cells to row
newRow.appendChild(nameCell);
newRow.appendChild(urlCell);
newRow.appendChild(logoCell);
newRow.appendChild(enabledCell);
newRow.appendChild(removeCell);
tbody.appendChild(newRow);
}
function removeCustomFeedRow(button) {
const row = button.closest('tr');
if (!row) return;
if (confirm('Remove this feed?')) {
const tbody = row.parentElement;
if (!tbody) return;
row.remove();
// Re-index remaining rows
const rows = tbody.querySelectorAll('.custom-feed-row');
rows.forEach((r, index) => {
const oldIndex = r.getAttribute('data-index');
r.setAttribute('data-index', index);
// Update all input names with new index
r.querySelectorAll('input, button').forEach(input => {
const name = input.getAttribute('name');
if (name) {
// Replace pattern like "feeds.custom_feeds.0.name" with "feeds.custom_feeds.1.name"
input.setAttribute('name', name.replace(/\.\d+\./, `.${index}.`));
}
const id = input.id;
if (id) {
// Keep IDs aligned after reindex (supports both _logo_<n> and _logo_preview_<n>)
input.id = id
.replace(/_logo_preview_\d+$/, `_logo_preview_${index}`)
.replace(/_logo_\d+$/, `_logo_${index}`);
}
// Keep dataset index aligned so event handlers remain correct after reindex
if (input.dataset && 'index' in input.dataset) {
input.dataset.index = String(index);
}
});
});
}
}
function handleCustomFeedLogoUpload(event, fieldId, index, pluginId, fullKey) {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
formData.append('plugin_id', pluginId);
fetch('/api/v3/plugins/assets/upload', {
method: 'POST',
body: formData
})
.then(response => {
// Check HTTP status before parsing JSON
if (!response.ok) {
return response.text().then(text => {
throw new Error(`Upload failed: ${response.status} ${response.statusText}${text ? ': ' + text : ''}`);
});
}
return response.json();
})
.then(data => {
if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
const uploadedFile = data.data.files[0];
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
if (row) {
const logoCell = row.querySelector('td:nth-child(3)');
const existingPathInput = logoCell.querySelector('input[name*=".logo.path"]');
const existingIdInput = logoCell.querySelector('input[name*=".logo.id"]');
const pathName = existingPathInput ? existingPathInput.name : `${fullKey}.${index}.logo.path`;
const idName = existingIdInput ? existingIdInput.name : `${fullKey}.${index}.logo.id`;
// Normalize path: remove leading slashes, then add single leading slash
const normalizedPath = String(uploadedFile.path || '').replace(/^\/+/, '');
const imageSrc = '/' + normalizedPath;
// Clear logoCell and build DOM safely to prevent XSS
logoCell.textContent = ''; // Clear existing content
// Create container div
const container = document.createElement('div');
container.className = 'flex items-center space-x-2';
// Create file input
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = `${fieldId}_logo_${index}`;
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
fileInput.style.display = 'none';
fileInput.dataset.index = String(index);
// Use addEventListener with dataset index to allow reindexing
fileInput.addEventListener('change', function(e) {
const idx = parseInt(e.target.dataset.index || '0', 10);
handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey);
});
// Create upload button
const uploadButton = document.createElement('button');
uploadButton.type = 'button';
uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded';
// Use fileInput directly instead of getElementById for reindexing compatibility
uploadButton.addEventListener('click', function() {
fileInput.click();
});
const uploadIcon = document.createElement('i');
uploadIcon.className = 'fas fa-upload mr-1';
uploadButton.appendChild(uploadIcon);
uploadButton.appendChild(document.createTextNode(' Upload'));
// Create img element - use normalized path, set src via property to prevent XSS
const img = document.createElement('img');
img.src = imageSrc; // Use property assignment with normalized path
img.alt = 'Logo';
img.className = 'w-8 h-8 object-cover rounded border';
img.id = `${fieldId}_logo_preview_${index}`;
// Create hidden input for path - set value via property to prevent XSS
const pathInput = document.createElement('input');
pathInput.type = 'hidden';
pathInput.name = pathName;
pathInput.value = imageSrc;
// Create hidden input for id - set value via property to prevent XSS
const idInput = document.createElement('input');
idInput.type = 'hidden';
idInput.name = idName;
idInput.value = String(uploadedFile.id); // Ensure it's a string
// Append all elements to container
container.appendChild(fileInput);
container.appendChild(uploadButton);
container.appendChild(img);
container.appendChild(pathInput);
container.appendChild(idInput);
// Append container to logoCell
logoCell.appendChild(container);
}
// Allow re-uploading the same file (change event won't fire otherwise)
event.target.value = '';
} else {
alert('Upload failed: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Upload error:', error);
alert('Upload failed: ' + error.message);
});
}
</script>
<!-- On-Demand Modal (moved here from plugins.html so it's always available) -->
<div id="on-demand-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;">
<div class="modal-content p-6 w-full max-w-md bg-white rounded-lg shadow-lg">
<div class="flex justify-between items-center mb-4">
<h3 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h3>
<button id="close-on-demand-modal" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<!-- Service Status Alert -->
<div id="on-demand-service-warning" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2"></i>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-800">Display service is not running</p>
<p class="text-xs text-yellow-700 mt-1">
The on-demand request will be queued but won't display until the service starts.
Enable "Start display service" below to automatically start it.
</p>
</div>
</div>
</div>
<form id="on-demand-form" class="space-y-4">
<div>
<label for="on-demand-mode" class="block text-sm font-medium text-gray-700 mb-1">Display Mode</label>
<select id="on-demand-mode" name="mode"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</select>
<p id="on-demand-mode-hint" class="text-xs text-gray-500 mt-1"></p>
</div>
<div>
<label for="on-demand-duration" class="block text-sm font-medium text-gray-700 mb-1">
Duration (seconds, optional)
</label>
<input type="number" min="0" id="on-demand-duration" name="duration"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
placeholder="Leave blank to use plugin default">
<p class="text-xs text-gray-500 mt-1">
Use 0 or leave empty to keep the plugin running until stopped manually.
</p>
</div>
<div class="flex items-center">
<input id="on-demand-pinned" name="pinned" type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="on-demand-pinned" class="ml-2 block text-sm text-gray-700">
Pin plugin to prevent rotation until stopped
</label>
</div>
<div class="flex items-center">
<input id="on-demand-start-service" name="start_service" type="checkbox" checked
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="on-demand-start-service" class="ml-2 block text-sm text-gray-700">
Start display service if it is not running
</label>
</div>
<div class="flex justify-end gap-3 pt-3">
<button type="button" id="cancel-on-demand"
class="px-4 py-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md">
Cancel
</button>
<button type="submit"
class="px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md font-semibold">
Start On-Demand
</button>
</div>
</form>
</div>
</div>
</body>
</html>