From fc9767256968788b6fcc748346f3d8eb7cd8bf6e Mon Sep 17 00:00:00 2001 From: Chuck Date: Wed, 14 Jan 2026 20:16:25 -0500 Subject: [PATCH 1/8] chore: Update basketball-scoreboard submodule for odds font fix --- plugins/basketball-scoreboard | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From bf2978d42575e0e13e9b0dc3b4c86a999d9af633 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 15 Jan 2026 16:43:06 -0500 Subject: [PATCH 2/8] feat(widgets): Add widget registry system for plugin configuration forms - Create core widget registry system (registry.js, base-widget.js) - Extract existing widgets to separate modules: - file-upload.js: Image upload with drag-and-drop, preview, delete, scheduling - checkbox-group.js: Multi-select checkboxes for array fields - custom-feeds.js: Table-based RSS feed editor with logo uploads - Implement plugin widget loading system (plugin-loader.js) - Add comprehensive documentation (widget-guide.md, README.md) - Include example custom widget (example-color-picker.js) - Maintain backwards compatibility with existing plugins - All widget handlers available globally for existing functionality This enables: - Reusable UI components for plugin configuration forms - Third-party plugins to create custom widgets without modifying LEDMatrix - Modular widget architecture for future enhancements Existing plugins (odds-ticker, static-image, news) continue to work without changes. --- docs/widget-guide.md | 347 +++++++++ web_interface/static/v3/js/widgets/README.md | 491 ++++++++++++ .../static/v3/js/widgets/base-widget.js | 169 ++++ .../static/v3/js/widgets/checkbox-group.js | 117 +++ .../static/v3/js/widgets/custom-feeds.js | 381 +++++++++ .../v3/js/widgets/example-color-picker.js | 186 +++++ .../static/v3/js/widgets/file-upload.js | 731 ++++++++++++++++++ .../static/v3/js/widgets/plugin-loader.js | 129 ++++ .../static/v3/js/widgets/registry.js | 217 ++++++ web_interface/templates/v3/base.html | 8 + 10 files changed, 2776 insertions(+) create mode 100644 docs/widget-guide.md create mode 100644 web_interface/static/v3/js/widgets/README.md create mode 100644 web_interface/static/v3/js/widgets/base-widget.js create mode 100644 web_interface/static/v3/js/widgets/checkbox-group.js create mode 100644 web_interface/static/v3/js/widgets/custom-feeds.js create mode 100644 web_interface/static/v3/js/widgets/example-color-picker.js create mode 100644 web_interface/static/v3/js/widgets/file-upload.js create mode 100644 web_interface/static/v3/js/widgets/plugin-loader.js create mode 100644 web_interface/static/v3/js/widgets/registry.js 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/web_interface/static/v3/js/widgets/README.md b/web_interface/static/v3/js/widgets/README.md new file mode 100644 index 00000000..1cb9b1a3 --- /dev/null +++ b/web_interface/static/v3/js/widgets/README.md @@ -0,0 +1,491 @@ +# 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; + const html = ` +
+ +
+ `; + container.innerHTML = html; + + // Attach event listeners + const input = container.querySelector('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) { + const input = document.querySelector(`#${fieldId}_input`); + return input ? input.value : null; + }, + + /** + * Set value programmatically + * @param {string} fieldId - Field ID + * @param {*} value - Value to set + */ + 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); + } + }, + + /** + * Helper: Escape HTML to prevent XSS + */ + escapeHtml: function(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +}); +``` + +### 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 + +### 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; + container.innerHTML = ` +
+ + +
+ `; + + const colorInput = container.querySelector('input[type="color"]'); + const hexInput = container.querySelector('input[type="text"]'); + + 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) { + return document.querySelector(`#${fieldId}_color`).value; + }, + + setValue: function(fieldId, value) { + const colorInput = document.querySelector(`#${fieldId}_color`); + const hexInput = document.querySelector(`#${fieldId}_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; + 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('input[type="range"]'); + const valueDisplay = container.querySelector(`#${fieldId}_value`); + + slider.addEventListener('input', (e) => { + valueDisplay.textContent = e.target.value; + this.handlers.onChange(fieldId, parseFloat(e.target.value)); + }); + }, + + getValue: function(fieldId) { + const slider = document.querySelector(`#${fieldId}_slider`); + return slider ? parseFloat(slider.value) : null; + }, + + setValue: function(fieldId, value) { + const slider = document.querySelector(`#${fieldId}_slider`); + const valueDisplay = document.querySelector(`#${fieldId}_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..facb29aa --- /dev/null +++ b/web_interface/static/v3/js/widgets/base-widget.js @@ -0,0 +1,169 @@ +/** + * 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 + const errorEl = document.createElement('div'); + errorEl.className = 'widget-error text-sm text-red-600 mt-2'; + errorEl.innerHTML = `${this.escapeHtml(message)}`; + 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 + * @param {string} text - Text to escape + * @returns {string} Escaped text + */ + escapeHtml(text) { + if (typeof text !== 'string') { + return String(text); + } + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + /** + * 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) { + const notifyFn = this.getNotificationFunction(); + if (notifyFn) { + notifyFn(message, type); + } else { + console.log(`[${type.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..311f1ad5 --- /dev/null +++ b/web_interface/static/v3/js/widgets/checkbox-group.js @@ -0,0 +1,117 @@ +/** + * 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; + } + + // 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; + checkbox.checked = values.includes(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..e90e24b9 --- /dev/null +++ b/web_interface/static/v3/js/widgets/custom-feeds.js @@ -0,0 +1,381 @@ +/** + * 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 + */ + setValue: function(fieldId, feeds) { + if (!Array.isArray(feeds)) { + console.error('[CustomFeedsWidget] setValue expects an array'); + return; + } + + // Clear existing rows + const tbody = document.getElementById(`${fieldId}_tbody`); + if (tbody) { + tbody.innerHTML = ''; + } + + // Add rows for each feed + feeds.forEach((feed, index) => { + // This would need the fullKey and pluginId from options + // For now, this is a placeholder + console.log('[CustomFeedsWidget] setValue called - full implementation requires template context'); + }); + }, + + 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..eff1399e --- /dev/null +++ b/web_interface/static/v3/js/widgets/example-color-picker.js @@ -0,0 +1,186 @@ +/** + * 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', ''); + const currentValue = value || config.default || '#000000'; + + // Escape HTML to prevent XSS + const escapeHtml = (text) => { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }; + + 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) { + const colorInput = document.querySelector(`#${fieldId}_color`); + const hexInput = document.querySelector(`#${fieldId}_hex`); + const preview = document.querySelector(`#${fieldId}_preview`); + + if (colorInput && hexInput) { + // Validate hex format + if (/^#[0-9A-Fa-f]{6}$/.test(value)) { + colorInput.value = value; + hexInput.value = value; + if (preview) { + preview.style.backgroundColor = value; + } + } else { + console.warn(`[ColorPickerWidget] Invalid hex color: ${value}`); + } + } + }, + + /** + * 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..7b122976 --- /dev/null +++ b/web_interface/static/v3/js/widgets/file-upload.js @@ -0,0 +1,731 @@ +/** + * 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 current files list + const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : []; + if (currentFiles.length + files.length > maxFiles) { + const notifyFn = window.showNotification || console.error; + notifyFn(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${files.length}.`, 'error'); + return; + } + + // Validate file types and sizes + const validFiles = []; + for (const file of files) { + if (file.size > maxSizeMB * 1024 * 1024) { + 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 + const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp', 'image/gif']; + 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); + } + + 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 + const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : []; + const newFiles = currentFiles.filter(file => (file.id || file.category_name) !== fileId); + 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 + * @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) { + const uploadConfig = window.getUploadConfig(fieldId); + const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image'; + + imageList.innerHTML = images.map((img, idx) => { + const imgSchedule = img.schedule || {}; + const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always'; + const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown'; + + // Escape HTML to prevent XSS + const escapeHtml = (text) => { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }; + + return ` +
+
+
+ ${escapeHtml(img.filename || '')} + +
+

