chore: remove dead code, deprecate unused plugin APIs (over-engineering audit)

Whole-tree audit. Every symbol was checked against core, the plugin
monorepo and all eight third-party plugins in plugins.json first.

- Deprecate (removal 3.10.0) plugin-facing methods nothing calls:
  LogoDownloader bulk download, ConfigManager backup/secret wrappers,
  APIHelper extras, BackgroundDataService poll API, PluginManager /
  PluginStateManager info readers, and a few CacheManager, FontManager,
  BaseOddsManager, DynamicTeamResolver methods and PluginTestCase.
  plugin_api_usage.py learns their receiver names; DEPRECATIONS doc
  regenerated.
- Remove core-internal dead code: CacheMetrics, Vegas status/stats
  plumbing, sync "new cycle" message (followers ignore unknown types),
  unused operation types, test-only PluginCatalog readers, IPC to_dict
  and ping, _parse_form_value, CacheStrategyProtocol, ErrorAggregator
  callbacks, duplicate web response helpers.
- Web UI: drop never-mounted json-file-manager.js, the example widget,
  utils/error_handler.js, four uncalled PluginAPI methods, and 29
  escapeHtml shims (call window.LEDEscape directly). Public globals,
  BaseWidget and widget names unchanged.
- Remove six one-off scripts (owner decision) and the unused markupsafe
  and pytest-mock pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-06 18:27:40 -04:00
