diff --git a/LEDMatrix.code-workspace b/LEDMatrix.code-workspace new file mode 100644 index 00000000..ef9f5d27 --- /dev/null +++ b/LEDMatrix.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/docs/widget-guide.md b/docs/widget-guide.md new file mode 100644 index 00000000..2a88291f --- /dev/null +++ b/docs/widget-guide.md @@ -0,0 +1,347 @@ +# Widget Development Guide + +## Overview + +The LEDMatrix Widget Registry system allows plugins to use reusable UI components for configuration forms. This enables: + +- **Reusable Components**: Use existing widgets (file upload, checkboxes, etc.) without custom code +- **Custom Widgets**: Create plugin-specific widgets without modifying the LEDMatrix codebase +- **Backwards Compatibility**: Existing plugins continue to work without changes + +## Available Core Widgets + +### File Upload Widget (`file-upload`) + +Upload and manage image files with drag-and-drop support, preview, delete, and scheduling. + +**Schema Configuration:** +```json +{ + "type": "array", + "x-widget": "file-upload", + "x-upload-config": { + "plugin_id": "my-plugin", + "max_files": 10, + "max_size_mb": 5, + "allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"] + } +} +``` + +**Used by:** static-image, news plugins + +### Checkbox Group Widget (`checkbox-group`) + +Multi-select checkboxes for array fields with enum items. + +**Schema Configuration:** +```json +{ + "type": "array", + "x-widget": "checkbox-group", + "items": { + "type": "string", + "enum": ["option1", "option2", "option3"] + }, + "x-options": { + "labels": { + "option1": "Option 1 Label", + "option2": "Option 2 Label" + } + } +} +``` + +**Used by:** odds-ticker, news plugins + +### Custom Feeds Widget (`custom-feeds`) + +Table-based RSS feed editor with logo uploads. + +**Schema Configuration:** +```json +{ + "type": "array", + "x-widget": "custom-feeds", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "enabled": { "type": "boolean" }, + "logo": { "type": "object" } + } + }, + "maxItems": 50 +} +``` + +**Used by:** news plugin (for custom RSS feeds) + +## Using Existing Widgets + +To use an existing widget in your plugin's `config_schema.json`, simply add the `x-widget` property: + +```json +{ + "properties": { + "my_images": { + "type": "array", + "x-widget": "file-upload", + "x-upload-config": { + "plugin_id": "my-plugin", + "max_files": 5 + } + }, + "enabled_leagues": { + "type": "array", + "x-widget": "checkbox-group", + "items": { + "type": "string", + "enum": ["nfl", "nba", "mlb"] + }, + "x-options": { + "labels": { + "nfl": "NFL", + "nba": "NBA", + "mlb": "MLB" + } + } + } + } +} +``` + +The widget will be automatically rendered when the plugin configuration form is loaded. + +## Creating Custom Widgets + +### Step 1: Create Widget File + +Create a JavaScript file in your plugin directory. The recommended location is `widgets/[widget-name].js`: + +```javascript +// Ensure LEDMatrixWidgets registry is available +if (typeof window.LEDMatrixWidgets === 'undefined') { + console.error('LEDMatrixWidgets registry not found'); + return; +} + +// Register your widget +window.LEDMatrixWidgets.register('my-custom-widget', { + name: 'My Custom Widget', + version: '1.0.0', + + /** + * Render the widget HTML + * @param {HTMLElement} container - Container element to render into + * @param {Object} config - Widget configuration from schema + * @param {*} value - Current value + * @param {Object} options - Additional options (fieldId, pluginId, etc.) + */ + render: function(container, config, value, options) { + const fieldId = options.fieldId || container.id; + + // Always escape HTML to prevent XSS + const escapeHtml = (text) => { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }; + + container.innerHTML = ` +
+ +
+ `; + + // Attach event listeners + const input = container.querySelector('input'); + input.addEventListener('change', (e) => { + this.handlers.onChange(fieldId, e.target.value); + }); + }, + + /** + * Get current value from widget + */ + getValue: function(fieldId) { + const input = document.querySelector(`#${fieldId}_input`); + return input ? input.value : null; + }, + + /** + * Set value programmatically + */ + setValue: function(fieldId, value) { + const input = document.querySelector(`#${fieldId}_input`); + if (input) { + input.value = value || ''; + } + }, + + /** + * Event handlers + */ + handlers: { + onChange: function(fieldId, value) { + // Trigger form change event + const event = new CustomEvent('widget-change', { + detail: { fieldId, value }, + bubbles: true + }); + document.dispatchEvent(event); + } + } +}); +``` + +### Step 2: Reference Widget in Schema + +In your plugin's `config_schema.json`: + +```json +{ + "properties": { + "my_field": { + "type": "string", + "description": "My custom field", + "x-widget": "my-custom-widget", + "default": "" + } + } +} +``` + +### Step 3: Widget Loading + +The widget will be automatically loaded when the plugin configuration form is rendered. The system will: + +1. Check if widget is registered in the core registry +2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js` +3. Render the widget using the registered `render` function + +**Note:** Currently, widgets are server-side rendered via Jinja2 templates. Custom widgets registered via the registry will have their handlers available, but full client-side rendering is a future enhancement. + +## Widget API Reference + +### Widget Definition Object + +```javascript +{ + name: string, // Human-readable widget name + version: string, // Widget version + render: function, // Required: Render function + getValue: function, // Optional: Get current value + setValue: function, // Optional: Set value programmatically + handlers: object // Optional: Event handlers +} +``` + +### Render Function + +```javascript +render(container, config, value, options) +``` + +**Parameters:** +- `container` (HTMLElement): Container element to render into +- `config` (Object): Widget configuration from schema +- `value` (*): Current field value +- `options` (Object): Additional options + - `fieldId` (string): Field ID + - `pluginId` (string): Plugin ID + - `fullKey` (string): Full field key path + +### Get Value Function + +```javascript +getValue(fieldId) +``` + +**Returns:** Current widget value + +### Set Value Function + +```javascript +setValue(fieldId, value) +``` + +**Parameters:** +- `fieldId` (string): Field ID +- `value` (*): Value to set + +## Examples + +See [`web_interface/static/v3/js/widgets/example-color-picker.js`](../web_interface/static/v3/js/widgets/example-color-picker.js) for a complete example of a custom color picker widget. + +## Best Practices + +### Security + +1. **Always escape HTML**: Use `escapeHtml()` or `textContent` to prevent XSS +2. **Validate inputs**: Validate user input before processing +3. **Sanitize values**: Clean values before storing + +### Performance + +1. **Lazy loading**: Load widget scripts only when needed +2. **Event delegation**: Use event delegation for dynamic content +3. **Debounce**: Debounce frequent events (e.g., input changes) + +### Accessibility + +1. **Labels**: Always associate labels with inputs +2. **ARIA attributes**: Use appropriate ARIA attributes +3. **Keyboard navigation**: Ensure keyboard accessibility + +## Troubleshooting + +### Widget Not Loading + +1. Check browser console for errors +2. Verify widget file path is correct +3. Ensure `LEDMatrixWidgets.register()` is called +4. Check that widget name matches schema `x-widget` value + +### Widget Not Rendering + +1. Verify `render` function is defined +2. Check container element exists +3. Ensure widget is registered before form loads +4. Check for JavaScript errors in console + +### Value Not Saving + +1. Ensure widget triggers `widget-change` event +2. Verify form submission includes widget value +3. Check `getValue` function returns correct type +4. Verify field name matches schema property + +## Current Implementation Status + +**Phase 1 Complete:** +- ✅ Widget registry system created +- ✅ Core widgets extracted to separate files +- ✅ Widget handlers available globally (backwards compatible) +- ✅ Plugin widget loading system implemented + +**Current Behavior:** +- Widgets are server-side rendered via Jinja2 templates (existing behavior preserved) +- Widget handlers are registered and available globally +- Custom widgets can be created and registered +- Full client-side rendering is a future enhancement + +**Backwards Compatibility:** +- All existing plugins using widgets continue to work without changes +- Server-side rendering remains the primary method +- Widget registry provides foundation for future enhancements + +## See Also + +- [Widget README](../web_interface/static/v3/js/widgets/README.md) - Complete widget development guide with examples +- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - General plugin development +- [Plugin Configuration Guide](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration setup diff --git a/plugins/basketball-scoreboard b/plugins/basketball-scoreboard index 960a716e..d626e538 160000 --- a/plugins/basketball-scoreboard +++ b/plugins/basketball-scoreboard @@ -1 +1 @@ -Subproject commit 960a716e1149c4be6897d64fff3195e9a486c353 +Subproject commit d626e538c0fbfcb92cbc3f1c351ffe6d2c6c00bb diff --git a/scripts/update_plugin_repos.py b/scripts/update_plugin_repos.py new file mode 100755 index 00000000..70b785db --- /dev/null +++ b/scripts/update_plugin_repos.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +Update all plugin repositories by pulling the latest changes. +This script updates all plugin repos without needing to modify +the LEDMatrix project itself. +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +# Paths +WORKSPACE_FILE = Path(__file__).parent.parent / "LEDMatrix.code-workspace" +GITHUB_DIR = Path(__file__).parent.parent.parent + + +def load_workspace_plugins(): + """Load plugin paths from workspace file.""" + try: + with open(WORKSPACE_FILE, 'r', encoding='utf-8') as f: + workspace = json.load(f) + except FileNotFoundError: + print(f"Error: Workspace file not found: {WORKSPACE_FILE}") + return [] + except PermissionError as e: + print(f"Error: Permission denied reading workspace file {WORKSPACE_FILE}: {e}") + return [] + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON in workspace file {WORKSPACE_FILE}: {e}") + return [] + + plugins = [] + for folder in workspace.get('folders', []): + path = folder.get('path', '') + name = folder.get('name', '') + + # Only process plugin folders (those starting with ../) + if path.startswith('../') and path != '../ledmatrix-plugins': + plugin_name = path.replace('../', '') + plugin_path = GITHUB_DIR / plugin_name + if plugin_path.exists(): + plugins.append({ + 'name': plugin_name, + 'display_name': name, + 'path': plugin_path + }) + + return plugins + + +def update_repo(repo_path): + """Update a git repository by pulling latest changes.""" + if not (repo_path / '.git').exists(): + print(f" ⚠️ {repo_path.name} is not a git repository, skipping") + return False + + try: + # Fetch latest changes + fetch_result = subprocess.run(['git', 'fetch', 'origin'], + cwd=repo_path, capture_output=True, text=True) + + if fetch_result.returncode != 0: + print(f" ✗ Failed to fetch {repo_path.name}: {fetch_result.stderr.strip()}") + return False + + # Get current branch + branch_result = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], + cwd=repo_path, capture_output=True, text=True) + current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else 'main' + + # Pull latest changes + pull_result = subprocess.run(['git', 'pull', 'origin', current_branch], + cwd=repo_path, capture_output=True, text=True) + + if pull_result.returncode == 0: + # Check if there were actual updates + if 'Already up to date' in pull_result.stdout: + print(f" ✓ {repo_path.name} is up to date") + else: + print(f" ✓ Updated {repo_path.name}") + return True + else: + print(f" ✗ Failed to update {repo_path.name}: {pull_result.stderr.strip()}") + return False + except (subprocess.SubprocessError, OSError) as e: + print(f" ✗ Error updating {repo_path.name}: {e}") + return False + + +def main(): + """Main function.""" + print("🔍 Finding plugin repositories...") + + plugins = load_workspace_plugins() + + if not plugins: + print(" No plugin repositories found!") + return 1 + + print(f" Found {len(plugins)} plugin repositories") + print(f"\n🚀 Updating plugins in {GITHUB_DIR}...") + print() + + success_count = 0 + for plugin in plugins: + print(f"Updating {plugin['name']}...") + if update_repo(plugin['path']): + success_count += 1 + print() + + print(f"\n✅ Updated {success_count}/{len(plugins)} plugins successfully!") + + if success_count < len(plugins): + print("⚠️ Some plugins failed to update. Check the errors above.") + return 1 + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/web_interface/static/v3/js/widgets/README.md b/web_interface/static/v3/js/widgets/README.md new file mode 100644 index 00000000..e6f6e336 --- /dev/null +++ b/web_interface/static/v3/js/widgets/README.md @@ -0,0 +1,544 @@ +# LEDMatrix Widget Development Guide + +## Overview + +The LEDMatrix Widget Registry system allows plugins to use reusable UI components (widgets) for configuration forms. This system enables: + +- **Reusable Components**: Use existing widgets (file upload, checkboxes, etc.) without custom code +- **Custom Widgets**: Create plugin-specific widgets without modifying the LEDMatrix codebase +- **Backwards Compatibility**: Existing plugins continue to work without changes + +## Available Core Widgets + +### 1. File Upload Widget (`file-upload`) + +Upload and manage image files with drag-and-drop support, preview, delete, and scheduling. + +**Schema Configuration:** +```json +{ + "type": "array", + "x-widget": "file-upload", + "x-upload-config": { + "plugin_id": "my-plugin", + "max_files": 10, + "max_size_mb": 5, + "allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"] + } +} +``` + +**Features:** +- Drag and drop file upload +- Image preview with thumbnails +- Delete functionality +- Schedule images to show at specific times +- Progress indicators during upload + +### 2. Checkbox Group Widget (`checkbox-group`) + +Multi-select checkboxes for array fields with enum items. + +**Schema Configuration:** +```json +{ + "type": "array", + "x-widget": "checkbox-group", + "items": { + "type": "string", + "enum": ["option1", "option2", "option3"] + }, + "x-options": { + "labels": { + "option1": "Option 1 Label", + "option2": "Option 2 Label" + } + } +} +``` + +**Features:** +- Multiple selection from enum list +- Custom labels for each option +- Automatic JSON array serialization + +### 3. Custom Feeds Widget (`custom-feeds`) + +Table-based RSS feed editor with logo uploads. + +**Schema Configuration:** +```json +{ + "type": "array", + "x-widget": "custom-feeds", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "url": { "type": "string", "format": "uri" }, + "enabled": { "type": "boolean" }, + "logo": { "type": "object" } + } + }, + "maxItems": 50 +} +``` + +**Features:** +- Add/remove feed rows +- Logo upload per feed +- Enable/disable individual feeds +- Automatic row re-indexing + +## Using Existing Widgets + +To use an existing widget in your plugin's `config_schema.json`, simply add the `x-widget` property to your field definition: + +```json +{ + "properties": { + "my_images": { + "type": "array", + "x-widget": "file-upload", + "x-upload-config": { + "plugin_id": "my-plugin", + "max_files": 5 + } + } + } +} +``` + +The widget will be automatically rendered when the plugin configuration form is loaded. + +## Creating Custom Widgets + +### Step 1: Create Widget File + +Create a JavaScript file in your plugin directory (e.g., `widgets/my-widget.js`): + +```javascript +// Ensure LEDMatrixWidgets registry is available +if (typeof window.LEDMatrixWidgets === 'undefined') { + console.error('LEDMatrixWidgets registry not found'); + return; +} + +// Register your widget +window.LEDMatrixWidgets.register('my-custom-widget', { + name: 'My Custom Widget', + version: '1.0.0', + + /** + * Render the widget HTML + * @param {HTMLElement} container - Container element to render into + * @param {Object} config - Widget configuration from schema + * @param {*} value - Current value + * @param {Object} options - Additional options (fieldId, pluginId, etc.) + */ + render: function(container, config, value, options) { + const fieldId = options.fieldId || container.id; + // Sanitize fieldId for safe use in DOM IDs and selectors + const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); + const safeFieldId = sanitizeId(fieldId); + + const html = ` +
+ +
+ `; + container.innerHTML = html; + + // Attach event listeners + const input = container.querySelector(`#${safeFieldId}_input`); + if (input) { + input.addEventListener('change', (e) => { + this.handlers.onChange(fieldId, e.target.value); + }); + } + }, + + /** + * Get current value from widget + * @param {string} fieldId - Field ID + * @returns {*} Current value + */ + getValue: function(fieldId) { + // Sanitize fieldId for safe selector use + const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); + const safeFieldId = sanitizeId(fieldId); + const input = document.querySelector(`#${safeFieldId}_input`); + return input ? input.value : null; + }, + + /** + * Set value programmatically + * @param {string} fieldId - Field ID + * @param {*} value - Value to set + */ + setValue: function(fieldId, value) { + // Sanitize fieldId for safe selector use + const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); + const safeFieldId = sanitizeId(fieldId); + const input = document.querySelector(`#${safeFieldId}_input`); + if (input) { + input.value = value || ''; + } + }, + + /** + * Event handlers + */ + handlers: { + onChange: function(fieldId, value) { + // Trigger form change event + const event = new CustomEvent('widget-change', { + detail: { fieldId, value }, + bubbles: true + }); + document.dispatchEvent(event); + } + }, + + /** + * Helper: Escape HTML to prevent XSS + */ + escapeHtml: function(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }, + + /** + * Helper: Sanitize identifier for use in DOM IDs and CSS selectors + */ + sanitizeId: function(id) { + return String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); + } +}); +``` + +### Step 2: Reference Widget in Schema + +In your plugin's `config_schema.json`: + +```json +{ + "properties": { + "my_field": { + "type": "string", + "description": "My custom field", + "x-widget": "my-custom-widget", + "default": "" + } + } +} +``` + +### Step 3: Widget Loading + +The widget will be automatically loaded when the plugin configuration form is rendered. The system will: + +1. Check if widget is registered in the core registry +2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js` +3. Render the widget using the registered `render` function + +## Widget API Reference + +### Widget Definition Object + +```javascript +{ + name: string, // Human-readable widget name + version: string, // Widget version + render: function, // Required: Render function + getValue: function, // Optional: Get current value + setValue: function, // Optional: Set value programmatically + handlers: object // Optional: Event handlers +} +``` + +### Render Function + +```javascript +render(container, config, value, options) +``` + +**Parameters:** +- `container` (HTMLElement): Container element to render into +- `config` (Object): Widget configuration from schema (`x-widget-config` or schema properties) +- `value` (*): Current field value +- `options` (Object): Additional options + - `fieldId` (string): Field ID + - `pluginId` (string): Plugin ID + - `fullKey` (string): Full field key path + +### Get Value Function + +```javascript +getValue(fieldId) +``` + +**Returns:** Current widget value + +### Set Value Function + +```javascript +setValue(fieldId, value) +``` + +**Parameters:** +- `fieldId` (string): Field ID +- `value` (*): Value to set + +### Event Handlers + +Widgets can define custom event handlers in the `handlers` object: + +```javascript +handlers: { + onChange: function(fieldId, value) { + // Handle value change + }, + onFocus: function(fieldId) { + // Handle focus + } +} +``` + +## Best Practices + +### Security + +1. **Always escape HTML**: Use `escapeHtml()` or `textContent` to prevent XSS +2. **Validate inputs**: Validate user input before processing +3. **Sanitize values**: Clean values before storing +4. **Sanitize identifiers**: Always sanitize identifiers (like `fieldId`) used as element IDs and in CSS selectors to prevent selector injection/XSS: + - Use `sanitizeId()` helper function (available in BaseWidget) or create your own + - Allow only safe characters: `[A-Za-z0-9_-]` + - Replace or remove invalid characters before using in: + - `getElementById()`, `querySelector()`, `querySelectorAll()` + - Setting `id` attributes + - Building CSS selectors + - Never interpolate raw `fieldId` into HTML strings or selectors without sanitization + - Example: `const safeId = fieldId.replace(/[^a-zA-Z0-9_-]/g, '_');` + +### Performance + +1. **Lazy loading**: Load widget scripts only when needed +2. **Event delegation**: Use event delegation for dynamic content +3. **Debounce**: Debounce frequent events (e.g., input changes) + +### Accessibility + +1. **Labels**: Always associate labels with inputs +2. **ARIA attributes**: Use appropriate ARIA attributes +3. **Keyboard navigation**: Ensure keyboard accessibility + +### Error Handling + +1. **Graceful degradation**: Handle missing dependencies +2. **User feedback**: Show clear error messages +3. **Logging**: Log errors for debugging + +## Examples + +### Example 1: Color Picker Widget + +```javascript +window.LEDMatrixWidgets.register('color-picker', { + name: 'Color Picker', + version: '1.0.0', + + render: function(container, config, value, options) { + const fieldId = options.fieldId; + // Sanitize fieldId for safe use in DOM IDs and selectors + const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); + const sanitizedFieldId = sanitizeId(fieldId); + + container.innerHTML = ` +
+ + +
+ `; + + const colorInput = container.querySelector(`#${sanitizedFieldId}_color`); + const hexInput = container.querySelector(`#${sanitizedFieldId}_hex`); + + if (colorInput && hexInput) { + colorInput.addEventListener('change', (e) => { + hexInput.value = e.target.value; + this.handlers.onChange(fieldId, e.target.value); + }); + + hexInput.addEventListener('change', (e) => { + if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) { + colorInput.value = e.target.value; + this.handlers.onChange(fieldId, e.target.value); + } + }); + } + }, + + getValue: function(fieldId) { + // Sanitize fieldId for safe selector use + const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); + const sanitizedFieldId = sanitizeId(fieldId); + const colorInput = document.querySelector(`#${sanitizedFieldId}_color`); + return colorInput ? colorInput.value : null; + }, + + setValue: function(fieldId, value) { + // Sanitize fieldId for safe selector use + const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); + const sanitizedFieldId = sanitizeId(fieldId); + const colorInput = document.querySelector(`#${sanitizedFieldId}_color`); + const hexInput = document.querySelector(`#${sanitizedFieldId}_hex`); + if (colorInput && hexInput) { + colorInput.value = value; + hexInput.value = value; + } + }, + + handlers: { + onChange: function(fieldId, value) { + const event = new CustomEvent('widget-change', { + detail: { fieldId, value }, + bubbles: true + }); + document.dispatchEvent(event); + } + } +}); +``` + +### Example 2: Slider Widget + +```javascript +window.LEDMatrixWidgets.register('slider', { + name: 'Slider Widget', + version: '1.0.0', + + render: function(container, config, value, options) { + const fieldId = options.fieldId; + // Sanitize fieldId for safe use in DOM IDs and selectors + const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); + const sanitizedFieldId = sanitizeId(fieldId); + + const min = config.minimum || 0; + const max = config.maximum || 100; + const step = config.step || 1; + const currentValue = value !== undefined ? value : (config.default || min); + + container.innerHTML = ` +
+ +
+ ${min} + ${currentValue} + ${max} +
+
+ `; + + const slider = container.querySelector(`#${sanitizedFieldId}_slider`); + const valueDisplay = container.querySelector(`#${sanitizedFieldId}_value`); + + if (slider && valueDisplay) { + slider.addEventListener('input', (e) => { + valueDisplay.textContent = e.target.value; + this.handlers.onChange(fieldId, parseFloat(e.target.value)); + }); + } + }, + + getValue: function(fieldId) { + // Sanitize fieldId for safe selector use + const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); + const sanitizedFieldId = sanitizeId(fieldId); + const slider = document.querySelector(`#${sanitizedFieldId}_slider`); + return slider ? parseFloat(slider.value) : null; + }, + + setValue: function(fieldId, value) { + // Sanitize fieldId for safe selector use + const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_'); + const sanitizedFieldId = sanitizeId(fieldId); + const slider = document.querySelector(`#${sanitizedFieldId}_slider`); + const valueDisplay = document.querySelector(`#${sanitizedFieldId}_value`); + if (slider) { + slider.value = value; + if (valueDisplay) { + valueDisplay.textContent = value; + } + } + }, + + handlers: { + onChange: function(fieldId, value) { + const event = new CustomEvent('widget-change', { + detail: { fieldId, value }, + bubbles: true + }); + document.dispatchEvent(event); + } + } +}); +``` + +## Troubleshooting + +### Widget Not Loading + +1. Check browser console for errors +2. Verify widget file path is correct +3. Ensure `LEDMatrixWidgets.register()` is called +4. Check that widget name matches schema `x-widget` value + +### Widget Not Rendering + +1. Verify `render` function is defined +2. Check container element exists +3. Ensure widget is registered before form loads +4. Check for JavaScript errors in console + +### Value Not Saving + +1. Ensure widget triggers `widget-change` event +2. Verify form submission includes widget value +3. Check `getValue` function returns correct type +4. Verify field name matches schema property + +## Migration from Server-Side Rendering + +Currently, widgets are server-side rendered via Jinja2 templates. The registry system provides: + +1. **Backwards Compatibility**: Existing server-side rendered widgets continue to work +2. **Future Enhancement**: Client-side rendering support for custom widgets +3. **Handler Availability**: All widget handlers are available globally + +Future versions may support full client-side rendering, but server-side rendering remains the primary method for core widgets. + +## Support + +For questions or issues: +- Check existing widget implementations for examples +- Review browser console for errors +- Test with simple widget first before complex implementations diff --git a/web_interface/static/v3/js/widgets/base-widget.js b/web_interface/static/v3/js/widgets/base-widget.js new file mode 100644 index 00000000..18ec5d40 --- /dev/null +++ b/web_interface/static/v3/js/widgets/base-widget.js @@ -0,0 +1,192 @@ +/** + * LEDMatrix Base Widget Class + * + * Provides common functionality and utilities for all widgets. + * Widgets can extend this or use it as a reference for best practices. + * + * @module BaseWidget + */ + +(function() { + 'use strict'; + + /** + * Base Widget Class + * Provides common utilities and patterns for widgets + */ + class BaseWidget { + constructor(name, version) { + this.name = name; + this.version = version || '1.0.0'; + } + + /** + * Validate widget configuration + * @param {Object} config - Configuration object from schema + * @param {Object} schema - Full schema object + * @returns {Object} Validation result {valid: boolean, errors: Array} + */ + validateConfig(config, schema) { + const errors = []; + + if (!config) { + errors.push('Configuration is required'); + return { valid: false, errors }; + } + + // Add widget-specific validation here + // This is a base implementation that can be overridden + + return { + valid: errors.length === 0, + errors + }; + } + + /** + * Sanitize value for storage + * @param {*} value - Raw value from widget + * @returns {*} Sanitized value + */ + sanitizeValue(value) { + // Base implementation - widgets should override for specific needs + if (typeof value === 'string') { + // Basic XSS prevention + return value.replace(/)<[^<]*)*<\/script>/gi, ''); + } + return value; + } + + /** + * Get field ID from container or options + * @param {HTMLElement} container - Container element + * @param {Object} options - Options object + * @returns {string} Field ID + */ + getFieldId(container, options) { + if (options && options.fieldId) { + return options.fieldId; + } + if (container && container.id) { + return container.id.replace(/_widget_container$/, ''); + } + return null; + } + + /** + * Show error message + * @param {HTMLElement} container - Container element + * @param {string} message - Error message + */ + showError(container, message) { + if (!container) return; + + // Remove existing error + const existingError = container.querySelector('.widget-error'); + if (existingError) { + existingError.remove(); + } + + // Create error element using DOM APIs to prevent XSS + const errorEl = document.createElement('div'); + errorEl.className = 'widget-error text-sm text-red-600 mt-2'; + + const icon = document.createElement('i'); + icon.className = 'fas fa-exclamation-circle mr-1'; + errorEl.appendChild(icon); + + const messageText = document.createTextNode(message); + errorEl.appendChild(messageText); + + container.appendChild(errorEl); + } + + /** + * Clear error message + * @param {HTMLElement} container - Container element + */ + clearError(container) { + if (!container) return; + const errorEl = container.querySelector('.widget-error'); + if (errorEl) { + errorEl.remove(); + } + } + + /** + * Escape HTML to prevent XSS + * Always escapes the input, even for non-strings, by coercing to string first + * @param {*} text - Text to escape (will be coerced to string) + * @returns {string} Escaped text + */ + escapeHtml(text) { + // Always coerce to string first, then escape + const textStr = String(text); + const div = document.createElement('div'); + div.textContent = textStr; + return div.innerHTML; + } + + /** + * Sanitize identifier for use in DOM IDs and CSS selectors + * @param {string} id - Identifier to sanitize + * @returns {string} Sanitized identifier safe for DOM/CSS + */ + sanitizeId(id) { + if (typeof id !== 'string') { + id = String(id); + } + // Allow only alphanumeric, underscore, and hyphen + return id.replace(/[^a-zA-Z0-9_-]/g, '_'); + } + + /** + * Trigger widget change event + * @param {string} fieldId - Field ID + * @param {*} value - New value + */ + triggerChange(fieldId, value) { + const event = new CustomEvent('widget-change', { + detail: { fieldId, value }, + bubbles: true, + cancelable: true + }); + document.dispatchEvent(event); + } + + /** + * Get notification function (if available) + * @returns {Function|null} Notification function or null + */ + getNotificationFunction() { + if (typeof window.showNotification === 'function') { + return window.showNotification; + } + return null; + } + + /** + * Show notification + * @param {string} message - Message to show + * @param {string} type - Notification type (success, error, info, warning) + */ + notify(message, type) { + // Normalize type to prevent errors when undefined/null + const normalizedType = type ? String(type) : 'info'; + + const notifyFn = this.getNotificationFunction(); + if (notifyFn) { + notifyFn(message, normalizedType); + } else { + console.log(`[${normalizedType.toUpperCase()}] ${message}`); + } + } + } + + // Export for use in widget implementations + if (typeof window !== 'undefined') { + window.BaseWidget = BaseWidget; + } + + console.log('[BaseWidget] Base widget class loaded'); +})(); diff --git a/web_interface/static/v3/js/widgets/checkbox-group.js b/web_interface/static/v3/js/widgets/checkbox-group.js new file mode 100644 index 00000000..837b3590 --- /dev/null +++ b/web_interface/static/v3/js/widgets/checkbox-group.js @@ -0,0 +1,121 @@ +/** + * Checkbox Group Widget + * + * Handles multi-select checkbox groups for array fields with enum items. + * Updates a hidden input with JSON array of selected values. + * + * @module CheckboxGroupWidget + */ + +(function() { + 'use strict'; + + // Ensure LEDMatrixWidgets registry exists + if (typeof window.LEDMatrixWidgets === 'undefined') { + console.error('[CheckboxGroupWidget] LEDMatrixWidgets registry not found. Load registry.js first.'); + return; + } + + /** + * Register the checkbox-group widget + */ + window.LEDMatrixWidgets.register('checkbox-group', { + name: 'Checkbox Group Widget', + version: '1.0.0', + + /** + * Render the checkbox group widget + * Note: This widget is currently server-side rendered via Jinja2 template. + * This registration ensures the handlers are available globally. + */ + render: function(container, config, value, options) { + // For now, widgets are server-side rendered + // This function is a placeholder for future client-side rendering + console.log('[CheckboxGroupWidget] Render called (server-side rendered)'); + }, + + /** + * Get current value from widget + * @param {string} fieldId - Field ID + * @returns {Array} Array of selected values + */ + getValue: function(fieldId) { + const hiddenInput = document.getElementById(`${fieldId}_data`); + if (hiddenInput && hiddenInput.value) { + try { + return JSON.parse(hiddenInput.value); + } catch (e) { + console.error('Error parsing checkbox group data:', e); + return []; + } + } + return []; + }, + + /** + * Set value in widget + * @param {string} fieldId - Field ID + * @param {Array} values - Array of values to select + */ + setValue: function(fieldId, values) { + if (!Array.isArray(values)) { + console.error('[CheckboxGroupWidget] setValue expects an array'); + return; + } + + // Normalize values to strings for consistent comparison + const normalizedValues = values.map(String); + + // Update checkboxes + const checkboxes = document.querySelectorAll(`input[type="checkbox"][data-checkbox-group="${fieldId}"]`); + checkboxes.forEach(checkbox => { + const optionValue = checkbox.getAttribute('data-option-value') || checkbox.value; + // Normalize optionValue to string for comparison + checkbox.checked = normalizedValues.includes(String(optionValue)); + }); + + // Update hidden input + updateCheckboxGroupData(fieldId); + }, + + handlers: { + // Handlers are attached to window for backwards compatibility + } + }); + + /** + * Update checkbox group data in hidden input + * Called when any checkbox in the group changes + * @param {string} fieldId - Field ID + */ + window.updateCheckboxGroupData = function(fieldId) { + // Update hidden _data input with currently checked values + const hiddenInput = document.getElementById(fieldId + '_data'); + if (!hiddenInput) { + console.warn(`[CheckboxGroupWidget] Hidden input not found for fieldId: ${fieldId}`); + return; + } + + const checkboxes = document.querySelectorAll(`input[type="checkbox"][data-checkbox-group="${fieldId}"]`); + const selectedValues = []; + + checkboxes.forEach(checkbox => { + if (checkbox.checked) { + const optionValue = checkbox.getAttribute('data-option-value') || checkbox.value; + selectedValues.push(optionValue); + } + }); + + hiddenInput.value = JSON.stringify(selectedValues); + + // Trigger change event for form validation + const event = new CustomEvent('widget-change', { + detail: { fieldId, value: selectedValues }, + bubbles: true, + cancelable: true + }); + hiddenInput.dispatchEvent(event); + }; + + console.log('[CheckboxGroupWidget] Checkbox group widget registered'); +})(); diff --git a/web_interface/static/v3/js/widgets/custom-feeds.js b/web_interface/static/v3/js/widgets/custom-feeds.js new file mode 100644 index 00000000..1cd4b063 --- /dev/null +++ b/web_interface/static/v3/js/widgets/custom-feeds.js @@ -0,0 +1,511 @@ +/** + * Custom Feeds Widget + * + * Handles table-based RSS feed editor with logo uploads. + * Allows adding, removing, and editing custom RSS feed entries. + * + * @module CustomFeedsWidget + */ + +(function() { + 'use strict'; + + // Ensure LEDMatrixWidgets registry exists + if (typeof window.LEDMatrixWidgets === 'undefined') { + console.error('[CustomFeedsWidget] LEDMatrixWidgets registry not found. Load registry.js first.'); + return; + } + + /** + * Register the custom-feeds widget + */ + window.LEDMatrixWidgets.register('custom-feeds', { + name: 'Custom Feeds Widget', + version: '1.0.0', + + /** + * Render the custom feeds widget + * Note: This widget is currently server-side rendered via Jinja2 template. + * This registration ensures the handlers are available globally. + */ + render: function(container, config, value, options) { + // For now, widgets are server-side rendered + // This function is a placeholder for future client-side rendering + console.log('[CustomFeedsWidget] Render called (server-side rendered)'); + }, + + /** + * Get current value from widget + * @param {string} fieldId - Field ID + * @returns {Array} Array of feed objects + */ + getValue: function(fieldId) { + const tbody = document.getElementById(`${fieldId}_tbody`); + if (!tbody) return []; + + const rows = tbody.querySelectorAll('.custom-feed-row'); + const feeds = []; + + rows.forEach((row, index) => { + const nameInput = row.querySelector('input[name*=".name"]'); + const urlInput = row.querySelector('input[name*=".url"]'); + const enabledInput = row.querySelector('input[name*=".enabled"]'); + const logoPathInput = row.querySelector('input[name*=".logo.path"]'); + const logoIdInput = row.querySelector('input[name*=".logo.id"]'); + + if (nameInput && urlInput) { + feeds.push({ + name: nameInput.value, + url: urlInput.value, + enabled: enabledInput ? enabledInput.checked : true, + logo: logoPathInput || logoIdInput ? { + path: logoPathInput ? logoPathInput.value : '', + id: logoIdInput ? logoIdInput.value : '' + } : null + }); + } + }); + + return feeds; + }, + + /** + * Set value in widget + * @param {string} fieldId - Field ID + * @param {Array} feeds - Array of feed objects + * @param {Object} options - Options containing fullKey and pluginId + */ + setValue: function(fieldId, feeds, options) { + if (!Array.isArray(feeds)) { + console.error('[CustomFeedsWidget] setValue expects an array'); + return; + } + + // Throw NotImplementedError if options are missing (defensive approach) + if (!options || !options.fullKey || !options.pluginId) { + throw new Error('CustomFeedsWidget.setValue not implemented: requires options.fullKey and options.pluginId'); + } + + const tbody = document.getElementById(`${fieldId}_tbody`); + if (!tbody) { + console.warn(`[CustomFeedsWidget] tbody not found for fieldId: ${fieldId}`); + return; + } + + // Clear existing rows immediately before appending new ones + tbody.innerHTML = ''; + + // Build rows for each feed using the same logic as addCustomFeedRow + feeds.forEach((feed, index) => { + const fullKey = options.fullKey; + const pluginId = options.pluginId; + + const newRow = document.createElement('tr'); + newRow.className = 'custom-feed-row'; + newRow.setAttribute('data-index', index); + + // 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}.${index}.name`; + nameInput.value = feed.name || ''; + 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}.${index}.url`; + urlInput.value = feed.url || ''; + 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_${index}`; + fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif'; + fileInput.style.display = 'none'; + fileInput.dataset.index = String(index); + 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'; + 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')); + + if (feed.logo && feed.logo.path) { + const img = document.createElement('img'); + img.src = feed.logo.path; + img.alt = 'Logo'; + img.className = 'w-8 h-8 object-cover rounded border'; + img.id = `${fieldId}_logo_preview_${index}`; + logoContainer.appendChild(img); + + // Create hidden inputs for logo data + const pathInput = document.createElement('input'); + pathInput.type = 'hidden'; + pathInput.name = `${fullKey}.${index}.logo.path`; + pathInput.value = feed.logo.path; + logoContainer.appendChild(pathInput); + + if (feed.logo.id) { + const idInput = document.createElement('input'); + idInput.type = 'hidden'; + idInput.name = `${fullKey}.${index}.logo.id`; + idInput.value = String(feed.logo.id); + logoContainer.appendChild(idInput); + } + } else { + const noLogoSpan = document.createElement('span'); + noLogoSpan.className = 'text-xs text-gray-400'; + noLogoSpan.textContent = 'No logo'; + logoContainer.appendChild(noLogoSpan); + } + + logoContainer.appendChild(fileInput); + logoContainer.appendChild(uploadButton); + 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}.${index}.enabled`; + enabledInput.checked = feed.enabled !== false; + 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'; + 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); + }); + }, + + handlers: { + // Handlers are attached to window for backwards compatibility + } + }); + + /** + * Add a new custom feed row to the table + * @param {string} fieldId - Field ID + * @param {string} fullKey - Full field key (e.g., "feeds.custom_feeds") + * @param {number} maxItems - Maximum number of items allowed + * @param {string} pluginId - Plugin ID + */ + window.addCustomFeedRow = function(fieldId, fullKey, maxItems, pluginId) { + const tbody = document.getElementById(fieldId + '_tbody'); + if (!tbody) return; + + const currentRows = tbody.querySelectorAll('.custom-feed-row'); + if (currentRows.length >= maxItems) { + const notifyFn = window.showNotification || alert; + notifyFn(`Maximum ${maxItems} feeds allowed`, 'error'); + 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); + 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'; + 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'; + 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); + }; + + /** + * Remove a custom feed row from the table + * @param {HTMLElement} button - The remove button element + */ + window.removeCustomFeedRow = function(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 + input.id = id + .replace(/_logo_preview_\d+$/, `_logo_preview_${index}`) + .replace(/_logo_\d+$/, `_logo_${index}`); + } + // Keep dataset index aligned + if (input.dataset && 'index' in input.dataset) { + input.dataset.index = String(index); + } + }); + }); + } + }; + + /** + * Handle custom feed logo upload + * @param {Event} event - File input change event + * @param {string} fieldId - Field ID + * @param {number} index - Feed row index + * @param {string} pluginId - Plugin ID + * @param {string} fullKey - Full field key + */ + window.handleCustomFeedLogoUpload = function(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); + 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'; + 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 + const img = document.createElement('img'); + img.src = imageSrc; + img.alt = 'Logo'; + img.className = 'w-8 h-8 object-cover rounded border'; + img.id = `${fieldId}_logo_preview_${index}`; + + // Create hidden input for path + const pathInput = document.createElement('input'); + pathInput.type = 'hidden'; + pathInput.name = pathName; + pathInput.value = imageSrc; + + // Create hidden input for id + const idInput = document.createElement('input'); + idInput.type = 'hidden'; + idInput.name = idName; + idInput.value = String(uploadedFile.id); + + // 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 + event.target.value = ''; + } else { + const notifyFn = window.showNotification || alert; + notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error'); + } + }) + .catch(error => { + console.error('Upload error:', error); + const notifyFn = window.showNotification || alert; + notifyFn('Upload failed: ' + error.message, 'error'); + }); + }; + + console.log('[CustomFeedsWidget] Custom feeds widget registered'); +})(); diff --git a/web_interface/static/v3/js/widgets/example-color-picker.js b/web_interface/static/v3/js/widgets/example-color-picker.js new file mode 100644 index 00000000..ea0a1d8b --- /dev/null +++ b/web_interface/static/v3/js/widgets/example-color-picker.js @@ -0,0 +1,194 @@ +/** + * Example: Color Picker Widget + * + * This is an example custom widget demonstrating how to create + * a plugin-specific widget for the LEDMatrix system. + * + * To use this widget: + * 1. Copy this file to your plugin's widgets directory + * 2. Reference it in your config_schema.json with "x-widget": "color-picker" + * 3. The widget will be automatically loaded when the plugin config form is rendered + * + * @module ColorPickerWidget + */ + +(function() { + 'use strict'; + + // Ensure LEDMatrixWidgets registry exists + if (typeof window.LEDMatrixWidgets === 'undefined') { + console.error('[ColorPickerWidget] LEDMatrixWidgets registry not found. Load registry.js first.'); + return; + } + + /** + * Register the color picker widget + */ + window.LEDMatrixWidgets.register('color-picker', { + name: 'Color Picker Widget', + version: '1.0.0', + + /** + * Render the color picker widget + * @param {HTMLElement} container - Container element to render into + * @param {Object} config - Widget configuration from schema + * @param {string} value - Current color value (hex format) + * @param {Object} options - Additional options + */ + render: function(container, config, value, options) { + const fieldId = options.fieldId || container.id.replace('_widget_container', ''); + let currentValue = value || config.default || '#000000'; + + // Validate hex color format - use safe default if invalid + const hexColorRegex = /^#[0-9A-Fa-f]{6}$/; + if (!hexColorRegex.test(currentValue)) { + currentValue = '#000000'; + } + + // Escape HTML to prevent XSS (for HTML contexts) + const escapeHtml = (text) => { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }; + + // Use validated/sanitized hex for style attribute and input values + const safeHex = currentValue; // Already validated above + + container.innerHTML = ` +
+
+ + +
+
+ + +
+
+
+
+
+
+

