Feature/widget registry system (#190)

* chore: Update basketball-scoreboard submodule for odds font fix

* 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.

* 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

* 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

* 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

* 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

* 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.

* 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.

* fix(plugins_manager): Add race condition protection to togglePlugin

- Initialize window._pluginToggleRequests map for per-plugin request tokens
- Generate unique token for each toggle request to track in-flight requests
- Disable checkbox and wrapper UI during request to prevent overlapping toggles
- Add visual feedback with opacity and pointer-events-none classes
- Verify token matches before applying response updates (both success and error)
- Ignore out-of-order responses to preserve latest user intent
- Clear token and re-enable UI after request completes

Prevents race conditions when users rapidly toggle plugins, ensuring
only the latest toggle request's response affects the UI state.

* refactor(escapeCssSelector): Use CSS.escape() for better selector safety

- Prefer CSS.escape() when available for proper CSS selector escaping
- Handles edge cases: unicode characters, leading digits, and spec compliance
- Keep regex-based fallback for older browsers without CSS.escape support
- Update all three instances: plugins_manager.js and both in base.html

CSS.escape() is the standard API for escaping CSS selectors and provides
more robust handling than custom regex, especially for unicode and edge cases.

* fix(plugins_manager): Fix syntax error - missing closing brace for file-upload if block

- Add missing closing brace before else-if for checkbox-group widget
- Fixes 'Unexpected token else' error at line 3138
- The if block for file-upload widget (line 3034) was missing its closing brace
- Now properly structured: if (file-upload) { ... } else if (checkbox-group) { ... }

* fix(plugins_manager): Fix indentation in file-upload widget if block

- Properly indent all code inside the file-upload if block
- Fix template string closing brace indentation
- Ensures proper structure: if (file-upload) { ... } else if (checkbox-group) { ... }
- Resolves syntax error at line 3138

* fix(plugins_manager): Skip checkbox-group [] inputs to prevent config leakage

- Add skip logic for keys ending with '[]' in handlePluginConfigSubmit
- Prevents checkbox-group bracket notation inputs from leaking into config
- Checkbox-group widgets emit name="...[]" checkboxes plus a _data JSON field
- The _data field is already processed correctly, so [] inputs are redundant
- Prevents schema validation failures and extra config keys

The checkbox-group widget creates:
1. Individual checkboxes with name="fullKey[]" (now skipped)
2. Hidden input with name="fullKey_data" containing JSON array (processed)
3. Sentinel hidden input with name="fullKey[]" and empty value (now skipped)

* fix(plugins_manager): Normalize string booleans when checkbox input is missing

- Fix boolean field processing to properly normalize string booleans in fallback path
- Prevents "false"/"0" from being coerced to true when checkbox element is missing
- Handles common string boolean representations: 'true', 'false', '1', '0', 'on', 'off'
- Applies to both schema-based (lines 2386-2400) and schema-less (lines 2423-2433) paths

When a checkbox element cannot be found, the fallback logic now:
1. Checks if value is a string and normalizes known boolean representations
2. Treats undefined/null as false
3. Coerces other types to boolean using Boolean()

This ensures string values like "false" or "0" are correctly converted to false
instead of being treated as truthy non-empty strings.

* fix(base.html): Improve escapeCssSelector fallback to match CSS.escape behavior

- Handle leading digits by converting to hex escape (e.g., '1' -> '\0031 ')
- Handle leading whitespace by converting to hex escape (e.g., ' ' -> '\0020 ')
- Escape internal spaces as '\ ' (preserving space in hex escapes)
- Ensures trailing space after hex escapes per CSS spec
- Applies to both Alpine.js method and standalone function

The fallback now better matches CSS.escape() behavior for older browsers:
1. Escapes leading digits (0-9) as hex escapes with trailing space
2. Escapes leading whitespace as hex escapes with trailing space
3. Escapes all special characters as before
4. Escapes internal spaces while preserving hex escape format

This prevents selector injection issues with field names starting with digits
or whitespace, matching the standard CSS.escape() API behavior.

---------

Co-authored-by: Chuck <chuck@example.com>
This commit is contained in:
Chuck
2026-01-16 14:09:38 -05:00
committed by GitHub
parent 3b8910ac09
commit 71584d4361
14 changed files with 3735 additions and 163 deletions

View File

@@ -0,0 +1,544 @@
# LEDMatrix Widget Development Guide
## Overview
The LEDMatrix Widget Registry system allows plugins to use reusable UI components (widgets) for configuration forms. This system enables:
- **Reusable Components**: Use existing widgets (file upload, checkboxes, etc.) without custom code
- **Custom Widgets**: Create plugin-specific widgets without modifying the LEDMatrix codebase
- **Backwards Compatibility**: Existing plugins continue to work without changes
## Available Core Widgets
### 1. File Upload Widget (`file-upload`)
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
**Schema Configuration:**
```json
{
"type": "array",
"x-widget": "file-upload",
"x-upload-config": {
"plugin_id": "my-plugin",
"max_files": 10,
"max_size_mb": 5,
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"]
}
}
```
**Features:**
- Drag and drop file upload
- Image preview with thumbnails
- Delete functionality
- Schedule images to show at specific times
- Progress indicators during upload
### 2. Checkbox Group Widget (`checkbox-group`)
Multi-select checkboxes for array fields with enum items.
**Schema Configuration:**
```json
{
"type": "array",
"x-widget": "checkbox-group",
"items": {
"type": "string",
"enum": ["option1", "option2", "option3"]
},
"x-options": {
"labels": {
"option1": "Option 1 Label",
"option2": "Option 2 Label"
}
}
}
```
**Features:**
- Multiple selection from enum list
- Custom labels for each option
- Automatic JSON array serialization
### 3. Custom Feeds Widget (`custom-feeds`)
Table-based RSS feed editor with logo uploads.
**Schema Configuration:**
```json
{
"type": "array",
"x-widget": "custom-feeds",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"url": { "type": "string", "format": "uri" },
"enabled": { "type": "boolean" },
"logo": { "type": "object" }
}
},
"maxItems": 50
}
```
**Features:**
- Add/remove feed rows
- Logo upload per feed
- Enable/disable individual feeds
- Automatic row re-indexing
## Using Existing Widgets
To use an existing widget in your plugin's `config_schema.json`, simply add the `x-widget` property to your field definition:
```json
{
"properties": {
"my_images": {
"type": "array",
"x-widget": "file-upload",
"x-upload-config": {
"plugin_id": "my-plugin",
"max_files": 5
}
}
}
}
```
The widget will be automatically rendered when the plugin configuration form is loaded.
## Creating Custom Widgets
### Step 1: Create Widget File
Create a JavaScript file in your plugin directory (e.g., `widgets/my-widget.js`):
```javascript
// Ensure LEDMatrixWidgets registry is available
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('LEDMatrixWidgets registry not found');
return;
}
// Register your widget
window.LEDMatrixWidgets.register('my-custom-widget', {
name: 'My Custom Widget',
version: '1.0.0',
/**
* Render the widget HTML
* @param {HTMLElement} container - Container element to render into
* @param {Object} config - Widget configuration from schema
* @param {*} value - Current value
* @param {Object} options - Additional options (fieldId, pluginId, etc.)
*/
render: function(container, config, value, options) {
const fieldId = options.fieldId || container.id;
// Sanitize fieldId for safe use in DOM IDs and selectors
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const safeFieldId = sanitizeId(fieldId);
const html = `
<div class="my-custom-widget">
<input type="text"
id="${safeFieldId}_input"
value="${this.escapeHtml(value || '')}"
class="w-full px-3 py-2 border border-gray-300 rounded">
</div>
`;
container.innerHTML = html;
// Attach event listeners
const input = container.querySelector(`#${safeFieldId}_input`);
if (input) {
input.addEventListener('change', (e) => {
this.handlers.onChange(fieldId, e.target.value);
});
}
},
/**
* Get current value from widget
* @param {string} fieldId - Field ID
* @returns {*} Current value
*/
getValue: function(fieldId) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const safeFieldId = sanitizeId(fieldId);
const input = document.querySelector(`#${safeFieldId}_input`);
return input ? input.value : null;
},
/**
* Set value programmatically
* @param {string} fieldId - Field ID
* @param {*} value - Value to set
*/
setValue: function(fieldId, value) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const safeFieldId = sanitizeId(fieldId);
const input = document.querySelector(`#${safeFieldId}_input`);
if (input) {
input.value = value || '';
}
},
/**
* Event handlers
*/
handlers: {
onChange: function(fieldId, value) {
// Trigger form change event
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true
});
document.dispatchEvent(event);
}
},
/**
* Helper: Escape HTML to prevent XSS
*/
escapeHtml: function(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
/**
* Helper: Sanitize identifier for use in DOM IDs and CSS selectors
*/
sanitizeId: function(id) {
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
}
});
```
### Step 2: Reference Widget in Schema
In your plugin's `config_schema.json`:
```json
{
"properties": {
"my_field": {
"type": "string",
"description": "My custom field",
"x-widget": "my-custom-widget",
"default": ""
}
}
}
```
### Step 3: Widget Loading
The widget will be automatically loaded when the plugin configuration form is rendered. The system will:
1. Check if widget is registered in the core registry
2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js`
3. Render the widget using the registered `render` function
## Widget API Reference
### Widget Definition Object
```javascript
{
name: string, // Human-readable widget name
version: string, // Widget version
render: function, // Required: Render function
getValue: function, // Optional: Get current value
setValue: function, // Optional: Set value programmatically
handlers: object // Optional: Event handlers
}
```
### Render Function
```javascript
render(container, config, value, options)
```
**Parameters:**
- `container` (HTMLElement): Container element to render into
- `config` (Object): Widget configuration from schema (`x-widget-config` or schema properties)
- `value` (*): Current field value
- `options` (Object): Additional options
- `fieldId` (string): Field ID
- `pluginId` (string): Plugin ID
- `fullKey` (string): Full field key path
### Get Value Function
```javascript
getValue(fieldId)
```
**Returns:** Current widget value
### Set Value Function
```javascript
setValue(fieldId, value)
```
**Parameters:**
- `fieldId` (string): Field ID
- `value` (*): Value to set
### Event Handlers
Widgets can define custom event handlers in the `handlers` object:
```javascript
handlers: {
onChange: function(fieldId, value) {
// Handle value change
},
onFocus: function(fieldId) {
// Handle focus
}
}
```
## Best Practices
### Security
1. **Always escape HTML**: Use `escapeHtml()` or `textContent` to prevent XSS
2. **Validate inputs**: Validate user input before processing
3. **Sanitize values**: Clean values before storing
4. **Sanitize identifiers**: Always sanitize identifiers (like `fieldId`) used as element IDs and in CSS selectors to prevent selector injection/XSS:
- Use `sanitizeId()` helper function (available in BaseWidget) or create your own
- Allow only safe characters: `[A-Za-z0-9_-]`
- Replace or remove invalid characters before using in:
- `getElementById()`, `querySelector()`, `querySelectorAll()`
- Setting `id` attributes
- Building CSS selectors
- Never interpolate raw `fieldId` into HTML strings or selectors without sanitization
- Example: `const safeId = fieldId.replace(/[^a-zA-Z0-9_-]/g, '_');`
### Performance
1. **Lazy loading**: Load widget scripts only when needed
2. **Event delegation**: Use event delegation for dynamic content
3. **Debounce**: Debounce frequent events (e.g., input changes)
### Accessibility
1. **Labels**: Always associate labels with inputs
2. **ARIA attributes**: Use appropriate ARIA attributes
3. **Keyboard navigation**: Ensure keyboard accessibility
### Error Handling
1. **Graceful degradation**: Handle missing dependencies
2. **User feedback**: Show clear error messages
3. **Logging**: Log errors for debugging
## Examples
### Example 1: Color Picker Widget
```javascript
window.LEDMatrixWidgets.register('color-picker', {
name: 'Color Picker',
version: '1.0.0',
render: function(container, config, value, options) {
const fieldId = options.fieldId;
// Sanitize fieldId for safe use in DOM IDs and selectors
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
container.innerHTML = `
<div class="flex items-center space-x-2">
<input type="color"
id="${sanitizedFieldId}_color"
value="${value || '#000000'}"
class="h-10 w-20">
<input type="text"
id="${sanitizedFieldId}_hex"
value="${value || '#000000'}"
pattern="^#[0-9A-Fa-f]{6}$"
class="px-2 py-1 border rounded">
</div>
`;
const colorInput = container.querySelector(`#${sanitizedFieldId}_color`);
const hexInput = container.querySelector(`#${sanitizedFieldId}_hex`);
if (colorInput && hexInput) {
colorInput.addEventListener('change', (e) => {
hexInput.value = e.target.value;
this.handlers.onChange(fieldId, e.target.value);
});
hexInput.addEventListener('change', (e) => {
if (/^#[0-9A-Fa-f]{6}$/.test(e.target.value)) {
colorInput.value = e.target.value;
this.handlers.onChange(fieldId, e.target.value);
}
});
}
},
getValue: function(fieldId) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const colorInput = document.querySelector(`#${sanitizedFieldId}_color`);
return colorInput ? colorInput.value : null;
},
setValue: function(fieldId, value) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const colorInput = document.querySelector(`#${sanitizedFieldId}_color`);
const hexInput = document.querySelector(`#${sanitizedFieldId}_hex`);
if (colorInput && hexInput) {
colorInput.value = value;
hexInput.value = value;
}
},
handlers: {
onChange: function(fieldId, value) {
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true
});
document.dispatchEvent(event);
}
}
});
```
### Example 2: Slider Widget
```javascript
window.LEDMatrixWidgets.register('slider', {
name: 'Slider Widget',
version: '1.0.0',
render: function(container, config, value, options) {
const fieldId = options.fieldId;
// Sanitize fieldId for safe use in DOM IDs and selectors
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const min = config.minimum || 0;
const max = config.maximum || 100;
const step = config.step || 1;
const currentValue = value !== undefined ? value : (config.default || min);
container.innerHTML = `
<div class="slider-widget">
<input type="range"
id="${sanitizedFieldId}_slider"
min="${min}"
max="${max}"
step="${step}"
value="${currentValue}"
class="w-full">
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>${min}</span>
<span id="${sanitizedFieldId}_value">${currentValue}</span>
<span>${max}</span>
</div>
</div>
`;
const slider = container.querySelector(`#${sanitizedFieldId}_slider`);
const valueDisplay = container.querySelector(`#${sanitizedFieldId}_value`);
if (slider && valueDisplay) {
slider.addEventListener('input', (e) => {
valueDisplay.textContent = e.target.value;
this.handlers.onChange(fieldId, parseFloat(e.target.value));
});
}
},
getValue: function(fieldId) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const slider = document.querySelector(`#${sanitizedFieldId}_slider`);
return slider ? parseFloat(slider.value) : null;
},
setValue: function(fieldId, value) {
// Sanitize fieldId for safe selector use
const sanitizeId = (id) => String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
const sanitizedFieldId = sanitizeId(fieldId);
const slider = document.querySelector(`#${sanitizedFieldId}_slider`);
const valueDisplay = document.querySelector(`#${sanitizedFieldId}_value`);
if (slider) {
slider.value = value;
if (valueDisplay) {
valueDisplay.textContent = value;
}
}
},
handlers: {
onChange: function(fieldId, value) {
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true
});
document.dispatchEvent(event);
}
}
});
```
## Troubleshooting
### Widget Not Loading
1. Check browser console for errors
2. Verify widget file path is correct
3. Ensure `LEDMatrixWidgets.register()` is called
4. Check that widget name matches schema `x-widget` value
### Widget Not Rendering
1. Verify `render` function is defined
2. Check container element exists
3. Ensure widget is registered before form loads
4. Check for JavaScript errors in console
### Value Not Saving
1. Ensure widget triggers `widget-change` event
2. Verify form submission includes widget value
3. Check `getValue` function returns correct type
4. Verify field name matches schema property
## Migration from Server-Side Rendering
Currently, widgets are server-side rendered via Jinja2 templates. The registry system provides:
1. **Backwards Compatibility**: Existing server-side rendered widgets continue to work
2. **Future Enhancement**: Client-side rendering support for custom widgets
3. **Handler Availability**: All widget handlers are available globally
Future versions may support full client-side rendering, but server-side rendering remains the primary method for core widgets.
## Support
For questions or issues:
- Check existing widget implementations for examples
- Review browser console for errors
- Test with simple widget first before complex implementations

View File

@@ -0,0 +1,192 @@
/**
* LEDMatrix Base Widget Class
*
* Provides common functionality and utilities for all widgets.
* Widgets can extend this or use it as a reference for best practices.
*
* @module BaseWidget
*/
(function() {
'use strict';
/**
* Base Widget Class
* Provides common utilities and patterns for widgets
*/
class BaseWidget {
constructor(name, version) {
this.name = name;
this.version = version || '1.0.0';
}
/**
* Validate widget configuration
* @param {Object} config - Configuration object from schema
* @param {Object} schema - Full schema object
* @returns {Object} Validation result {valid: boolean, errors: Array}
*/
validateConfig(config, schema) {
const errors = [];
if (!config) {
errors.push('Configuration is required');
return { valid: false, errors };
}
// Add widget-specific validation here
// This is a base implementation that can be overridden
return {
valid: errors.length === 0,
errors
};
}
/**
* Sanitize value for storage
* @param {*} value - Raw value from widget
* @returns {*} Sanitized value
*/
sanitizeValue(value) {
// Base implementation - widgets should override for specific needs
if (typeof value === 'string') {
// Basic XSS prevention
return value.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
}
return value;
}
/**
* Get field ID from container or options
* @param {HTMLElement} container - Container element
* @param {Object} options - Options object
* @returns {string} Field ID
*/
getFieldId(container, options) {
if (options && options.fieldId) {
return options.fieldId;
}
if (container && container.id) {
return container.id.replace(/_widget_container$/, '');
}
return null;
}
/**
* Show error message
* @param {HTMLElement} container - Container element
* @param {string} message - Error message
*/
showError(container, message) {
if (!container) return;
// Remove existing error
const existingError = container.querySelector('.widget-error');
if (existingError) {
existingError.remove();
}
// Create error element using DOM APIs to prevent XSS
const errorEl = document.createElement('div');
errorEl.className = 'widget-error text-sm text-red-600 mt-2';
const icon = document.createElement('i');
icon.className = 'fas fa-exclamation-circle mr-1';
errorEl.appendChild(icon);
const messageText = document.createTextNode(message);
errorEl.appendChild(messageText);
container.appendChild(errorEl);
}
/**
* Clear error message
* @param {HTMLElement} container - Container element
*/
clearError(container) {
if (!container) return;
const errorEl = container.querySelector('.widget-error');
if (errorEl) {
errorEl.remove();
}
}
/**
* Escape HTML to prevent XSS
* Always escapes the input, even for non-strings, by coercing to string first
* @param {*} text - Text to escape (will be coerced to string)
* @returns {string} Escaped text
*/
escapeHtml(text) {
// Always coerce to string first, then escape
const textStr = String(text);
const div = document.createElement('div');
div.textContent = textStr;
return div.innerHTML;
}
/**
* Sanitize identifier for use in DOM IDs and CSS selectors
* @param {string} id - Identifier to sanitize
* @returns {string} Sanitized identifier safe for DOM/CSS
*/
sanitizeId(id) {
if (typeof id !== 'string') {
id = String(id);
}
// Allow only alphanumeric, underscore, and hyphen
return id.replace(/[^a-zA-Z0-9_-]/g, '_');
}
/**
* Trigger widget change event
* @param {string} fieldId - Field ID
* @param {*} value - New value
*/
triggerChange(fieldId, value) {
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true,
cancelable: true
});
document.dispatchEvent(event);
}
/**
* Get notification function (if available)
* @returns {Function|null} Notification function or null
*/
getNotificationFunction() {
if (typeof window.showNotification === 'function') {
return window.showNotification;
}
return null;
}
/**
* Show notification
* @param {string} message - Message to show
* @param {string} type - Notification type (success, error, info, warning)
*/
notify(message, type) {
// Normalize type to prevent errors when undefined/null
const normalizedType = type ? String(type) : 'info';
const notifyFn = this.getNotificationFunction();
if (notifyFn) {
notifyFn(message, normalizedType);
} else {
console.log(`[${normalizedType.toUpperCase()}] ${message}`);
}
}
}
// Export for use in widget implementations
if (typeof window !== 'undefined') {
window.BaseWidget = BaseWidget;
}
console.log('[BaseWidget] Base widget class loaded');
})();

View File

@@ -0,0 +1,121 @@
/**
* Checkbox Group Widget
*
* Handles multi-select checkbox groups for array fields with enum items.
* Updates a hidden input with JSON array of selected values.
*
* @module CheckboxGroupWidget
*/
(function() {
'use strict';
// Ensure LEDMatrixWidgets registry exists
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[CheckboxGroupWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
return;
}
/**
* Register the checkbox-group widget
*/
window.LEDMatrixWidgets.register('checkbox-group', {
name: 'Checkbox Group Widget',
version: '1.0.0',
/**
* Render the checkbox group widget
* Note: This widget is currently server-side rendered via Jinja2 template.
* This registration ensures the handlers are available globally.
*/
render: function(container, config, value, options) {
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
console.log('[CheckboxGroupWidget] Render called (server-side rendered)');
},
/**
* Get current value from widget
* @param {string} fieldId - Field ID
* @returns {Array} Array of selected values
*/
getValue: function(fieldId) {
const hiddenInput = document.getElementById(`${fieldId}_data`);
if (hiddenInput && hiddenInput.value) {
try {
return JSON.parse(hiddenInput.value);
} catch (e) {
console.error('Error parsing checkbox group data:', e);
return [];
}
}
return [];
},
/**
* Set value in widget
* @param {string} fieldId - Field ID
* @param {Array} values - Array of values to select
*/
setValue: function(fieldId, values) {
if (!Array.isArray(values)) {
console.error('[CheckboxGroupWidget] setValue expects an array');
return;
}
// Normalize values to strings for consistent comparison
const normalizedValues = values.map(String);
// Update checkboxes
const checkboxes = document.querySelectorAll(`input[type="checkbox"][data-checkbox-group="${fieldId}"]`);
checkboxes.forEach(checkbox => {
const optionValue = checkbox.getAttribute('data-option-value') || checkbox.value;
// Normalize optionValue to string for comparison
checkbox.checked = normalizedValues.includes(String(optionValue));
});
// Update hidden input
updateCheckboxGroupData(fieldId);
},
handlers: {
// Handlers are attached to window for backwards compatibility
}
});
/**
* Update checkbox group data in hidden input
* Called when any checkbox in the group changes
* @param {string} fieldId - Field ID
*/
window.updateCheckboxGroupData = function(fieldId) {
// Update hidden _data input with currently checked values
const hiddenInput = document.getElementById(fieldId + '_data');
if (!hiddenInput) {
console.warn(`[CheckboxGroupWidget] Hidden input not found for fieldId: ${fieldId}`);
return;
}
const checkboxes = document.querySelectorAll(`input[type="checkbox"][data-checkbox-group="${fieldId}"]`);
const selectedValues = [];
checkboxes.forEach(checkbox => {
if (checkbox.checked) {
const optionValue = checkbox.getAttribute('data-option-value') || checkbox.value;
selectedValues.push(optionValue);
}
});
hiddenInput.value = JSON.stringify(selectedValues);
// Trigger change event for form validation
const event = new CustomEvent('widget-change', {
detail: { fieldId, value: selectedValues },
bubbles: true,
cancelable: true
});
hiddenInput.dispatchEvent(event);
};
console.log('[CheckboxGroupWidget] Checkbox group widget registered');
})();

View File

@@ -0,0 +1,511 @@
/**
* Custom Feeds Widget
*
* Handles table-based RSS feed editor with logo uploads.
* Allows adding, removing, and editing custom RSS feed entries.
*
* @module CustomFeedsWidget
*/
(function() {
'use strict';
// Ensure LEDMatrixWidgets registry exists
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[CustomFeedsWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
return;
}
/**
* Register the custom-feeds widget
*/
window.LEDMatrixWidgets.register('custom-feeds', {
name: 'Custom Feeds Widget',
version: '1.0.0',
/**
* Render the custom feeds widget
* Note: This widget is currently server-side rendered via Jinja2 template.
* This registration ensures the handlers are available globally.
*/
render: function(container, config, value, options) {
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
console.log('[CustomFeedsWidget] Render called (server-side rendered)');
},
/**
* Get current value from widget
* @param {string} fieldId - Field ID
* @returns {Array} Array of feed objects
*/
getValue: function(fieldId) {
const tbody = document.getElementById(`${fieldId}_tbody`);
if (!tbody) return [];
const rows = tbody.querySelectorAll('.custom-feed-row');
const feeds = [];
rows.forEach((row, index) => {
const nameInput = row.querySelector('input[name*=".name"]');
const urlInput = row.querySelector('input[name*=".url"]');
const enabledInput = row.querySelector('input[name*=".enabled"]');
const logoPathInput = row.querySelector('input[name*=".logo.path"]');
const logoIdInput = row.querySelector('input[name*=".logo.id"]');
if (nameInput && urlInput) {
feeds.push({
name: nameInput.value,
url: urlInput.value,
enabled: enabledInput ? enabledInput.checked : true,
logo: logoPathInput || logoIdInput ? {
path: logoPathInput ? logoPathInput.value : '',
id: logoIdInput ? logoIdInput.value : ''
} : null
});
}
});
return feeds;
},
/**
* Set value in widget
* @param {string} fieldId - Field ID
* @param {Array} feeds - Array of feed objects
* @param {Object} options - Options containing fullKey and pluginId
*/
setValue: function(fieldId, feeds, options) {
if (!Array.isArray(feeds)) {
console.error('[CustomFeedsWidget] setValue expects an array');
return;
}
// Throw NotImplementedError if options are missing (defensive approach)
if (!options || !options.fullKey || !options.pluginId) {
throw new Error('CustomFeedsWidget.setValue not implemented: requires options.fullKey and options.pluginId');
}
const tbody = document.getElementById(`${fieldId}_tbody`);
if (!tbody) {
console.warn(`[CustomFeedsWidget] tbody not found for fieldId: ${fieldId}`);
return;
}
// Clear existing rows immediately before appending new ones
tbody.innerHTML = '';
// Build rows for each feed using the same logic as addCustomFeedRow
feeds.forEach((feed, index) => {
const fullKey = options.fullKey;
const pluginId = options.pluginId;
const newRow = document.createElement('tr');
newRow.className = 'custom-feed-row';
newRow.setAttribute('data-index', index);
// Create name cell
const nameCell = document.createElement('td');
nameCell.className = 'px-4 py-3 whitespace-nowrap';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.name = `${fullKey}.${index}.name`;
nameInput.value = feed.name || '';
nameInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
nameInput.placeholder = 'Feed Name';
nameInput.required = true;
nameCell.appendChild(nameInput);
// Create URL cell
const urlCell = document.createElement('td');
urlCell.className = 'px-4 py-3 whitespace-nowrap';
const urlInput = document.createElement('input');
urlInput.type = 'url';
urlInput.name = `${fullKey}.${index}.url`;
urlInput.value = feed.url || '';
urlInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
urlInput.placeholder = 'https://example.com/feed';
urlInput.required = true;
urlCell.appendChild(urlInput);
// Create logo cell
const logoCell = document.createElement('td');
logoCell.className = 'px-4 py-3 whitespace-nowrap';
const logoContainer = document.createElement('div');
logoContainer.className = 'flex items-center space-x-2';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = `${fieldId}_logo_${index}`;
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
fileInput.style.display = 'none';
fileInput.dataset.index = String(index);
fileInput.addEventListener('change', function(e) {
const idx = parseInt(e.target.dataset.index || '0', 10);
handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey);
});
const uploadButton = document.createElement('button');
uploadButton.type = 'button';
uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded';
uploadButton.addEventListener('click', function() {
fileInput.click();
});
const uploadIcon = document.createElement('i');
uploadIcon.className = 'fas fa-upload mr-1';
uploadButton.appendChild(uploadIcon);
uploadButton.appendChild(document.createTextNode(' Upload'));
if (feed.logo && feed.logo.path) {
const img = document.createElement('img');
img.src = feed.logo.path;
img.alt = 'Logo';
img.className = 'w-8 h-8 object-cover rounded border';
img.id = `${fieldId}_logo_preview_${index}`;
logoContainer.appendChild(img);
// Create hidden inputs for logo data
const pathInput = document.createElement('input');
pathInput.type = 'hidden';
pathInput.name = `${fullKey}.${index}.logo.path`;
pathInput.value = feed.logo.path;
logoContainer.appendChild(pathInput);
if (feed.logo.id) {
const idInput = document.createElement('input');
idInput.type = 'hidden';
idInput.name = `${fullKey}.${index}.logo.id`;
idInput.value = String(feed.logo.id);
logoContainer.appendChild(idInput);
}
} else {
const noLogoSpan = document.createElement('span');
noLogoSpan.className = 'text-xs text-gray-400';
noLogoSpan.textContent = 'No logo';
logoContainer.appendChild(noLogoSpan);
}
logoContainer.appendChild(fileInput);
logoContainer.appendChild(uploadButton);
logoCell.appendChild(logoContainer);
// Create enabled cell
const enabledCell = document.createElement('td');
enabledCell.className = 'px-4 py-3 whitespace-nowrap text-center';
const enabledInput = document.createElement('input');
enabledInput.type = 'checkbox';
enabledInput.name = `${fullKey}.${index}.enabled`;
enabledInput.checked = feed.enabled !== false;
enabledInput.value = 'true';
enabledInput.className = 'h-4 w-4 text-blue-600';
enabledCell.appendChild(enabledInput);
// Create remove cell
const removeCell = document.createElement('td');
removeCell.className = 'px-4 py-3 whitespace-nowrap text-center';
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeButton.addEventListener('click', function() {
removeCustomFeedRow(this);
});
const removeIcon = document.createElement('i');
removeIcon.className = 'fas fa-trash';
removeButton.appendChild(removeIcon);
removeCell.appendChild(removeButton);
// Append all cells to row
newRow.appendChild(nameCell);
newRow.appendChild(urlCell);
newRow.appendChild(logoCell);
newRow.appendChild(enabledCell);
newRow.appendChild(removeCell);
tbody.appendChild(newRow);
});
},
handlers: {
// Handlers are attached to window for backwards compatibility
}
});
/**
* Add a new custom feed row to the table
* @param {string} fieldId - Field ID
* @param {string} fullKey - Full field key (e.g., "feeds.custom_feeds")
* @param {number} maxItems - Maximum number of items allowed
* @param {string} pluginId - Plugin ID
*/
window.addCustomFeedRow = function(fieldId, fullKey, maxItems, pluginId) {
const tbody = document.getElementById(fieldId + '_tbody');
if (!tbody) return;
const currentRows = tbody.querySelectorAll('.custom-feed-row');
if (currentRows.length >= maxItems) {
const notifyFn = window.showNotification || alert;
notifyFn(`Maximum ${maxItems} feeds allowed`, 'error');
return;
}
const newIndex = currentRows.length;
const newRow = document.createElement('tr');
newRow.className = 'custom-feed-row';
newRow.setAttribute('data-index', newIndex);
// Create name cell
const nameCell = document.createElement('td');
nameCell.className = 'px-4 py-3 whitespace-nowrap';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.name = `${fullKey}.${newIndex}.name`;
nameInput.value = '';
nameInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
nameInput.placeholder = 'Feed Name';
nameInput.required = true;
nameCell.appendChild(nameInput);
// Create URL cell
const urlCell = document.createElement('td');
urlCell.className = 'px-4 py-3 whitespace-nowrap';
const urlInput = document.createElement('input');
urlInput.type = 'url';
urlInput.name = `${fullKey}.${newIndex}.url`;
urlInput.value = '';
urlInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
urlInput.placeholder = 'https://example.com/feed';
urlInput.required = true;
urlCell.appendChild(urlInput);
// Create logo cell
const logoCell = document.createElement('td');
logoCell.className = 'px-4 py-3 whitespace-nowrap';
const logoContainer = document.createElement('div');
logoContainer.className = 'flex items-center space-x-2';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = `${fieldId}_logo_${newIndex}`;
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
fileInput.style.display = 'none';
fileInput.dataset.index = String(newIndex);
fileInput.addEventListener('change', function(e) {
const idx = parseInt(e.target.dataset.index || '0', 10);
handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey);
});
const uploadButton = document.createElement('button');
uploadButton.type = 'button';
uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded';
uploadButton.addEventListener('click', function() {
fileInput.click();
});
const uploadIcon = document.createElement('i');
uploadIcon.className = 'fas fa-upload mr-1';
uploadButton.appendChild(uploadIcon);
uploadButton.appendChild(document.createTextNode(' Upload'));
const noLogoSpan = document.createElement('span');
noLogoSpan.className = 'text-xs text-gray-400';
noLogoSpan.textContent = 'No logo';
logoContainer.appendChild(fileInput);
logoContainer.appendChild(uploadButton);
logoContainer.appendChild(noLogoSpan);
logoCell.appendChild(logoContainer);
// Create enabled cell
const enabledCell = document.createElement('td');
enabledCell.className = 'px-4 py-3 whitespace-nowrap text-center';
const enabledInput = document.createElement('input');
enabledInput.type = 'checkbox';
enabledInput.name = `${fullKey}.${newIndex}.enabled`;
enabledInput.checked = true;
enabledInput.value = 'true';
enabledInput.className = 'h-4 w-4 text-blue-600';
enabledCell.appendChild(enabledInput);
// Create remove cell
const removeCell = document.createElement('td');
removeCell.className = 'px-4 py-3 whitespace-nowrap text-center';
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeButton.addEventListener('click', function() {
removeCustomFeedRow(this);
});
const removeIcon = document.createElement('i');
removeIcon.className = 'fas fa-trash';
removeButton.appendChild(removeIcon);
removeCell.appendChild(removeButton);
// Append all cells to row
newRow.appendChild(nameCell);
newRow.appendChild(urlCell);
newRow.appendChild(logoCell);
newRow.appendChild(enabledCell);
newRow.appendChild(removeCell);
tbody.appendChild(newRow);
};
/**
* Remove a custom feed row from the table
* @param {HTMLElement} button - The remove button element
*/
window.removeCustomFeedRow = function(button) {
const row = button.closest('tr');
if (!row) return;
if (confirm('Remove this feed?')) {
const tbody = row.parentElement;
if (!tbody) return;
row.remove();
// Re-index remaining rows
const rows = tbody.querySelectorAll('.custom-feed-row');
rows.forEach((r, index) => {
const oldIndex = r.getAttribute('data-index');
r.setAttribute('data-index', index);
// Update all input names with new index
r.querySelectorAll('input, button').forEach(input => {
const name = input.getAttribute('name');
if (name) {
// Replace pattern like "feeds.custom_feeds.0.name" with "feeds.custom_feeds.1.name"
input.setAttribute('name', name.replace(/\.\d+\./, `.${index}.`));
}
const id = input.id;
if (id) {
// Keep IDs aligned after reindex
input.id = id
.replace(/_logo_preview_\d+$/, `_logo_preview_${index}`)
.replace(/_logo_\d+$/, `_logo_${index}`);
}
// Keep dataset index aligned
if (input.dataset && 'index' in input.dataset) {
input.dataset.index = String(index);
}
});
});
}
};
/**
* Handle custom feed logo upload
* @param {Event} event - File input change event
* @param {string} fieldId - Field ID
* @param {number} index - Feed row index
* @param {string} pluginId - Plugin ID
* @param {string} fullKey - Full field key
*/
window.handleCustomFeedLogoUpload = function(event, fieldId, index, pluginId, fullKey) {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
formData.append('plugin_id', pluginId);
fetch('/api/v3/plugins/assets/upload', {
method: 'POST',
body: formData
})
.then(response => {
// Check HTTP status before parsing JSON
if (!response.ok) {
return response.text().then(text => {
throw new Error(`Upload failed: ${response.status} ${response.statusText}${text ? ': ' + text : ''}`);
});
}
return response.json();
})
.then(data => {
if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
const uploadedFile = data.data.files[0];
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
if (row) {
const logoCell = row.querySelector('td:nth-child(3)');
const existingPathInput = logoCell.querySelector('input[name*=".logo.path"]');
const existingIdInput = logoCell.querySelector('input[name*=".logo.id"]');
const pathName = existingPathInput ? existingPathInput.name : `${fullKey}.${index}.logo.path`;
const idName = existingIdInput ? existingIdInput.name : `${fullKey}.${index}.logo.id`;
// Normalize path: remove leading slashes, then add single leading slash
const normalizedPath = String(uploadedFile.path || '').replace(/^\/+/, '');
const imageSrc = '/' + normalizedPath;
// Clear logoCell and build DOM safely to prevent XSS
logoCell.textContent = ''; // Clear existing content
// Create container div
const container = document.createElement('div');
container.className = 'flex items-center space-x-2';
// Create file input
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = `${fieldId}_logo_${index}`;
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
fileInput.style.display = 'none';
fileInput.dataset.index = String(index);
fileInput.addEventListener('change', function(e) {
const idx = parseInt(e.target.dataset.index || '0', 10);
handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey);
});
// Create upload button
const uploadButton = document.createElement('button');
uploadButton.type = 'button';
uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded';
uploadButton.addEventListener('click', function() {
fileInput.click();
});
const uploadIcon = document.createElement('i');
uploadIcon.className = 'fas fa-upload mr-1';
uploadButton.appendChild(uploadIcon);
uploadButton.appendChild(document.createTextNode(' Upload'));
// Create img element
const img = document.createElement('img');
img.src = imageSrc;
img.alt = 'Logo';
img.className = 'w-8 h-8 object-cover rounded border';
img.id = `${fieldId}_logo_preview_${index}`;
// Create hidden input for path
const pathInput = document.createElement('input');
pathInput.type = 'hidden';
pathInput.name = pathName;
pathInput.value = imageSrc;
// Create hidden input for id
const idInput = document.createElement('input');
idInput.type = 'hidden';
idInput.name = idName;
idInput.value = String(uploadedFile.id);
// Append all elements to container
container.appendChild(fileInput);
container.appendChild(uploadButton);
container.appendChild(img);
container.appendChild(pathInput);
container.appendChild(idInput);
// Append container to logoCell
logoCell.appendChild(container);
}
// Allow re-uploading the same file
event.target.value = '';
} else {
const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + (data.message || 'Unknown error'), 'error');
}
})
.catch(error => {
console.error('Upload error:', error);
const notifyFn = window.showNotification || alert;
notifyFn('Upload failed: ' + error.message, 'error');
});
};
console.log('[CustomFeedsWidget] Custom feeds widget registered');
})();

View File

@@ -0,0 +1,194 @@
/**
* Example: Color Picker Widget
*
* This is an example custom widget demonstrating how to create
* a plugin-specific widget for the LEDMatrix system.
*
* To use this widget:
* 1. Copy this file to your plugin's widgets directory
* 2. Reference it in your config_schema.json with "x-widget": "color-picker"
* 3. The widget will be automatically loaded when the plugin config form is rendered
*
* @module ColorPickerWidget
*/
(function() {
'use strict';
// Ensure LEDMatrixWidgets registry exists
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[ColorPickerWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
return;
}
/**
* Register the color picker widget
*/
window.LEDMatrixWidgets.register('color-picker', {
name: 'Color Picker Widget',
version: '1.0.0',
/**
* Render the color picker widget
* @param {HTMLElement} container - Container element to render into
* @param {Object} config - Widget configuration from schema
* @param {string} value - Current color value (hex format)
* @param {Object} options - Additional options
*/
render: function(container, config, value, options) {
const fieldId = options.fieldId || container.id.replace('_widget_container', '');
let currentValue = value || config.default || '#000000';
// Validate hex color format - use safe default if invalid
const hexColorRegex = /^#[0-9A-Fa-f]{6}$/;
if (!hexColorRegex.test(currentValue)) {
currentValue = '#000000';
}
// Escape HTML to prevent XSS (for HTML contexts)
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
// Use validated/sanitized hex for style attribute and input values
const safeHex = currentValue; // Already validated above
container.innerHTML = `
<div class="color-picker-widget flex items-center space-x-3">
<div class="flex items-center space-x-2">
<label for="${escapeHtml(fieldId)}_color" class="text-sm text-gray-700">Color:</label>
<input type="color"
id="${escapeHtml(fieldId)}_color"
value="${safeHex}"
class="h-10 w-20 border border-gray-300 rounded cursor-pointer">
</div>
<div class="flex items-center space-x-2">
<label for="${escapeHtml(fieldId)}_hex" class="text-sm text-gray-700">Hex:</label>
<input type="text"
id="${escapeHtml(fieldId)}_hex"
value="${safeHex}"
pattern="^#[0-9A-Fa-f]{6}$"
maxlength="7"
class="px-3 py-2 border border-gray-300 rounded-md text-sm font-mono w-24"
placeholder="#000000">
</div>
<div class="flex-1">
<div id="${escapeHtml(fieldId)}_preview"
class="h-10 w-full border border-gray-300 rounded"
style="background-color: ${safeHex}">
</div>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">Select a color using the color picker or enter a hex code</p>
`;
// Get references to elements
const colorInput = container.querySelector('input[type="color"]');
const hexInput = container.querySelector('input[type="text"]');
const preview = container.querySelector(`#${fieldId}_preview`);
// Update hex when color picker changes
colorInput.addEventListener('input', (e) => {
const color = e.target.value;
hexInput.value = color;
if (preview) {
preview.style.backgroundColor = color;
}
this.handlers.onChange(fieldId, color);
});
// Update color picker and preview when hex input changes
hexInput.addEventListener('input', (e) => {
const hex = e.target.value;
// Validate hex format
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
colorInput.value = hex;
if (preview) {
preview.style.backgroundColor = hex;
}
hexInput.classList.remove('border-red-500');
hexInput.classList.add('border-gray-300');
this.handlers.onChange(fieldId, hex);
} else if (hex.length > 0) {
// Show error state for invalid hex
hexInput.classList.remove('border-gray-300');
hexInput.classList.add('border-red-500');
}
});
// Validate on blur
hexInput.addEventListener('blur', (e) => {
const hex = e.target.value;
if (hex && !/^#[0-9A-Fa-f]{6}$/.test(hex)) {
// Reset to current color picker value
e.target.value = colorInput.value;
e.target.classList.remove('border-red-500');
e.target.classList.add('border-gray-300');
}
});
},
/**
* Get current value from widget
* @param {string} fieldId - Field ID
* @returns {string} Current hex color value
*/
getValue: function(fieldId) {
const colorInput = document.querySelector(`#${fieldId}_color`);
return colorInput ? colorInput.value : null;
},
/**
* Set value programmatically
* @param {string} fieldId - Field ID
* @param {string} value - Hex color value to set
*/
setValue: function(fieldId, value) {
// Validate hex color format before using
const hexColorRegex = /^#[0-9A-Fa-f]{6}$/;
const safeValue = hexColorRegex.test(value) ? value : '#000000';
const colorInput = document.querySelector(`#${fieldId}_color`);
const hexInput = document.querySelector(`#${fieldId}_hex`);
const preview = document.querySelector(`#${fieldId}_preview`);
if (colorInput && hexInput) {
colorInput.value = safeValue;
hexInput.value = safeValue;
if (preview) {
preview.style.backgroundColor = safeValue;
}
}
},
/**
* Event handlers
*/
handlers: {
/**
* Handle color change
* @param {string} fieldId - Field ID
* @param {string} value - New color value
*/
onChange: function(fieldId, value) {
// Trigger form change event for validation and saving
const event = new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true,
cancelable: true
});
document.dispatchEvent(event);
// Also update any hidden input if it exists
const hiddenInput = document.querySelector(`input[name*="${fieldId}"][type="hidden"]`);
if (hiddenInput) {
hiddenInput.value = value;
}
}
}
});
console.log('[ColorPickerWidget] Color picker widget registered (example)');
})();