${escapeHtml(img.original_filename || img.filename || 'Image')}

+

${window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB')} • ${window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || '')}

+

+ ${escapeHtml(scheduleSummary)} +

+
+
+
+ + +
+
+ + +
+ `; + }).join(''); + } + }; + + /** + * 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']; + + 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 (PNG, JPG, GIF, BMP)

+ `; + 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; + + const scheduleContainer = document.getElementById(`schedule_${imageId || imageIdx}`); + if (!scheduleContainer) return; + + // Toggle visibility + const isVisible = !scheduleContainer.classList.contains('hidden'); + + if (isVisible) { + scheduleContainer.classList.add('hidden'); + return; + } + + scheduleContainer.classList.remove('hidden'); + + const schedule = image.schedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} }; + + // Escape HTML helper + const escapeHtml = (text) => { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }; + + 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('')} +
+
+
+
+ `; + }; + + /** + * Toggle image schedule enabled state + */ + window.toggleImageScheduleEnabled = function(fieldId, imageId, imageIdx) { + const currentImages = window.getCurrentImages(fieldId); + const image = currentImages[imageIdx]; + if (!image) return; + + const checkbox = document.getElementById(`schedule_enabled_${imageId}`); + const enabled = checkbox.checked; + + if (!image.schedule) { + image.schedule = { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} }; + } + + image.schedule.enabled = enabled; + + const optionsDiv = document.getElementById(`schedule_options_${imageId}`); + if (optionsDiv) { + optionsDiv.style.display = enabled ? 'block' : 'none'; + } + + 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; + + if (!image.schedule) { + image.schedule = { enabled: true, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} }; + } + + const modeSelect = document.getElementById(`schedule_mode_${imageId}`); + const mode = modeSelect.value; + + image.schedule.mode = mode; + + const timeRangeDiv = document.getElementById(`time_range_${imageId}`); + const perDayDiv = document.getElementById(`per_day_${imageId}`); + + if (timeRangeDiv) timeRangeDiv.style.display = mode === 'time_range' ? 'grid' : 'none'; + if (perDayDiv) perDayDiv.style.display = mode === 'per_day' ? 'block' : 'none'; + + 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; + + if (!image.schedule) { + image.schedule = { enabled: true, mode: 'time_range', start_time: '08:00', end_time: '18:00' }; + } + + const startInput = document.getElementById(`schedule_start_${imageId}`); + const endInput = document.getElementById(`schedule_end_${imageId}`); + + if (startInput) image.schedule.start_time = startInput.value || '08:00'; + if (endInput) image.schedule.end_time = endInput.value || '18:00'; + + 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; + + if (!image.schedule) { + image.schedule = { enabled: true, mode: 'per_day', days: {} }; + } + + if (!image.schedule.days) { + image.schedule.days = {}; + } + + const checkbox = document.getElementById(`day_${day}_${imageId}`); + const startInput = document.getElementById(`day_${day}_start_${imageId}`); + const endInput = document.getElementById(`day_${day}_end_${imageId}`); + + const enabled = checkbox ? checkbox.checked : true; + + if (!image.schedule.days[day]) { + image.schedule.days[day] = { enabled: true, start_time: '08:00', end_time: '18:00' }; + } + + image.schedule.days[day].enabled = enabled; + if (startInput) image.schedule.days[day].start_time = startInput.value || '08:00'; + if (endInput) image.schedule.days[day].end_time = endInput.value || '18:00'; + + const dayTimesDiv = document.getElementById(`day_times_${day}_${imageId}`); + 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/templates/v3/base.html b/web_interface/templates/v3/base.html index 7e0ff186..4560ac26 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -4817,6 +4817,14 @@

+ + + + + + + + From cbe747b46ee64e3cf2b9963753f76b8ad0ab649a Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 15 Jan 2026 16:57:26 -0500 Subject: [PATCH 3/8] fix(widgets): Security and correctness fixes for widget system - base-widget.js: Fix escapeHtml to always escape (coerce to string first) - base-widget.js: Add sanitizeId helper for safe DOM ID usage - base-widget.js: Use DOM APIs in showError instead of innerHTML - checkbox-group.js: Normalize types in setValue for consistent comparison - custom-feeds.js: Implement setValue with full row creation logic - example-color-picker.js: Validate hex colors before using in style attributes - file-upload.js: Replace innerHTML with DOM creation to prevent XSS - file-upload.js: Preserve open schedule editors when updating image list - file-upload.js: Normalize types when filtering deleted files - file-upload.js: Sanitize imageId in openImageSchedule and all schedule handlers - file-upload.js: Fix max-files check order and use allowed_types from config - README.md: Add security guidance for ID sanitization in examples --- LEDMatrix.code-workspace | 7 + scripts/update_plugin_repos.py | 109 ++++++ web_interface/static/v3/js/widgets/README.md | 42 ++- .../static/v3/js/widgets/base-widget.js | 34 +- .../static/v3/js/widgets/checkbox-group.js | 6 +- .../static/v3/js/widgets/custom-feeds.js | 146 +++++++- .../v3/js/widgets/example-color-picker.js | 46 +-- .../static/v3/js/widgets/file-upload.js | 319 ++++++++++++------ 8 files changed, 570 insertions(+), 139 deletions(-) create mode 100644 LEDMatrix.code-workspace create mode 100755 scripts/update_plugin_repos.py 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/scripts/update_plugin_repos.py b/scripts/update_plugin_repos.py new file mode 100755 index 00000000..69f13679 --- /dev/null +++ b/scripts/update_plugin_repos.py @@ -0,0 +1,109 @@ +#!/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.""" + with open(WORKSPACE_FILE, 'r') as f: + workspace = json.load(f) + + 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 + result = subprocess.run(['git', 'fetch', 'origin'], + cwd=repo_path, capture_output=True, text=True) + + # 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 + result = subprocess.run(['git', 'pull', 'origin', current_branch], + cwd=repo_path, capture_output=True, text=True) + + if result.returncode == 0: + # Check if there were actual updates + if 'Already up to date' in 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}: {result.stderr.strip()}") + return False + except Exception 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 index 1cb9b1a3..379c3ca6 100644 --- a/web_interface/static/v3/js/widgets/README.md +++ b/web_interface/static/v3/js/widgets/README.md @@ -138,10 +138,14 @@ window.LEDMatrixWidgets.register('my-custom-widget', { */ 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 = `
@@ -149,10 +153,12 @@ window.LEDMatrixWidgets.register('my-custom-widget', { container.innerHTML = html; // Attach event listeners - const input = container.querySelector('input'); - input.addEventListener('change', (e) => { - this.handlers.onChange(fieldId, e.target.value); - }); + const input = container.querySelector(`#${safeFieldId}_input`); + if (input) { + input.addEventListener('change', (e) => { + this.handlers.onChange(fieldId, e.target.value); + }); + } }, /** @@ -161,7 +167,10 @@ window.LEDMatrixWidgets.register('my-custom-widget', { * @returns {*} Current value */ getValue: function(fieldId) { - const input = document.querySelector(`#${fieldId}_input`); + // 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; }, @@ -171,7 +180,10 @@ window.LEDMatrixWidgets.register('my-custom-widget', { * @param {*} value - Value to set */ setValue: function(fieldId, value) { - const input = document.querySelector(`#${fieldId}_input`); + // 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 || ''; } @@ -198,6 +210,13 @@ window.LEDMatrixWidgets.register('my-custom-widget', { 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, '_'); } }); ``` @@ -297,6 +316,15 @@ handlers: { 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 diff --git a/web_interface/static/v3/js/widgets/base-widget.js b/web_interface/static/v3/js/widgets/base-widget.js index facb29aa..7020f360 100644 --- a/web_interface/static/v3/js/widgets/base-widget.js +++ b/web_interface/static/v3/js/widgets/base-widget.js @@ -87,10 +87,17 @@ existingError.remove(); } - // Create error element + // 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'; - errorEl.innerHTML = `${this.escapeHtml(message)}`; + + 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); } @@ -108,18 +115,31 @@ /** * Escape HTML to prevent XSS - * @param {string} text - Text to escape + * 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) { - if (typeof text !== 'string') { - return String(text); - } + // Always coerce to string first, then escape + const textStr = String(text); const div = document.createElement('div'); - div.textContent = text; + 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 diff --git a/web_interface/static/v3/js/widgets/checkbox-group.js b/web_interface/static/v3/js/widgets/checkbox-group.js index 311f1ad5..837b3590 100644 --- a/web_interface/static/v3/js/widgets/checkbox-group.js +++ b/web_interface/static/v3/js/widgets/checkbox-group.js @@ -63,11 +63,15 @@ 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; - checkbox.checked = values.includes(optionValue); + // Normalize optionValue to string for comparison + checkbox.checked = normalizedValues.includes(String(optionValue)); }); // Update hidden input diff --git a/web_interface/static/v3/js/widgets/custom-feeds.js b/web_interface/static/v3/js/widgets/custom-feeds.js index e90e24b9..1cd4b063 100644 --- a/web_interface/static/v3/js/widgets/custom-feeds.js +++ b/web_interface/static/v3/js/widgets/custom-feeds.js @@ -73,24 +73,154 @@ * 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) { + setValue: function(fieldId, feeds, options) { if (!Array.isArray(feeds)) { console.error('[CustomFeedsWidget] setValue expects an array'); return; } - // Clear existing rows + // 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) { - tbody.innerHTML = ''; + if (!tbody) { + console.warn(`[CustomFeedsWidget] tbody not found for fieldId: ${fieldId}`); + return; } - // Add rows for each feed + // 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) => { - // This would need the fullKey and pluginId from options - // For now, this is a placeholder - console.log('[CustomFeedsWidget] setValue called - full implementation requires template context'); + 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); }); }, diff --git a/web_interface/static/v3/js/widgets/example-color-picker.js b/web_interface/static/v3/js/widgets/example-color-picker.js index eff1399e..ea0a1d8b 100644 --- a/web_interface/static/v3/js/widgets/example-color-picker.js +++ b/web_interface/static/v3/js/widgets/example-color-picker.js @@ -37,38 +37,47 @@ */ render: function(container, config, value, options) { const fieldId = options.fieldId || container.id.replace('_widget_container', ''); - const currentValue = value || config.default || '#000000'; + let currentValue = value || config.default || '#000000'; - // Escape HTML to prevent XSS + // 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 = `
- +
- +
-
+ style="background-color: ${safeHex}">
@@ -137,20 +146,19 @@ * @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) { - // Validate hex format - if (/^#[0-9A-Fa-f]{6}$/.test(value)) { - colorInput.value = value; - hexInput.value = value; - if (preview) { - preview.style.backgroundColor = value; - } - } else { - console.warn(`[ColorPickerWidget] Invalid hex color: ${value}`); + colorInput.value = safeValue; + hexInput.value = safeValue; + if (preview) { + preview.style.backgroundColor = safeValue; } } }, diff --git a/web_interface/static/v3/js/widgets/file-upload.js b/web_interface/static/v3/js/widgets/file-upload.js index 7b122976..f03de176 100644 --- a/web_interface/static/v3/js/widgets/file-upload.js +++ b/web_interface/static/v3/js/widgets/file-upload.js @@ -101,15 +101,13 @@ 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) : []; - if (currentFiles.length + files.length > maxFiles) { - const notifyFn = window.showNotification || console.error; - notifyFn(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${files.length}.`, 'error'); - return; - } - // Validate file types and sizes + // Validate file types and sizes first, build validFiles const validFiles = []; for (const file of files) { if (file.size > maxSizeMB * 1024 * 1024) { @@ -126,8 +124,7 @@ continue; } } else { - // Validate image files - const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp', 'image/gif']; + // 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'); @@ -138,6 +135,13 @@ 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; } @@ -231,9 +235,13 @@ const data = await response.json(); if (data.status === 'success') { - // Remove from current list + // Remove from current list - normalize types for comparison const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : []; - const newFiles = currentFiles.filter(file => (file.id || file.category_name) !== fileId); + 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); } @@ -317,6 +325,7 @@ /** * 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 */ @@ -328,62 +337,164 @@ // Update the display const imageList = document.getElementById(`${fieldId}_image_list`); - if (imageList) { - const uploadConfig = window.getUploadConfig(fieldId); - const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image'; + 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'; - imageList.innerHTML = images.map((img, idx) => { - const imgSchedule = img.schedule || {}; - const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always'; - const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown'; - - // Escape HTML to prevent XSS - const escapeHtml = (text) => { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - }; - - return ` -
-
-
- ${escapeHtml(img.filename || '')} - -
-

${escapeHtml(img.original_filename || img.filename || 'Image')}

-

${window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB')} • ${window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || '')}

-

- ${escapeHtml(scheduleSummary)} -

-
-
-
- - -
-
- - -
- `; - }).join(''); - } + // 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); + }); }; /** @@ -491,7 +602,9 @@ const image = currentImages[imageIdx]; if (!image) return; - const scheduleContainer = document.getElementById(`schedule_${imageId || imageIdx}`); + // 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 @@ -513,6 +626,7 @@ return div.innerHTML; }; + // Use sanitizedId for all ID references in the schedule HTML scheduleContainer.innerHTML = `

@@ -523,9 +637,9 @@
@@ -533,11 +647,11 @@
-
+
-
-
+
${['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].map(day => { @@ -576,24 +690,24 @@
-
+
@@ -615,8 +729,10 @@ const image = currentImages[imageIdx]; if (!image) return; - const checkbox = document.getElementById(`schedule_enabled_${imageId}`); - const enabled = checkbox.checked; + // 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: {} }; @@ -624,7 +740,7 @@ image.schedule.enabled = enabled; - const optionsDiv = document.getElementById(`schedule_options_${imageId}`); + const optionsDiv = document.getElementById(`schedule_options_${sanitizedId}`); if (optionsDiv) { optionsDiv.style.display = enabled ? 'block' : 'none'; } @@ -642,17 +758,20 @@ 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_${imageId}`); - const mode = modeSelect.value; + const modeSelect = document.getElementById(`schedule_mode_${sanitizedId}`); + const mode = modeSelect ? modeSelect.value : 'always'; image.schedule.mode = mode; - const timeRangeDiv = document.getElementById(`time_range_${imageId}`); - const perDayDiv = document.getElementById(`per_day_${imageId}`); + 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'; @@ -670,12 +789,15 @@ 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_${imageId}`); - const endInput = document.getElementById(`schedule_end_${imageId}`); + 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'; @@ -693,6 +815,9 @@ 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: {} }; } @@ -701,9 +826,9 @@ image.schedule.days = {}; } - const checkbox = document.getElementById(`day_${day}_${imageId}`); - const startInput = document.getElementById(`day_${day}_start_${imageId}`); - const endInput = document.getElementById(`day_${day}_end_${imageId}`); + const 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; @@ -715,7 +840,7 @@ 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}_${imageId}`); + const dayTimesDiv = document.getElementById(`day_times_${day}_${sanitizedId}`); if (dayTimesDiv) { dayTimesDiv.style.display = enabled ? 'grid' : 'none'; } From 9d8debf14b917cd90a05f05b8029bd15353fcd17 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 15 Jan 2026 18:02:20 -0500 Subject: [PATCH 4/8] fix(widgets): Additional security and error handling improvements - scripts/update_plugin_repos.py: Add explicit UTF-8 encoding and proper error handling for file operations - scripts/update_plugin_repos.py: Fix git fetch/pull error handling with returncode checks and specific exception types - base-widget.js: Guard notify method against undefined/null type parameter - file-upload.js: Remove inline handlers from schedule template, use addEventListener with data attributes - file-upload.js: Update hideUploadProgress to show dynamic file types from config instead of hardcoded list - README.md: Update Color Picker example to use sanitized fieldId throughout --- scripts/update_plugin_repos.py | 34 +++-- web_interface/static/v3/js/widgets/README.md | 47 ++++--- .../static/v3/js/widgets/base-widget.js | 7 +- .../static/v3/js/widgets/file-upload.js | 126 ++++++++++++++++-- 4 files changed, 177 insertions(+), 37 deletions(-) diff --git a/scripts/update_plugin_repos.py b/scripts/update_plugin_repos.py index 69f13679..70b785db 100755 --- a/scripts/update_plugin_repos.py +++ b/scripts/update_plugin_repos.py @@ -18,8 +18,18 @@ def load_workspace_plugins(): """Load plugin paths from workspace file.""" - with open(WORKSPACE_FILE, 'r') as f: - workspace = json.load(f) + 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', []): @@ -48,8 +58,12 @@ def update_repo(repo_path): try: # Fetch latest changes - result = subprocess.run(['git', 'fetch', 'origin'], - cwd=repo_path, capture_output=True, text=True) + 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'], @@ -57,20 +71,20 @@ def update_repo(repo_path): current_branch = branch_result.stdout.strip() if branch_result.returncode == 0 else 'main' # Pull latest changes - result = subprocess.run(['git', 'pull', 'origin', current_branch], - cwd=repo_path, capture_output=True, text=True) + pull_result = subprocess.run(['git', 'pull', 'origin', current_branch], + cwd=repo_path, capture_output=True, text=True) - if result.returncode == 0: + if pull_result.returncode == 0: # Check if there were actual updates - if 'Already up to date' in result.stdout: + 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}: {result.stderr.strip()}") + print(f" ✗ Failed to update {repo_path.name}: {pull_result.stderr.strip()}") return False - except Exception as e: + except (subprocess.SubprocessError, OSError) as e: print(f" ✗ Error updating {repo_path.name}: {e}") return False diff --git a/web_interface/static/v3/js/widgets/README.md b/web_interface/static/v3/js/widgets/README.md index 379c3ca6..00886b60 100644 --- a/web_interface/static/v3/js/widgets/README.md +++ b/web_interface/static/v3/js/widgets/README.md @@ -355,43 +355,56 @@ window.LEDMatrixWidgets.register('color-picker', { 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('input[type="color"]'); - const hexInput = container.querySelector('input[type="text"]'); - - colorInput.addEventListener('change', (e) => { - hexInput.value = e.target.value; - this.handlers.onChange(fieldId, e.target.value); - }); + const colorInput = container.querySelector(`#${sanitizedFieldId}_color`); + const hexInput = container.querySelector(`#${sanitizedFieldId}_hex`); - hexInput.addEventListener('change', (e) => { - if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) { - colorInput.value = e.target.value; + 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) { - return document.querySelector(`#${fieldId}_color`).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`); + return colorInput ? colorInput.value : null; }, setValue: function(fieldId, value) { - const colorInput = document.querySelector(`#${fieldId}_color`); - const hexInput = document.querySelector(`#${fieldId}_hex`); + // 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; diff --git a/web_interface/static/v3/js/widgets/base-widget.js b/web_interface/static/v3/js/widgets/base-widget.js index 7020f360..18ec5d40 100644 --- a/web_interface/static/v3/js/widgets/base-widget.js +++ b/web_interface/static/v3/js/widgets/base-widget.js @@ -171,11 +171,14 @@ * @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, type); + notifyFn(message, normalizedType); } else { - console.log(`[${type.toUpperCase()}] ${message}`); + console.log(`[${normalizedType.toUpperCase()}] ${message}`); } } } diff --git a/web_interface/static/v3/js/widgets/file-upload.js b/web_interface/static/v3/js/widgets/file-upload.js index f03de176..9e0397cf 100644 --- a/web_interface/static/v3/js/widgets/file-upload.js +++ b/web_interface/static/v3/js/widgets/file-upload.js @@ -523,12 +523,27 @@ 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 (PNG, JPG, GIF, BMP)

+

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

`; dropZone.style.pointerEvents = 'auto'; } @@ -627,6 +642,7 @@ }; // Use sanitizedId for all ID references in the schedule HTML + // Use data attributes instead of inline handlers to prevent JS injection scheduleContainer.innerHTML = `

@@ -638,8 +654,10 @@ @@ -651,7 +669,9 @@

@@ -691,8 +715,11 @@ @@ -700,14 +727,20 @@
@@ -719,6 +752,83 @@
`; + + // 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); + }); + } + }); }; /** From 2bef8ed847217adcc397286a7c496f09dc7c7267 Mon Sep 17 00:00:00 2001 From: Chuck Date: Thu, 15 Jan 2026 18:33:44 -0500 Subject: [PATCH 5/8] fix(widgets): Update Slider example to use sanitized fieldId - Add sanitizeId helper to Slider example render, getValue, and setValue methods - Use sanitizedFieldId for all DOM IDs and query selectors - Maintain consistency with Color Picker example pattern --- web_interface/static/v3/js/widgets/README.md | 34 +++++++++++++------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/web_interface/static/v3/js/widgets/README.md b/web_interface/static/v3/js/widgets/README.md index 00886b60..e6f6e336 100644 --- a/web_interface/static/v3/js/widgets/README.md +++ b/web_interface/static/v3/js/widgets/README.md @@ -432,6 +432,10 @@ window.LEDMatrixWidgets.register('slider', { 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; @@ -440,7 +444,7 @@ window.LEDMatrixWidgets.register('slider', { container.innerHTML = `
${min} - ${currentValue} + ${currentValue} ${max}
`; - const slider = container.querySelector('input[type="range"]'); - const valueDisplay = container.querySelector(`#${fieldId}_value`); + const slider = container.querySelector(`#${sanitizedFieldId}_slider`); + const valueDisplay = container.querySelector(`#${sanitizedFieldId}_value`); - slider.addEventListener('input', (e) => { - valueDisplay.textContent = e.target.value; - this.handlers.onChange(fieldId, parseFloat(e.target.value)); - }); + if (slider && valueDisplay) { + slider.addEventListener('input', (e) => { + valueDisplay.textContent = e.target.value; + this.handlers.onChange(fieldId, parseFloat(e.target.value)); + }); + } }, getValue: function(fieldId) { - const slider = document.querySelector(`#${fieldId}_slider`); + // 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) { - const slider = document.querySelector(`#${fieldId}_slider`); - const valueDisplay = document.querySelector(`#${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) { From b5e5a331452ad81d6d6287712d2c82763357fbe9 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 16 Jan 2026 10:21:43 -0500 Subject: [PATCH 6/8] fix(plugins_manager): Move configurePlugin and togglePlugin to top of file - Move configurePlugin and togglePlugin definitions to top level (after uninstallPlugin) - Ensures these critical functions are available immediately when script loads - Fixes 'Critical functions not available after 20 attempts' error - Functions are now defined before any HTML rendering checks --- web_interface/static/v3/plugins_manager.js | 284 +++++++++++---------- 1 file changed, 144 insertions(+), 140 deletions(-) diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 532ce175..d5528262 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -161,6 +161,148 @@ 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'); + } + } +}; + +// 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'; + + // 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(); + } + }); +}; + // Cleanup orphaned modals from previous executions to prevent duplicates when moving to body try { const existingModals = document.querySelectorAll('#plugin-config-modal'); @@ -306,146 +448,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) { From 6d7bc9799e18f476b159a437fc040417547927fd Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 16 Jan 2026 11:21:03 -0500 Subject: [PATCH 7/8] fix(plugins_manager): Fix checkbox state saving using querySelector - Add escapeCssSelector helper function for safe CSS selector usage - Replace form.elements[actualKey] with form.querySelector for boolean fields - Properly handle checkbox checked state using element.checked property - Fix both schema-based and schema-less boolean field processing - Ensures checkboxes with dot notation names (nested fields) work correctly Fixes issue where checkbox states were not properly saved when field names use dot notation (e.g., 'display.scroll_enabled'). The form.elements collection doesn't reliably handle dot notation in bracket notation access. --- web_interface/static/v3/plugins_manager.js | 32 ++++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index d5528262..216defd6 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -2149,6 +2149,15 @@ 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); + } + // Escape special CSS selector characters + return str.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&'); +} + // Helper function to convert dot notation to nested object function dotToNested(obj) { const result = {}; @@ -2314,8 +2323,20 @@ 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 - check if value in FormData indicates checked + // Checkboxes send "on" when checked, nothing when unchecked + // If key exists in FormData, checkbox was checked + flatConfig[actualKey] = actualValue !== undefined && actualValue !== null && actualValue !== ''; + } } else { flatConfig[actualKey] = actualValue; } @@ -2338,10 +2359,15 @@ 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 { + // Not a checkbox or element not found - use the value as-is flatConfig[actualKey] = actualValue; } } From b8b4793447d6e39b0ead45875f2be6a7a97b3410 Mon Sep 17 00:00:00 2001 From: Chuck Date: Fri, 16 Jan 2026 11:54:21 -0500 Subject: [PATCH 8/8] fix(base.html): Fix form element lookup for dot notation field names - Add escapeCssSelector helper function (both as method and standalone) - Replace form.elements[key] with form.querySelector for element type detection - Fixes element lookup failures when field names use dot notation - Ensures checkbox and multi-select skipping logic works correctly - Applies fix to both Alpine.js method and standalone function This complements the fix in plugins_manager.js to ensure all form element lookups handle nested field names (e.g., 'display.scroll_enabled') reliably across the entire web interface. --- web_interface/templates/v3/base.html | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 4560ac26..374e0954 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -3108,6 +3108,15 @@

return null; }, + // Helper function to escape CSS selector special characters + escapeCssSelector(str) { + if (typeof str !== 'string') { + str = String(str); + } + // Escape special CSS selector characters + return str.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&'); + }, + async savePluginConfig(pluginId, event) { try { // Get the form element for this plugin @@ -3179,7 +3188,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 +3943,15 @@

return null; } + // Helper function to escape CSS selector special characters + function escapeCssSelector(str) { + if (typeof str !== 'string') { + str = String(str); + } + // Escape special CSS selector characters + return str.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&'); + } + async function savePluginConfig(pluginId) { try { console.log('Saving config for plugin:', pluginId); @@ -4016,7 +4036,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 }