Select a color using the color picker or enter a hex code

+ `; + + // Get references to elements + const colorInput = container.querySelector('input[type="color"]'); + const hexInput = container.querySelector('input[type="text"]'); + const preview = container.querySelector(`#${fieldId}_preview`); + + // Update hex when color picker changes + colorInput.addEventListener('input', (e) => { + const color = e.target.value; + hexInput.value = color; + if (preview) { + preview.style.backgroundColor = color; + } + this.handlers.onChange(fieldId, color); + }); + + // Update color picker and preview when hex input changes + hexInput.addEventListener('input', (e) => { + const hex = e.target.value; + // Validate hex format + if (/^#[0-9A-Fa-f]{6}$/.test(hex)) { + colorInput.value = hex; + if (preview) { + preview.style.backgroundColor = hex; + } + hexInput.classList.remove('border-red-500'); + hexInput.classList.add('border-gray-300'); + this.handlers.onChange(fieldId, hex); + } else if (hex.length > 0) { + // Show error state for invalid hex + hexInput.classList.remove('border-gray-300'); + hexInput.classList.add('border-red-500'); + } + }); + + // Validate on blur + hexInput.addEventListener('blur', (e) => { + const hex = e.target.value; + if (hex && !/^#[0-9A-Fa-f]{6}$/.test(hex)) { + // Reset to current color picker value + e.target.value = colorInput.value; + e.target.classList.remove('border-red-500'); + e.target.classList.add('border-gray-300'); + } + }); + }, + + /** + * Get current value from widget + * @param {string} fieldId - Field ID + * @returns {string} Current hex color value + */ + getValue: function(fieldId) { + const colorInput = document.querySelector(`#${fieldId}_color`); + return colorInput ? colorInput.value : null; + }, + + /** + * Set value programmatically + * @param {string} fieldId - Field ID + * @param {string} value - Hex color value to set + */ + setValue: function(fieldId, value) { + // Validate hex color format before using + const hexColorRegex = /^#[0-9A-Fa-f]{6}$/; + const safeValue = hexColorRegex.test(value) ? value : '#000000'; + + const colorInput = document.querySelector(`#${fieldId}_color`); + const hexInput = document.querySelector(`#${fieldId}_hex`); + const preview = document.querySelector(`#${fieldId}_preview`); + + if (colorInput && hexInput) { + colorInput.value = safeValue; + hexInput.value = safeValue; + if (preview) { + preview.style.backgroundColor = safeValue; + } + } + }, + + /** + * Event handlers + */ + handlers: { + /** + * Handle color change + * @param {string} fieldId - Field ID + * @param {string} value - New color value + */ + onChange: function(fieldId, value) { + // Trigger form change event for validation and saving + const event = new CustomEvent('widget-change', { + detail: { fieldId, value }, + bubbles: true, + cancelable: true + }); + document.dispatchEvent(event); + + // Also update any hidden input if it exists + const hiddenInput = document.querySelector(`input[name*="${fieldId}"][type="hidden"]`); + if (hiddenInput) { + hiddenInput.value = value; + } + } + } + }); + + console.log('[ColorPickerWidget] Color picker widget registered (example)'); +})(); diff --git a/web_interface/static/v3/js/widgets/file-upload.js b/web_interface/static/v3/js/widgets/file-upload.js new file mode 100644 index 00000000..9e0397cf --- /dev/null +++ b/web_interface/static/v3/js/widgets/file-upload.js @@ -0,0 +1,966 @@ +/** + * File Upload Widget + * + * Handles file uploads (primarily images) with drag-and-drop support, + * preview, delete, and scheduling functionality. + * + * @module FileUploadWidget + */ + +(function() { + 'use strict'; + + // Ensure LEDMatrixWidgets registry exists + if (typeof window.LEDMatrixWidgets === 'undefined') { + console.error('[FileUploadWidget] LEDMatrixWidgets registry not found. Load registry.js first.'); + return; + } + + /** + * Register the file-upload widget + */ + window.LEDMatrixWidgets.register('file-upload', { + name: 'File Upload Widget', + version: '1.0.0', + + /** + * Render the file upload widget + * Note: This widget is currently server-side rendered via Jinja2 template. + * This registration ensures the handlers are available globally. + * Future enhancement: Full client-side rendering support. + */ + render: function(container, config, value, options) { + // For now, widgets are server-side rendered + // This function is a placeholder for future client-side rendering + console.log('[FileUploadWidget] Render called (server-side rendered)'); + }, + + /** + * Get current value from widget + * @param {string} fieldId - Field ID + * @returns {Array} Array of uploaded files + */ + getValue: function(fieldId) { + return window.getCurrentImages ? window.getCurrentImages(fieldId) : []; + }, + + /** + * Set value in widget + * @param {string} fieldId - Field ID + * @param {Array} images - Array of image objects + */ + setValue: function(fieldId, images) { + if (window.updateImageList) { + window.updateImageList(fieldId, images); + } + }, + + handlers: { + // Handlers are attached to window for backwards compatibility + } + }); + + // ===== File Upload Handlers (Backwards Compatible) ===== + // These functions are called from the server-rendered template + + /** + * Handle file drop event + * @param {Event} event - Drop event + * @param {string} fieldId - Field ID + */ + window.handleFileDrop = function(event, fieldId) { + event.preventDefault(); + const files = event.dataTransfer.files; + if (files.length > 0) { + window.handleFiles(fieldId, Array.from(files)); + } + }; + + /** + * Handle file select event + * @param {Event} event - Change event + * @param {string} fieldId - Field ID + */ + window.handleFileSelect = function(event, fieldId) { + const files = event.target.files; + if (files.length > 0) { + window.handleFiles(fieldId, Array.from(files)); + } + }; + + /** + * Handle multiple files upload + * @param {string} fieldId - Field ID + * @param {Array} files - Files to upload + */ + window.handleFiles = async function(fieldId, files) { + const uploadConfig = window.getUploadConfig ? window.getUploadConfig(fieldId) : {}; + const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image'; + const maxFiles = uploadConfig.max_files || 10; + const maxSizeMB = uploadConfig.max_size_mb || 5; + const fileType = uploadConfig.file_type || 'image'; + const customUploadEndpoint = uploadConfig.endpoint || '/api/v3/plugins/assets/upload'; + + // Get allowed types from config, with fallback + const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp', 'image/gif']; + + // Get current files list + const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : []; + + // Validate file types and sizes first, build validFiles + const validFiles = []; + for (const file of files) { + if (file.size > maxSizeMB * 1024 * 1024) { + const notifyFn = window.showNotification || console.error; + notifyFn(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error'); + continue; + } + + if (fileType === 'json') { + // Validate JSON files + if (!file.name.toLowerCase().endsWith('.json')) { + const notifyFn = window.showNotification || console.error; + notifyFn(`File ${file.name} must be a JSON file (.json)`, 'error'); + continue; + } + } else { + // Validate image files using allowedTypes from config + if (!allowedTypes.includes(file.type)) { + const notifyFn = window.showNotification || console.error; + notifyFn(`File ${file.name} is not a valid image type`, 'error'); + continue; + } + } + + validFiles.push(file); + } + + // Check max files AFTER building validFiles + if (currentFiles.length + validFiles.length > maxFiles) { + const notifyFn = window.showNotification || console.error; + notifyFn(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${validFiles.length}.`, 'error'); + return; + } + + if (validFiles.length === 0) { + return; + } + + // Show upload progress + if (window.showUploadProgress) { + window.showUploadProgress(fieldId, validFiles.length); + } + + // Upload files + const formData = new FormData(); + if (fileType !== 'json') { + formData.append('plugin_id', pluginId); + } + validFiles.forEach(file => formData.append('files', file)); + + try { + const response = await fetch(customUploadEndpoint, { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (data.status === 'success') { + // Add uploaded files to current list + const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : []; + const newFiles = [...currentFiles, ...(data.uploaded_files || data.data?.files || [])]; + if (window.updateImageList) { + window.updateImageList(fieldId, newFiles); + } + + const notifyFn = window.showNotification || console.log; + notifyFn(`Successfully uploaded ${data.uploaded_files?.length || data.data?.files?.length || 0} ${fileType === 'json' ? 'file(s)' : 'image(s)'}`, 'success'); + } else { + const notifyFn = window.showNotification || console.error; + notifyFn(`Upload failed: ${data.message}`, 'error'); + } + } catch (error) { + console.error('Upload error:', error); + const notifyFn = window.showNotification || console.error; + notifyFn(`Upload error: ${error.message}`, 'error'); + } finally { + if (window.hideUploadProgress) { + window.hideUploadProgress(fieldId); + } + // Clear file input + const fileInput = document.getElementById(`${fieldId}_file_input`); + if (fileInput) { + fileInput.value = ''; + } + } + }; + + /** + * Delete uploaded image + * @param {string} fieldId - Field ID + * @param {string} imageId - Image ID + * @param {string} pluginId - Plugin ID + */ + window.deleteUploadedImage = async function(fieldId, imageId, pluginId) { + return window.deleteUploadedFile(fieldId, imageId, pluginId, 'image', null); + }; + + /** + * Delete uploaded file (generic) + * @param {string} fieldId - Field ID + * @param {string} fileId - File ID + * @param {string} pluginId - Plugin ID + * @param {string} fileType - File type ('image' or 'json') + * @param {string|null} customDeleteEndpoint - Custom delete endpoint + */ + window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType, customDeleteEndpoint) { + const fileTypeLabel = fileType === 'json' ? 'file' : 'image'; + if (!confirm(`Are you sure you want to delete this ${fileTypeLabel}?`)) { + return; + } + + try { + const deleteEndpoint = customDeleteEndpoint || (fileType === 'json' ? '/api/v3/plugins/of-the-day/json/delete' : '/api/v3/plugins/assets/delete'); + const requestBody = fileType === 'json' + ? { file_id: fileId } + : { plugin_id: pluginId, image_id: fileId }; + + const response = await fetch(deleteEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + const data = await response.json(); + + if (data.status === 'success') { + // Remove from current list - normalize types for comparison + const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : []; + const fileIdStr = String(fileId); + const newFiles = currentFiles.filter(file => { + const fileIdValue = String(file.id || file.category_name || ''); + return fileIdValue !== fileIdStr; + }); + if (window.updateImageList) { + window.updateImageList(fieldId, newFiles); + } + + const notifyFn = window.showNotification || console.log; + notifyFn(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success'); + } else { + const notifyFn = window.showNotification || console.error; + notifyFn(`Delete failed: ${data.message}`, 'error'); + } + } catch (error) { + console.error('Delete error:', error); + const notifyFn = window.showNotification || console.error; + notifyFn(`Delete error: ${error.message}`, 'error'); + } + }; + + /** + * Get upload configuration from schema + * @param {string} fieldId - Field ID + * @returns {Object} Upload configuration + */ + window.getUploadConfig = function(fieldId) { + // Extract config from schema + const schema = window.currentPluginConfig?.schema; + if (!schema || !schema.properties) return {}; + + // Find the property that matches this fieldId + // FieldId is like "image_config_images" for "image_config.images" + const key = fieldId.replace(/_/g, '.'); + const keys = key.split('.'); + let prop = schema.properties; + + for (const k of keys) { + if (prop && prop[k]) { + prop = prop[k]; + if (prop.properties && prop.type === 'object') { + prop = prop.properties; + } else if (prop.type === 'array' && prop['x-widget'] === 'file-upload') { + break; + } else { + break; + } + } + } + + // If we found an array with x-widget, get its config + if (prop && prop.type === 'array' && prop['x-widget'] === 'file-upload') { + return prop['x-upload-config'] || {}; + } + + // Try to find nested images array + if (schema.properties && schema.properties.image_config && + schema.properties.image_config.properties && + schema.properties.image_config.properties.images) { + const imagesProp = schema.properties.image_config.properties.images; + if (imagesProp['x-widget'] === 'file-upload') { + return imagesProp['x-upload-config'] || {}; + } + } + + return {}; + }; + + /** + * Get current images from hidden input + * @param {string} fieldId - Field ID + * @returns {Array} Array of image objects + */ + window.getCurrentImages = function(fieldId) { + const hiddenInput = document.getElementById(`${fieldId}_images_data`); + if (hiddenInput && hiddenInput.value) { + try { + return JSON.parse(hiddenInput.value); + } catch (e) { + console.error('Error parsing images data:', e); + } + } + return []; + }; + + /** + * Update image list display and hidden input + * Uses DOM creation to prevent XSS and preserves open schedule editors + * @param {string} fieldId - Field ID + * @param {Array} images - Array of image objects + */ + window.updateImageList = function(fieldId, images) { + const hiddenInput = document.getElementById(`${fieldId}_images_data`); + if (hiddenInput) { + hiddenInput.value = JSON.stringify(images); + } + + // Update the display + const imageList = document.getElementById(`${fieldId}_image_list`); + if (!imageList) return; + + const uploadConfig = window.getUploadConfig(fieldId); + const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image'; + + // Detect which schedule is currently open (if any) + const openScheduleId = (() => { + const existingItems = imageList.querySelectorAll('[id^="img_"]'); + for (const item of existingItems) { + const scheduleDiv = item.querySelector('[id^="schedule_"]'); + if (scheduleDiv && !scheduleDiv.classList.contains('hidden')) { + // Extract the ID from schedule_ + const match = scheduleDiv.id.match(/^schedule_(.+)$/); + if (match) { + return match[1]; + } + } + } + return null; + })(); + + // Preserve open schedule content if it exists + const preservedScheduleContent = openScheduleId ? (() => { + const scheduleDiv = document.getElementById(`schedule_${openScheduleId}`); + return scheduleDiv ? scheduleDiv.innerHTML : null; + })() : null; + + // Clear and rebuild using DOM creation + imageList.innerHTML = ''; + + images.forEach((img, idx) => { + const imgId = img.id || idx; + const sanitizedId = String(imgId).replace(/[^a-zA-Z0-9_-]/g, '_'); + const imgSchedule = img.schedule || {}; + const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always'; + const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown'; + + // Create container div + const container = document.createElement('div'); + container.id = `img_${sanitizedId}`; + container.className = 'bg-gray-50 p-3 rounded-lg border border-gray-200'; + + // Create main content div + const mainDiv = document.createElement('div'); + mainDiv.className = 'flex items-center justify-between mb-2'; + + // Create left section with image and info + const leftSection = document.createElement('div'); + leftSection.className = 'flex items-center space-x-3 flex-1'; + + // Create image element + const imgEl = document.createElement('img'); + const imgPath = String(img.path || '').replace(/^\/+/, ''); + imgEl.src = '/' + imgPath; + imgEl.alt = String(img.filename || ''); + imgEl.className = 'w-16 h-16 object-cover rounded'; + imgEl.addEventListener('error', function() { + this.style.display = 'none'; + if (this.nextElementSibling) { + this.nextElementSibling.style.display = 'block'; + } + }); + + // Create placeholder div for broken images + const placeholderDiv = document.createElement('div'); + placeholderDiv.style.display = 'none'; + placeholderDiv.className = 'w-16 h-16 bg-gray-200 rounded flex items-center justify-center'; + const placeholderIcon = document.createElement('i'); + placeholderIcon.className = 'fas fa-image text-gray-400'; + placeholderDiv.appendChild(placeholderIcon); + + // Create info div + const infoDiv = document.createElement('div'); + infoDiv.className = 'flex-1 min-w-0'; + + // Filename + const filenameP = document.createElement('p'); + filenameP.className = 'text-sm font-medium text-gray-900 truncate'; + filenameP.textContent = img.original_filename || img.filename || 'Image'; + + // Size and date + const sizeDateP = document.createElement('p'); + sizeDateP.className = 'text-xs text-gray-500'; + const fileSize = window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB'); + const uploadedDate = window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || ''); + sizeDateP.textContent = `${fileSize} • ${uploadedDate}`; + + // Schedule summary + const scheduleP = document.createElement('p'); + scheduleP.className = 'text-xs text-blue-600 mt-1'; + const clockIcon = document.createElement('i'); + clockIcon.className = 'fas fa-clock mr-1'; + scheduleP.appendChild(clockIcon); + scheduleP.appendChild(document.createTextNode(scheduleSummary)); + + infoDiv.appendChild(filenameP); + infoDiv.appendChild(sizeDateP); + infoDiv.appendChild(scheduleP); + + leftSection.appendChild(imgEl); + leftSection.appendChild(placeholderDiv); + leftSection.appendChild(infoDiv); + + // Create right section with buttons + const rightSection = document.createElement('div'); + rightSection.className = 'flex items-center space-x-2 ml-4'; + + // Schedule button + const scheduleBtn = document.createElement('button'); + scheduleBtn.type = 'button'; + scheduleBtn.className = 'text-blue-600 hover:text-blue-800 p-2'; + scheduleBtn.title = 'Schedule this image'; + scheduleBtn.dataset.fieldId = fieldId; + scheduleBtn.dataset.imageId = String(imgId); + scheduleBtn.dataset.imageIdx = String(idx); + scheduleBtn.addEventListener('click', function() { + window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId, parseInt(this.dataset.imageIdx, 10)); + }); + const scheduleIcon = document.createElement('i'); + scheduleIcon.className = 'fas fa-calendar-alt'; + scheduleBtn.appendChild(scheduleIcon); + + // Delete button + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'text-red-600 hover:text-red-800 p-2'; + deleteBtn.title = 'Delete image'; + deleteBtn.dataset.fieldId = fieldId; + deleteBtn.dataset.imageId = String(imgId); + deleteBtn.dataset.pluginId = pluginId; + deleteBtn.addEventListener('click', function() { + window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId); + }); + const deleteIcon = document.createElement('i'); + deleteIcon.className = 'fas fa-trash'; + deleteBtn.appendChild(deleteIcon); + + rightSection.appendChild(scheduleBtn); + rightSection.appendChild(deleteBtn); + + mainDiv.appendChild(leftSection); + mainDiv.appendChild(rightSection); + + // Create schedule container + const scheduleContainer = document.createElement('div'); + scheduleContainer.id = `schedule_${sanitizedId}`; + scheduleContainer.className = 'hidden mt-3 pt-3 border-t border-gray-300'; + + // Restore preserved schedule content if this is the open one + if (openScheduleId === sanitizedId && preservedScheduleContent) { + scheduleContainer.innerHTML = preservedScheduleContent; + scheduleContainer.classList.remove('hidden'); + } + + container.appendChild(mainDiv); + container.appendChild(scheduleContainer); + imageList.appendChild(container); + }); + }; + + /** + * Show upload progress + * @param {string} fieldId - Field ID + * @param {number} totalFiles - Total number of files + */ + window.showUploadProgress = function(fieldId, totalFiles) { + const dropZone = document.getElementById(`${fieldId}_drop_zone`); + if (dropZone) { + dropZone.innerHTML = ` + +