View File

@@ -0,0 +1,966 @@
/**
* File Upload Widget
*
* Handles file uploads (primarily images) with drag-and-drop support,
* preview, delete, and scheduling functionality.
*
* @module FileUploadWidget
*/
(function() {
'use strict';
// Ensure LEDMatrixWidgets registry exists
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[FileUploadWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
return;
}
/**
* Register the file-upload widget
*/
window.LEDMatrixWidgets.register('file-upload', {
name: 'File Upload Widget',
version: '1.0.0',
/**
* Render the file upload widget
* Note: This widget is currently server-side rendered via Jinja2 template.
* This registration ensures the handlers are available globally.
* Future enhancement: Full client-side rendering support.
*/
render: function(container, config, value, options) {
// For now, widgets are server-side rendered
// This function is a placeholder for future client-side rendering
console.log('[FileUploadWidget] Render called (server-side rendered)');
},
/**
* Get current value from widget
* @param {string} fieldId - Field ID
* @returns {Array} Array of uploaded files
*/
getValue: function(fieldId) {
return window.getCurrentImages ? window.getCurrentImages(fieldId) : [];
},
/**
* Set value in widget
* @param {string} fieldId - Field ID
* @param {Array} images - Array of image objects
*/
setValue: function(fieldId, images) {
if (window.updateImageList) {
window.updateImageList(fieldId, images);
}
},
handlers: {
// Handlers are attached to window for backwards compatibility
}
});
// ===== File Upload Handlers (Backwards Compatible) =====
// These functions are called from the server-rendered template
/**
* Handle file drop event
* @param {Event} event - Drop event
* @param {string} fieldId - Field ID
*/
window.handleFileDrop = function(event, fieldId) {
event.preventDefault();
const files = event.dataTransfer.files;
if (files.length > 0) {
window.handleFiles(fieldId, Array.from(files));
}
};
/**
* Handle file select event
* @param {Event} event - Change event
* @param {string} fieldId - Field ID
*/
window.handleFileSelect = function(event, fieldId) {
const files = event.target.files;
if (files.length > 0) {
window.handleFiles(fieldId, Array.from(files));
}
};
/**
* Handle multiple files upload
* @param {string} fieldId - Field ID
* @param {Array<File>} files - Files to upload
*/
window.handleFiles = async function(fieldId, files) {
const uploadConfig = window.getUploadConfig ? window.getUploadConfig(fieldId) : {};
const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image';
const maxFiles = uploadConfig.max_files || 10;
const maxSizeMB = uploadConfig.max_size_mb || 5;
const fileType = uploadConfig.file_type || 'image';
const customUploadEndpoint = uploadConfig.endpoint || '/api/v3/plugins/assets/upload';
// Get allowed types from config, with fallback
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp', 'image/gif'];
// Get current files list
const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : [];
// Validate file types and sizes first, build validFiles
const validFiles = [];
for (const file of files) {
if (file.size > maxSizeMB * 1024 * 1024) {
const notifyFn = window.showNotification || console.error;
notifyFn(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
continue;
}
if (fileType === 'json') {
// Validate JSON files
if (!file.name.toLowerCase().endsWith('.json')) {
const notifyFn = window.showNotification || console.error;
notifyFn(`File ${file.name} must be a JSON file (.json)`, 'error');
continue;
}
} else {
// Validate image files using allowedTypes from config
if (!allowedTypes.includes(file.type)) {
const notifyFn = window.showNotification || console.error;
notifyFn(`File ${file.name} is not a valid image type`, 'error');
continue;
}
}
validFiles.push(file);
}
// Check max files AFTER building validFiles
if (currentFiles.length + validFiles.length > maxFiles) {
const notifyFn = window.showNotification || console.error;
notifyFn(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${validFiles.length}.`, 'error');
return;
}
if (validFiles.length === 0) {
return;
}
// Show upload progress
if (window.showUploadProgress) {
window.showUploadProgress(fieldId, validFiles.length);
}
// Upload files
const formData = new FormData();
if (fileType !== 'json') {
formData.append('plugin_id', pluginId);
}
validFiles.forEach(file => formData.append('files', file));
try {
const response = await fetch(customUploadEndpoint, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.status === 'success') {
// Add uploaded files to current list
const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : [];
const newFiles = [...currentFiles, ...(data.uploaded_files || data.data?.files || [])];
if (window.updateImageList) {
window.updateImageList(fieldId, newFiles);
}
const notifyFn = window.showNotification || console.log;
notifyFn(`Successfully uploaded ${data.uploaded_files?.length || data.data?.files?.length || 0} ${fileType === 'json' ? 'file(s)' : 'image(s)'}`, 'success');
} else {
const notifyFn = window.showNotification || console.error;
notifyFn(`Upload failed: ${data.message}`, 'error');
}
} catch (error) {
console.error('Upload error:', error);
const notifyFn = window.showNotification || console.error;
notifyFn(`Upload error: ${error.message}`, 'error');
} finally {
if (window.hideUploadProgress) {
window.hideUploadProgress(fieldId);
}
// Clear file input
const fileInput = document.getElementById(`${fieldId}_file_input`);
if (fileInput) {
fileInput.value = '';
}
}
};
/**
* Delete uploaded image
* @param {string} fieldId - Field ID
* @param {string} imageId - Image ID
* @param {string} pluginId - Plugin ID
*/
window.deleteUploadedImage = async function(fieldId, imageId, pluginId) {
return window.deleteUploadedFile(fieldId, imageId, pluginId, 'image', null);
};
/**
* Delete uploaded file (generic)
* @param {string} fieldId - Field ID
* @param {string} fileId - File ID
* @param {string} pluginId - Plugin ID
* @param {string} fileType - File type ('image' or 'json')
* @param {string|null} customDeleteEndpoint - Custom delete endpoint
*/
window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType, customDeleteEndpoint) {
const fileTypeLabel = fileType === 'json' ? 'file' : 'image';
if (!confirm(`Are you sure you want to delete this ${fileTypeLabel}?`)) {
return;
}
try {
const deleteEndpoint = customDeleteEndpoint || (fileType === 'json' ? '/api/v3/plugins/of-the-day/json/delete' : '/api/v3/plugins/assets/delete');
const requestBody = fileType === 'json'
? { file_id: fileId }
: { plugin_id: pluginId, image_id: fileId };
const response = await fetch(deleteEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
const data = await response.json();
if (data.status === 'success') {
// Remove from current list - normalize types for comparison
const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : [];
const fileIdStr = String(fileId);
const newFiles = currentFiles.filter(file => {
const fileIdValue = String(file.id || file.category_name || '');
return fileIdValue !== fileIdStr;
});
if (window.updateImageList) {
window.updateImageList(fieldId, newFiles);
}
const notifyFn = window.showNotification || console.log;
notifyFn(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success');
} else {
const notifyFn = window.showNotification || console.error;
notifyFn(`Delete failed: ${data.message}`, 'error');
}
} catch (error) {
console.error('Delete error:', error);
const notifyFn = window.showNotification || console.error;
notifyFn(`Delete error: ${error.message}`, 'error');
}
};
/**
* Get upload configuration from schema
* @param {string} fieldId - Field ID
* @returns {Object} Upload configuration
*/
window.getUploadConfig = function(fieldId) {
// Extract config from schema
const schema = window.currentPluginConfig?.schema;
if (!schema || !schema.properties) return {};
// Find the property that matches this fieldId
// FieldId is like "image_config_images" for "image_config.images"
const key = fieldId.replace(/_/g, '.');
const keys = key.split('.');
let prop = schema.properties;
for (const k of keys) {
if (prop && prop[k]) {
prop = prop[k];
if (prop.properties && prop.type === 'object') {
prop = prop.properties;
} else if (prop.type === 'array' && prop['x-widget'] === 'file-upload') {
break;
} else {
break;
}
}
}
// If we found an array with x-widget, get its config
if (prop && prop.type === 'array' && prop['x-widget'] === 'file-upload') {
return prop['x-upload-config'] || {};
}
// Try to find nested images array
if (schema.properties && schema.properties.image_config &&
schema.properties.image_config.properties &&
schema.properties.image_config.properties.images) {
const imagesProp = schema.properties.image_config.properties.images;
if (imagesProp['x-widget'] === 'file-upload') {
return imagesProp['x-upload-config'] || {};
}
}
return {};
};
/**
* Get current images from hidden input
* @param {string} fieldId - Field ID
* @returns {Array} Array of image objects
*/
window.getCurrentImages = function(fieldId) {
const hiddenInput = document.getElementById(`${fieldId}_images_data`);
if (hiddenInput && hiddenInput.value) {
try {
return JSON.parse(hiddenInput.value);
} catch (e) {
console.error('Error parsing images data:', e);
}
}
return [];
};
/**
* Update image list display and hidden input
* Uses DOM creation to prevent XSS and preserves open schedule editors
* @param {string} fieldId - Field ID
* @param {Array} images - Array of image objects
*/
window.updateImageList = function(fieldId, images) {
const hiddenInput = document.getElementById(`${fieldId}_images_data`);
if (hiddenInput) {
hiddenInput.value = JSON.stringify(images);
}
// Update the display
const imageList = document.getElementById(`${fieldId}_image_list`);
if (!imageList) return;
const uploadConfig = window.getUploadConfig(fieldId);
const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image';
// Detect which schedule is currently open (if any)
const openScheduleId = (() => {
const existingItems = imageList.querySelectorAll('[id^="img_"]');
for (const item of existingItems) {
const scheduleDiv = item.querySelector('[id^="schedule_"]');
if (scheduleDiv && !scheduleDiv.classList.contains('hidden')) {
// Extract the ID from schedule_<id>
const match = scheduleDiv.id.match(/^schedule_(.+)$/);
if (match) {
return match[1];
}
}
}
return null;
})();
// Preserve open schedule content if it exists
const preservedScheduleContent = openScheduleId ? (() => {
const scheduleDiv = document.getElementById(`schedule_${openScheduleId}`);
return scheduleDiv ? scheduleDiv.innerHTML : null;
})() : null;
// Clear and rebuild using DOM creation
imageList.innerHTML = '';
images.forEach((img, idx) => {
const imgId = img.id || idx;
const sanitizedId = String(imgId).replace(/[^a-zA-Z0-9_-]/g, '_');
const imgSchedule = img.schedule || {};
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown';
// Create container div
const container = document.createElement('div');
container.id = `img_${sanitizedId}`;
container.className = 'bg-gray-50 p-3 rounded-lg border border-gray-200';
// Create main content div
const mainDiv = document.createElement('div');
mainDiv.className = 'flex items-center justify-between mb-2';
// Create left section with image and info
const leftSection = document.createElement('div');
leftSection.className = 'flex items-center space-x-3 flex-1';
// Create image element
const imgEl = document.createElement('img');
const imgPath = String(img.path || '').replace(/^\/+/, '');
imgEl.src = '/' + imgPath;
imgEl.alt = String(img.filename || '');
imgEl.className = 'w-16 h-16 object-cover rounded';
imgEl.addEventListener('error', function() {
this.style.display = 'none';
if (this.nextElementSibling) {
this.nextElementSibling.style.display = 'block';
}
});
// Create placeholder div for broken images
const placeholderDiv = document.createElement('div');
placeholderDiv.style.display = 'none';
placeholderDiv.className = 'w-16 h-16 bg-gray-200 rounded flex items-center justify-center';
const placeholderIcon = document.createElement('i');
placeholderIcon.className = 'fas fa-image text-gray-400';
placeholderDiv.appendChild(placeholderIcon);
// Create info div
const infoDiv = document.createElement('div');
infoDiv.className = 'flex-1 min-w-0';
// Filename
const filenameP = document.createElement('p');
filenameP.className = 'text-sm font-medium text-gray-900 truncate';
filenameP.textContent = img.original_filename || img.filename || 'Image';
// Size and date
const sizeDateP = document.createElement('p');
sizeDateP.className = 'text-xs text-gray-500';
const fileSize = window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB');
const uploadedDate = window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || '');
sizeDateP.textContent = `${fileSize}${uploadedDate}`;
// Schedule summary
const scheduleP = document.createElement('p');
scheduleP.className = 'text-xs text-blue-600 mt-1';
const clockIcon = document.createElement('i');
clockIcon.className = 'fas fa-clock mr-1';
scheduleP.appendChild(clockIcon);
scheduleP.appendChild(document.createTextNode(scheduleSummary));
infoDiv.appendChild(filenameP);
infoDiv.appendChild(sizeDateP);
infoDiv.appendChild(scheduleP);
leftSection.appendChild(imgEl);
leftSection.appendChild(placeholderDiv);
leftSection.appendChild(infoDiv);
// Create right section with buttons
const rightSection = document.createElement('div');
rightSection.className = 'flex items-center space-x-2 ml-4';
// Schedule button
const scheduleBtn = document.createElement('button');
scheduleBtn.type = 'button';
scheduleBtn.className = 'text-blue-600 hover:text-blue-800 p-2';
scheduleBtn.title = 'Schedule this image';
scheduleBtn.dataset.fieldId = fieldId;
scheduleBtn.dataset.imageId = String(imgId);
scheduleBtn.dataset.imageIdx = String(idx);
scheduleBtn.addEventListener('click', function() {
window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId, parseInt(this.dataset.imageIdx, 10));
});
const scheduleIcon = document.createElement('i');
scheduleIcon.className = 'fas fa-calendar-alt';
scheduleBtn.appendChild(scheduleIcon);
// Delete button
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'text-red-600 hover:text-red-800 p-2';
deleteBtn.title = 'Delete image';
deleteBtn.dataset.fieldId = fieldId;
deleteBtn.dataset.imageId = String(imgId);
deleteBtn.dataset.pluginId = pluginId;
deleteBtn.addEventListener('click', function() {
window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId);
});
const deleteIcon = document.createElement('i');
deleteIcon.className = 'fas fa-trash';
deleteBtn.appendChild(deleteIcon);
rightSection.appendChild(scheduleBtn);
rightSection.appendChild(deleteBtn);
mainDiv.appendChild(leftSection);
mainDiv.appendChild(rightSection);
// Create schedule container
const scheduleContainer = document.createElement('div');
scheduleContainer.id = `schedule_${sanitizedId}`;
scheduleContainer.className = 'hidden mt-3 pt-3 border-t border-gray-300';
// Restore preserved schedule content if this is the open one
if (openScheduleId === sanitizedId && preservedScheduleContent) {
scheduleContainer.innerHTML = preservedScheduleContent;
scheduleContainer.classList.remove('hidden');
}
container.appendChild(mainDiv);
container.appendChild(scheduleContainer);
imageList.appendChild(container);
});
};
/**
* Show upload progress
* @param {string} fieldId - Field ID
* @param {number} totalFiles - Total number of files
*/
window.showUploadProgress = function(fieldId, totalFiles) {
const dropZone = document.getElementById(`${fieldId}_drop_zone`);
if (dropZone) {
dropZone.innerHTML = `
<i class="fas fa-spinner fa-spin text-3xl text-blue-500 mb-2"></i>
<p class="text-sm text-gray-600">Uploading ${totalFiles} file(s)...</p>
`;
dropZone.style.pointerEvents = 'none';
}
};
/**
* Hide upload progress and restore drop zone
* @param {string} fieldId - Field ID
*/
window.hideUploadProgress = function(fieldId) {
const uploadConfig = window.getUploadConfig(fieldId);
const maxFiles = uploadConfig.max_files || 10;
const maxSizeMB = uploadConfig.max_size_mb || 5;
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
// Generate user-friendly extension list from allowedTypes
const extensionMap = {
'image/png': 'PNG',
'image/jpeg': 'JPG',
'image/jpg': 'JPG',
'image/bmp': 'BMP',
'image/gif': 'GIF',
'image/webp': 'WEBP'
};
const extensions = allowedTypes
.map(type => extensionMap[type] || type.split('/')[1]?.toUpperCase() || type)
.filter((ext, idx, arr) => arr.indexOf(ext) === idx) // Remove duplicates
.join(', ');
const extensionText = extensions || 'PNG, JPG, GIF, BMP';
const dropZone = document.getElementById(`${fieldId}_drop_zone`);
if (dropZone) {
dropZone.innerHTML = `
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
<p class="text-sm text-gray-600">Drag and drop images here or click to browse</p>
<p class="text-xs text-gray-500 mt-1">Max ${maxFiles} files, ${maxSizeMB}MB each (${extensionText})</p>
`;
dropZone.style.pointerEvents = 'auto';
}
};
/**
* Format file size
* @param {number} bytes - File size in bytes
* @returns {string} Formatted file size
*/
window.formatFileSize = function(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
/**
* Format date string
* @param {string} dateString - Date string
* @returns {string} Formatted date
*/
window.formatDate = function(dateString) {
if (!dateString) return 'Unknown date';
try {
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch (e) {
return dateString;
}
};
/**
* Get schedule summary text
* @param {Object} schedule - Schedule object
* @returns {string} Schedule summary
*/
window.getScheduleSummary = function(schedule) {
if (!schedule || !schedule.enabled || schedule.mode === 'always') {
return 'Always shown';
}
if (schedule.mode === 'time_range') {
return `${schedule.start_time || '08:00'} - ${schedule.end_time || '18:00'} (daily)`;
}
if (schedule.mode === 'per_day' && schedule.days) {
const enabledDays = Object.entries(schedule.days)
.filter(([day, config]) => config && config.enabled)
.map(([day]) => day.charAt(0).toUpperCase() + day.slice(1, 3));
if (enabledDays.length === 0) {
return 'Never shown';
}
return enabledDays.join(', ') + ' only';
}
return 'Scheduled';
};
/**
* Open image schedule editor
* @param {string} fieldId - Field ID
* @param {string|number} imageId - Image ID
* @param {number} imageIdx - Image index
*/
window.openImageSchedule = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
if (!image) return;
// Sanitize imageId to match updateImageList's sanitization
const sanitizedId = (imageId || imageIdx).toString().replace(/[^a-zA-Z0-9_-]/g, '_');
const scheduleContainer = document.getElementById(`schedule_${sanitizedId}`);
if (!scheduleContainer) return;
// Toggle visibility
const isVisible = !scheduleContainer.classList.contains('hidden');
if (isVisible) {
scheduleContainer.classList.add('hidden');
return;
}
scheduleContainer.classList.remove('hidden');
const schedule = image.schedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
// Escape HTML helper
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
// Use sanitizedId for all ID references in the schedule HTML
// Use data attributes instead of inline handlers to prevent JS injection
scheduleContainer.innerHTML = `
<div class="bg-white rounded-lg border border-blue-200 p-4">
<h4 class="text-sm font-semibold text-gray-900 mb-3">
<i class="fas fa-clock mr-2"></i>Schedule Settings
</h4>
<!-- Enable Schedule -->
<div class="mb-4">
<label class="flex items-center">
<input type="checkbox"
id="schedule_enabled_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
${schedule.enabled ? 'checked' : ''}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm font-medium text-gray-700">Enable schedule for this image</span>
</label>
<p class="ml-6 text-xs text-gray-500 mt-1">When enabled, this image will only display during scheduled times</p>
</div>
<!-- Schedule Mode -->
<div id="schedule_options_${sanitizedId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
<select id="schedule_mode_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
<option value="always" ${schedule.mode === 'always' ? 'selected' : ''}>Always Show (No Schedule)</option>
<option value="time_range" ${schedule.mode === 'time_range' ? 'selected' : ''}>Same Time Every Day</option>
<option value="per_day" ${schedule.mode === 'per_day' ? 'selected' : ''}>Different Times Per Day</option>
</select>
</div>
<!-- Time Range Mode -->
<div id="time_range_${sanitizedId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
<input type="time"
id="schedule_start_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
value="${escapeHtml(schedule.start_time || '08:00')}"
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
<input type="time"
id="schedule_end_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
value="${escapeHtml(schedule.end_time || '18:00')}"
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
</div>
</div>
<!-- Per-Day Mode -->
<div id="per_day_${sanitizedId}" style="display: ${schedule.mode === 'per_day' ? 'block' : 'none'};">
<label class="block text-xs font-medium text-gray-700 mb-2">Day-Specific Times</label>
<div class="bg-gray-50 rounded p-3 space-y-2 max-h-64 overflow-y-auto">
${['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].map(day => {
const dayConfig = (schedule.days && schedule.days[day]) || { enabled: true, start_time: '08:00', end_time: '18:00' };
return `
<div class="bg-white rounded p-2 border border-gray-200">
<div class="flex items-center justify-between mb-2">
<label class="flex items-center">
<input type="checkbox"
id="day_${day}_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
data-day="${day}"
${dayConfig.enabled ? 'checked' : ''}
class="h-3 w-3 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-xs font-medium text-gray-700 capitalize">${day}</span>
</label>
</div>
<div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${sanitizedId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
<input type="time"
id="day_${day}_start_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
data-day="${day}"
value="${escapeHtml(dayConfig.start_time || '08:00')}"
class="text-xs px-2 py-1 border border-gray-300 rounded"
${!dayConfig.enabled ? 'disabled' : ''}>
<input type="time"
id="day_${day}_end_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}"
data-day="${day}"
value="${escapeHtml(dayConfig.end_time || '18:00')}"
class="text-xs px-2 py-1 border border-gray-300 rounded"
${!dayConfig.enabled ? 'disabled' : ''}>
</div>
</div>
`;
}).join('')}
</div>
</div>
</div>
</div>
`;
// Attach event listeners using data attributes (prevents JS injection)
const enabledCheckbox = document.getElementById(`schedule_enabled_${sanitizedId}`);
if (enabledCheckbox) {
enabledCheckbox.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
window.toggleImageScheduleEnabled(fieldId, imageId, imageIdx);
});
}
const modeSelect = document.getElementById(`schedule_mode_${sanitizedId}`);
if (modeSelect) {
modeSelect.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
window.updateImageScheduleMode(fieldId, imageId, imageIdx);
});
}
const startInput = document.getElementById(`schedule_start_${sanitizedId}`);
if (startInput) {
startInput.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
window.updateImageScheduleTime(fieldId, imageId, imageIdx);
});
}
const endInput = document.getElementById(`schedule_end_${sanitizedId}`);
if (endInput) {
endInput.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
window.updateImageScheduleTime(fieldId, imageId, imageIdx);
});
}
// Attach listeners for per-day inputs
['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].forEach(day => {
const dayCheckbox = document.getElementById(`day_${day}_${sanitizedId}`);
if (dayCheckbox) {
dayCheckbox.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
const day = this.dataset.day;
window.updateImageScheduleDay(fieldId, imageId, imageIdx, day);
});
}
const dayStartInput = document.getElementById(`day_${day}_start_${sanitizedId}`);
if (dayStartInput) {
dayStartInput.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
const day = this.dataset.day;
window.updateImageScheduleDay(fieldId, imageId, imageIdx, day);
});
}
const dayEndInput = document.getElementById(`day_${day}_end_${sanitizedId}`);
if (dayEndInput) {
dayEndInput.addEventListener('change', function() {
const fieldId = this.dataset.fieldId;
const imageId = this.dataset.imageId;
const imageIdx = parseInt(this.dataset.imageIdx, 10);
const day = this.dataset.day;
window.updateImageScheduleDay(fieldId, imageId, imageIdx, day);
});
}
});
};
/**
* Toggle image schedule enabled state
*/
window.toggleImageScheduleEnabled = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
if (!image) return;
// Sanitize imageId for DOM lookup
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
const checkbox = document.getElementById(`schedule_enabled_${sanitizedId}`);
const enabled = checkbox ? checkbox.checked : false;
if (!image.schedule) {
image.schedule = { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
}
image.schedule.enabled = enabled;
const optionsDiv = document.getElementById(`schedule_options_${sanitizedId}`);
if (optionsDiv) {
optionsDiv.style.display = enabled ? 'block' : 'none';
}
if (window.updateImageList) {
window.updateImageList(fieldId, currentImages);
}
};
/**
* Update image schedule mode
*/
window.updateImageScheduleMode = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
if (!image) return;
// Sanitize imageId for DOM lookup
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
if (!image.schedule) {
image.schedule = { enabled: true, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
}
const modeSelect = document.getElementById(`schedule_mode_${sanitizedId}`);
const mode = modeSelect ? modeSelect.value : 'always';
image.schedule.mode = mode;
const timeRangeDiv = document.getElementById(`time_range_${sanitizedId}`);
const perDayDiv = document.getElementById(`per_day_${sanitizedId}`);
if (timeRangeDiv) timeRangeDiv.style.display = mode === 'time_range' ? 'grid' : 'none';
if (perDayDiv) perDayDiv.style.display = mode === 'per_day' ? 'block' : 'none';
if (window.updateImageList) {
window.updateImageList(fieldId, currentImages);
}
};
/**
* Update image schedule time
*/
window.updateImageScheduleTime = function(fieldId, imageId, imageIdx) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
if (!image) return;
// Sanitize imageId for DOM lookup
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
if (!image.schedule) {
image.schedule = { enabled: true, mode: 'time_range', start_time: '08:00', end_time: '18:00' };
}
const startInput = document.getElementById(`schedule_start_${sanitizedId}`);
const endInput = document.getElementById(`schedule_end_${sanitizedId}`);
if (startInput) image.schedule.start_time = startInput.value || '08:00';
if (endInput) image.schedule.end_time = endInput.value || '18:00';
if (window.updateImageList) {
window.updateImageList(fieldId, currentImages);
}
};
/**
* Update image schedule day
*/
window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) {
const currentImages = window.getCurrentImages(fieldId);
const image = currentImages[imageIdx];
if (!image) return;
// Sanitize imageId for DOM lookup
const sanitizedId = String(imageId).replace(/[^a-zA-Z0-9_-]/g, '_');
if (!image.schedule) {
image.schedule = { enabled: true, mode: 'per_day', days: {} };
}
if (!image.schedule.days) {
image.schedule.days = {};
}
const checkbox = document.getElementById(`day_${day}_${sanitizedId}`);
const startInput = document.getElementById(`day_${day}_start_${sanitizedId}`);
const endInput = document.getElementById(`day_${day}_end_${sanitizedId}`);
const enabled = checkbox ? checkbox.checked : true;
if (!image.schedule.days[day]) {
image.schedule.days[day] = { enabled: true, start_time: '08:00', end_time: '18:00' };
}
image.schedule.days[day].enabled = enabled;
if (startInput) image.schedule.days[day].start_time = startInput.value || '08:00';
if (endInput) image.schedule.days[day].end_time = endInput.value || '18:00';
const dayTimesDiv = document.getElementById(`day_times_${day}_${sanitizedId}`);
if (dayTimesDiv) {
dayTimesDiv.style.display = enabled ? 'grid' : 'none';
}
if (startInput) startInput.disabled = !enabled;
if (endInput) endInput.disabled = !enabled;
if (window.updateImageList) {
window.updateImageList(fieldId, currentImages);
}
};
console.log('[FileUploadWidget] File upload widget registered');
})();

View File

@@ -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<void>} 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<boolean>} 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<string>>} 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');
})();

View File

@@ -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<string>} 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');
})();

View File

@@ -161,6 +161,199 @@ window.uninstallPlugin = window.uninstallPlugin || function(pluginId) {
});
};
// Define configurePlugin early to ensure it's always available
window.configurePlugin = window.configurePlugin || async function(pluginId) {
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] configurePlugin called for', pluginId);
// Switch to the plugin's configuration tab instead of opening a modal
// This matches the behavior of clicking the plugin tab at the top
function getAppComponent() {
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
return appElement._x_dataStack[0];
}
}
return null;
}
const appComponent = getAppComponent();
if (appComponent) {
// Set the active tab to the plugin ID
appComponent.activeTab = pluginId;
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] Switched to plugin tab:', pluginId);
// Scroll to top of page to ensure the tab is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
console.error('Alpine.js app instance not found');
if (typeof showNotification === 'function') {
showNotification('Unable to switch to plugin configuration. Please refresh the page.', 'error');
}
}
};
// Initialize per-plugin toggle request token map for race condition protection
if (!window._pluginToggleRequests) {
window._pluginToggleRequests = {};
}
// Define togglePlugin early to ensure it's always available
window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] togglePlugin called for', pluginId, 'enabled:', enabled);
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
const action = enabled ? 'enabling' : 'disabling';
// Generate unique token for this toggle request to prevent race conditions
const requestToken = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
window._pluginToggleRequests[pluginId] = requestToken;
// Update UI immediately for better UX
const toggleCheckbox = document.getElementById(`toggle-${pluginId}`);
const toggleLabel = document.getElementById(`toggle-label-${pluginId}`);
const wrapperDiv = toggleCheckbox?.parentElement?.querySelector('.flex.items-center.gap-2');
const toggleTrack = wrapperDiv?.querySelector('.relative.w-14');
const toggleHandle = toggleTrack?.querySelector('.absolute');
// Disable checkbox and add disabled class to prevent overlapping requests
if (toggleCheckbox) {
toggleCheckbox.checked = enabled;
toggleCheckbox.disabled = true;
toggleCheckbox.classList.add('opacity-50', 'cursor-not-allowed');
}
// Disable wrapper to provide visual feedback
if (wrapperDiv) {
wrapperDiv.classList.add('opacity-50', 'pointer-events-none');
}
// Update wrapper background and border
if (wrapperDiv) {
if (enabled) {
wrapperDiv.classList.remove('bg-gray-50', 'border-gray-300');
wrapperDiv.classList.add('bg-green-50', 'border-green-500');
} else {
wrapperDiv.classList.remove('bg-green-50', 'border-green-500');
wrapperDiv.classList.add('bg-gray-50', 'border-gray-300');
}
}
// Update toggle track
if (toggleTrack) {
if (enabled) {
toggleTrack.classList.remove('bg-gray-300');
toggleTrack.classList.add('bg-green-500');
} else {
toggleTrack.classList.remove('bg-green-500');
toggleTrack.classList.add('bg-gray-300');
}
}
// Update toggle handle
if (toggleHandle) {
if (enabled) {
toggleHandle.classList.add('translate-x-full', 'border-green-500');
toggleHandle.classList.remove('border-gray-400');
toggleHandle.innerHTML = '<i class="fas fa-check text-green-600 text-xs"></i>';
} else {
toggleHandle.classList.remove('translate-x-full', 'border-green-500');
toggleHandle.classList.add('border-gray-400');
toggleHandle.innerHTML = '<i class="fas fa-times text-gray-400 text-xs"></i>';
}
}
// Update label with icon and text
if (toggleLabel) {
if (enabled) {
toggleLabel.className = 'text-sm font-semibold text-green-700 flex items-center gap-1.5';
toggleLabel.innerHTML = '<i class="fas fa-toggle-on text-green-600"></i><span>Enabled</span>';
} else {
toggleLabel.className = 'text-sm font-semibold text-gray-600 flex items-center gap-1.5';
toggleLabel.innerHTML = '<i class="fas fa-toggle-off text-gray-400"></i><span>Disabled</span>';
}
}
if (typeof showNotification === 'function') {
showNotification(`${action.charAt(0).toUpperCase() + action.slice(1)} ${pluginName}...`, 'info');
}
fetch('/api/v3/plugins/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId, enabled: enabled })
})
.then(response => response.json())
.then(data => {
// Verify this response is for the latest request (prevent race conditions)
if (window._pluginToggleRequests[pluginId] !== requestToken) {
console.log(`[togglePlugin] Ignoring out-of-order response for ${pluginId}`);
return;
}
if (typeof showNotification === 'function') {
showNotification(data.message, data.status);
}
if (data.status === 'success') {
// Update local state
if (plugin) {
plugin.enabled = enabled;
}
// Refresh the list to ensure consistency
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
}
} else {
// Revert the toggle if API call failed
if (plugin) {
plugin.enabled = !enabled;
}
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
}
}
// Clear token and re-enable UI
delete window._pluginToggleRequests[pluginId];
if (toggleCheckbox) {
toggleCheckbox.disabled = false;
toggleCheckbox.classList.remove('opacity-50', 'cursor-not-allowed');
}
if (wrapperDiv) {
wrapperDiv.classList.remove('opacity-50', 'pointer-events-none');
}
})
.catch(error => {
// Verify this error is for the latest request (prevent race conditions)
if (window._pluginToggleRequests[pluginId] !== requestToken) {
console.log(`[togglePlugin] Ignoring out-of-order error for ${pluginId}`);
return;
}
if (typeof showNotification === 'function') {
showNotification('Error toggling plugin: ' + error.message, 'error');
}
// Revert the toggle if API call failed
if (plugin) {
plugin.enabled = !enabled;
}
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
}
// Clear token and re-enable UI
delete window._pluginToggleRequests[pluginId];
if (toggleCheckbox) {
toggleCheckbox.disabled = false;
toggleCheckbox.classList.remove('opacity-50', 'cursor-not-allowed');
}
if (wrapperDiv) {
wrapperDiv.classList.remove('opacity-50', 'pointer-events-none');
}
});
};
// Cleanup orphaned modals from previous executions to prevent duplicates when moving to body
try {
const existingModals = document.querySelectorAll('#plugin-config-modal');
@@ -306,146 +499,8 @@ window.__pluginDomReady = window.__pluginDomReady || false;
console.log('[PLUGINS SCRIPT] Global event delegation set up');
})();
window.configurePlugin = window.configurePlugin || async function(pluginId) {
console.log('[DEBUG] ===== configurePlugin called =====');
console.log('[DEBUG] Plugin ID:', pluginId);
// Switch to the plugin's configuration tab instead of opening a modal
// This matches the behavior of clicking the plugin tab at the top
function getAppComponent() {
if (window.Alpine) {
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
return appElement._x_dataStack[0];
}
}
return null;
}
const appComponent = getAppComponent();
if (appComponent) {
// Set the active tab to the plugin ID
appComponent.activeTab = pluginId;
console.log('[DEBUG] Switched to plugin tab:', pluginId);
// Scroll to top of page to ensure the tab is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
console.error('Alpine.js app instance not found');
if (typeof showNotification === 'function') {
showNotification('Unable to switch to plugin configuration. Please refresh the page.', 'error');
}
}
};
window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
console.log('[DEBUG] ===== togglePlugin called =====');
console.log('[DEBUG] Plugin ID:', pluginId, 'Enabled:', enabled);
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
const action = enabled ? 'enabling' : 'disabling';
// Update UI immediately for better UX
const toggleCheckbox = document.getElementById(`toggle-${pluginId}`);
const toggleLabel = document.getElementById(`toggle-label-${pluginId}`);
const wrapperDiv = toggleCheckbox?.parentElement?.querySelector('.flex.items-center.gap-2');
const toggleTrack = wrapperDiv?.querySelector('.relative.w-14');
const toggleHandle = toggleTrack?.querySelector('.absolute');
if (toggleCheckbox) toggleCheckbox.checked = enabled;
// Update wrapper background and border
if (wrapperDiv) {
if (enabled) {
wrapperDiv.classList.remove('bg-gray-50', 'border-gray-300');
wrapperDiv.classList.add('bg-green-50', 'border-green-500');
} else {
wrapperDiv.classList.remove('bg-green-50', 'border-green-500');
wrapperDiv.classList.add('bg-gray-50', 'border-gray-300');
}
}
// Update toggle track
if (toggleTrack) {
if (enabled) {
toggleTrack.classList.remove('bg-gray-300');
toggleTrack.classList.add('bg-green-500');
} else {
toggleTrack.classList.remove('bg-green-500');
toggleTrack.classList.add('bg-gray-300');
}
}
// Update toggle handle
if (toggleHandle) {
if (enabled) {
toggleHandle.classList.add('translate-x-full', 'border-green-500');
toggleHandle.classList.remove('border-gray-400');
toggleHandle.innerHTML = '<i class="fas fa-check text-green-600 text-xs"></i>';
} else {
toggleHandle.classList.remove('translate-x-full', 'border-green-500');
toggleHandle.classList.add('border-gray-400');
toggleHandle.innerHTML = '<i class="fas fa-times text-gray-400 text-xs"></i>';
}
}
// Update label with icon and text
if (toggleLabel) {
if (enabled) {
toggleLabel.className = 'text-sm font-semibold text-green-700 flex items-center gap-1.5';
toggleLabel.innerHTML = '<i class="fas fa-toggle-on text-green-600"></i><span>Enabled</span>';
} else {
toggleLabel.className = 'text-sm font-semibold text-gray-600 flex items-center gap-1.5';
toggleLabel.innerHTML = '<i class="fas fa-toggle-off text-gray-400"></i><span>Disabled</span>';
}
}
if (typeof showNotification === 'function') {
showNotification(`${action.charAt(0).toUpperCase() + action.slice(1)} ${pluginName}...`, 'info');
}
fetch('/api/v3/plugins/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId, enabled: enabled })
})
.then(response => response.json())
.then(data => {
if (typeof showNotification === 'function') {
showNotification(data.message, data.status);
}
if (data.status === 'success') {
// Update local state
if (plugin) {
plugin.enabled = enabled;
}
// Refresh the list to ensure consistency
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
}
} else {
// Revert the toggle if API call failed
if (plugin) {
plugin.enabled = !enabled;
}
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
}
}
})
.catch(error => {
if (typeof showNotification === 'function') {
showNotification('Error toggling plugin: ' + error.message, 'error');
}
// Revert the toggle if API call failed
if (plugin) {
plugin.enabled = !enabled;
}
if (typeof loadInstalledPlugins === 'function') {
loadInstalledPlugins();
}
});
};
// Note: configurePlugin and togglePlugin are now defined at the top of the file (after uninstallPlugin)
// to ensure they're available immediately when the script loads
// Verify functions are defined (debug only)
if (_PLUGIN_DEBUG_EARLY) {
@@ -2145,6 +2200,19 @@ function getSchemaPropertyType(schema, path) {
return prop; // Return the full property object (was returning just type, but callers expect object)
}
// Helper function to escape CSS selector special characters
function escapeCssSelector(str) {
if (typeof str !== 'string') {
str = String(str);
}
// Use CSS.escape() when available (handles unicode, leading digits, and edge cases)
if (typeof CSS !== 'undefined' && CSS.escape) {
return CSS.escape(str);
}
// Fallback to regex-based escaping for older browsers
return str.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
}
// Helper function to convert dot notation to nested object
function dotToNested(obj) {
const result = {};
@@ -2240,6 +2308,12 @@ function handlePluginConfigSubmit(e) {
}
}
// Skip checkbox-group inputs with bracket notation (they're handled by the hidden _data input)
// Pattern: fieldName[] - these are individual checkboxes, actual data is in fieldName_data
if (key.endsWith('[]')) {
continue;
}
// Skip key_value pair inputs (they're handled by the hidden _data input)
if (key.includes('[key_') || key.includes('[value_')) {
continue;
@@ -2310,8 +2384,35 @@ function handlePluginConfigSubmit(e) {
} else if (propType === 'number') {
flatConfig[actualKey] = parseFloat(actualValue);
} else if (propType === 'boolean') {
const formElement = form.elements[actualKey] || form.elements[key];
flatConfig[actualKey] = formElement ? formElement.checked : (actualValue === 'true' || actualValue === true);
// Use querySelector to reliably find checkbox by name attribute
// Escape special CSS selector characters in the name
const escapedKey = escapeCssSelector(key);
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
if (formElement) {
// Element found - use its checked state
flatConfig[actualKey] = formElement.checked;
} else {
// Element not found - normalize string booleans and check FormData value
// Checkboxes send "on" when checked, nothing when unchecked
// Normalize string representations of booleans
if (typeof actualValue === 'string') {
const lowerValue = actualValue.toLowerCase().trim();
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
flatConfig[actualKey] = true;
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
flatConfig[actualKey] = false;
} else {
// Non-empty string that's not a boolean representation - treat as truthy
flatConfig[actualKey] = true;
}
} else if (actualValue === undefined || actualValue === null) {
flatConfig[actualKey] = false;
} else {
// Non-string value - coerce to boolean
flatConfig[actualKey] = Boolean(actualValue);
}
}
} else {
flatConfig[actualKey] = actualValue;
}
@@ -2334,11 +2435,29 @@ function handlePluginConfigSubmit(e) {
flatConfig[actualKey] = actualValue;
}
} else {
const formElement = form.elements[actualKey] || form.elements[key];
// No schema - try to detect checkbox by finding the element
const escapedKey = escapeCssSelector(key);
const formElement = form.querySelector(`input[type="checkbox"][name="${escapedKey}"]`);
if (formElement && formElement.type === 'checkbox') {
// Found checkbox element - use its checked state
flatConfig[actualKey] = formElement.checked;
} else {
flatConfig[actualKey] = actualValue;
// Not a checkbox or element not found - normalize string booleans
if (typeof actualValue === 'string') {
const lowerValue = actualValue.toLowerCase().trim();
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'on') {
flatConfig[actualKey] = true;
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'off' || lowerValue === '') {
flatConfig[actualKey] = false;
} else {
// Non-empty string that's not a boolean representation - keep as string
flatConfig[actualKey] = actualValue;
}
} else {
// Non-string value - use as-is
flatConfig[actualKey] = actualValue;
}
}
}
}
@@ -2947,20 +3066,20 @@ function generateFieldHtml(key, prop, value, prefix = '') {
// Check for file-upload widget FIRST (to avoid breaking static-image plugin)
if (xWidgetValue === 'file-upload' || xWidgetValue2 === 'file-upload') {
console.log(`[DEBUG] ✅ Detected file-upload widget for ${fullKey} - rendering upload zone`);
const uploadConfig = prop['x-upload-config'] || {};
const pluginId = uploadConfig.plugin_id || currentPluginConfig?.pluginId || 'static-image';
const maxFiles = uploadConfig.max_files || 10;
const fileType = uploadConfig.file_type || 'image'; // 'image' or 'json'
const allowedTypes = uploadConfig.allowed_types || (fileType === 'json' ? ['application/json'] : ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']);
const maxSizeMB = uploadConfig.max_size_mb || 5;
const customUploadEndpoint = uploadConfig.endpoint; // Custom endpoint if specified
const customDeleteEndpoint = uploadConfig.delete_endpoint; // Custom delete endpoint if specified
const currentFiles = Array.isArray(value) ? value : [];
const fieldId = fullKey.replace(/\./g, '_');
html += `
console.log(`[DEBUG] ✅ Detected file-upload widget for ${fullKey} - rendering upload zone`);
const uploadConfig = prop['x-upload-config'] || {};
const pluginId = uploadConfig.plugin_id || currentPluginConfig?.pluginId || 'static-image';
const maxFiles = uploadConfig.max_files || 10;
const fileType = uploadConfig.file_type || 'image'; // 'image' or 'json'
const allowedTypes = uploadConfig.allowed_types || (fileType === 'json' ? ['application/json'] : ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']);
const maxSizeMB = uploadConfig.max_size_mb || 5;
const customUploadEndpoint = uploadConfig.endpoint; // Custom endpoint if specified
const customDeleteEndpoint = uploadConfig.delete_endpoint; // Custom delete endpoint if specified
const currentFiles = Array.isArray(value) ? value : [];
const fieldId = fullKey.replace(/\./g, '_');
html += `
<div id="${fieldId}_upload_widget" class="mt-1">
<!-- File Upload Drop Zone -->
<div id="${fieldId}_drop_zone"
@@ -3044,8 +3163,8 @@ function generateFieldHtml(key, prop, value, prefix = '') {
data-upload-endpoint="${customUploadEndpoint || '/api/v3/plugins/assets/upload'}"
data-file-type="${fileType}">
</div>
`;
} else if (xWidgetValue === 'checkbox-group' || xWidgetValue2 === 'checkbox-group') {
`;
} else if (xWidgetValue === 'checkbox-group' || xWidgetValue2 === 'checkbox-group') {
// Checkbox group widget for multi-select arrays with enum items
// Use _data hidden input pattern to serialize selected values correctly
console.log(`[DEBUG] ✅ Detected checkbox-group widget for ${fullKey} - rendering checkboxes`);