mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-07 07:36:37 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// 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">↻</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">📁</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">×</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">×</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">×</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">⚠</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">📁</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>📄 ${filename}</span>
|
||||
<span>📊 ${f.entry_count ?? 0} entries · ${this._fmtSize(f.size || 0)}</span>
|
||||
<span>🕑 ${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">✎ Edit</button>` : ''}
|
||||
${hasDelete ? `<button type="button" class="jfm-btn jfm-btn-danger jfm-btn-sm jfm-del" data-jfm="del-file" title="Delete file">🗑</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>';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) + ')' : ''} — ${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) + ')' : ''} — ${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">
|
||||
|
||||
Reference in New Issue
Block a user