Uploading ${totalFiles} file(s)...

+ `; + dropZone.style.pointerEvents = 'none'; + } + }; + + /** + * Hide upload progress and restore drop zone + * @param {string} fieldId - Field ID + */ + window.hideUploadProgress = function(fieldId) { + const uploadConfig = window.getUploadConfig(fieldId); + const maxFiles = uploadConfig.max_files || 10; + const maxSizeMB = uploadConfig.max_size_mb || 5; + const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']; + + // Generate user-friendly extension list from allowedTypes + const extensionMap = { + 'image/png': 'PNG', + 'image/jpeg': 'JPG', + 'image/jpg': 'JPG', + 'image/bmp': 'BMP', + 'image/gif': 'GIF', + 'image/webp': 'WEBP' + }; + const extensions = allowedTypes + .map(type => extensionMap[type] || type.split('/')[1]?.toUpperCase() || type) + .filter((ext, idx, arr) => arr.indexOf(ext) === idx) // Remove duplicates + .join(', '); + const extensionText = extensions || 'PNG, JPG, GIF, BMP'; + + const dropZone = document.getElementById(`${fieldId}_drop_zone`); + if (dropZone) { + dropZone.innerHTML = ` + +

Drag and drop images here or click to browse

+

Max ${maxFiles} files, ${maxSizeMB}MB each (${extensionText})