co-authored by Claude Opus 5.5
parent e40bc47d28
commit 7e066174d9
128 changed files with 914 additions and 4168 deletions
+2 -3
View File
@@ -15,7 +15,7 @@
* is the one Alpine uses)
* end of <body>, defer, in this order: app.js, js/tooltips.js,
* js/settings-search.js, js/utils/dialog.js,
* js/utils/error_handler.js, js/plugins/api_client.js,
* js/plugins/api_client.js,
* state_manager.js, install_manager.js, list_filter.js,
* the widget bundle (web_interface/widget_bundle.py),
* plugins_manager.js
@@ -252,8 +252,7 @@ document.addEventListener('keydown', function(e) {
// alone because during the x-transition between tabs both are visible.
// requestSubmit() runs validation and onsubmit guards like a real submit;
// with no visible form, do nothing. Inside a modal dialog the shortcut is
// the dialog's (json-file-manager saves its file on Ctrl+S), so the tab's
// form is left alone.
// the dialog's, so the tab's form is left alone.
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
const active = document.activeElement;
+1 -1
View File
@@ -15,7 +15,7 @@
* is the one Alpine uses)
* end of <body>, defer, in this order: app.js, js/tooltips.js,
* js/settings-search.js, js/utils/dialog.js,
* js/utils/error_handler.js, js/plugins/api_client.js,
* js/plugins/api_client.js,
* state_manager.js, install_manager.js, list_filter.js,
* the widget bundle (web_interface/widget_bundle.py),
* plugins_manager.js
+1 -1
View File
@@ -17,7 +17,7 @@
* is the one Alpine uses)
* end of <body>, defer, in this order: app.js, js/tooltips.js,
* js/settings-search.js, js/utils/dialog.js,
* js/utils/error_handler.js, js/plugins/api_client.js,
* js/plugins/api_client.js,
* state_manager.js, install_manager.js, list_filter.js,
* the widget bundle (web_interface/widget_bundle.py),
* plugins_manager.js
@@ -219,55 +219,6 @@ const PluginAPI = {
return [];
},
/**
* Toggle plugin enabled/disabled.
*
* @param {string} pluginId - Plugin identifier
* @param {boolean} enabled - Whether plugin should be enabled
* @returns {Promise<Object>} Response data
*/
async togglePlugin(pluginId, enabled) {
return await this.request('/plugins/toggle', 'POST', {
plugin_id: pluginId,
enabled: enabled
});
},
/**
* Get plugin configuration.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Plugin configuration
*/
async getPluginConfig(pluginId) {
const response = await this.request(`/plugins/config?plugin_id=${encodeURIComponent(pluginId)}`);
return response.data || {};
},
/**
* Save plugin configuration.
*
* @param {string} pluginId - Plugin identifier
* @param {Object} config - Configuration data
* @returns {Promise<Object>} Response data
*/
async savePluginConfig(pluginId, config) {
return await this.request('/plugins/config', 'POST', {
plugin_id: pluginId,
config: config
});
},
/**
* Reset plugin configuration to defaults.
*
* @param {string} pluginId - Plugin identifier
* @returns {Promise<Object>} Response data
*/
async resetPluginConfig(pluginId) {
return await this.request(`/plugins/config/reset?plugin_id=${encodeURIComponent(pluginId)}`, 'POST');
},
/**
* Update plugin.
*
@@ -22,8 +22,9 @@ const PluginStateManager = {
window.installedPlugins = plugins; // For backward compatibility
return plugins;
} catch (error) {
if (window.errorHandler) {
window.errorHandler.displayError(error, 'Failed to load installed plugins');
if (typeof window.showNotification === 'function') {
window.showNotification('Failed to load installed plugins: '
+ ((error && error.message) || String(error)), 'error');
}
throw error;
}
@@ -1,350 +0,0 @@
/* global showNotification */
/**
* Frontend error handling utilities.
*
* Provides user-friendly error formatting and display with enhanced UI.
*/
/**
* Comprehensive error code to user-friendly message mapping.
* Used only when the server response carries no message of its own.
*/
const ERROR_MESSAGES = {
// Configuration errors
'CONFIG_SAVE_FAILED': "Couldn't save your settings. Check the values and try again",
'CONFIG_LOAD_FAILED': "Couldn't load your settings. Reload the page to try again",
'CONFIG_VALIDATION_FAILED': 'Some settings have invalid values. Fix the highlighted fields and save again',
'CONFIG_ROLLBACK_FAILED': "Couldn't restore the previous settings. Try again, or restore from Backup & Restore",
// Plugin errors
'PLUGIN_NOT_FOUND': "That plugin isn't installed. Check the Plugin Manager",
'PLUGIN_INSTALL_FAILED': "Couldn't install the plugin. Check the Pi's internet connection and try again",
'PLUGIN_UPDATE_FAILED': "Couldn't update the plugin. Try again in a moment",
'PLUGIN_UNINSTALL_FAILED': "Couldn't uninstall the plugin. Try again in a moment",
'PLUGIN_LOAD_FAILED': "The plugin couldn't start. Check the Logs tab for details",
'PLUGIN_OPERATION_CONFLICT': 'Another plugin operation is still running. Wait for it to finish, then try again',
// Validation errors
'VALIDATION_ERROR': 'Some values are invalid. Fix them and try again',
'SCHEMA_VALIDATION_FAILED': "Some settings don't match what the plugin expects. Fix them and save again",
'INVALID_INPUT': "That value isn't valid. Check it and try again",
// Network errors
'NETWORK_ERROR': "Couldn't reach the LEDMatrix device. Check that it's on and connected, then try again",
'API_ERROR': 'The request failed. Try again in a moment',
'TIMEOUT': 'This took too long to respond. Try again in a moment',
// Permission errors
'PERMISSION_DENIED': "LEDMatrix doesn't have permission to do that. See the troubleshooting guide",
'FILE_PERMISSION_ERROR': "LEDMatrix can't write a file it needs. See the troubleshooting guide",
// System errors
'SYSTEM_ERROR': 'Something went wrong on the device. Check the Logs tab for details',
'SERVICE_UNAVAILABLE': 'The LEDMatrix service is not running. Restart it from the Overview tab',
// Unknown errors
'UNKNOWN_ERROR': 'Something went wrong. Try again, and check the Logs tab if it keeps happening'
};
/**
* Error code to troubleshooting documentation links.
*/
const TROUBLESHOOTING_URL = 'https://github.com/ChuckBuilds/LEDMatrix/blob/main/docs/TROUBLESHOOTING.md';
const ERROR_DOCS = {
'CONFIG_SAVE_FAILED': TROUBLESHOOTING_URL + '#4-check-configuration',
'CONFIG_VALIDATION_FAILED': TROUBLESHOOTING_URL + '#4-check-configuration',
'PLUGIN_INSTALL_FAILED': TROUBLESHOOTING_URL + '#plugin-issues',
'PLUGIN_OPERATION_CONFLICT': TROUBLESHOOTING_URL + '#plugin-issues',
'PERMISSION_DENIED': TROUBLESHOOTING_URL + '#permission-issues',
'FILE_PERMISSION_ERROR': TROUBLESHOOTING_URL + '#permission-issues'
};
/**
* Format error message for display to user.
*
* @param {Object} error - Error object from API response
* @returns {string} Formatted error message
*/
function formatError(error) {
if (!error) {
return ERROR_MESSAGES.UNKNOWN_ERROR;
}
// If error is a string, return it
if (typeof error === 'string') {
return error;
}
// If error has a message, use it
if (error.message) {
return error.message;
}
// If error has error_code, format it
if (error.error_code) {
const message = ERROR_MESSAGES[error.error_code] || error.message || ERROR_MESSAGES.UNKNOWN_ERROR;
// Add details if available
if (error.details) {
return `${message}: ${error.details}`;
}
return message;
}
return ERROR_MESSAGES.UNKNOWN_ERROR;
}
/**
* Get suggested fixes for an error.
*
* @param {Object} error - Error object from API response
* @returns {Array<string>} Array of suggested fixes
*/
function getSuggestedFixes(error) {
if (!error || !error.suggested_fixes) {
return [];
}
return error.suggested_fixes;
}
/**
* Display error with suggestions in a rich UI.
*
* @param {Object} error - Error object from API response
* @param {string} context - Optional context about what was being done
* @param {Object} options - Display options
* @param {boolean} options.showDetails - Whether to show detailed error modal
* @param {boolean} options.showCopyButton - Whether to show copy button
*/
function displayError(error, context = null, options = {}) {
const message = formatError(error);
const suggestions = getSuggestedFixes(error);
const errorCode = error?.error_code;
const docLink = errorCode ? ERROR_DOCS[errorCode] : null;
// Build full message
let fullMessage = message;
if (context) {
fullMessage = `${context}: ${message}`;
}
if (suggestions.length > 0) {
fullMessage += '\n\nSuggested fixes:\n' + suggestions.map(s => `• ${s}`).join('\n');
}
// If showDetails is true, show a rich error modal
if (options.showDetails !== false && (suggestions.length > 0 || docLink || error?.details)) {
showErrorModal(error, context, message, suggestions, docLink);
} else {
// Simple notification
showNotification(fullMessage, 'error');
}
}
// Release function for the open error modal's focus trap (see utils/dialog.js).
let errorModalRelease = null;
/**
* Show a rich error modal with details, suggestions, and copy button.
*
* @param {Object} error - Error object
* @param {string} context - Context
* @param {string} message - Formatted message
* @param {Array<string>} suggestions - Suggested fixes
* @param {string} docLink - Documentation link
*/
function showErrorModal(error, context, message, suggestions, docLink) {
error = error || {};
suggestions = suggestions || [];
// Create modal container if it doesn't exist
let modalContainer = document.getElementById('error-modal-container');
if (!modalContainer) {
modalContainer = document.createElement('div');
modalContainer.id = 'error-modal-container';
modalContainer.className = 'fixed inset-0 z-50 overflow-y-auto';
modalContainer.style.display = 'none';
document.body.appendChild(modalContainer);
}
// Re-opening while open: release the previous trap before replacing it.
if (errorModalRelease) {
errorModalRelease();
errorModalRelease = null;
}
// Build modal content
const contextText = context ? `<div class="text-sm text-gray-600 mb-2">${window.LEDEscape.html(context)}</div>` : '';
const suggestionsHtml = suggestions.length > 0 ? `
<div class="mt-4">
<h4 class="text-sm font-semibold text-gray-900 mb-2">Suggested fixes:</h4>
<ul class="list-disc list-inside space-y-1 text-sm text-gray-700">
${suggestions.map(s => `<li>${window.LEDEscape.html(s)}</li>`).join('')}
</ul>
</div>
` : '';
const docLinkHtml = docLink ? `
<div class="mt-4">
<a href="${window.LEDEscape.html(docLink)}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 text-sm underline">
<i class="fas fa-book mr-1" aria-hidden="true"></i>View troubleshooting guide<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;"> (opens in a new tab)</span>
</a>
</div>
` : '';
const detailsHtml = error.details ? `
<div class="mt-4">
<details class="cursor-pointer">
<summary class="text-sm font-medium text-gray-700 hover:text-gray-900">Technical details</summary>
<pre class="mt-2 text-xs bg-gray-100 p-3 rounded overflow-auto max-h-48 text-gray-800">${window.LEDEscape.html(error.details)}</pre>
</details>
</div>
` : '';
const errorCodeHtml = error.error_code ? `
<div class="mt-2 text-xs text-gray-500">
Error code: <code class="bg-gray-100 px-1 py-0.5 rounded">${window.LEDEscape.html(error.error_code)}</code>
</div>
` : '';
modalContainer.innerHTML = `
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" onclick="window.errorHandler.closeErrorModal()"></div>
<div id="error-modal-panel" class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full" style="position:relative;">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10" aria-hidden="true">
<i class="fas fa-exclamation-triangle text-red-600"></i>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 id="error-modal-title" class="text-lg leading-6 font-medium text-gray-900">Something went wrong</h3>
<div class="mt-2" id="error-modal-description">
${contextText}
<p class="text-sm text-gray-500">${window.LEDEscape.html(message)}</p>
${errorCodeHtml}
${suggestionsHtml}
${docLinkHtml}
${detailsHtml}
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" id="error-modal-copy-btn"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
<i class="fas fa-copy mr-2" aria-hidden="true"></i>Copy Error Details
</button>
<button type="button" id="error-modal-close-btn" onclick="window.errorHandler.closeErrorModal()"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Close
</button>
</div>
</div>
</div>
`;
// Attach event listener to copy button
const copyBtn = modalContainer.querySelector('#error-modal-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
copyErrorDetails(error);
});
}
modalContainer.style.display = 'block';
const panel = modalContainer.querySelector('#error-modal-panel');
if (panel) {
panel.setAttribute('aria-describedby', 'error-modal-description');
if (window.LEDDialog) {
errorModalRelease = window.LEDDialog.trap(panel, {
labelledBy: 'error-modal-title',
initialFocus: '#error-modal-close-btn',
onEscape: closeErrorModal
});
}
}
}
/**
* Close the error modal.
*/
function closeErrorModal() {
const modalContainer = document.getElementById('error-modal-container');
if (modalContainer) {
modalContainer.style.display = 'none';
}
if (errorModalRelease) {
const release = errorModalRelease;
errorModalRelease = null;
release();
}
}
/**
* Copy error details to clipboard.
*
* @param {Object} error - Error object from API response
*/
function copyErrorDetails(error) {
const errorText = JSON.stringify(error, null, 2);
function copyFailed(err) {
console.error('Failed to copy error details:', err);
showNotification("Couldn't copy to the clipboard. Open Technical details and copy the text by hand.", 'warning');
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(errorText).then(() => {
showNotification('Error details copied to clipboard', 'success');
}).catch(copyFailed);
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = errorText;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
// Keep focus inside the open dialog while copying.
const panel = document.getElementById('error-modal-panel');
const host = panel && panel.offsetParent !== null ? panel : document.body;
const returnFocus = document.activeElement;
host.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
showNotification('Error details copied to clipboard', 'success');
} catch (err) {
copyFailed(err);
}
host.removeChild(textArea);
if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus();
}
}
// Export functions
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
formatError,
getSuggestedFixes,
displayError,
copyErrorDetails,
showErrorModal,
closeErrorModal,
ERROR_MESSAGES,
ERROR_DOCS
};
} else {
// Make available globally
window.errorHandler = {
formatError,
getSuggestedFixes,
displayError,
copyErrorDetails,
showErrorModal,
closeErrorModal,
ERROR_MESSAGES,
ERROR_DOCS
};
}
+1 -4
View File
@@ -28,7 +28,7 @@ set by `BUNDLE_ORDER` there.
| `file-upload-single` | string | One image upload; stores the uploaded file's relative path |
| `google-oauth` | string | Step 2 of the calendar plugin's Google sign-in |
| `plugin-file-manager` | null | Inline file manager driven by the plugin's `web_ui_actions` |
| `json-file-manager` | null | JSON data-file manager driven by `web_ui_actions` |
| `json-file-manager` | null | Embeds the plugin's own `web_ui/file_manager.html` in an iframe |
| `toggle-switch` | boolean | On/off switch |
| `slider` | integer / number | Range slider using `minimum` / `maximum` |
| `number-input` | integer / number | Number field with min/max check |
@@ -51,7 +51,6 @@ Other files here:
| `notification.js` | Toast notifications; owns `window.showNotification` |
| `plugin-order-list.js` | Drag-and-drop plugin order list used by the Display and Durations tabs (`window.PluginOrderList`) |
| `plugin-loader.js` | Loads a plugin-supplied widget on demand |
| `example-color-picker.js` | Example custom widget. Not bundled: it registers `color-picker` and would replace the real one |
Each widget file's header comment gives its schema options. The sections
below cover the ones that need more than a line.
@@ -270,8 +269,6 @@ directory is the only place the core serves plugin widgets from.
})();
```
[`example-color-picker.js`](example-color-picker.js) is a longer example.
### 2. Reference it in the schema
```json
@@ -25,8 +25,6 @@
const base = window.BaseWidget ? new window.BaseWidget('ColorPicker', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -141,7 +139,7 @@
html += '</div>';
// Hidden input for form submission
html += `<input type="hidden" id="${fieldId}_input" name="${escapeHtml(options.name || fieldId)}" value="${currentValue}">`;
html += `<input type="hidden" id="${fieldId}_input" name="${window.LEDEscape.html(options.name || fieldId)}" value="${currentValue}">`;
// Preset colors - only render valid hex colors
if (Array.isArray(presets) && presets.length > 0) {
@@ -155,12 +153,12 @@
html += `
<button type="button"
${disabled ? 'disabled' : ''}
data-color="${escapeHtml(normalized)}"
data-color="${window.LEDEscape.html(normalized)}"
onclick="window.LEDMatrixWidgets.getHandlers('color-picker').onPresetClick('${fieldId}', this.dataset.color)"
class="w-6 h-6 rounded border border-gray-300 hover:scale-110 transition-transform ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
style="background-color: ${escapeHtml(normalized)};"
title="${escapeHtml(normalized)}"
aria-label="Use color ${escapeHtml(normalized)}">
style="background-color: ${window.LEDEscape.html(normalized)};"
title="${window.LEDEscape.html(normalized)}"
aria-label="Use color ${window.LEDEscape.html(normalized)}">
</button>
`;
}
@@ -26,8 +26,6 @@
const base = window.BaseWidget ? new window.BaseWidget('DatePicker', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -70,11 +68,11 @@
<div class="relative flex-1">
<input type="date"
id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
value="${escapeHtml(currentValue)}"
${min ? `min="${escapeHtml(min)}"` : ''}
${max ? `max="${escapeHtml(max)}"` : ''}
${placeholder ? `placeholder="${escapeHtml(placeholder)}"` : ''}
name="${window.LEDEscape.html(options.name || fieldId)}"
value="${window.LEDEscape.html(currentValue)}"
${min ? `min="${window.LEDEscape.html(min)}"` : ''}
${max ? `max="${window.LEDEscape.html(max)}"` : ''}
${placeholder ? `placeholder="${window.LEDEscape.html(placeholder)}"` : ''}
${disabled ? 'disabled' : ''}
${required ? 'required' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('date-picker').onChange('${fieldId}')"
@@ -109,7 +107,7 @@
} else {
constraintText = `Until ${max}`;
}
html += `<div class="text-xs text-gray-400 mt-1">${escapeHtml(constraintText)}</div>`;
html += `<div class="text-xs text-gray-400 mt-1">${window.LEDEscape.html(constraintText)}</div>`;
}
// Error message area
@@ -50,8 +50,6 @@
// Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('DaySelector', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -100,7 +98,7 @@
// Hidden input to store the value as JSON array
// Note: Using single quotes for attribute, JSON uses double quotes, so no escaping needed
html += `<input type="hidden" id="${fieldId}_data" name="${escapeHtml(inputName)}" value='${JSON.stringify(selectedDays)}'>`;
html += `<input type="hidden" id="${fieldId}_data" name="${window.LEDEscape.html(inputName)}" value='${JSON.stringify(selectedDays)}'>`;
// Select All toggle
if (showSelectAll) {
@@ -141,7 +139,7 @@
${isChecked ? 'checked' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('day-selector').onChange('${fieldId}')"
class="day-checkbox h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-1 text-sm text-gray-700">${escapeHtml(label)}</span>
<span class="ml-1 text-sm text-gray-700">${window.LEDEscape.html(label)}</span>
</label>
`;
}
@@ -24,8 +24,6 @@
const base = window.BaseWidget ? new window.BaseWidget('EmailInput', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -73,9 +71,9 @@
html += `
<input type="email"
id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
value="${escapeHtml(currentValue)}"
placeholder="${escapeHtml(placeholder)}"
name="${window.LEDEscape.html(options.name || fieldId)}"
value="${window.LEDEscape.html(currentValue)}"
placeholder="${window.LEDEscape.html(placeholder)}"
${disabled ? 'disabled' : ''}
${required ? 'required' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('email-input').onChange('${fieldId}')"
@@ -1,200 +0,0 @@
/**
* 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
*
* Do NOT add this file to base.html's widget script list: it registers
* under the name 'color-picker' and would shadow the built-in
* color-picker.js widget.
*
* @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)
// Quotes too: the result lands in quoted attribute values below, and
// the textContent/innerHTML round-trip only escapes &, < and >.
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = String(text ?? '');
return div.innerHTML
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
// 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;
}
}
}
});
})();
@@ -33,8 +33,6 @@
const base = window.BaseWidget ? new window.BaseWidget('FileUploadSingle', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -78,22 +76,22 @@
const currentValue = value || '';
const hasImage = isImagePath(currentValue);
let html = `<div id="${fieldId}_widget" class="file-upload-single-widget" data-field-id="${fieldId}" data-plugin-id="${escapeHtml(pluginId)}">`;
let html = `<div id="${fieldId}_widget" class="file-upload-single-widget" data-field-id="${fieldId}" data-plugin-id="${window.LEDEscape.html(pluginId)}">`;
// Hidden input carries the actual string value
html += `<input type="hidden" id="${fieldId}" name="${escapeHtml(options.name || fieldId)}" value="${escapeHtml(currentValue)}">`;
html += `<input type="hidden" id="${fieldId}" name="${window.LEDEscape.html(options.name || fieldId)}" value="${window.LEDEscape.html(currentValue)}">`;
// Preview area (shown when a value is set)
html += `<div id="${fieldId}_preview" class="${hasImage ? '' : 'hidden'} flex items-center space-x-3 mb-2 p-2 bg-gray-50 rounded border border-gray-200">`;
html += `<img id="${fieldId}_thumb" src="/${escapeHtml(currentValue)}" alt="Preview"
html += `<img id="${fieldId}_thumb" src="/${window.LEDEscape.html(currentValue)}" alt="Preview"
class="w-12 h-12 object-cover rounded"
onerror="this.style.display='none';document.getElementById('${fieldId}_thumb_placeholder').style.display='flex'">`;
html += `<div id="${fieldId}_thumb_placeholder" style="display:none" class="w-12 h-12 bg-gray-200 rounded flex items-center justify-center">
<i class="fas fa-image text-gray-400 text-lg"></i>
</div>`;
html += `<div class="flex-1 min-w-0">
<p id="${fieldId}_filename" class="text-xs text-gray-600 truncate">${escapeHtml(currentValue.split('/').pop() || '')}</p>
<p id="${fieldId}_fullpath" class="text-xs text-gray-400">${escapeHtml(currentValue)}</p>
<p id="${fieldId}_filename" class="text-xs text-gray-600 truncate">${window.LEDEscape.html(currentValue.split('/').pop() || '')}</p>
<p id="${fieldId}_fullpath" class="text-xs text-gray-400">${window.LEDEscape.html(currentValue)}</p>
</div>`;
html += `<button type="button"
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
@@ -113,12 +111,12 @@
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('${fieldId}_file_input').click();}">
<input type="file"
id="${fieldId}_file_input"
accept="${escapeHtml(allowedTypes)}"
accept="${window.LEDEscape.html(allowedTypes)}"
style="display:none"
data-field-id="${fieldId}"
data-plugin-id="${escapeHtml(pluginId)}"
data-plugin-id="${window.LEDEscape.html(pluginId)}"
data-max-size-mb="${maxSizeMb}"
data-allowed-types="${escapeHtml(allowedTypes)}"
data-allowed-types="${window.LEDEscape.html(allowedTypes)}"
onchange="window.LEDMatrixWidgets.getHandlers('file-upload-single').onFileSelect(event, '${fieldId}')">
<i class="fas fa-cloud-upload-alt text-xl text-gray-400 mb-1"></i>
<p class="text-xs text-gray-500">${hasImage ? 'Click to replace image' : 'Click or drag to upload image'}</p>
@@ -31,8 +31,6 @@
let fontCatalogCache = null;
let fontCatalogPromise = null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -187,7 +185,7 @@
container.innerHTML = `
<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">
<select id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
name="${window.LEDEscape.html(options.name || fieldId)}"
disabled
class="form-select w-full rounded-md border-gray-300 shadow-sm bg-gray-100 text-black">
<option value="">Loading fonts...</option>
@@ -223,7 +221,7 @@
html += `
<select id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
name="${window.LEDEscape.html(options.name || fieldId)}"
${disabled ? 'disabled' : ''}
${required ? 'required' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('font-selector').onChange('${fieldId}')"
@@ -232,7 +230,7 @@
// Placeholder option
if (placeholder && !required) {
html += `<option value="" ${!currentValue ? 'selected' : ''}>${escapeHtml(placeholder)}</option>`;
html += `<option value="" ${!currentValue ? 'selected' : ''}>${window.LEDEscape.html(placeholder)}</option>`;
}
// Font options
@@ -242,7 +240,7 @@
const fontType = font.type ? ` (${font.type.toUpperCase()})` : '';
const isSelected = String(fontValue) === currentValue;
html += `<option value="${escapeHtml(String(fontValue))}" ${isSelected ? 'selected' : ''}>${escapeHtml(displayName)}${escapeHtml(fontType)}</option>`;
html += `<option value="${window.LEDEscape.html(String(fontValue))}" ${isSelected ? 'selected' : ''}>${window.LEDEscape.html(displayName)}${window.LEDEscape.html(fontType)}</option>`;
}
html += '</select>';
@@ -268,9 +266,9 @@
container.innerHTML = `
<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">
<select id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
name="${window.LEDEscape.html(options.name || fieldId)}"
class="form-select w-full rounded-md border-gray-300 shadow-sm bg-white text-black">
<option value="${escapeHtml(currentValue)}" selected>${escapeHtml(currentValue || 'Error loading fonts')}</option>
<option value="${window.LEDEscape.html(currentValue)}" selected>${window.LEDEscape.html(currentValue || 'Error loading fonts')}</option>
</select>
<div class="text-sm text-red-600 mt-1">Failed to load font list</div>
</div>
@@ -179,9 +179,7 @@
container.innerHTML = '';
const p = document.createElement('p');
p.className = 'text-xs text-red-400 mt-1 flex items-center gap-1';
p.innerHTML = '<i class="fas fa-exclamation-triangle"></i> ' + escapeHtml(message);
p.innerHTML = '<i class="fas fa-exclamation-triangle"></i> ' + window.LEDEscape.html(message);
container.appendChild(p);
}
function escapeHtml(str) { return window.LEDEscape.html(str); }
})();
@@ -1,821 +0,0 @@
/**
* JsonFileManager — reusable JSON file management widget for LEDMatrix plugins.
*
* Usage via config_schema.json:
* "file_manager": {
* "type": "null",
* "title": "Data Files",
* "x-widget": "json-file-manager",
* "x-widget-config": {
* "actions": {
* "list": "list-files", // required
* "get": "get-file", // required for editing
* "save": "save-file", // required for editing
* "upload": "upload-file", // optional
* "delete": "delete-file", // optional
* "create": "create-file", // optional
* "toggle": "toggle-category" // optional
* },
* "upload_hint": "Hint text under the drop zone",
* "directory_label": "of_the_day/",
* "create_fields": [
* { "key": "category_name", "label": "Category Name",
* "placeholder": "my_words", "pattern": "^[a-z0-9_]+$",
* "hint": "Used as filename" },
* { "key": "display_name", "label": "Display Name",
* "placeholder": "My Words" }
* ],
* "toggle_key": "category_name"
* }
* }
*
* No CDN dependencies. Works on all modern browsers.
*/
(function () {
'use strict';
class JsonFileManager {
constructor(container, config, pluginId) {
// Prevent duplicate instances on the same container
if (container._jfmInstance) {
container._jfmInstance._destroy();
}
container._jfmInstance = this;
this.el = container;
this.pluginId = pluginId;
this.actions = config.actions || {};
this.uploadHint = config.upload_hint || '';
this.dirLabel = config.directory_label || '';
this.createFields = config.create_fields || [];
this.toggleKey = config.toggle_key || null;
// Unique prefix for all DOM IDs in this instance
this._uid = 'jfm_' + Array.from(crypto.getRandomValues(new Uint8Array(4)), b => b.toString(16).padStart(2, '0')).join('');
// Mutable state
this._editFile = null;
this._deleteFile = null;
this._keyHandler = this._onKey.bind(this);
// Focus-trap release functions for open modals (utils/dialog.js)
this._releases = new Map();
this._inject();
this._bind();
this._loadList();
}
// ── Lifecycle ────────────────────────────────────────────────────────
_destroy() {
document.removeEventListener('keydown', this._keyHandler);
for (const key of Array.from(this._releases.keys())) this._release(key);
this.el._jfmInstance = null;
}
// Open a modal's focus trap. Escape is routed here, so _onKey no
// longer handles it.
_trap(key, modal, titleId, initialFocus, onClose) {
if (!modal || !window.LEDDialog) return;
const box = modal.querySelector('.jfm-modal-box');
this._releases.set(key, window.LEDDialog.trap(box, {
labelledBy: titleId,
initialFocus: initialFocus || null,
onEscape: onClose
}));
}
_release(key) {
const release = this._releases.get(key);
if (release) {
this._releases.delete(key);
release();
}
}
// ── DOM Injection ────────────────────────────────────────────────────
_inject() {
const u = this._uid;
const hasUpload = !!this.actions.upload;
const hasCreate = !!this.actions.create;
const hasDelete = !!this.actions.delete;
this.el.innerHTML = this._css(u) + `
<div id="${u}" class="jfm">
<div class="jfm-header">
<div class="jfm-header-left">
<span class="jfm-title">Data Files</span>
${this.dirLabel ? `<code class="jfm-dir">${this._esc(this.dirLabel)}</code>` : ''}
</div>
<div class="jfm-header-right">
${hasCreate ? `<button type="button" class="jfm-btn jfm-btn-primary jfm-btn-sm" data-jfm="open-create">+ New File</button>` : ''}
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="refresh" title="Refresh file list">&#8635;</button>
</div>
</div>
<div id="${u}-list" class="jfm-list">
<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>
</div>
${hasUpload ? `
<div class="jfm-upload-wrap">
<input type="file" accept=".json" id="${u}-fileinput" tabindex="-1">
<div class="jfm-dropzone" id="${u}-dropzone" data-jfm="open-picker" role="button" tabindex="0"
aria-label="Upload JSON file">
<span class="jfm-drop-icon">&#128193;</span>
<p class="jfm-drop-primary">Drop a JSON file here, or click to browse</p>
${this.uploadHint ? `<p class="jfm-drop-hint">${this._esc(this.uploadHint)}</p>` : ''}
</div>
</div>` : ''}
<!-- ── Edit modal ─────────────────────────────────────── -->
<div class="jfm-modal" id="${u}-edit-modal" hidden>
<div class="jfm-modal-box jfm-modal-wide">
<div class="jfm-modal-head">
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
<div class="jfm-modal-tools">
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close editor">&times;</button>
</div>
</div>
<div id="${u}-edit-err" class="jfm-err-bar" role="alert" hidden></div>
<textarea id="${u}-editor" class="jfm-editor"
spellcheck="false" autocomplete="off"
autocorrect="off" autocapitalize="off"
aria-label="JSON editor" aria-describedby="${u}-editor-hint"></textarea>
<span id="${u}-editor-hint" hidden>Tab inserts two spaces. Shift+Tab moves focus back to the toolbar. Ctrl+S saves.</span>
<div class="jfm-modal-foot">
<span id="${u}-charcount" class="jfm-stat"></span>
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="save" id="${u}-save-btn">Save</button>
</div>
</div>
</div>
<!-- ── Delete modal ───────────────────────────────────── -->
${hasDelete ? `
<div class="jfm-modal" id="${u}-del-modal" hidden>
<div class="jfm-modal-box">
<div class="jfm-modal-head">
<span id="${u}-del-title" class="jfm-modal-title">Delete file</span>
<button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">&times;</button>
</div>
<div class="jfm-modal-body">
<p>Delete <strong id="${u}-del-name"></strong>?</p>
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
</div>
<div class="jfm-modal-foot">
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del" id="${u}-del-cancel">Cancel</button>
<button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
</div>
</div>
</div>` : ''}
<!-- ── Create modal ───────────────────────────────────── -->
${hasCreate ? `
<div class="jfm-modal" id="${u}-create-modal" hidden>
<div class="jfm-modal-box">
<div class="jfm-modal-head">
<span id="${u}-create-title" class="jfm-modal-title">Create new file</span>
<button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">&times;</button>
</div>
<div class="jfm-modal-body">
${this.createFields.map(f => `
<div class="jfm-field">
<label for="${u}-cf-${this._esc(f.key)}">${this._esc(f.label)}</label>
<input type="text" id="${u}-cf-${this._esc(f.key)}"
placeholder="${this._esc(f.placeholder || '')}"
${f.pattern ? `pattern="${this._esc(f.pattern)}"` : ''}>
${f.hint ? `<span class="jfm-hint">${this._esc(f.hint)}</span>` : ''}
</div>`).join('')}
</div>
<div class="jfm-modal-foot">
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-create">Cancel</button>
<button type="button" class="jfm-btn jfm-btn-primary" data-jfm="do-create" id="${u}-create-btn">Create</button>
</div>
</div>
</div>` : ''}
</div>`; // end #${u}
// Cache frequently-used elements
this._root = document.getElementById(u);
this._listEl = document.getElementById(`${u}-list`);
this._editorEl = document.getElementById(`${u}-editor`);
this._editModal = document.getElementById(`${u}-edit-modal`);
this._delModal = document.getElementById(`${u}-del-modal`);
this._createModal = document.getElementById(`${u}-create-modal`);
this._dropzone = document.getElementById(`${u}-dropzone`);
this._fileInput = document.getElementById(`${u}-fileinput`);
}
_css(u) {
// Colors come from the app theme tokens (app.css :root / [data-theme="dark"])
// with the original light values as fallbacks for pages without app.css.
// Shades the tokens don't cover are local variables with a dark override.
return `<style>
#${u}{--jfm-border-strong:#d1d5db;--jfm-border-hover:#9ca3af;--jfm-subtle:#f3f4f6;--jfm-accent-soft:#93c5fd;--jfm-danger-border:#fecaca;--jfm-danger-text:#991b1b;--jfm-text-muted:#6b7280;}
[data-theme="dark"] #${u}{--jfm-border-strong:#4b5563;--jfm-border-hover:#6b7280;--jfm-subtle:#374151;--jfm-accent-soft:#1e40af;--jfm-danger-border:#991b1b;--jfm-danger-text:#fecaca;--jfm-text-muted:#9ca3af;}
#${u}{font-family:inherit;color:var(--color-text-primary,#111827);}
#${u} *{box-sizing:border-box;}
/* Header */
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;}
#${u} .jfm-header-left{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:var(--color-text-primary,#111827);}
#${u} .jfm-dir{font-size:.75rem;color:var(--jfm-text-muted);background:var(--jfm-subtle);padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
#${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
/* Buttons */
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid var(--jfm-border-strong);background:var(--color-surface,#fff);color:var(--color-text-secondary,#374151);font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
#${u} .jfm-btn:hover:not(:disabled){background:var(--color-background,#f9fafb);border-color:var(--jfm-border-hover);}
#${u} .jfm-btn:focus-visible,#${u} .jfm-close-btn:focus-visible{outline:2px solid var(--color-primary,#3b82f6);outline-offset:1px;}
#${u} .jfm-btn:disabled{opacity:.5;cursor:not-allowed;}
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;}
#${u} .jfm-btn-primary{background:var(--color-primary,#3b82f6);border-color:var(--color-primary,#3b82f6);color:#fff;}
#${u} .jfm-btn-primary:hover:not(:disabled){background:var(--color-primary-hover,#2563eb);border-color:var(--color-primary-hover,#2563eb);}
#${u} .jfm-btn-danger{background:#dc2626;border-color:#dc2626;color:#fff;}
#${u} .jfm-btn-danger:hover:not(:disabled){background:#b91c1c;border-color:#b91c1c;}
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:var(--jfm-text-muted);}
#${u} .jfm-btn-ghost:hover:not(:disabled){background:var(--jfm-subtle);color:var(--color-text-secondary,#374151);}
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:var(--jfm-text-muted);font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
#${u} .jfm-close-btn:hover{background:var(--jfm-subtle);color:var(--color-text-secondary,#374151);}
/* File list */
#${u} .jfm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.625rem;margin-bottom:1rem;min-height:5rem;}
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:var(--jfm-text-muted);font-size:.875rem;}
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:var(--jfm-text-muted);}
#${u} .jfm-empty-icon{font-size:2.25rem;margin-bottom:.625rem;}
#${u} .jfm-empty-title{font-weight:600;color:var(--color-text-secondary,#374151);margin:0 0 .25rem;}
#${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
/* File cards */
#${u} .jfm-card{border:1px solid var(--color-border,#e5e7eb);border-radius:.5rem;padding:.875rem;background:var(--color-surface,#fff);display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
#${u} .jfm-card:hover{border-color:var(--jfm-accent-soft);box-shadow:var(--shadow-md,0 2px 8px rgba(59,130,246,.1));}
#${u} .jfm-card.jfm-off{opacity:.6;}
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:var(--color-text-primary,#111827);flex:1;}
#${u} .jfm-card-meta{font-size:.75rem;color:var(--jfm-text-muted);display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid var(--color-border-light,#f3f4f6);margin-top:.125rem;}
#${u} .jfm-card-actions .jfm-btn{flex:1;justify-content:center;}
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
/* Toggle */
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:var(--jfm-text-muted);white-space:nowrap;flex-shrink:0;}
#${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
/* Upload zone */
#${u} .jfm-upload-wrap{margin-top:.25rem;}
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;}
#${u} .jfm-dropzone{border:2px dashed var(--jfm-border-strong);border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:var(--color-background,#f9fafb);user-select:none;}
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:var(--color-primary,#3b82f6);background:var(--color-info-bg,#eff6ff);border-style:solid;outline:none;}
#${u} .jfm-drop-icon{font-size:1.75rem;display:block;margin-bottom:.375rem;}
#${u} .jfm-drop-primary{font-size:.875rem;color:var(--color-text-secondary,#374151);margin:0 0 .25rem;}
#${u} .jfm-drop-hint{font-size:.75rem;color:var(--jfm-text-muted);margin:0;}
/* Modals */
#${u} .jfm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(1px);}
#${u} .jfm-modal[hidden]{display:none;}
#${u} .jfm-modal-box{background:var(--color-surface,#fff);color:var(--color-text-primary,#111827);border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
#${u} .jfm-modal-box:focus{outline:none;}
#${u} .jfm-modal-wide{max-width:880px;}
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid var(--color-border,#e5e7eb);flex-shrink:0;gap:.5rem;}
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:var(--color-text-primary,#111827);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
#${u} .jfm-modal-tools{display:flex;gap:.25rem;align-items:center;flex-shrink:0;}
#${u} .jfm-modal-body{padding:1.125rem;overflow-y:auto;flex:1;}
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid var(--color-border,#e5e7eb);flex-shrink:0;background:var(--color-background,#f9fafb);border-radius:0 0 .5rem .5rem;}
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:var(--jfm-text-muted);font-variant-numeric:tabular-nums;}
/* JSON editor */
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:var(--color-text-primary,#1e293b);background:var(--color-background,#fafafa);tab-size:2;}
#${u} .jfm-editor:focus-visible{box-shadow:inset 0 0 0 2px var(--color-primary,#3b82f6);}
#${u} .jfm-err-bar{background:var(--color-error-bg,#fef2f2);border-bottom:1px solid var(--jfm-danger-border);color:var(--jfm-danger-text);font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
#${u} .jfm-err-bar[hidden]{display:none;}
/* Create form */
#${u} .jfm-field{margin-bottom:.875rem;}
#${u} .jfm-field:last-child{margin-bottom:0;}
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:var(--color-text-secondary,#374151);margin-bottom:.3125rem;}
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid var(--jfm-border-strong);border-radius:.375rem;font-size:.875rem;color:var(--color-text-primary,#111827);background:var(--color-surface,#fff);}
#${u} .jfm-field input:focus{outline:none;border-color:var(--color-primary,#3b82f6);box-shadow:0 0 0 3px rgba(59,130,246,.25);}
#${u} .jfm-hint{display:block;font-size:.75rem;color:var(--jfm-text-muted);margin-top:.25rem;}
#${u} .jfm-muted{font-size:.875rem;color:var(--jfm-text-muted);margin-top:.375rem;}
/* Spinner */
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid var(--color-border,#e5e7eb);border-top-color:var(--color-primary,#3b82f6);border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
@media (prefers-reduced-motion: reduce){
#${u} .jfm-btn,#${u} .jfm-card,#${u} .jfm-dropzone{transition:none;}
#${u} .jfm-spin{animation-duration:1.5s;}
#${u} .jfm-modal{backdrop-filter:none;}
}
</style>`;
}
// ── Event Binding ────────────────────────────────────────────────────
_bind() {
// Delegated clicks on the widget root
this._root.addEventListener('click', this._onClick.bind(this));
this._root.addEventListener('change', this._onChange.bind(this));
// Drag-and-drop on the dropzone
if (this._dropzone) {
this._dropzone.addEventListener('dragover', e => {
e.preventDefault();
this._dropzone.classList.add('jfm-over');
});
this._dropzone.addEventListener('dragleave', () => {
this._dropzone.classList.remove('jfm-over');
});
this._dropzone.addEventListener('drop', e => {
e.preventDefault();
this._dropzone.classList.remove('jfm-over');
const file = e.dataTransfer?.files[0];
if (file) this._uploadFile(file);
});
// Keyboard activation of drop zone
this._dropzone.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._fileInput?.click();
}
});
}
// Modal backdrop clicks
[this._editModal, this._delModal, this._createModal].forEach(m => {
if (m) m.addEventListener('click', e => { if (e.target === m) this._closeAll(); });
});
// Editor: char count + Tab indent
if (this._editorEl) {
this._editorEl.addEventListener('input', () => this._updateStat());
this._editorEl.addEventListener('keydown', e => {
// Plain Tab indents; Shift+Tab is left alone so keyboard
// users can always move focus out of the editor.
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
const s = this._editorEl.selectionStart;
const end = this._editorEl.selectionEnd;
const v = this._editorEl.value;
this._editorEl.value = v.slice(0, s) + ' ' + v.slice(end);
this._editorEl.selectionStart = this._editorEl.selectionEnd = s + 2;
this._updateStat();
}
});
}
// Global keyboard shortcuts
document.addEventListener('keydown', this._keyHandler);
}
_onKey(e) {
// Escape is handled by the dialog focus trap (see _trap).
const editOpen = this._editModal && !this._editModal.hidden;
if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
e.preventDefault();
this._doSave();
}
}
_onClick(e) {
const btn = e.target.closest('[data-jfm]');
if (!btn) return;
const action = btn.dataset.jfm;
switch (action) {
case 'refresh': this._loadList(); break;
case 'open-picker': this._fileInput?.click(); break;
case 'open-create': this._openCreate(); break;
case 'close-edit': this._closeEdit(); break;
case 'close-del': this._closeDel(); break;
case 'close-create': this._closeCreate(); break;
case 'fmt': this._formatJson(); break;
case 'validate': this._validateJson(); break;
case 'save': this._doSave(); break;
case 'confirm-del': this._doDelete(); break;
case 'do-create': this._doCreate(); break;
case 'edit-file': {
const card = btn.closest('[data-jfm-file]');
if (card) this._openEdit(card.dataset.jfmFile);
break;
}
case 'del-file': {
const card = btn.closest('[data-jfm-file]');
if (card) this._openDel(card.dataset.jfmFile);
break;
}
}
}
_onChange(e) {
// Toggle checkbox
if (e.target.classList.contains('jfm-toggle-cb')) {
const catName = e.target.dataset.cat;
const enabled = e.target.checked;
this._doToggle(catName, enabled, e.target);
}
// File input
if (e.target === this._fileInput) {
const file = e.target.files?.[0];
if (file) this._uploadFile(file);
e.target.value = '';
}
}
// ── API helper ───────────────────────────────────────────────────────
async _api(actionKey, params) {
const actionId = Object.prototype.hasOwnProperty.call(this.actions, actionKey) ? this.actions[actionKey] : undefined;
if (!actionId) throw new Error(`Action "${actionKey}" not configured`);
const body = { plugin_id: this.pluginId, action_id: actionId };
if (params !== undefined) body.params = params;
const r = await fetch('/api/v3/plugins/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const ct = r.headers.get('content-type') || '';
if (!r.ok) {
// A failing action script comes back as a 400 whose JSON body
// carries the script's own message; surface it, not the status.
const data = ct.includes('application/json') ? await r.json().catch(() => null) : null;
throw new Error(data?.message || 'Server error ' + r.status);
}
if (!ct.includes('application/json')) {
const txt = await r.text();
throw new Error('Unexpected response: ' + txt.slice(0, 120));
}
return r.json();
}
// ── File List ────────────────────────────────────────────────────────
async _loadList() {
this._listEl.innerHTML = `<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>`;
try {
const data = await this._api('list');
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
this._renderList(data.files || []);
} catch (err) {
this._listEl.innerHTML = `
<div class="jfm-empty">
<div class="jfm-empty-icon">&#9888;</div>
<p class="jfm-empty-title">Failed to load files</p>
<p class="jfm-empty-sub">${this._esc(err.message)}</p>
</div>`;
}
}
_renderList(files) {
if (!files.length) {
this._listEl.innerHTML = `
<div class="jfm-empty">
<div class="jfm-empty-icon">&#128193;</div>
<p class="jfm-empty-title">No files yet</p>
<p class="jfm-empty-sub">Upload or create a JSON file to get started</p>
</div>`;
return;
}
this._listEl.innerHTML = files.map(f => this._card(f)).join('');
}
_card(f) {
const enabled = f.enabled !== false;
const displayName = this._esc(f.display_name || f.filename);
const filename = this._esc(f.filename);
const catName = this.toggleKey ? this._esc(f[this.toggleKey] || '') : '';
const showToggle = !!(this.actions.toggle && this.toggleKey && f[this.toggleKey]);
const hasEdit = !!this.actions.get && !!this.actions.save;
const hasDelete = !!this.actions.delete;
return `
<div class="jfm-card${enabled ? '' : ' jfm-off'}" data-jfm-file="${filename}">
<div class="jfm-card-top">
<span class="jfm-card-name" title="${filename}">${displayName}</span>
${showToggle ? `
<label class="jfm-toggle" title="${enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}">
<input type="checkbox" class="jfm-toggle-cb" data-cat="${catName}" ${enabled ? 'checked' : ''}>
<span>${enabled ? 'On' : 'Off'}</span>
</label>` : ''}
</div>
<div class="jfm-card-meta">
<span>&#128196; ${filename}</span>
<span>&#128202; ${f.entry_count ?? 0} entries &middot; ${this._fmtSize(f.size || 0)}</span>
<span>&#128337; ${this._fmtDate(f.modified)}</span>
</div>
<div class="jfm-card-actions">
${hasEdit ? `<button type="button" class="jfm-btn jfm-btn-sm" data-jfm="edit-file">&#9998; Edit</button>` : ''}
${hasDelete ? `<button type="button" class="jfm-btn jfm-btn-danger jfm-btn-sm jfm-del" data-jfm="del-file" title="Delete file">&#128465;</button>` : ''}
</div>
</div>`;
}
// ── Edit flow ────────────────────────────────────────────────────────
async _openEdit(filename) {
this._editFile = filename;
document.getElementById(`${this._uid}-edit-title`).textContent = `Edit: ${filename}`;
this._clearErr();
this._editorEl.value = 'Loading…';
this._updateStat();
this._editModal.hidden = false;
this._trap('edit', this._editModal, `${this._uid}-edit-title`, this._editorEl, () => this._closeEdit());
try {
const data = await this._api('get', { filename });
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
this._editorEl.value = JSON.stringify(data.content, null, 2);
this._updateStat();
this._editorEl.focus();
this._editorEl.setSelectionRange(0, 0);
this._editorEl.scrollTop = 0;
} catch (err) {
this._showErr(`Couldn't load this file (${err.message}). Close the editor and try again.`);
this._editorEl.value = '';
}
}
_closeEdit() {
if (this._editModal) this._editModal.hidden = true;
this._editFile = null;
this._clearErr();
this._release('edit');
}
_formatJson() {
try {
const parsed = JSON.parse(this._editorEl.value);
this._editorEl.value = JSON.stringify(parsed, null, 2);
this._updateStat();
this._clearErr();
} catch (err) {
this._showErr('Invalid JSON — ' + err.message);
}
}
_validateJson() {
try {
const parsed = JSON.parse(this._editorEl.value);
const n = (typeof parsed === 'object' && parsed !== null) ? Object.keys(parsed).length : '?';
this._clearErr();
this._notify(`Valid JSON — ${n} top-level keys`, 'success');
} catch (err) {
this._showErr('Invalid JSON — ' + err.message);
}
}
async _doSave() {
if (!this._editFile) return;
let contentStr;
try {
const parsed = JSON.parse(this._editorEl.value);
contentStr = JSON.stringify(parsed, null, 2);
} catch (err) {
this._showErr('Cannot save — fix JSON first: ' + err.message);
return;
}
const btn = document.getElementById(`${this._uid}-save-btn`);
this._busy(btn, 'Saving…');
try {
const data = await this._api('save', { filename: this._editFile, content: contentStr });
if (data.status !== 'success') throw new Error(data.message || 'Save failed');
this._notify('File saved', 'success');
this._closeEdit();
this._loadList();
} catch (err) {
this._showErr('Save failed: ' + err.message);
} finally {
this._idle(btn, 'Save');
}
}
// ── Delete flow ──────────────────────────────────────────────────────
_openDel(filename) {
this._deleteFile = filename;
const el = document.getElementById(`${this._uid}-del-name`);
if (el) el.textContent = filename;
if (this._delModal) {
this._delModal.hidden = false;
// Destructive dialog: start on Cancel.
this._trap('del', this._delModal, `${this._uid}-del-title`,
document.getElementById(`${this._uid}-del-cancel`), () => this._closeDel());
}
}
_closeDel() {
if (this._delModal) this._delModal.hidden = true;
this._deleteFile = null;
this._release('del');
}
async _doDelete() {
if (!this._deleteFile) return;
const btn = document.getElementById(`${this._uid}-del-btn`);
this._busy(btn, 'Deleting…');
try {
const data = await this._api('delete', { filename: this._deleteFile });
if (data.status !== 'success') throw new Error(data.message || 'Delete failed');
this._notify('File deleted', 'success');
this._closeDel();
this._loadList();
} catch (err) {
this._notify('Delete failed: ' + err.message, 'error');
} finally {
this._idle(btn, 'Delete');
}
}
// ── Create flow ──────────────────────────────────────────────────────
_openCreate() {
if (!this._createModal) return;
this.createFields.forEach(f => {
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
if (el) el.value = '';
});
this._createModal.hidden = false;
const first = this.createFields[0];
const firstInput = first ? document.getElementById(`${this._uid}-cf-${first.key}`) : null;
this._trap('create', this._createModal, `${this._uid}-create-title`, firstInput, () => this._closeCreate());
}
_closeCreate() {
if (this._createModal) this._createModal.hidden = true;
this._release('create');
}
async _doCreate() {
const params = {};
for (const f of this.createFields) {
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
const val = (el?.value || '').trim();
// display_name may be blank — auto-derived from category_name below
if (!val && f.key !== 'display_name') {
this._notify(`"${f.label}" is required`, 'error');
el?.focus();
return;
}
if (f.pattern && val && el && el.validity.patternMismatch) {
this._notify(`"${f.label}" format is invalid`, 'error');
el?.focus();
return;
}
if (val) params[f.key] = val;
}
// Auto-derive display_name from category_name when left blank
if (!params.display_name && params.category_name) {
params.display_name = params.category_name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
const btn = document.getElementById(`${this._uid}-create-btn`);
this._busy(btn, 'Creating…');
try {
const data = await this._api('create', params);
if (data.status !== 'success') throw new Error(data.message || 'Create failed');
this._notify('File created', 'success');
this._closeCreate();
this._loadList();
} catch (err) {
this._notify('Create failed: ' + err.message, 'error');
} finally {
this._idle(btn, 'Create');
}
}
// ── Upload ───────────────────────────────────────────────────────────
async _uploadFile(file) {
if (!file.name.endsWith('.json')) {
this._notify('Please select a .json file', 'error');
return;
}
let content;
try {
content = await file.text();
JSON.parse(content); // client-side validation
} catch (err) {
this._notify('Invalid JSON: ' + err.message, 'error');
return;
}
if (this._dropzone) this._dropzone.style.opacity = '.5';
try {
const data = await this._api('upload', { filename: file.name, content });
if (data.status !== 'success') throw new Error(data.message || 'Upload failed');
this._notify(`"${file.name}" uploaded`, 'success');
this._loadList();
} catch (err) {
this._notify('Upload failed: ' + err.message, 'error');
} finally {
if (this._dropzone) this._dropzone.style.opacity = '';
}
}
// ── Toggle ───────────────────────────────────────────────────────────
async _doToggle(catName, enabled, checkbox) {
checkbox.disabled = true;
try {
const params = { enabled };
if (this.toggleKey) params[this.toggleKey] = catName;
const data = await this._api('toggle', params);
if (data.status !== 'success') throw new Error(data.message || 'Toggle failed');
this._notify(enabled ? 'Category enabled' : 'Category disabled', 'success');
this._loadList();
} catch (err) {
this._notify('Toggle failed: ' + err.message, 'error');
checkbox.checked = !enabled; // revert
checkbox.disabled = false;
}
}
// ── Helpers ──────────────────────────────────────────────────────────
_closeAll() {
this._closeEdit();
this._closeDel();
this._closeCreate();
}
_updateStat() {
const v = this._editorEl?.value || '';
const lines = v ? v.split('\n').length : 0;
const el = document.getElementById(`${this._uid}-charcount`);
if (el) el.textContent = `${lines.toLocaleString()} lines · ${v.length.toLocaleString()} chars`;
}
_showErr(msg) {
const el = document.getElementById(`${this._uid}-edit-err`);
if (el) { el.textContent = msg; el.hidden = false; }
}
_clearErr() {
const el = document.getElementById(`${this._uid}-edit-err`);
if (el) { el.textContent = ''; el.hidden = true; }
}
_notify(msg, type) {
window.showNotification(msg, type || 'info');
}
_busy(btn, label) {
if (!btn) return;
btn._jfmOrigText = btn.textContent;
btn.disabled = true;
btn.textContent = '';
const spin = document.createElement('span');
spin.className = 'jfm-spin';
btn.appendChild(spin);
btn.appendChild(document.createTextNode(' ' + label));
}
_idle(btn, label) {
if (!btn) return;
btn.disabled = false;
btn.textContent = btn._jfmOrigText !== undefined ? btn._jfmOrigText : label;
delete btn._jfmOrigText;
}
_esc(str) {
return window.LEDEscape.html(str);
}
_fmtSize(bytes) {
if (!bytes) return '0 B';
const i = Math.min(Math.floor(Math.log2(bytes + 1) / 10), 2);
const unit = ['B', 'KB', 'MB'][i];
const val = bytes / Math.pow(1024, i);
return (i ? val.toFixed(1) : val) + ' ' + unit;
}
_fmtDate(str) {
if (!str) return '—';
try {
return new Date(str).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', year: 'numeric'
});
} catch { return str; }
}
}
// ── Widget registry integration ──────────────────────────────────────────
window.JsonFileManager = JsonFileManager;
if (typeof window.LEDMatrixWidgets !== 'undefined') {
window.LEDMatrixWidgets.register('json-file-manager', {
name: 'JSON File Manager',
version: '1.0.0',
render(container, config, _value, options) {
new JsonFileManager(container, config || {}, options?.pluginId || '');
},
getValue() { return null; },
setValue() {}
});
}
})();
@@ -161,8 +161,6 @@
setTimeout(() => { region.textContent = text; }, 50);
}
function escapeHtml(text) { return window.LEDEscape.html(text); }
function clearTimer(notificationId) {
const t = timers.get(notificationId);
if (t && t.timer) clearTimeout(t.timer);
@@ -294,7 +292,7 @@
html += `<i class="fas ${style.icon} flex-shrink-0" aria-hidden="true"></i>`;
}
html += `<span class="flex-1 text-sm"><span style="${VISUALLY_HIDDEN}">${style.label}: </span>${escapeHtml(message)}</span>`;
html += `<span class="flex-1 text-sm"><span style="${VISUALLY_HIDDEN}">${style.label}: </span>${window.LEDEscape.html(message)}</span>`;
// Optional inline action button (e.g. "Restart Now" on a restart nudge).
// The callback is stored by id and invoked via triggerAction, which
@@ -306,7 +304,7 @@
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')"
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150"
style="background:rgba(255,255,255,.2);color:inherit;border:0;">
${escapeHtml(options.actionLabel)}
${window.LEDEscape.html(options.actionLabel)}
</button>
`;
}
@@ -28,8 +28,6 @@
const base = window.BaseWidget ? new window.BaseWidget('NumberInput', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -80,16 +78,16 @@
const currentValue = rawValue === '' ? '' : (isNaN(Number(rawValue)) ? '' : String(Number(rawValue)));
// Escape values for safe HTML attribute interpolation
const safeMin = min !== null ? escapeHtml(String(min)) : '';
const safeMax = max !== null ? escapeHtml(String(max)) : '';
const safeStep = escapeHtml(String(step));
const safeMin = min !== null ? window.LEDEscape.html(String(min)) : '';
const safeMax = max !== null ? window.LEDEscape.html(String(max)) : '';
const safeStep = window.LEDEscape.html(String(step));
let html = `<div id="${fieldId}_widget" class="number-input-widget" data-field-id="${fieldId}" data-min="${safeMin}" data-max="${safeMax}" data-step="${safeStep}">`;
html += '<div class="flex items-center">';
if (prefix) {
html += `<span class="inline-flex items-center px-3 text-sm text-gray-500 bg-gray-100 border border-r-0 border-gray-300 rounded-l-md">${escapeHtml(prefix)}</span>`;
html += `<span class="inline-flex items-center px-3 text-sm text-gray-500 bg-gray-100 border border-r-0 border-gray-300 rounded-l-md">${window.LEDEscape.html(prefix)}</span>`;
}
if (showButtons && !disabled) {
@@ -108,9 +106,9 @@
html += `
<input type="number"
id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
value="${escapeHtml(currentValue)}"
placeholder="${escapeHtml(placeholder)}"
name="${window.LEDEscape.html(options.name || fieldId)}"
value="${window.LEDEscape.html(currentValue)}"
placeholder="${window.LEDEscape.html(placeholder)}"
${min !== null ? `min="${safeMin}"` : ''}
${max !== null ? `max="${safeMax}"` : ''}
step="${safeStep}"
@@ -132,7 +130,7 @@
}
if (suffix) {
html += `<span class="inline-flex items-center px-3 text-sm text-gray-500 bg-gray-100 border border-l-0 border-gray-300 rounded-r-md">${escapeHtml(suffix)}</span>`;
html += `<span class="inline-flex items-center px-3 text-sm text-gray-500 bg-gray-100 border border-l-0 border-gray-300 rounded-r-md">${window.LEDEscape.html(suffix)}</span>`;
}
html += '</div>';
@@ -142,7 +140,7 @@
const rangeText = min !== null && max !== null
? `${min} - ${max}`
: (min !== null ? `Min: ${min}` : `Max: ${max}`);
html += `<div class="text-xs text-gray-400 mt-1">${escapeHtml(rangeText)}</div>`;
html += `<div class="text-xs text-gray-400 mt-1">${window.LEDEscape.html(rangeText)}</div>`;
}
// Error message area
@@ -28,8 +28,6 @@
const base = window.BaseWidget ? new window.BaseWidget('PasswordInput', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -120,9 +118,9 @@
html += `
<input type="password"
id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
value="${escapeHtml(currentValue)}"
placeholder="${escapeHtml(placeholder)}"
name="${window.LEDEscape.html(options.name || fieldId)}"
value="${window.LEDEscape.html(currentValue)}"
placeholder="${window.LEDEscape.html(placeholder)}"
${sanitizedMinLength > 0 ? `minlength="${sanitizedMinLength}"` : ''}
${disabled ? 'disabled' : ''}
${required ? 'required' : ''}
@@ -238,8 +238,6 @@
window.showNotification(msg, type);
}
function escHtml(s) { return window.LEDEscape.html(s); }
function formatSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1024).toFixed(2) + ' KB';
@@ -399,17 +397,17 @@
modal.className = 'pfm-modal';
safeSetHTML(modal, `
<div class="pfm-modal-header">
<span class="pfm-modal-title" id="${escHtml(fieldId)}_modal_title"><i class="fas fa-edit mr-2" aria-hidden="true"></i>${escHtml(filename)}</span>
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close" aria-label="Close">
<span class="pfm-modal-title" id="${window.LEDEscape.html(fieldId)}_modal_title"><i class="fas fa-edit mr-2" aria-hidden="true"></i>${window.LEDEscape.html(filename)}</span>
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${window.LEDEscape.html(fieldId)}_modal_close" aria-label="Close">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<div class="pfm-modal-body" id="${escHtml(fieldId)}_edit_body">
<div class="pfm-modal-body" id="${window.LEDEscape.html(fieldId)}_edit_body">
<div class="pfm-empty"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i>Loading…</div>
</div>
<div class="pfm-modal-footer">
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
<button type="button" class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
<button type="button" class="pfm-btn pfm-btn-secondary" id="${window.LEDEscape.html(fieldId)}_modal_cancel">Cancel</button>
<button type="button" class="pfm-btn pfm-btn-primary" id="${window.LEDEscape.html(fieldId)}_save_btn">
<i class="fas fa-save mr-1" aria-hidden="true"></i>Save
</button>
</div>`);
@@ -423,7 +421,7 @@
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
const body = document.getElementById(`${fieldId}_edit_body`);
if (!data || data.status !== 'success' || !body) {
if (body) safeSetHTML(body, `<div class="pfm-empty pfm-error-text">Couldn't load ${escHtml(filename)}. Close this window and try again.</div>`);
if (body) safeSetHTML(body, `<div class="pfm-empty pfm-error-text">Couldn't load ${window.LEDEscape.html(filename)}. Close this window and try again.</div>`);
return;
}
@@ -438,10 +436,10 @@
// Textarea path: _editData stays null; save() reads from the <textarea>
st._editData = null;
safeSetHTML(body, `
<textarea id="${escHtml(fieldId)}_json_ta" rows="20" class="pfm-json-ta"
aria-label="File contents (JSON)" aria-describedby="${escHtml(fieldId)}_json_err"
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
<div id="${escHtml(fieldId)}_json_err" class="pfm-json-err" role="alert"></div>`);
<textarea id="${window.LEDEscape.html(fieldId)}_json_ta" rows="20" class="pfm-json-ta"
aria-label="File contents (JSON)" aria-describedby="${window.LEDEscape.html(fieldId)}_json_err"
>${window.LEDEscape.html(JSON.stringify(content, null, 2))}</textarea>
<div id="${window.LEDEscape.html(fieldId)}_json_err" class="pfm-json-err" role="alert"></div>`);
}
};
@@ -465,7 +463,7 @@
// Delegated listener: day/col reach _pfmCellEdit only through data-*
// attributes, never through a JS string spliced into an inline
// handler -- the browser HTML-decodes attribute values before
// running them as script, which undoes escHtml's quote escaping and
// running them as script, which undoes LEDEscape's quote escaping and
// reopens the exact injection it exists to close. `container` is a
// fresh element per modal open (see _pfmOpenEdit), so this attaches
// exactly once per table, even though buildPage() re-renders below.
@@ -498,21 +496,21 @@
<thead>
<tr>
<th class="pfm-day-col">Day</th>
${cols.map(c => `<th>${escHtml(c.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()))}</th>`).join('')}
${cols.map(c => `<th>${window.LEDEscape.html(c.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()))}</th>`).join('')}
</tr>
</thead>
<tbody>
${pageEntries.map(([day, val]) => `
<tr data-day="${escHtml(day)}" class="${parseInt(day) === todayDoy ? 'today-row' : ''}">
<td class="pfm-day-col" style="user-select:none;">${escHtml(day)}</td>
<tr data-day="${window.LEDEscape.html(day)}" class="${parseInt(day) === todayDoy ? 'today-row' : ''}">
<td class="pfm-day-col" style="user-select:none;">${window.LEDEscape.html(day)}</td>
${cols.map(col => {
const v = val[col] ?? '';
const isLong = String(v).length > 60 || col === 'description' || col === 'definition' || col === 'content';
return isLong
? `<td><textarea data-day="${escHtml(day)}" data-col="${escHtml(col)}" rows="2"
>${escHtml(String(v))}</textarea></td>`
: `<td><input type="text" data-day="${escHtml(day)}" data-col="${escHtml(col)}"
value="${escHtml(String(v))}"></td>`;
? `<td><textarea data-day="${window.LEDEscape.html(day)}" data-col="${window.LEDEscape.html(col)}" rows="2"
>${window.LEDEscape.html(String(v))}</textarea></td>`
: `<td><input type="text" data-day="${window.LEDEscape.html(day)}" data-col="${window.LEDEscape.html(col)}"
value="${window.LEDEscape.html(String(v))}"></td>`;
}).join('')}
</tr>`).join('')}
</tbody>
@@ -604,20 +602,20 @@
modal.style.maxWidth = '28rem';
safeSetHTML(modal, `
<div class="pfm-modal-header">
<span class="pfm-modal-title" id="${escHtml(fieldId)}_del_title"><i class="fas fa-trash mr-2" aria-hidden="true"></i>Delete File</span>
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close" aria-label="Close">
<span class="pfm-modal-title" id="${window.LEDEscape.html(fieldId)}_del_title"><i class="fas fa-trash mr-2" aria-hidden="true"></i>Delete File</span>
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${window.LEDEscape.html(fieldId)}_del_close" aria-label="Close">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<div class="pfm-modal-body">
<div class="pfm-danger-box">
<strong>${escHtml(filename)}</strong> will be permanently deleted and removed
<strong>${window.LEDEscape.html(filename)}</strong> will be permanently deleted and removed
from the plugin configuration. This cannot be undone.
</div>
</div>
<div class="pfm-modal-footer">
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
<button type="button" class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
<button type="button" class="pfm-btn pfm-btn-secondary" id="${window.LEDEscape.html(fieldId)}_del_cancel">Cancel</button>
<button type="button" class="pfm-btn pfm-btn-danger" id="${window.LEDEscape.html(fieldId)}_del_confirm">
<i class="fas fa-trash mr-1" aria-hidden="true"></i>Delete
</button>
</div>`);
@@ -654,25 +652,25 @@
modal.style.maxWidth = '32rem';
safeSetHTML(modal, `
<div class="pfm-modal-header">
<span class="pfm-modal-title" id="${escHtml(fieldId)}_cre_title"><i class="fas fa-plus-circle mr-2" aria-hidden="true"></i>Create New File</span>
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close" aria-label="Close">
<span class="pfm-modal-title" id="${window.LEDEscape.html(fieldId)}_cre_title"><i class="fas fa-plus-circle mr-2" aria-hidden="true"></i>Create New File</span>
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${window.LEDEscape.html(fieldId)}_cre_close" aria-label="Close">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<div class="pfm-modal-body">
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" role="alert" style="margin-bottom:.5rem;"></div>
<div id="${window.LEDEscape.html(fieldId)}_create_err" class="pfm-field-error" role="alert" style="margin-bottom:.5rem;"></div>
${fields.map(f => `
<div class="pfm-field">
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
<input type="text" id="${escHtml(fieldId)}_cf_${escHtml(f.key)}"
placeholder="${escHtml(f.placeholder || '')}"
${f.pattern ? `pattern="${escHtml(f.pattern)}"` : ''}>
${f.hint ? `<div class="pfm-field-hint">${escHtml(f.hint)}</div>` : ''}
<label for="${window.LEDEscape.html(fieldId)}_cf_${window.LEDEscape.html(f.key)}">${window.LEDEscape.html(f.label || f.key)}</label>
<input type="text" id="${window.LEDEscape.html(fieldId)}_cf_${window.LEDEscape.html(f.key)}"
placeholder="${window.LEDEscape.html(f.placeholder || '')}"
${f.pattern ? `pattern="${window.LEDEscape.html(f.pattern)}"` : ''}>
${f.hint ? `<div class="pfm-field-hint">${window.LEDEscape.html(f.hint)}</div>` : ''}
</div>`).join('')}
</div>
<div class="pfm-modal-footer">
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
<button type="button" class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
<button type="button" class="pfm-btn pfm-btn-secondary" id="${window.LEDEscape.html(fieldId)}_cre_cancel">Cancel</button>
<button type="button" class="pfm-btn pfm-btn-create" id="${window.LEDEscape.html(fieldId)}_create_btn">
<i class="fas fa-plus mr-1" aria-hidden="true"></i>Create
</button>
</div>`);
@@ -818,7 +816,7 @@
<div class="pfm-header">
<div>
<div class="pfm-title">File Explorer</div>
${st.directoryLabel ? `<div class="pfm-dir">Manage files in <code>${escHtml(st.directoryLabel)}</code></div>` : ''}
${st.directoryLabel ? `<div class="pfm-dir">Manage files in <code>${window.LEDEscape.html(st.directoryLabel)}</code></div>` : ''}
</div>
<div style="display:flex;gap:.375rem;">
${actions.create ? `
@@ -841,7 +839,7 @@
onchange="if(this.files[0])window._pfmUpload('${fieldId}',this.files[0]);this.value=''">
<i class="fas fa-cloud-upload-alt pfm-upload-icon" aria-hidden="true"></i>
<p>Drag and drop or click to upload</p>
<small>${escHtml(st.uploadHint)}</small>
<small>${window.LEDEscape.html(st.uploadHint)}</small>
</div>` : ''}
<div class="pfm-grid">
@@ -33,8 +33,6 @@
const base = window.BaseWidget ? new window.BaseWidget('RadioGroup', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -83,15 +81,15 @@
<div class="flex items-center h-5">
<input type="radio"
id="${optId}"
name="${escapeHtml(options.name || fieldId)}"
value="${escapeHtml(String(optValue))}"
name="${window.LEDEscape.html(options.name || fieldId)}"
value="${window.LEDEscape.html(String(optValue))}"
${isChecked ? 'checked' : ''}
${disabled ? 'disabled' : ''}
class="h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}">
</div>
<div class="ml-3">
<span class="text-sm font-medium text-gray-900">${escapeHtml(label)}</span>
${description ? `<p class="text-xs text-gray-500">${escapeHtml(description)}</p>` : ''}
<span class="text-sm font-medium text-gray-900">${window.LEDEscape.html(label)}</span>
${description ? `<p class="text-xs text-gray-500">${window.LEDEscape.html(description)}</p>` : ''}
</div>
</label>
`;
@@ -54,8 +54,6 @@
// Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -239,7 +237,7 @@
<label for="${fieldId}_global_start" class="block text-sm font-medium text-gray-700">Start Time</label>
<input type="time"
id="${fieldId}_global_start"
value="${escapeHtml(schedule.start_time)}"
value="${window.LEDEscape.html(schedule.start_time)}"
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onGlobalTimeChange('${fieldId}')"
class="form-control mt-1">
<p class="mt-1 text-sm text-gray-600">When to start displaying content (HH:MM)</p>
@@ -248,7 +246,7 @@
<label for="${fieldId}_global_end" class="block text-sm font-medium text-gray-700">End Time</label>
<input type="time"
id="${fieldId}_global_end"
value="${escapeHtml(schedule.end_time)}"
value="${window.LEDEscape.html(schedule.end_time)}"
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onGlobalTimeChange('${fieldId}')"
class="form-control mt-1">
<p class="mt-1 text-sm text-gray-600">When to stop displaying content (HH:MM)</p>
@@ -279,7 +277,7 @@
// Render each day row
DAYS.forEach(day => {
const dayConfig = schedule.days[day];
const dayLabel = escapeHtml(DAY_LABEL_MAP.get(day) || day);
const dayLabel = window.LEDEscape.html(DAY_LABEL_MAP.get(day) || day);
const disabled = !dayConfig.enabled;
const disabledClass = disabled ? 'bg-gray-100' : '';
@@ -300,7 +298,7 @@
<input type="time"
id="${fieldId}_${day}_start"
aria-label="${dayLabel} start time"
value="${escapeHtml(dayConfig.start_time)}"
value="${window.LEDEscape.html(dayConfig.start_time)}"
${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 ${disabledClass}">
@@ -309,7 +307,7 @@
<input type="time"
id="${fieldId}_${day}_end"
aria-label="${dayLabel} end time"
value="${escapeHtml(dayConfig.end_time)}"
value="${window.LEDEscape.html(dayConfig.end_time)}"
${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 ${disabledClass}">
@@ -345,15 +343,15 @@
html += `<input type="hidden" id="${fieldId}_mode_value" name="mode" value="${schedule.mode}">`;
// Global times (used when mode is global)
html += `<input type="hidden" id="${fieldId}_start_time_hidden" name="start_time" value="${escapeHtml(schedule.start_time)}">`;
html += `<input type="hidden" id="${fieldId}_end_time_hidden" name="end_time" value="${escapeHtml(schedule.end_time)}">`;
html += `<input type="hidden" id="${fieldId}_start_time_hidden" name="start_time" value="${window.LEDEscape.html(schedule.start_time)}">`;
html += `<input type="hidden" id="${fieldId}_end_time_hidden" name="end_time" value="${window.LEDEscape.html(schedule.end_time)}">`;
// Per-day values (used when mode is per_day)
DAYS.forEach(day => {
const dayConfig = schedule.days[day];
html += `<input type="hidden" id="${fieldId}_${day}_enabled_hidden" name="${day}_enabled" value="${dayConfig.enabled}">`;
html += `<input type="hidden" id="${fieldId}_${day}_start_hidden" name="${day}_start" value="${escapeHtml(dayConfig.start_time)}">`;
html += `<input type="hidden" id="${fieldId}_${day}_end_hidden" name="${day}_end" value="${escapeHtml(dayConfig.end_time)}">`;
html += `<input type="hidden" id="${fieldId}_${day}_start_hidden" name="${day}_start" value="${window.LEDEscape.html(dayConfig.start_time)}">`;
html += `<input type="hidden" id="${fieldId}_${day}_end_hidden" name="${day}_end" value="${window.LEDEscape.html(dayConfig.end_time)}">`;
});
return html;
@@ -28,8 +28,6 @@
const base = window.BaseWidget ? new window.BaseWidget('SelectDropdown', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -73,7 +71,7 @@
html += `
<select id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
name="${window.LEDEscape.html(options.name || fieldId)}"
${disabled ? 'disabled' : ''}
${required ? 'required' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('select-dropdown').onChange('${fieldId}')"
@@ -82,14 +80,14 @@
// Placeholder option
if (placeholder && !required) {
html += `<option value="" ${!currentValue ? 'selected' : ''}>${escapeHtml(placeholder)}</option>`;
html += `<option value="" ${!currentValue ? 'selected' : ''}>${window.LEDEscape.html(placeholder)}</option>`;
}
// Options
for (const optValue of enumValues) {
const label = labels[optValue] || String(optValue).replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
const isSelected = String(optValue) === currentValue;
html += `<option value="${escapeHtml(String(optValue))}" ${isSelected ? 'selected' : ''}>${escapeHtml(label)}</option>`;
html += `<option value="${window.LEDEscape.html(String(optValue))}" ${isSelected ? 'selected' : ''}>${window.LEDEscape.html(label)}</option>`;
}
html += '</select>';
+9 -13
View File
@@ -28,10 +28,6 @@
const base = window.BaseWidget ? new window.BaseWidget('Slider', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function escapeAttr(text) { return window.LEDEscape.attr(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -78,14 +74,14 @@
const currentValue = value !== null && value !== undefined ? value : min;
const colorClass = COLOR_CLASSES[color] || COLOR_CLASSES.blue;
let html = `<div id="${fieldId}_widget" class="slider-widget" data-field-id="${fieldId}" data-prefix="${escapeAttr(prefix)}" data-suffix="${escapeAttr(suffix)}">`;
let html = `<div id="${fieldId}_widget" class="slider-widget" data-field-id="${fieldId}" data-prefix="${window.LEDEscape.attr(prefix)}" data-suffix="${window.LEDEscape.attr(suffix)}">`;
// Value display above slider
if (showValue) {
html += `
<div class="flex justify-center mb-2">
<span id="${fieldId}_value" class="text-lg font-semibold text-gray-700">
${escapeHtml(prefix)}${escapeHtml(currentValue)}${escapeHtml(suffix)}
${window.LEDEscape.html(prefix)}${window.LEDEscape.html(currentValue)}${window.LEDEscape.html(suffix)}
</span>
</div>
`;
@@ -95,11 +91,11 @@
html += `
<input type="range"
id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
value="${escapeAttr(currentValue)}"
min="${escapeAttr(min)}"
max="${escapeAttr(max)}"
step="${escapeAttr(step)}"
name="${window.LEDEscape.html(options.name || fieldId)}"
value="${window.LEDEscape.attr(currentValue)}"
min="${window.LEDEscape.attr(min)}"
max="${window.LEDEscape.attr(max)}"
step="${window.LEDEscape.attr(step)}"
${disabled ? 'disabled' : ''}
oninput="window.LEDMatrixWidgets.getHandlers('slider').onInput('${fieldId}')"
onchange="window.LEDMatrixWidgets.getHandlers('slider').onChange('${fieldId}')"
@@ -110,8 +106,8 @@
if (showMinMax) {
html += `
<div class="flex justify-between mt-1">
<span class="text-xs text-gray-400">${escapeHtml(prefix)}${escapeHtml(min)}${escapeHtml(suffix)}</span>
<span class="text-xs text-gray-400">${escapeHtml(prefix)}${escapeHtml(max)}${escapeHtml(suffix)}</span>
<span class="text-xs text-gray-400">${window.LEDEscape.html(prefix)}${window.LEDEscape.html(min)}${window.LEDEscape.html(suffix)}</span>
<span class="text-xs text-gray-400">${window.LEDEscape.html(prefix)}${window.LEDEscape.html(max)}${window.LEDEscape.html(suffix)}</span>
</div>
`;
}
@@ -29,8 +29,6 @@
const base = window.BaseWidget ? new window.BaseWidget('TextInput', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -80,14 +78,14 @@
const currentValue = value !== null && value !== undefined ? String(value) : '';
let html = `<div id="${fieldId}_widget" class="text-input-widget" data-field-id="${fieldId}" data-pattern-message="${escapeHtml(patternMessage)}">`;
let html = `<div id="${fieldId}_widget" class="text-input-widget" data-field-id="${fieldId}" data-pattern-message="${window.LEDEscape.html(patternMessage)}">`;
// Container for prefix/input/suffix layout
const hasAddons = prefix || suffix || clearable;
if (hasAddons) {
html += '<div class="flex items-center">';
if (prefix) {
html += `<span class="inline-flex items-center px-3 text-sm text-gray-500 bg-gray-100 border border-r-0 border-gray-300 rounded-l-md">${escapeHtml(prefix)}</span>`;
html += `<span class="inline-flex items-center px-3 text-sm text-gray-500 bg-gray-100 border border-r-0 border-gray-300 rounded-l-md">${window.LEDEscape.html(prefix)}</span>`;
}
}
@@ -98,10 +96,10 @@
html += `
<input type="text"
id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
value="${escapeHtml(currentValue)}"
placeholder="${escapeHtml(placeholder)}"
${pattern ? `pattern="${escapeHtml(pattern)}"` : ''}
name="${window.LEDEscape.html(options.name || fieldId)}"
value="${window.LEDEscape.html(currentValue)}"
placeholder="${window.LEDEscape.html(placeholder)}"
${pattern ? `pattern="${window.LEDEscape.html(pattern)}"` : ''}
${minLength !== null ? `minlength="${minLength}"` : ''}
${maxLength !== null ? `maxlength="${maxLength}"` : ''}
${disabled ? 'disabled' : ''}
@@ -123,7 +121,7 @@
}
if (suffix) {
html += `<span class="inline-flex items-center px-3 text-sm text-gray-500 bg-gray-100 border border-l-0 border-gray-300 rounded-r-md">${escapeHtml(suffix)}</span>`;
html += `<span class="inline-flex items-center px-3 text-sm text-gray-500 bg-gray-100 border border-l-0 border-gray-300 rounded-r-md">${window.LEDEscape.html(suffix)}</span>`;
}
if (hasAddons) {
@@ -26,8 +26,6 @@
const base = window.BaseWidget ? new window.BaseWidget('Textarea', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -75,15 +73,15 @@
html += `
<textarea id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
name="${window.LEDEscape.html(options.name || fieldId)}"
rows="${rows}"
placeholder="${escapeHtml(placeholder)}"
placeholder="${window.LEDEscape.html(placeholder)}"
${maxLength ? `maxlength="${maxLength}"` : ''}
${minLength ? `minlength="${minLength}"` : ''}
${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('textarea').onChange('${fieldId}')"
oninput="window.LEDMatrixWidgets.getHandlers('textarea').onInput('${fieldId}')"
class="form-textarea w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${resizeClass} ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black placeholder:text-gray-400">${escapeHtml(currentValue)}</textarea>
class="form-textarea w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${resizeClass} ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black placeholder:text-gray-400">${window.LEDEscape.html(currentValue)}</textarea>
`;
// Character count
@@ -25,8 +25,6 @@
const base = window.BaseWidget ? new window.BaseWidget('TimePicker', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -77,9 +75,9 @@
<div class="relative flex-1">
<input type="time"
id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
value="${escapeHtml(currentValue)}"
${placeholder ? `placeholder="${escapeHtml(placeholder)}"` : ''}
name="${window.LEDEscape.html(options.name || fieldId)}"
value="${window.LEDEscape.html(currentValue)}"
${placeholder ? `placeholder="${window.LEDEscape.html(placeholder)}"` : ''}
${disabled ? 'disabled' : ''}
${required ? 'required' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('time-picker').onChange('${fieldId}')"
@@ -32,8 +32,6 @@
// Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('TimeRange', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -149,18 +147,18 @@
let html = `<div id="${fieldId}_widget" class="time-range-widget" data-field-id="${fieldId}" data-allow-overnight="${allowOvernight}">`;
// Hidden inputs for form submission
html += `<input type="hidden" id="${fieldId}_start_time" name="${inputName}_start_time" value="${escapeHtml(startTime)}">`;
html += `<input type="hidden" id="${fieldId}_end_time" name="${inputName}_end_time" value="${escapeHtml(endTime)}">`;
html += `<input type="hidden" id="${fieldId}_start_time" name="${inputName}_start_time" value="${window.LEDEscape.html(startTime)}">`;
html += `<input type="hidden" id="${fieldId}_end_time" name="${inputName}_end_time" value="${window.LEDEscape.html(endTime)}">`;
html += `<div class="grid grid-cols-1 md:grid-cols-2 gap-4">`;
// Start time input
html += `
<div class="form-group">
<label for="${fieldId}_start_input" class="block text-sm font-medium text-gray-700">${escapeHtml(startLabel)}</label>
<label for="${fieldId}_start_input" class="block text-sm font-medium text-gray-700">${window.LEDEscape.html(startLabel)}</label>
<input type="time"
id="${fieldId}_start_input"
value="${escapeHtml(startTime)}"
value="${window.LEDEscape.html(startTime)}"
${disabledAttr}
onchange="window.LEDMatrixWidgets.getHandlers('time-range').onChange('${fieldId}')"
class="form-control mt-1 ${disabledClass}">
@@ -170,10 +168,10 @@
// End time input
html += `
<div class="form-group">
<label for="${fieldId}_end_input" class="block text-sm font-medium text-gray-700">${escapeHtml(endLabel)}</label>
<label for="${fieldId}_end_input" class="block text-sm font-medium text-gray-700">${window.LEDEscape.html(endLabel)}</label>
<input type="time"
id="${fieldId}_end_input"
value="${escapeHtml(endTime)}"
value="${window.LEDEscape.html(endTime)}"
${disabledAttr}
onchange="window.LEDMatrixWidgets.getHandlers('time-range').onChange('${fieldId}')"
class="form-control mt-1 ${disabledClass}">
@@ -187,7 +185,7 @@
const duration = calculateDuration(startTime, endTime, allowOvernight);
html += `
<div id="${fieldId}_duration" class="mt-2 text-sm text-gray-500">
Duration: <span class="font-medium">${escapeHtml(duration)}</span>
Duration: <span class="font-medium">${window.LEDEscape.html(duration)}</span>
</div>
`;
}
@@ -23,8 +23,6 @@
const base = window.BaseWidget ? new window.BaseWidget('TimezoneSelector', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -230,7 +228,7 @@
let html = `<div id="${fieldId}_widget" class="timezone-selector-widget" data-field-id="${fieldId}">`;
// Hidden input for form submission
html += `<input type="hidden" id="${fieldId}_data" name="${escapeHtml(options.name || fieldId)}" value="${escapeHtml(currentValue)}">`;
html += `<input type="hidden" id="${fieldId}_data" name="${window.LEDEscape.html(options.name || fieldId)}" value="${window.LEDEscape.html(currentValue)}">`;
html += `
<select id="${fieldId}_input"
@@ -240,11 +238,11 @@
`;
// Placeholder option
html += `<option value="" ${!currentValue ? 'selected' : ''} disabled>${escapeHtml(placeholder)}</option>`;
html += `<option value="" ${!currentValue ? 'selected' : ''} disabled>${window.LEDEscape.html(placeholder)}</option>`;
// Build options grouped by region
for (const [groupName, timezones] of Object.entries(TIMEZONE_GROUPS)) {
html += `<optgroup label="${escapeHtml(groupName)}">`;
html += `<optgroup label="${window.LEDEscape.html(groupName)}">`;
for (const tz of timezones) {
const isSelected = currentValue === tz.value;
@@ -258,7 +256,7 @@
}
}
html += `<option value="${escapeHtml(tz.value)}" ${isSelected ? 'selected' : ''}>${escapeHtml(displayLabel)}</option>`;
html += `<option value="${window.LEDEscape.html(tz.value)}" ${isSelected ? 'selected' : ''}>${window.LEDEscape.html(displayLabel)}</option>`;
}
html += '</optgroup>';
@@ -26,8 +26,6 @@
const base = window.BaseWidget ? new window.BaseWidget('ToggleSwitch', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -90,10 +88,10 @@
const sizeClass = SIZE_CLASSES[size] || SIZE_CLASSES.medium;
const colorClass = COLOR_CLASSES[colorOn] || COLOR_CLASSES.blue;
let html = `<div id="${fieldId}_widget" class="toggle-switch-widget flex items-center" data-field-id="${fieldId}" data-label-on="${escapeHtml(labelOn)}" data-label-off="${escapeHtml(labelOff)}" data-color="${colorOn}">`;
let html = `<div id="${fieldId}_widget" class="toggle-switch-widget flex items-center" data-field-id="${fieldId}" data-label-on="${window.LEDEscape.html(labelOn)}" data-label-off="${window.LEDEscape.html(labelOff)}" data-color="${colorOn}">`;
// Hidden checkbox for form submission
html += `<input type="hidden" id="${fieldId}_hidden" name="${escapeHtml(options.name || fieldId)}" value="${isChecked}">`;
html += `<input type="hidden" id="${fieldId}_hidden" name="${window.LEDEscape.html(options.name || fieldId)}" value="${isChecked}">`;
html += `
<button type="button"
@@ -113,7 +111,7 @@
if (showLabels) {
html += `
<span id="${fieldId}_label" class="ml-3 text-sm font-medium ${isChecked ? 'text-gray-900' : 'text-gray-500'}">
${escapeHtml(isChecked ? labelOn : labelOff)}
${window.LEDEscape.html(isChecked ? labelOn : labelOff)}
</span>
`;
}
@@ -26,8 +26,6 @@
const base = window.BaseWidget ? new window.BaseWidget('UrlInput', '1.0.0') : null;
function escapeHtml(text) { return window.LEDEscape.html(text); }
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -127,7 +125,7 @@
const currentValue = value || '';
// Escape the protocols for safe HTML attribute interpolation
const escapedProtocols = escapeHtml(allowedProtocols.join(','));
const escapedProtocols = window.LEDEscape.html(allowedProtocols.join(','));
let html = `<div id="${fieldId}_widget" class="url-input-widget" data-field-id="${fieldId}" data-protocols="${escapedProtocols}">`;
html += '<div class="relative">';
@@ -143,9 +141,9 @@
html += `
<input type="url"
id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
value="${escapeHtml(currentValue)}"
placeholder="${escapeHtml(placeholder)}"
name="${window.LEDEscape.html(options.name || fieldId)}"
value="${window.LEDEscape.html(currentValue)}"
placeholder="${window.LEDEscape.html(placeholder)}"
${disabled ? 'disabled' : ''}
${required ? 'required' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('url-input').onChange('${fieldId}')"
@@ -160,7 +158,7 @@
html += `
<div id="${fieldId}_preview" class="mt-2 ${currentValue && isValidUrl(currentValue, allowedProtocols) ? '' : 'hidden'}">
<a id="${fieldId}_preview_link"
href="${escapeHtml(safeHref(currentValue, allowedProtocols))}"
href="${window.LEDEscape.html(safeHref(currentValue, allowedProtocols))}"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-blue-600 hover:text-blue-800 flex items-center">
+52 -56
View File
@@ -21,7 +21,7 @@
* is the one Alpine uses)
* end of <body>, defer, in this order: app.js, js/tooltips.js,
* js/settings-search.js, js/utils/dialog.js,
* js/utils/error_handler.js, js/plugins/api_client.js,
* js/plugins/api_client.js,
* state_manager.js, install_manager.js, list_filter.js,
* the widget bundle (web_interface/widget_bundle.py),
* plugins_manager.js
@@ -1090,7 +1090,7 @@ function renderInstalledCards(plugins, total) {
const enabledBool = Boolean(plugin.enabled);
// Escape plugin ID for use in HTML attributes and JavaScript
const escapedPluginId = escapeAttribute(plugin.id);
const escapedPluginId = window.LEDEscape.attr(plugin.id);
return `
<div class="plugin-card">
@@ -1100,7 +1100,7 @@ function renderInstalledCards(plugins, total) {
card's full width. -->
<div class="flex items-start justify-between gap-3 mb-2">
<div class="flex items-center flex-wrap gap-2 min-w-0">
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(plugin.name || plugin.id)}</h4>
<h4 class="font-semibold text-gray-900 text-base">${window.LEDEscape.html(plugin.name || plugin.id)}</h4>
${plugin.is_starlark_app ? '<span class="badge badge-warning"><i class="fas fa-star mr-1"></i>Starlark</span>' : ''}
${plugin.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : ''}
</div>
@@ -1112,7 +1112,7 @@ function renderInstalledCards(plugins, total) {
<input type="checkbox"
class="sr-only peer"
role="switch"
aria-label="Enable ${escapeAttribute(plugin.name || plugin.id)}"
aria-label="Enable ${window.LEDEscape.attr(plugin.name || plugin.id)}"
id="toggle-${escapedPluginId}"
${enabledBool ? 'checked' : ''}
data-plugin-id="${escapedPluginId}"
@@ -1134,17 +1134,17 @@ function renderInstalledCards(plugins, total) {
</div>
</div>
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p>
${plugin.version ? `<p class="flex items-center flex-wrap gap-1.5"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}${plugin.update_available && plugin.latest_version ? `<span class="badge badge-info" title="Installed v${escapeAttribute(plugin.version)} → latest v${escapeAttribute(plugin.latest_version)}"><i class="fas fa-arrow-circle-up mr-1"></i>v${escapeHtml(plugin.latest_version)} available</span>` : ''}</p>` : ''}
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${window.LEDEscape.html(plugin.author || 'Unknown')}</p>
${plugin.version ? `<p class="flex items-center flex-wrap gap-1.5"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${window.LEDEscape.html(plugin.version)}${plugin.update_available && plugin.latest_version ? `<span class="badge badge-info" title="Installed v${window.LEDEscape.attr(plugin.version)} → latest v${window.LEDEscape.attr(plugin.latest_version)}"><i class="fas fa-arrow-circle-up mr-1"></i>v${window.LEDEscape.html(plugin.latest_version)} available</span>` : ''}</p>` : ''}
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${window.LEDEscape.html(plugin.category || 'General')}</p>
</div>
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p>
<p class="text-sm text-gray-700 leading-relaxed">${window.LEDEscape.html(plugin.description || 'No description available')}</p>
</div>
<!-- Plugin Tags -->
${plugin.tags && plugin.tags.length > 0 ? `
<div class="flex flex-wrap gap-1.5 mb-4">
${plugin.tags.map(tag => `<span class="badge badge-info">${escapeHtml(tag)}</span>`).join('')}
${plugin.tags.map(tag => `<span class="badge badge-info">${window.LEDEscape.html(tag)}</span>`).join('')}
</div>
` : ''}
@@ -1161,8 +1161,8 @@ function renderInstalledCards(plugins, total) {
style="flex: 1;"
data-plugin-id="${escapedPluginId}"
data-action="update"
title="${plugin.update_available && plugin.latest_version ? 'Update to v' + escapeAttribute(plugin.latest_version) : 'Reinstall the latest published version'}">
<i class="fas ${plugin.update_available ? 'fa-arrow-circle-up' : 'fa-sync'} mr-2"></i>${plugin.update_available && plugin.latest_version ? 'Update to v' + escapeHtml(plugin.latest_version) : 'Update'}
title="${plugin.update_available && plugin.latest_version ? 'Update to v' + window.LEDEscape.attr(plugin.latest_version) : 'Reinstall the latest published version'}">
<i class="fas ${plugin.update_available ? 'fa-arrow-circle-up' : 'fa-sync'} mr-2"></i>${plugin.update_available && plugin.latest_version ? 'Update to v' + window.LEDEscape.html(plugin.latest_version) : 'Update'}
</button>
<button class="btn bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-semibold"
style="flex: 1;"
@@ -1846,15 +1846,15 @@ window.executePluginAction = function(actionId, actionIndex, pluginId) {
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
statusDiv.innerHTML = `<div class="text-green-600"><i class="fas fa-check-circle mr-2"></i>${escapeHtml(data.message || 'Action completed successfully')}</div>`;
statusDiv.innerHTML = `<div class="text-green-600"><i class="fas fa-check-circle mr-2"></i>${window.LEDEscape.html(data.message || 'Action completed successfully')}</div>`;
btn.innerHTML = originalText;
btn.disabled = false;
delete btn.dataset.step;
showNotification(data.message || 'Action completed successfully!', 'success');
} else {
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>${escapeHtml(data.message || 'Error')}</div>`;
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>${window.LEDEscape.html(data.message || 'Error')}</div>`;
if (data.output) {
statusDiv.innerHTML += `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${escapeHtml(data.output)}</pre>`;
statusDiv.innerHTML += `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${window.LEDEscape.html(data.output)}</pre>`;
}
btn.innerHTML = originalText;
btn.disabled = false;
@@ -1862,7 +1862,7 @@ window.executePluginAction = function(actionId, actionIndex, pluginId) {
}
})
.catch(error => {
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>Error: ${escapeHtml(error.message)}</div>`;
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>Error: ${window.LEDEscape.html(error.message)}</div>`;
btn.innerHTML = originalText;
btn.disabled = false;
delete btn.dataset.step;
@@ -1894,12 +1894,12 @@ window.executePluginAction = function(actionId, actionIndex, pluginId) {
statusDiv.innerHTML = `
<div class="bg-blue-50 border border-blue-200 rounded p-3">
<div class="text-blue-900 font-medium mb-2">
<i class="fas fa-link mr-2"></i>${escapeHtml(data.message || 'Authorization URL Generated')}
<i class="fas fa-link mr-2"></i>${window.LEDEscape.html(data.message || 'Authorization URL Generated')}
</div>
<div class="mb-3">
<p class="text-sm text-blue-700 mb-2">1. Click the link below to authorize:</p>
<a href="${data.auth_url && data.auth_url.startsWith('http') ? escapeHtml(data.auth_url) : '#'}" target="_blank" class="text-blue-600 hover:text-blue-800 underline break-all">
${escapeHtml(data.auth_url || '')}
<a href="${data.auth_url && data.auth_url.startsWith('http') ? window.LEDEscape.html(data.auth_url) : '#'}" target="_blank" class="text-blue-600 hover:text-blue-800 underline break-all">
${window.LEDEscape.html(data.auth_url || '')}
</a>
</div>
<div class="mb-2">
@@ -1917,9 +1917,9 @@ window.executePluginAction = function(actionId, actionIndex, pluginId) {
statusDiv.innerHTML = `
<div class="bg-green-50 border border-green-200 rounded p-3">
<div class="text-green-900 font-medium mb-2">
<i class="fas fa-check-circle mr-2"></i>${escapeHtml(data.message || 'Action completed successfully')}
<i class="fas fa-check-circle mr-2"></i>${window.LEDEscape.html(data.message || 'Action completed successfully')}
</div>
${data.output ? `<pre class="mt-2 text-xs bg-green-50 p-2 rounded overflow-auto max-h-32">${escapeHtml(data.output)}</pre>` : ''}
${data.output ? `<pre class="mt-2 text-xs bg-green-50 p-2 rounded overflow-auto max-h-32">${window.LEDEscape.html(data.output)}</pre>` : ''}
</div>
`;
btn.innerHTML = originalText;
@@ -1930,9 +1930,9 @@ window.executePluginAction = function(actionId, actionIndex, pluginId) {
statusDiv.innerHTML = `
<div class="bg-red-50 border border-red-200 rounded p-3">
<div class="text-red-900 font-medium mb-2">
<i class="fas fa-exclamation-circle mr-2"></i>${escapeHtml(data.message || 'Action failed')}
<i class="fas fa-exclamation-circle mr-2"></i>${window.LEDEscape.html(data.message || 'Action failed')}
</div>
${data.output ? `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${escapeHtml(data.output)}</pre>` : ''}
${data.output ? `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${window.LEDEscape.html(data.output)}</pre>` : ''}
</div>
`;
btn.innerHTML = originalText;
@@ -1940,7 +1940,7 @@ window.executePluginAction = function(actionId, actionIndex, pluginId) {
}
})
.catch(error => {
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>Error: ${escapeHtml(error.message)}</div>`;
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>Error: ${window.LEDEscape.html(error.message)}</div>`;
btn.innerHTML = originalText;
btn.disabled = false;
});
@@ -2403,33 +2403,33 @@ function renderPluginStore(plugins) {
+ (plugin.plugin_path ? '/' + plugin.plugin_path.split('/').map(encodeURIComponent).join('/') : '')
: '';
const commitHtml = !commit ? '' : (commitUrl
? `<a href="${escapeAttribute(commitUrl)}" target="_blank" rel="noopener noreferrer" class="text-xs font-mono text-gray-500 hover:underline" title="Source of this version: commit ${escapeAttribute(commit)}">${escapeHtml(commit.slice(0, 7))}</a>`
: `<span class="text-xs font-mono text-gray-500" title="Commit ${escapeAttribute(commit)}">${escapeHtml(commit.slice(0, 7))}</span>`);
? `<a href="${window.LEDEscape.attr(commitUrl)}" target="_blank" rel="noopener noreferrer" class="text-xs font-mono text-gray-500 hover:underline" title="Source of this version: commit ${window.LEDEscape.attr(commit)}">${window.LEDEscape.html(commit.slice(0, 7))}</a>`
: `<span class="text-xs font-mono text-gray-500" title="Commit ${window.LEDEscape.attr(commit)}">${window.LEDEscape.html(commit.slice(0, 7))}</span>`);
return `
<div class="plugin-card">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0">
<div class="flex items-center flex-wrap gap-1.5 mb-2">
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(plugin.name || plugin.id)}</h4>
<h4 class="font-semibold text-gray-900 text-base">${window.LEDEscape.html(plugin.name || plugin.id)}</h4>
${plugin.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : ''}
${installed ? '<span class="badge badge-success"><i class="fas fa-check mr-1"></i>Installed</span>' : ''}
${isNewPlugin(plugin.last_updated) ? '<span class="badge badge-info"><i class="fas fa-sparkles mr-1"></i>New</span>' : ''}
${plugin._source === 'custom_repository' ? `<span class="badge badge-accent" title="From: ${escapeHtml(plugin._repository_name || plugin._repository_url || 'Custom Repository')}"><i class="fas fa-bookmark mr-1"></i>Custom</span>` : ''}
${plugin.incompatible_reason ? `<span class="badge badge-warning" title="${escapeAttribute(plugin.incompatible_reason)}"><i class="fas fa-exclamation-triangle mr-1"></i>Needs LEDMatrix ${escapeHtml(plugin.ledmatrix_min_version || 'update')}+</span>` : ''}
${plugin._source === 'custom_repository' ? `<span class="badge badge-accent" title="From: ${window.LEDEscape.html(plugin._repository_name || plugin._repository_url || 'Custom Repository')}"><i class="fas fa-bookmark mr-1"></i>Custom</span>` : ''}
${plugin.incompatible_reason ? `<span class="badge badge-warning" title="${window.LEDEscape.attr(plugin.incompatible_reason)}"><i class="fas fa-exclamation-triangle mr-1"></i>Needs LEDMatrix ${window.LEDEscape.html(plugin.ledmatrix_min_version || 'update')}+</span>` : ''}
</div>
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p>
${plugin.version ? `<p class="flex items-center flex-wrap gap-1.5"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}${commitHtml}</p>` : ''}
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${window.LEDEscape.html(plugin.author || 'Unknown')}</p>
${plugin.version ? `<p class="flex items-center flex-wrap gap-1.5"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${window.LEDEscape.html(plugin.version)}${commitHtml}</p>` : ''}
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${window.LEDEscape.html(plugin.category || 'General')}</p>
</div>
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p>
<p class="text-sm text-gray-700 leading-relaxed">${window.LEDEscape.html(plugin.description || 'No description available')}</p>
</div>
</div>
<!-- Plugin Tags -->
${plugin.tags && plugin.tags.length > 0 ? `
<div class="flex flex-wrap gap-1.5 mb-4">
${plugin.tags.map(tag => `<span class="badge badge-info">${escapeHtml(tag)}</span>`).join('')}
${plugin.tags.map(tag => `<span class="badge badge-info">${window.LEDEscape.html(tag)}</span>`).join('')}
</div>
` : ''}
@@ -2658,11 +2658,11 @@ function renderSavedRepositories(repositories) {
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<i class="fas ${repoType === 'registry' ? 'fa-folder-open' : 'fa-code-branch'} text-gray-400 text-xs"></i>
<span class="text-sm font-medium text-gray-900 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoName)}</span>
<span class="text-sm font-medium text-gray-900 truncate" title="${window.LEDEscape.attr(repoUrl)}">${window.LEDEscape.html(repoName)}</span>
</div>
<p class="text-xs text-gray-500 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoUrl)}</p>
<p class="text-xs text-gray-500 truncate" title="${window.LEDEscape.attr(repoUrl)}">${window.LEDEscape.html(repoUrl)}</p>
</div>
<button onclick='if(window.removeSavedRepository){window.removeSavedRepository(${jsStringAttr(repoUrl)})}else{console.error("removeSavedRepository not available")}' class="ml-2 text-red-600 hover:text-red-800 text-xs px-2 py-1" title="Remove repository" aria-label="Remove saved repository ${escapeAttribute(repoName)}">
<button onclick='if(window.removeSavedRepository){window.removeSavedRepository(${jsStringAttr(repoUrl)})}else{console.error("removeSavedRepository not available")}' class="ml-2 text-red-600 hover:text-red-800 text-xs px-2 py-1" title="Remove repository" aria-label="Remove saved repository ${window.LEDEscape.attr(repoName)}">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
@@ -2779,7 +2779,7 @@ function attachInstallButtonHandler() {
debugLog('[attachInstallButtonHandler] Response data:', data);
if (data.status === 'success') {
if (pluginStatusDiv) {
pluginStatusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Successfully installed: ${escapeHtml(data.plugin_id)}</span>`;
pluginStatusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Successfully installed: ${window.LEDEscape.html(data.plugin_id)}</span>`;
}
pluginUrlInput.value = '';
window.noteRestartRequired(data);
@@ -2790,14 +2790,14 @@ function attachInstallButtonHandler() {
}, 1000);
} else {
if (pluginStatusDiv) {
pluginStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>${escapeHtml(data.message || 'Installation failed')}</span>`;
pluginStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>${window.LEDEscape.html(data.message || 'Installation failed')}</span>`;
}
}
})
.catch(error => {
console.error('[attachInstallButtonHandler] Error:', error);
if (pluginStatusDiv) {
pluginStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${escapeHtml(error.message)}</span>`;
pluginStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${window.LEDEscape.html(error.message)}</span>`;
}
})
.finally(() => {
@@ -2946,7 +2946,7 @@ function setupGitHubInstallHandlers() {
}
})
.catch(error => {
registryStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${escapeHtml(error.message)}</span>`;
registryStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${window.LEDEscape.html(error.message)}</span>`;
customRegistryPlugins.classList.add('hidden');
})
.finally(() => {
@@ -3045,8 +3045,8 @@ function renderCustomRegistryPlugins(plugins, registryUrl) {
<div class="bg-white border border-gray-200 rounded-lg p-3">
<div class="flex items-start justify-between mb-2">
<div class="flex-1">
<h5 class="font-semibold text-sm text-gray-900">${escapeHtml(plugin.name || plugin.id)}</h5>
<p class="text-xs text-gray-600 mt-1 line-clamp-2">${escapeHtml(plugin.description || 'No description')}</p>
<h5 class="font-semibold text-sm text-gray-900">${window.LEDEscape.html(plugin.name || plugin.id)}</h5>
<p class="text-xs text-gray-600 mt-1 line-clamp-2">${window.LEDEscape.html(plugin.description || 'No description')}</p>
</div>
</div>
<div class="space-y-2 mt-2 pt-2 border-t border-gray-100">
@@ -3080,7 +3080,7 @@ function showInstalledLoadError(message) {
content.innerHTML = `
<div class="text-center py-8">
<i class="fas fa-exclamation-triangle text-4xl text-red-400 mb-2"></i>
<p class="text-red-600">${escapeHtml(message)}</p>
<p class="text-red-600">${window.LEDEscape.html(message)}</p>
</div>
`;
}
@@ -3104,10 +3104,8 @@ function isGithubUrl(url) {
}
}
// Short local names for window.LEDEscape (app-early.js), which says what each
// one is for. Function declarations, so they are usable anywhere in this IIFE.
function escapeHtml(text) { return window.LEDEscape.html(text); }
function escapeAttribute(text) { return window.LEDEscape.attr(text); }
// Short local name for window.LEDEscape.jsStringAttr (app-early.js). A function
// declaration, so it is usable anywhere in this IIFE.
function jsStringAttr(value) { return window.LEDEscape.jsStringAttr(value); }
function isNewPlugin(lastUpdated) {
@@ -3428,8 +3426,6 @@ document.addEventListener('htmx:afterSettle', function() {
let starlarkDataLoaded = false;
// ── Helpers ─────────────────────────────────────────────────────────────
function escapeHtml(str) { return window.LEDEscape.html(str); }
function isStarlarkInstalled(appId) {
// Check window.installedPlugins (populated by loadInstalledPlugins)
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
@@ -3487,7 +3483,7 @@ document.addEventListener('htmx:afterSettle', function() {
if (!banner) return;
if (data.pixlet_available) {
banner.innerHTML = `<div class="bg-green-50 border border-green-200 rounded-lg p-3 text-sm text-green-800">
<i class="fas fa-check-circle mr-2"></i>Pixlet available${data.pixlet_version ? ' (' + escapeHtml(data.pixlet_version) + ')' : ''} &mdash; ${data.installed_apps || 0} app(s) installed
<i class="fas fa-check-circle mr-2"></i>Pixlet available${data.pixlet_version ? ' (' + window.LEDEscape.html(data.pixlet_version) + ')' : ''} &mdash; ${data.installed_apps || 0} app(s) installed
</div>`;
} else {
banner.innerHTML = `<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800">
@@ -3514,7 +3510,7 @@ document.addEventListener('htmx:afterSettle', function() {
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
if (grid) grid.innerHTML = `<div class="col-span-full text-center py-8 text-red-500"><i class="fas fa-exclamation-circle mr-2"></i>${escapeHtml(data.message || 'Failed to load')}</div>`;
if (grid) grid.innerHTML = `<div class="col-span-full text-center py-8 text-red-500"><i class="fas fa-exclamation-circle mr-2"></i>${window.LEDEscape.html(data.message || 'Failed to load')}</div>`;
return;
}
@@ -3693,22 +3689,22 @@ document.addEventListener('htmx:afterSettle', function() {
setGridHtmlIfChanged(grid, apps.map(app => {
const installed = isStarlarkInstalled(app.id);
return `
<div class="plugin-card" data-app-id="${escapeHtml(app.id)}">
<div class="plugin-card" data-app-id="${window.LEDEscape.html(app.id)}">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0">
<div class="flex items-center flex-wrap gap-1.5 mb-2">
<!-- break-words: Starlark app names come from the
community repo and some are long single tokens,
which overflowed the card instead of wrapping. -->
<h4 class="font-semibold text-gray-900 text-base break-words">${escapeHtml(app.name || app.id)}</h4>
<h4 class="font-semibold text-gray-900 text-base break-words">${window.LEDEscape.html(app.name || app.id)}</h4>
<span class="badge badge-warning"><i class="fas fa-star mr-1"></i>Starlark</span>
${installed ? '<span class="badge badge-success"><i class="fas fa-check mr-1"></i>Installed</span>' : ''}
</div>
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
${app.author ? `<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(app.author)}</p>` : ''}
${app.category ? `<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(app.category)}</p>` : ''}
${app.author ? `<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${window.LEDEscape.html(app.author)}</p>` : ''}
${app.category ? `<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${window.LEDEscape.html(app.category)}</p>` : ''}
</div>
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(app.summary || app.desc || 'No description')}</p>
<p class="text-sm text-gray-700 leading-relaxed">${window.LEDEscape.html(app.summary || app.desc || 'No description')}</p>
</div>
</div>
<div class="flex gap-2 mt-auto pt-3 border-t border-gray-200">