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 = ` +