+ `; + dropZone.style.pointerEvents = 'auto'; + } + }; + + /** + * Format file size + * @param {number} bytes - File size in bytes + * @returns {string} Formatted file size + */ + window.formatFileSize = function(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; + }; + + /** + * Format date string + * @param {string} dateString - Date string + * @returns {string} Formatted date + */ + window.formatDate = function(dateString) { + if (!dateString) return 'Unknown date'; + try { + const date = new Date(dateString); + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } catch (e) { + return dateString; + } + }; + + /** + * Get schedule summary text + * @param {Object} schedule - Schedule object + * @returns {string} Schedule summary + */ + window.getScheduleSummary = function(schedule) { + if (!schedule || !schedule.enabled || schedule.mode === 'always') { + return 'Always shown'; + } + + if (schedule.mode === 'time_range') { + return `${schedule.start_time || '08:00'} - ${schedule.end_time || '18:00'} (daily)`; + } + + if (schedule.mode === 'per_day' && schedule.days) { + const enabledDays = Object.entries(schedule.days) + .filter(([day, config]) => config && config.enabled) + .map(([day]) => day.charAt(0).toUpperCase() + day.slice(1, 3)); + + if (enabledDays.length === 0) { + return 'Never shown'; + } + + return enabledDays.join(', ') + ' only'; + } + + return 'Scheduled'; + }; + + /** + * Open image schedule editor + * @param {string} fieldId - Field ID + * @param {string|number} imageId - Image ID + * @param {number} imageIdx - Image index + */ + window.openImageSchedule = function(fieldId, imageId, imageIdx) { + const currentImages = window.getCurrentImages(fieldId); + const image = currentImages[imageIdx]; + if (!image) return; + + // Sanitize imageId to match updateImageList's sanitization + const sanitizedId = (imageId || imageIdx).toString().replace(/[^a-zA-Z0-9_-]/g, '_'); + const scheduleContainer = document.getElementById(`schedule_${sanitizedId}`); + if (!scheduleContainer) return; + + // Toggle visibility + const isVisible = !scheduleContainer.classList.contains('hidden'); + + if (isVisible) { + scheduleContainer.classList.add('hidden'); + return; + } + + scheduleContainer.classList.remove('hidden'); + + const schedule = image.schedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} }; + + // Escape HTML helper + const escapeHtml = (text) => { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }; + + // Use sanitizedId for all ID references in the schedule HTML + // Use data attributes instead of inline handlers to prevent JS injection + scheduleContainer.innerHTML = ` +
+

+ Schedule Settings +

+ + +
+ +

When enabled, this image will only display during scheduled times

+
+ + +
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ ${['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].map(day => { + const dayConfig = (schedule.days && schedule.days[day]) || { enabled: true, start_time: '08:00', end_time: '18:00' }; + return ` +
+
+ +
+
+ + +
+
+ `; + }).join('')} +
+
+
+
+ `; + + // Attach event listeners using data attributes (prevents JS injection) + const enabledCheckbox = document.getElementById(`schedule_enabled_${sanitizedId}`); + if (enabledCheckbox) { + enabledCheckbox.addEventListener('change', function() { + const fieldId = this.dataset.fieldId; + const imageId = this.dataset.imageId; + const imageIdx = parseInt(this.dataset.imageIdx, 10); + window.toggleImageScheduleEnabled(fieldId, imageId, imageIdx); + }); + } + + const modeSelect = document.getElementById(`schedule_mode_${sanitizedId}`); + if (modeSelect) { + modeSelect.addEventListener('change', function() { + const fieldId = this.dataset.fieldId; + const imageId = this.dataset.imageId; + const imageIdx = parseInt(this.dataset.imageIdx, 10); + window.updateImageScheduleMode(fieldId, imageId, imageIdx); + }); + } + + const startInput = document.getElementById(`schedule_start_${sanitizedId}`); + if (startInput) { + startInput.addEventListener('change', function() { + const fieldId = this.dataset.fieldId; + const imageId = this.dataset.imageId; + const imageIdx = parseInt(this.dataset.imageIdx, 10); + window.updateImageScheduleTime(fieldId, imageId, imageIdx); + }); + } + + const endInput = document.getElementById(`schedule_end_${sanitizedId}`); + if (endInput) { + endInput.addEventListener('change', function() { + const fieldId = this.dataset.fieldId; + const imageId = this.dataset.imageId; + const imageIdx = parseInt(this.dataset.imageIdx, 10); + window.updateImageScheduleTime(fieldId, imageId, imageIdx); + }); + } + + // Attach listeners for per-day inputs + ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].forEach(day => { + const dayCheckbox = document.getElementById(`day_${day}_${sanitizedId}`); + if (dayCheckbox) { + dayCheckbox.addEventListener('change', function() { + const fieldId = this.dataset.fieldId; + const imageId = this.dataset.imageId; + const imageIdx = parseInt(this.dataset.imageIdx, 10); + const day = this.dataset.day; + window.updateImageScheduleDay(fieldId, imageId, imageIdx, day); + }); + } + + const dayStartInput = document.getElementById(`day_${day}_start_${sanitizedId}`); + if (dayStartInput) { + dayStartInput.addEventListener('change', function() { + const fieldId = this.dataset.fieldId; + const imageId = this.dataset.imageId; + const imageIdx = parseInt(this.dataset.imageIdx, 10); + const day = this.dataset.day; + window.updateImageScheduleDay(fieldId, imageId, imageIdx, day); + }); + } + + const dayEndInput = document.getElementById(`day_${day}_end_${sanitizedId}`); + if (dayEndInput) { + dayEndInput.addEventListener('change', function() { + const fieldId = this.dataset.fieldId; + const imageId = this.dataset.imageId; + const imageIdx = parseInt(this.dataset.imageIdx, 10); + const day = this.dataset.day; + window.updateImageScheduleDay(fieldId, imageId, imageIdx, day); + }); + } + }); + }; + + /** + * Toggle image schedule enabled state + */ + window.toggleImageScheduleEnabled = function(fieldId, imageId, imageIdx) { + const currentImages = window.getCurrentImages(fieldId); + const image = currentImages[imageIdx]; + if (!image) return; + + // Sanitize imageId for DOM lookup + const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_'); + const checkbox = document.getElementById(`schedule_enabled_${sanitizedId}`); + const enabled = checkbox ? checkbox.checked : false; + + if (!image.schedule) { + image.schedule = { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} }; + } + + image.schedule.enabled = enabled; + + const optionsDiv = document.getElementById(`schedule_options_${sanitizedId}`); + if (optionsDiv) { + optionsDiv.style.display = enabled ? 'block' : 'none'; + } + + if (window.updateImageList) { + window.updateImageList(fieldId, currentImages); + } + }; + + /** + * Update image schedule mode + */ + window.updateImageScheduleMode = function(fieldId, imageId, imageIdx) { + const currentImages = window.getCurrentImages(fieldId); + const image = currentImages[imageIdx]; + if (!image) return; + + // Sanitize imageId for DOM lookup + const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_'); + + if (!image.schedule) { + image.schedule = { enabled: true, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} }; + } + + const modeSelect = document.getElementById(`schedule_mode_${sanitizedId}`); + const mode = modeSelect ? modeSelect.value : 'always'; + + image.schedule.mode = mode; + + const timeRangeDiv = document.getElementById(`time_range_${sanitizedId}`); + const perDayDiv = document.getElementById(`per_day_${sanitizedId}`); + + if (timeRangeDiv) timeRangeDiv.style.display = mode === 'time_range' ? 'grid' : 'none'; + if (perDayDiv) perDayDiv.style.display = mode === 'per_day' ? 'block' : 'none'; + + if (window.updateImageList) { + window.updateImageList(fieldId, currentImages); + } + }; + + /** + * Update image schedule time + */ + window.updateImageScheduleTime = function(fieldId, imageId, imageIdx) { + const currentImages = window.getCurrentImages(fieldId); + const image = currentImages[imageIdx]; + if (!image) return; + + // Sanitize imageId for DOM lookup + const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_'); + + if (!image.schedule) { + image.schedule = { enabled: true, mode: 'time_range', start_time: '08:00', end_time: '18:00' }; + } + + const startInput = document.getElementById(`schedule_start_${sanitizedId}`); + const endInput = document.getElementById(`schedule_end_${sanitizedId}`); + + if (startInput) image.schedule.start_time = startInput.value || '08:00'; + if (endInput) image.schedule.end_time = endInput.value || '18:00'; + + if (window.updateImageList) { + window.updateImageList(fieldId, currentImages); + } + }; + + /** + * Update image schedule day + */ + window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) { + const currentImages = window.getCurrentImages(fieldId); + const image = currentImages[imageIdx]; + if (!image) return; + + // Sanitize imageId for DOM lookup + const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_'); + + if (!image.schedule) { + image.schedule = { enabled: true, mode: 'per_day', days: {} }; + } + + if (!image.schedule.days) { + image.schedule.days = {}; + } + + const checkbox = document.getElementById(`day_${day}_${sanitizedId}`); + const startInput = document.getElementById(`day_${day}_start_${sanitizedId}`); + const endInput = document.getElementById(`day_${day}_end_${sanitizedId}`); + + const enabled = checkbox ? checkbox.checked : true; + + if (!image.schedule.days[day]) { + image.schedule.days[day] = { enabled: true, start_time: '08:00', end_time: '18:00' }; + } + + image.schedule.days[day].enabled = enabled; + if (startInput) image.schedule.days[day].start_time = startInput.value || '08:00'; + if (endInput) image.schedule.days[day].end_time = endInput.value || '18:00'; + + const dayTimesDiv = document.getElementById(`day_times_${day}_${sanitizedId}`); + if (dayTimesDiv) { + dayTimesDiv.style.display = enabled ? 'grid' : 'none'; + } + if (startInput) startInput.disabled = !enabled; + if (endInput) endInput.disabled = !enabled; + + if (window.updateImageList) { + window.updateImageList(fieldId, currentImages); + } + }; + + console.log('[FileUploadWidget] File upload widget registered'); +})(); diff --git a/web_interface/static/v3/js/widgets/plugin-loader.js b/web_interface/static/v3/js/widgets/plugin-loader.js new file mode 100644 index 00000000..77e6d5ce --- /dev/null +++ b/web_interface/static/v3/js/widgets/plugin-loader.js @@ -0,0 +1,129 @@ +/** + * Plugin Widget Loader + * + * Handles loading of plugin-specific custom widgets from plugin directories. + * Allows third-party plugins to provide their own widget implementations. + * + * @module PluginWidgetLoader + */ + +(function() { + 'use strict'; + + // Ensure LEDMatrixWidgets registry exists + if (typeof window.LEDMatrixWidgets === 'undefined') { + console.error('[PluginWidgetLoader] LEDMatrixWidgets registry not found. Load registry.js first.'); + return; + } + + /** + * Load a plugin-specific widget + * @param {string} pluginId - Plugin ID + * @param {string} widgetName - Widget name + * @returns {Promise} Promise that resolves when widget is loaded + */ + window.LEDMatrixWidgets.loadPluginWidget = async function(pluginId, widgetName) { + if (!pluginId || !widgetName) { + throw new Error('Plugin ID and widget name are required'); + } + + // Check if widget is already registered + if (this.has(widgetName)) { + console.log(`[PluginWidgetLoader] Widget ${widgetName} already registered`); + return; + } + + // Try multiple possible paths for plugin widgets + const possiblePaths = [ + `/static/plugin-widgets/${pluginId}/${widgetName}.js`, + `/plugins/${pluginId}/widgets/${widgetName}.js`, + `/static/plugins/${pluginId}/widgets/${widgetName}.js` + ]; + + let lastError = null; + for (const widgetPath of possiblePaths) { + try { + // Dynamic import of plugin widget + await import(widgetPath); + console.log(`[PluginWidgetLoader] Loaded plugin widget: ${pluginId}/${widgetName} from ${widgetPath}`); + + // Verify widget was registered + if (this.has(widgetName)) { + return; + } else { + console.warn(`[PluginWidgetLoader] Widget ${widgetName} loaded but not registered. Make sure the script calls LEDMatrixWidgets.register().`); + } + } catch (error) { + lastError = error; + // Continue to next path + continue; + } + } + + // If all paths failed, throw error + throw new Error(`Failed to load plugin widget ${pluginId}/${widgetName} from any path. Last error: ${lastError?.message || 'Unknown error'}`); + }; + + /** + * Auto-load widget when detected in schema + * Called automatically when a widget is referenced in a plugin's config schema + * @param {string} widgetName - Widget name + * @param {string} pluginId - Plugin ID (optional, for plugin-specific widgets) + * @returns {Promise} True if widget is available (either already registered or successfully loaded) + */ + window.LEDMatrixWidgets.ensureWidget = async function(widgetName, pluginId) { + // Check if widget is already registered + if (this.has(widgetName)) { + return true; + } + + // If plugin ID provided, try to load as plugin widget + if (pluginId) { + try { + await this.loadPluginWidget(pluginId, widgetName); + return this.has(widgetName); + } catch (error) { + console.warn(`[PluginWidgetLoader] Could not load widget ${widgetName} from plugin ${pluginId}:`, error); + // Continue to check if it's a core widget + } + } + + // Widget not found + return false; + }; + + /** + * Load all widgets specified in plugin manifest + * @param {string} pluginId - Plugin ID + * @param {Object} manifest - Plugin manifest object + * @returns {Promise>} Array of successfully loaded widget names + */ + window.LEDMatrixWidgets.loadPluginWidgetsFromManifest = async function(pluginId, manifest) { + if (!manifest || !manifest.widgets || !Array.isArray(manifest.widgets)) { + return []; + } + + const loadedWidgets = []; + + for (const widgetDef of manifest.widgets) { + const widgetName = widgetDef.name || widgetDef.script?.replace(/\.js$/, ''); + if (!widgetName) { + console.warn(`[PluginWidgetLoader] Invalid widget definition in manifest:`, widgetDef); + continue; + } + + try { + await this.loadPluginWidget(pluginId, widgetName); + if (this.has(widgetName)) { + loadedWidgets.push(widgetName); + } + } catch (error) { + console.error(`[PluginWidgetLoader] Failed to load widget ${widgetName} from plugin ${pluginId}:`, error); + } + } + + return loadedWidgets; + }; + + console.log('[PluginWidgetLoader] Plugin widget loader initialized'); +})(); diff --git a/web_interface/static/v3/js/widgets/registry.js b/web_interface/static/v3/js/widgets/registry.js new file mode 100644 index 00000000..a3687121 --- /dev/null +++ b/web_interface/static/v3/js/widgets/registry.js @@ -0,0 +1,217 @@ +/** + * LEDMatrix Widget Registry + * + * Central registry for all UI widgets used in plugin configuration forms. + * Allows plugins to use existing widgets and enables third-party developers + * to create custom widgets without modifying the LEDMatrix codebase. + * + * @module LEDMatrixWidgets + */ + +(function() { + 'use strict'; + + // Global widget registry + window.LEDMatrixWidgets = { + _widgets: new Map(), + _handlers: new Map(), + + /** + * Register a widget with the registry + * @param {string} widgetName - Unique identifier for the widget + * @param {Object} definition - Widget definition object + * @param {string} definition.name - Human-readable widget name + * @param {string} definition.version - Widget version + * @param {Function} definition.render - Function to render the widget HTML + * @param {Function} definition.getValue - Function to get current widget value + * @param {Function} definition.setValue - Function to set widget value programmatically + * @param {Object} definition.handlers - Event handlers for the widget + */ + register: function(widgetName, definition) { + if (!widgetName || typeof widgetName !== 'string') { + console.error('[WidgetRegistry] Invalid widget name:', widgetName); + return false; + } + + if (!definition || typeof definition !== 'object') { + console.error('[WidgetRegistry] Invalid widget definition for:', widgetName); + return false; + } + + // Validate required properties + if (typeof definition.render !== 'function') { + console.error('[WidgetRegistry] Widget must have a render function:', widgetName); + return false; + } + + this._widgets.set(widgetName, definition); + + if (definition.handlers) { + this._handlers.set(widgetName, definition.handlers); + } + + console.log(`[WidgetRegistry] Registered widget: ${widgetName}`); + return true; + }, + + /** + * Get widget definition + * @param {string} widgetName - Widget identifier + * @returns {Object|null} Widget definition or null if not found + */ + get: function(widgetName) { + return this._widgets.get(widgetName) || null; + }, + + /** + * Get widget handlers + * @param {string} widgetName - Widget identifier + * @returns {Object} Widget handlers object (empty object if not found) + */ + getHandlers: function(widgetName) { + return this._handlers.get(widgetName) || {}; + }, + + /** + * Check if widget exists in registry + * @param {string} widgetName - Widget identifier + * @returns {boolean} True if widget is registered + */ + has: function(widgetName) { + return this._widgets.has(widgetName); + }, + + /** + * List all registered widgets + * @returns {Array} Array of widget names + */ + list: function() { + return Array.from(this._widgets.keys()); + }, + + /** + * Render a widget into a container element + * @param {string} widgetName - Widget identifier + * @param {string|HTMLElement} container - Container element or ID + * @param {Object} config - Widget configuration from schema + * @param {*} value - Current value for the widget + * @param {Object} options - Additional options (fieldId, pluginId, etc.) + * @returns {boolean} True if rendering succeeded + */ + render: function(widgetName, container, config, value, options) { + const widget = this.get(widgetName); + if (!widget) { + console.error(`[WidgetRegistry] Widget not found: ${widgetName}`); + return false; + } + + // Resolve container element + let containerEl = container; + if (typeof container === 'string') { + containerEl = document.getElementById(container); + if (!containerEl) { + console.error(`[WidgetRegistry] Container not found: ${container}`); + return false; + } + } + + if (!containerEl || !(containerEl instanceof HTMLElement)) { + console.error('[WidgetRegistry] Invalid container element'); + return false; + } + + try { + // Call widget's render function + widget.render(containerEl, config, value, options || {}); + return true; + } catch (error) { + console.error(`[WidgetRegistry] Error rendering widget ${widgetName}:`, error); + return false; + } + }, + + /** + * Get current value from a widget + * @param {string} widgetName - Widget identifier + * @param {string} fieldId - Field ID + * @returns {*} Current widget value + */ + getValue: function(widgetName, fieldId) { + const widget = this.get(widgetName); + if (!widget || typeof widget.getValue !== 'function') { + console.warn(`[WidgetRegistry] Widget ${widgetName} does not support getValue`); + return null; + } + + try { + return widget.getValue(fieldId); + } catch (error) { + console.error(`[WidgetRegistry] Error getting value from widget ${widgetName}:`, error); + return null; + } + }, + + /** + * Set value in a widget + * @param {string} widgetName - Widget identifier + * @param {string} fieldId - Field ID + * @param {*} value - Value to set + * @returns {boolean} True if setting succeeded + */ + setValue: function(widgetName, fieldId, value) { + const widget = this.get(widgetName); + if (!widget || typeof widget.setValue !== 'function') { + console.warn(`[WidgetRegistry] Widget ${widgetName} does not support setValue`); + return false; + } + + try { + widget.setValue(fieldId, value); + return true; + } catch (error) { + console.error(`[WidgetRegistry] Error setting value in widget ${widgetName}:`, error); + return false; + } + }, + + /** + * Unregister a widget (for testing/cleanup) + * @param {string} widgetName - Widget identifier + * @returns {boolean} True if widget was removed + */ + unregister: function(widgetName) { + const removed = this._widgets.delete(widgetName); + this._handlers.delete(widgetName); + if (removed) { + console.log(`[WidgetRegistry] Unregistered widget: ${widgetName}`); + } + return removed; + }, + + /** + * Clear all registered widgets (for testing/cleanup) + */ + clear: function() { + this._widgets.clear(); + this._handlers.clear(); + console.log('[WidgetRegistry] Cleared all widgets'); + } + }; + + // Expose registry for debugging + if (typeof window !== 'undefined' && window.console) { + window.LEDMatrixWidgets.debug = function() { + console.log('[WidgetRegistry] Registered widgets:', Array.from(this._widgets.keys())); + console.log('[WidgetRegistry] Widget details:', Array.from(this._widgets.entries()).map(([name, def]) => ({ + name, + version: def.version || 'unknown', + hasRender: typeof def.render === 'function', + hasGetValue: typeof def.getValue === 'function', + hasSetValue: typeof def.setValue === 'function', + hasHandlers: !!def.handlers + }))); + }; + } + + console.log('[WidgetRegistry] Widget registry initialized'); +})(); diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 532ce175..c05fe043 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -161,6 +161,199 @@ window.uninstallPlugin = window.uninstallPlugin || function(pluginId) { }); }; +// Define configurePlugin early to ensure it's always available +window.configurePlugin = window.configurePlugin || async function(pluginId) { + if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] configurePlugin called for', pluginId); + + // Switch to the plugin's configuration tab instead of opening a modal + // This matches the behavior of clicking the plugin tab at the top + function getAppComponent() { + if (window.Alpine) { + const appElement = document.querySelector('[x-data="app()"]'); + if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) { + return appElement._x_dataStack[0]; + } + } + return null; + } + + const appComponent = getAppComponent(); + if (appComponent) { + // Set the active tab to the plugin ID + appComponent.activeTab = pluginId; + if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] Switched to plugin tab:', pluginId); + + // Scroll to top of page to ensure the tab is visible + window.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + console.error('Alpine.js app instance not found'); + if (typeof showNotification === 'function') { + showNotification('Unable to switch to plugin configuration. Please refresh the page.', 'error'); + } + } +}; + +// Initialize per-plugin toggle request token map for race condition protection +if (!window._pluginToggleRequests) { + window._pluginToggleRequests = {}; +} + +// Define togglePlugin early to ensure it's always available +window.togglePlugin = window.togglePlugin || function(pluginId, enabled) { + if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] togglePlugin called for', pluginId, 'enabled:', enabled); + + const plugin = (window.installedPlugins || []).find(p => p.id === pluginId); + const pluginName = plugin ? (plugin.name || pluginId) : pluginId; + const action = enabled ? 'enabling' : 'disabling'; + + // Generate unique token for this toggle request to prevent race conditions + const requestToken = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + window._pluginToggleRequests[pluginId] = requestToken; + + // Update UI immediately for better UX + const toggleCheckbox = document.getElementById(`toggle-${pluginId}`); + const toggleLabel = document.getElementById(`toggle-label-${pluginId}`); + const wrapperDiv = toggleCheckbox?.parentElement?.querySelector('.flex.items-center.gap-2'); + const toggleTrack = wrapperDiv?.querySelector('.relative.w-14'); + const toggleHandle = toggleTrack?.querySelector('.absolute'); + + // Disable checkbox and add disabled class to prevent overlapping requests + if (toggleCheckbox) { + toggleCheckbox.checked = enabled; + toggleCheckbox.disabled = true; + toggleCheckbox.classList.add('opacity-50', 'cursor-not-allowed'); + } + + // Disable wrapper to provide visual feedback + if (wrapperDiv) { + wrapperDiv.classList.add('opacity-50', 'pointer-events-none'); + } + + // Update wrapper background and border + if (wrapperDiv) { + if (enabled) { + wrapperDiv.classList.remove('bg-gray-50', 'border-gray-300'); + wrapperDiv.classList.add('bg-green-50', 'border-green-500'); + } else { + wrapperDiv.classList.remove('bg-green-50', 'border-green-500'); + wrapperDiv.classList.add('bg-gray-50', 'border-gray-300'); + } + } + + // Update toggle track + if (toggleTrack) { + if (enabled) { + toggleTrack.classList.remove('bg-gray-300'); + toggleTrack.classList.add('bg-green-500'); + } else { + toggleTrack.classList.remove('bg-green-500'); + toggleTrack.classList.add('bg-gray-300'); + } + } + + // Update toggle handle + if (toggleHandle) { + if (enabled) { + toggleHandle.classList.add('translate-x-full', 'border-green-500'); + toggleHandle.classList.remove('border-gray-400'); + toggleHandle.innerHTML = ''; + } else { + toggleHandle.classList.remove('translate-x-full', 'border-green-500'); + toggleHandle.classList.add('border-gray-400'); + toggleHandle.innerHTML = ''; + } + } + + // Update label with icon and text + if (toggleLabel) { + if (enabled) { + toggleLabel.className = 'text-sm font-semibold text-green-700 flex items-center gap-1.5'; + toggleLabel.innerHTML = 'Enabled'; + } else { + toggleLabel.className = 'text-sm font-semibold text-gray-600 flex items-center gap-1.5'; + toggleLabel.innerHTML = 'Disabled'; + } + } + + if (typeof showNotification === 'function') { + showNotification(`${action.charAt(0).toUpperCase() + action.slice(1)} ${pluginName}...`, 'info'); + } + + fetch('/api/v3/plugins/toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plugin_id: pluginId, enabled: enabled }) + }) + .then(response => response.json()) + .then(data => { + // Verify this response is for the latest request (prevent race conditions) + if (window._pluginToggleRequests[pluginId] !== requestToken) { + console.log(`[togglePlugin] Ignoring out-of-order response for ${pluginId}`); + return; + } + + if (typeof showNotification === 'function') { + showNotification(data.message, data.status); + } + if (data.status === 'success') { + // Update local state + if (plugin) { + plugin.enabled = enabled; + } + // Refresh the list to ensure consistency + if (typeof loadInstalledPlugins === 'function') { + loadInstalledPlugins(); + } + } else { + // Revert the toggle if API call failed + if (plugin) { + plugin.enabled = !enabled; + } + if (typeof loadInstalledPlugins === 'function') { + loadInstalledPlugins(); + } + } + + // Clear token and re-enable UI + delete window._pluginToggleRequests[pluginId]; + if (toggleCheckbox) { + toggleCheckbox.disabled = false; + toggleCheckbox.classList.remove('opacity-50', 'cursor-not-allowed'); + } + if (wrapperDiv) { + wrapperDiv.classList.remove('opacity-50', 'pointer-events-none'); + } + }) + .catch(error => { + // Verify this error is for the latest request (prevent race conditions) + if (window._pluginToggleRequests[pluginId] !== requestToken) { + console.log(`[togglePlugin] Ignoring out-of-order error for ${pluginId}`); + return; + } + + if (typeof showNotification === 'function') { + showNotification('Error toggling plugin: ' + error.message, 'error'); + } + // Revert the toggle if API call failed + if (plugin) { + plugin.enabled = !enabled; + } + if (typeof loadInstalledPlugins === 'function') { + loadInstalledPlugins(); + } + + // Clear token and re-enable UI + delete window._pluginToggleRequests[pluginId]; + if (toggleCheckbox) { + toggleCheckbox.disabled = false; + toggleCheckbox.classList.remove('opacity-50', 'cursor-not-allowed'); + } + if (wrapperDiv) { + wrapperDiv.classList.remove('opacity-50', 'pointer-events-none'); + } + }); +}; + // Cleanup orphaned modals from previous executions to prevent duplicates when moving to body try { const existingModals = document.querySelectorAll('#plugin-config-modal'); @@ -306,146 +499,8 @@ window.__pluginDomReady = window.__pluginDomReady || false; console.log('[PLUGINS SCRIPT] Global event delegation set up'); })(); -window.configurePlugin = window.configurePlugin || async function(pluginId) { - console.log('[DEBUG] ===== configurePlugin called ====='); - console.log('[DEBUG] Plugin ID:', pluginId); - - // Switch to the plugin's configuration tab instead of opening a modal - // This matches the behavior of clicking the plugin tab at the top - function getAppComponent() { - if (window.Alpine) { - const appElement = document.querySelector('[x-data="app()"]'); - if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) { - return appElement._x_dataStack[0]; - } - } - return null; - } - - const appComponent = getAppComponent(); - if (appComponent) { - // Set the active tab to the plugin ID - appComponent.activeTab = pluginId; - console.log('[DEBUG] Switched to plugin tab:', pluginId); - - // Scroll to top of page to ensure the tab is visible - window.scrollTo({ top: 0, behavior: 'smooth' }); - } else { - console.error('Alpine.js app instance not found'); - if (typeof showNotification === 'function') { - showNotification('Unable to switch to plugin configuration. Please refresh the page.', 'error'); - } - } -}; - -window.togglePlugin = window.togglePlugin || function(pluginId, enabled) { - console.log('[DEBUG] ===== togglePlugin called ====='); - console.log('[DEBUG] Plugin ID:', pluginId, 'Enabled:', enabled); - const plugin = (window.installedPlugins || []).find(p => p.id === pluginId); - const pluginName = plugin ? (plugin.name || pluginId) : pluginId; - const action = enabled ? 'enabling' : 'disabling'; - - // Update UI immediately for better UX - const toggleCheckbox = document.getElementById(`toggle-${pluginId}`); - const toggleLabel = document.getElementById(`toggle-label-${pluginId}`); - const wrapperDiv = toggleCheckbox?.parentElement?.querySelector('.flex.items-center.gap-2'); - const toggleTrack = wrapperDiv?.querySelector('.relative.w-14'); - const toggleHandle = toggleTrack?.querySelector('.absolute'); - - if (toggleCheckbox) toggleCheckbox.checked = enabled; - - // Update wrapper background and border - if (wrapperDiv) { - if (enabled) { - wrapperDiv.classList.remove('bg-gray-50', 'border-gray-300'); - wrapperDiv.classList.add('bg-green-50', 'border-green-500'); - } else { - wrapperDiv.classList.remove('bg-green-50', 'border-green-500'); - wrapperDiv.classList.add('bg-gray-50', 'border-gray-300'); - } - } - - // Update toggle track - if (toggleTrack) { - if (enabled) { - toggleTrack.classList.remove('bg-gray-300'); - toggleTrack.classList.add('bg-green-500'); - } else { - toggleTrack.classList.remove('bg-green-500'); - toggleTrack.classList.add('bg-gray-300'); - } - } - - // Update toggle handle - if (toggleHandle) { - if (enabled) { - toggleHandle.classList.add('translate-x-full', 'border-green-500'); - toggleHandle.classList.remove('border-gray-400'); - toggleHandle.innerHTML = ''; - } else { - toggleHandle.classList.remove('translate-x-full', 'border-green-500'); - toggleHandle.classList.add('border-gray-400'); - toggleHandle.innerHTML = ''; - } - } - - // Update label with icon and text - if (toggleLabel) { - if (enabled) { - toggleLabel.className = 'text-sm font-semibold text-green-700 flex items-center gap-1.5'; - toggleLabel.innerHTML = 'Enabled'; - } else { - toggleLabel.className = 'text-sm font-semibold text-gray-600 flex items-center gap-1.5'; - toggleLabel.innerHTML = 'Disabled'; - } - } - - if (typeof showNotification === 'function') { - showNotification(`${action.charAt(0).toUpperCase() + action.slice(1)} ${pluginName}...`, 'info'); - } - - fetch('/api/v3/plugins/toggle', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ plugin_id: pluginId, enabled: enabled }) - }) - .then(response => response.json()) - .then(data => { - if (typeof showNotification === 'function') { - showNotification(data.message, data.status); - } - if (data.status === 'success') { - // Update local state - if (plugin) { - plugin.enabled = enabled; - } - // Refresh the list to ensure consistency - if (typeof loadInstalledPlugins === 'function') { - loadInstalledPlugins(); - } - } else { - // Revert the toggle if API call failed - if (plugin) { - plugin.enabled = !enabled; - } - if (typeof loadInstalledPlugins === 'function') { - loadInstalledPlugins(); - } - } - }) - .catch(error => { - if (typeof showNotification === 'function') { - showNotification('Error toggling plugin: ' + error.message, 'error'); - } - // Revert the toggle if API call failed - if (plugin) { - plugin.enabled = !enabled; - } - if (typeof loadInstalledPlugins === 'function') { - loadInstalledPlugins(); - } - }); -}; +// Note: configurePlugin and togglePlugin are now defined at the top of the file (after uninstallPlugin) +// to ensure they're available immediately when the script loads // Verify functions are defined (debug only) if (_PLUGIN_DEBUG_EARLY) { @@ -2145,6 +2200,19 @@ function getSchemaPropertyType(schema, path) { return prop; // Return the full property object (was returning just type, but callers expect object) } +// Helper function to escape CSS selector special characters +function escapeCssSelector(str) { + if (typeof str !== 'string') { + str = String(str); + } + // Use CSS.escape() when available (handles unicode, leading digits, and edge cases) + if (typeof CSS !== 'undefined' && CSS.escape) { + return CSS.escape(str); + } + // Fallback to regex-based escaping for older browsers + return str.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&'); +} + // Helper function to convert dot notation to nested object function dotToNested(obj) { const result = {}; @@ -2240,6 +2308,12 @@ function handlePluginConfigSubmit(e) { } } + // Skip checkbox-group inputs with bracket notation (they're handled by the hidden _data input) + // Pattern: fieldName[] - these are individual checkboxes, actual data is in fieldName_data + if (key.endsWith('[]')) { + continue; + } + // Skip key_value pair inputs (they're handled by the hidden _data input) if (key.includes('[key_') || key.includes('[value_')) { continue; @@ -2310,8 +2384,35 @@ function handlePluginConfigSubmit(e) { } else if (propType === 'number') { flatConfig[actualKey] = parseFloat(actualValue); } else if (propType === 'boolean') { - const formElement = form.elements[actualKey] || form.elements[key]; - flatConfig[actualKey] = formElement ? formElement.checked : (actualValue === 'true' || actualValue === true); + // Use querySelector to reliably find checkbox by name attribute + // Escape special CSS selector characters in the name + const escapedKey = escapeCssSelector(key); + const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`); + + if (formElement) { + // Element found - use its checked state + flatConfig[actualKey] = formElement.checked; + } else { + // Element not found - normalize string booleans and check FormData value + // Checkboxes send "on" when checked, nothing when unchecked + // Normalize string representations of booleans + if (typeof actualValue === 'string') { + const lowerValue = actualValue.toLowerCase().trim(); + if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') { + flatConfig[actualKey] = true; + } else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') { + flatConfig[actualKey] = false; + } else { + // Non-empty string that's not a boolean representation - treat as truthy + flatConfig[actualKey] = true; + } + } else if (actualValue === undefined || actualValue === null) { + flatConfig[actualKey] = false; + } else { + // Non-string value - coerce to boolean + flatConfig[actualKey] = Boolean(actualValue); + } + } } else { flatConfig[actualKey] = actualValue; } @@ -2334,11 +2435,29 @@ function handlePluginConfigSubmit(e) { flatConfig[actualKey] = actualValue; } } else { - const formElement = form.elements[actualKey] || form.elements[key]; + // No schema - try to detect checkbox by finding the element + const escapedKey = escapeCssSelector(key); + const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`); + if (formElement && formElement.type === 'checkbox') { + // Found checkbox element - use its checked state flatConfig[actualKey] = formElement.checked; } else { - flatConfig[actualKey] = actualValue; + // Not a checkbox or element not found - normalize string booleans + if (typeof actualValue === 'string') { + const lowerValue = actualValue.toLowerCase().trim(); + if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') { + flatConfig[actualKey] = true; + } else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') { + flatConfig[actualKey] = false; + } else { + // Non-empty string that's not a boolean representation - keep as string + flatConfig[actualKey] = actualValue; + } + } else { + // Non-string value - use as-is + flatConfig[actualKey] = actualValue; + } } } } @@ -2947,20 +3066,20 @@ function generateFieldHtml(key, prop, value, prefix = '') { // Check for file-upload widget FIRST (to avoid breaking static-image plugin) if (xWidgetValue === 'file-upload' || xWidgetValue2 === 'file-upload') { - console.log(`[DEBUG] ✅ Detected file-upload widget for ${fullKey} - rendering upload zone`); - const uploadConfig = prop['x-upload-config'] || {}; - const pluginId = uploadConfig.plugin_id || currentPluginConfig?.pluginId || 'static-image'; - const maxFiles = uploadConfig.max_files || 10; - const fileType = uploadConfig.file_type || 'image'; // 'image' or 'json' - const allowedTypes = uploadConfig.allowed_types || (fileType === 'json' ? ['application/json'] : ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']); - const maxSizeMB = uploadConfig.max_size_mb || 5; - const customUploadEndpoint = uploadConfig.endpoint; // Custom endpoint if specified - const customDeleteEndpoint = uploadConfig.delete_endpoint; // Custom delete endpoint if specified - - const currentFiles = Array.isArray(value) ? value : []; - const fieldId = fullKey.replace(/\./g, '_'); - - html += ` + console.log(`[DEBUG] ✅ Detected file-upload widget for ${fullKey} - rendering upload zone`); + const uploadConfig = prop['x-upload-config'] || {}; + const pluginId = uploadConfig.plugin_id || currentPluginConfig?.pluginId || 'static-image'; + const maxFiles = uploadConfig.max_files || 10; + const fileType = uploadConfig.file_type || 'image'; // 'image' or 'json' + const allowedTypes = uploadConfig.allowed_types || (fileType === 'json' ? ['application/json'] : ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']); + const maxSizeMB = uploadConfig.max_size_mb || 5; + const customUploadEndpoint = uploadConfig.endpoint; // Custom endpoint if specified + const customDeleteEndpoint = uploadConfig.delete_endpoint; // Custom delete endpoint if specified + + const currentFiles = Array.isArray(value) ? value : []; + const fieldId = fullKey.replace(/\./g, '_'); + + html += `
- `; - } else if (xWidgetValue === 'checkbox-group' || xWidgetValue2 === 'checkbox-group') { + `; + } else if (xWidgetValue === 'checkbox-group' || xWidgetValue2 === 'checkbox-group') { // Checkbox group widget for multi-select arrays with enum items // Use _data hidden input pattern to serialize selected values correctly console.log(`[DEBUG] ✅ Detected checkbox-group widget for ${fullKey} - rendering checkboxes`); diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 7e0ff186..8e3d7581 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -3108,6 +3108,51 @@

return null; }, + // Helper function to escape CSS selector special characters + escapeCssSelector(str) { + if (typeof str !== 'string') { + str = String(str); + } + // Use CSS.escape() when available (handles unicode, leading digits, and edge cases) + if (typeof CSS !== 'undefined' && CSS.escape) { + return CSS.escape(str); + } + // Fallback to regex-based escaping for older browsers + // First, handle leading digits and whitespace (must be done before regex) + let escaped = str; + let hasLeadingHexEscape = false; + if (escaped.length > 0) { + const firstChar = escaped[0]; + const firstCode = firstChar.charCodeAt(0); + + // Escape leading digit (0-9: U+0030-U+0039) + if (firstCode >= 0x30 && firstCode <= 0x39) { + const hex = firstCode.toString(16).toUpperCase().padStart(4, '0'); + escaped = '\\' + hex + ' ' + escaped.slice(1); + hasLeadingHexEscape = true; + } + // Escape leading whitespace (space: U+0020, tab: U+0009, etc.) + else if (/\s/.test(firstChar)) { + const hex = firstCode.toString(16).toUpperCase().padStart(4, '0'); + escaped = '\\' + hex + ' ' + escaped.slice(1); + hasLeadingHexEscape = true; + } + } + + // Escape special characters + escaped = escaped.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&'); + + // Escape internal spaces (replace spaces with \ ), but preserve space in hex escape + if (hasLeadingHexEscape) { + // Skip the first 6 characters (e.g., "\0030 ") when replacing spaces + escaped = escaped.slice(0, 6) + escaped.slice(6).replace(/ /g, '\\ '); + } else { + escaped = escaped.replace(/ /g, '\\ '); + } + + return escaped; + }, + async savePluginConfig(pluginId, event) { try { // Get the form element for this plugin @@ -3179,7 +3224,9 @@

// 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]; + // Use querySelector to reliably find element by name (handles dot notation) + const escapedKey = this.escapeCssSelector(key); + const element = form.querySelector(`[name="${escapedKey}"]`); if (element && element.type === 'checkbox') { // Also skip checkbox groups (name ends with []) if (key.endsWith('[]')) { @@ -3932,6 +3979,51 @@

return null; } + // Helper function to escape CSS selector special characters + function escapeCssSelector(str) { + if (typeof str !== 'string') { + str = String(str); + } + // Use CSS.escape() when available (handles unicode, leading digits, and edge cases) + if (typeof CSS !== 'undefined' && CSS.escape) { + return CSS.escape(str); + } + // Fallback to regex-based escaping for older browsers + // First, handle leading digits and whitespace (must be done before regex) + let escaped = str; + let hasLeadingHexEscape = false; + if (escaped.length > 0) { + const firstChar = escaped[0]; + const firstCode = firstChar.charCodeAt(0); + + // Escape leading digit (0-9: U+0030-U+0039) + if (firstCode >= 0x30 && firstCode <= 0x39) { + const hex = firstCode.toString(16).toUpperCase().padStart(4, '0'); + escaped = '\\' + hex + ' ' + escaped.slice(1); + hasLeadingHexEscape = true; + } + // Escape leading whitespace (space: U+0020, tab: U+0009, etc.) + else if (/\s/.test(firstChar)) { + const hex = firstCode.toString(16).toUpperCase().padStart(4, '0'); + escaped = '\\' + hex + ' ' + escaped.slice(1); + hasLeadingHexEscape = true; + } + } + + // Escape special characters + escaped = escaped.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&'); + + // Escape internal spaces (replace spaces with \ ), but preserve space in hex escape + if (hasLeadingHexEscape) { + // Skip the first 6 characters (e.g., "\0030 ") when replacing spaces + escaped = escaped.slice(0, 6) + escaped.slice(6).replace(/ /g, '\\ '); + } else { + escaped = escaped.replace(/ /g, '\\ '); + } + + return escaped; + } + async function savePluginConfig(pluginId) { try { console.log('Saving config for plugin:', pluginId); @@ -4016,7 +4108,9 @@

// 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]; + // Use querySelector to reliably find element by name (handles dot notation) + const escapedKey = escapeCssSelector(key); + const element = form.querySelector(`[name="${escapedKey}"]`); if (element && element.type === 'checkbox') { continue; // Already processed } @@ -4817,6 +4911,14 @@

+ + + + + + + +