mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
fix(web): seven web UI bugs, and remove dead plugins_manager.js helpers (#647)
- Operation History: the plugin filter lists the installed plugin ids
instead of one option, "plugins" (Object.keys of {plugins: [...]}).
- Ctrl/Cmd+S submits the active tab's first visible form with
requestSubmit() (validation and onsubmit guards run) instead of a bare
Event on the first form in the document; skipped inside a modal dialog
and on tabs without a form.
- Overview "Check Updates" confirms like "Update Code", takes its button
explicitly (no implicit global event) and shows the server's message.
Both, and the Tools tab git pull, raise the restart-pending banner on
restart_required.
- Tools: toolsAction and diagnostics show the server's error message;
only a non-JSON body falls back to HTTP <status>.
- Installed list after uninstall: PluginAPI writes clear the throttler's
GET cache, a forced loadInstalledPlugins clears it too, and the
post-uninstall reload goes through refreshInstalledPlugins().
- Plugin widgets load from /static/plugin-widgets/ only (the other two
paths have no route).
- Raw JSON editor escapes the parse error; slider escapes value/min/max/step.
- Removed the unreferenced array-of-objects and key-value helpers from
plugins_manager.js, the textarea auto-resize and Ctrl+R handlers in
app.js, and a redundant ?v= on the plugins_manager.js script tag.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,15 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
- `check_system_compatibility.sh` no longer reports installed packages as missing.
|
- `check_system_compatibility.sh` no longer reports installed packages as missing.
|
||||||
- A network failure fetching GitHub repo info logs a warning, not an error.
|
- A network failure fetching GitHub repo info logs a warning, not an error.
|
||||||
|
|
||||||
|
- Web UI fixes:
|
||||||
|
- The Operation History plugin filter lists installed plugins (it showed one option, "plugins").
|
||||||
|
- Ctrl/Cmd+S submits the active tab's visible form (with its validation) instead of the first form in the page; it does nothing inside a dialog or on a tab without a form. The Ctrl/Cmd+R override (the browser's own reload) and the textarea auto-resize (no textarea exists at load) are removed.
|
||||||
|
- Overview "Check Updates" asks for the same confirmation as "Update Code" and shows the server's message. Both, and the Tools tab's git pull, show the restart-pending banner when the update needs a restart.
|
||||||
|
- Tools tab actions and diagnostics show the server's error message; only a non-JSON error falls back to `HTTP <status>`.
|
||||||
|
- An uninstalled plugin no longer reappears in the installed list: writes through `PluginAPI` clear its 5s GET cache, and Refresh and the post-uninstall reload bypass both list caches.
|
||||||
|
- Plugin widgets load from `/static/plugin-widgets/` only; the two other paths it tried have no route.
|
||||||
|
- The raw JSON editor escapes the parse error, and the slider widget escapes its value, min, max and step.
|
||||||
|
- Removed unused array-of-objects and key-value helpers from `plugins_manager.js` (about 640 lines, no callers) and a redundant `?v=` on its script tag.
|
||||||
- Web UI and `src.common` fixes:
|
- Web UI and `src.common` fixes:
|
||||||
- A wrong Wi-Fi password is reported as one again ("Incorrect password for ..."); the fallback that restores the old network or brings up the setup AP was replacing the signal.
|
- A wrong Wi-Fi password is reported as one again ("Incorrect password for ..."); the fallback that restores the old network or brings up the setup AP was replacing the signal.
|
||||||
- Plugin tabs show the manifest's `icon`: `/api/v3/plugins/installed` now includes it.
|
- Plugin tabs show the manifest's `icon`: `/api/v3/plugins/installed` now includes it.
|
||||||
|
|||||||
@@ -229,31 +229,32 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in
|
// SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in
|
||||||
// js/app-shell.js — do not open EventSources for stats/display here.
|
// js/app-shell.js — do not open EventSources for stats/display here.
|
||||||
|
|
||||||
// Auto-resize textareas
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const textareas = document.querySelectorAll('textarea');
|
|
||||||
textareas.forEach(textarea => {
|
|
||||||
textarea.addEventListener('input', function() {
|
|
||||||
this.style.height = 'auto';
|
|
||||||
this.style.height = this.scrollHeight + 'px';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
// Ctrl/Cmd + R to refresh
|
// Ctrl/Cmd + S to save the form on the active tab: the first visible form
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
|
// in that tab's panel (#<tab>-content, or #plugin-config-<id> for a plugin
|
||||||
e.preventDefault();
|
// tab). The panel is looked up by activeTab rather than by visibility
|
||||||
location.reload();
|
// 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
|
||||||
// Ctrl/Cmd + S to save current form
|
// the dialog's (json-file-manager saves its file on Ctrl+S), so the tab's
|
||||||
|
// form is left alone.
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = document.querySelector('form');
|
const active = document.activeElement;
|
||||||
if (form) {
|
if (active && active.closest && active.closest('[aria-modal="true"]')) {
|
||||||
form.dispatchEvent(new Event('submit'));
|
return;
|
||||||
|
}
|
||||||
|
const app = typeof window.getApp === 'function' ? window.getApp() : null;
|
||||||
|
const tab = app && app.activeTab;
|
||||||
|
const panel = (tab && (document.getElementById(tab + '-content') ||
|
||||||
|
document.getElementById('plugin-config-' + tab))) ||
|
||||||
|
document.getElementById('tab-content');
|
||||||
|
if (!panel) return;
|
||||||
|
const form = Array.from(panel.querySelectorAll('form'))
|
||||||
|
.find(f => f.getClientRects().length > 0);
|
||||||
|
if (form && typeof form.requestSubmit === 'function') {
|
||||||
|
form.requestSubmit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -212,8 +212,16 @@ const PluginAPI = {
|
|||||||
// Use throttling for GET requests, immediate execution for POST/PUT/DELETE
|
// Use throttling for GET requests, immediate execution for POST/PUT/DELETE
|
||||||
if (useThrottle && method === 'GET') {
|
if (useThrottle && method === 'GET') {
|
||||||
return await RequestThrottler.throttle(requestKey, makeRequest, 100);
|
return await RequestThrottler.throttle(requestKey, makeRequest, 100);
|
||||||
} else {
|
} else if (method === 'GET') {
|
||||||
return await makeRequest();
|
return await makeRequest();
|
||||||
|
} else {
|
||||||
|
// A write (install, uninstall, toggle, config save) can change any
|
||||||
|
// cached GET, e.g. the installed list; drop the cache once it lands.
|
||||||
|
try {
|
||||||
|
return await makeRequest();
|
||||||
|
} finally {
|
||||||
|
RequestThrottler.clearCache();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try multiple possible paths for plugin widgets
|
// The one route that serves plugin widgets (serve_plugin_widget in
|
||||||
|
// blueprints/pages_v3.py); nothing is served under /plugins/<id>/ or
|
||||||
|
// /static/plugins/<id>/, so trying those only added two failed imports.
|
||||||
const possiblePaths = [
|
const possiblePaths = [
|
||||||
`/static/plugin-widgets/${pluginId}/${widgetName}.js`,
|
`/static/plugin-widgets/${pluginId}/${widgetName}.js`
|
||||||
`/plugins/${pluginId}/widgets/${widgetName}.js`,
|
|
||||||
`/static/plugins/${pluginId}/widgets/${widgetName}.js`
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
html += `
|
html += `
|
||||||
<div class="flex justify-center mb-2">
|
<div class="flex justify-center mb-2">
|
||||||
<span id="${fieldId}_value" class="text-lg font-semibold text-gray-700">
|
<span id="${fieldId}_value" class="text-lg font-semibold text-gray-700">
|
||||||
${escapeHtml(prefix)}${currentValue}${escapeHtml(suffix)}
|
${escapeHtml(prefix)}${escapeHtml(currentValue)}${escapeHtml(suffix)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -96,10 +96,10 @@
|
|||||||
<input type="range"
|
<input type="range"
|
||||||
id="${fieldId}_input"
|
id="${fieldId}_input"
|
||||||
name="${escapeHtml(options.name || fieldId)}"
|
name="${escapeHtml(options.name || fieldId)}"
|
||||||
value="${currentValue}"
|
value="${escapeAttr(currentValue)}"
|
||||||
min="${min}"
|
min="${escapeAttr(min)}"
|
||||||
max="${max}"
|
max="${escapeAttr(max)}"
|
||||||
step="${step}"
|
step="${escapeAttr(step)}"
|
||||||
${disabled ? 'disabled' : ''}
|
${disabled ? 'disabled' : ''}
|
||||||
oninput="window.LEDMatrixWidgets.getHandlers('slider').onInput('${fieldId}')"
|
oninput="window.LEDMatrixWidgets.getHandlers('slider').onInput('${fieldId}')"
|
||||||
onchange="window.LEDMatrixWidgets.getHandlers('slider').onChange('${fieldId}')"
|
onchange="window.LEDMatrixWidgets.getHandlers('slider').onChange('${fieldId}')"
|
||||||
@@ -110,8 +110,8 @@
|
|||||||
if (showMinMax) {
|
if (showMinMax) {
|
||||||
html += `
|
html += `
|
||||||
<div class="flex justify-between mt-1">
|
<div class="flex justify-between mt-1">
|
||||||
<span class="text-xs text-gray-400">${escapeHtml(prefix)}${min}${escapeHtml(suffix)}</span>
|
<span class="text-xs text-gray-400">${escapeHtml(prefix)}${escapeHtml(min)}${escapeHtml(suffix)}</span>
|
||||||
<span class="text-xs text-gray-400">${escapeHtml(prefix)}${max}${escapeHtml(suffix)}</span>
|
<span class="text-xs text-gray-400">${escapeHtml(prefix)}${escapeHtml(max)}${escapeHtml(suffix)}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
* togglePlugin, the GitHub token helpers, handleGitHubPluginInstall,
|
* togglePlugin, the GitHub token helpers, handleGitHubPluginInstall,
|
||||||
* checkGitHubAuthStatus); then the plugin-manager IIFE (private state:
|
* checkGitHubAuthStatus); then the plugin-manager IIFE (private state:
|
||||||
* installedPlugins, the store cache, the on-demand poller); then the
|
* installedPlugins, the store cache, the on-demand poller); then the
|
||||||
* array-of-objects field helpers; then the Starlark IIFE.
|
* Starlark IIFE.
|
||||||
*
|
*
|
||||||
* The installed list is published only by renderInstalledPlugins: it sets
|
* The installed list is published only by renderInstalledPlugins: it sets
|
||||||
* window.installedPlugins and dispatches one `pluginsUpdated` event, which
|
* window.installedPlugins and dispatches one `pluginsUpdated` event, which
|
||||||
@@ -44,9 +44,6 @@
|
|||||||
* configurePlugin, togglePlugin, uninstallPlugin, installPlugin,
|
* configurePlugin, togglePlugin, uninstallPlugin, installPlugin,
|
||||||
* installFromCustomRegistry, removeSavedRepository, executePluginAction,
|
* installFromCustomRegistry, removeSavedRepository, executePluginAction,
|
||||||
* openOnDemandModal, requestOnDemandStop, loadOnDemandStatus,
|
* openOnDemandModal, requestOnDemandStop, loadOnDemandStatus,
|
||||||
* renderArrayObjectItem, addArrayObjectItem, removeArrayObjectItem,
|
|
||||||
* updateArrayObjectData, handleArrayObjectFileUpload, removeArrayObjectFile,
|
|
||||||
* removeKeyValuePair, updateKeyValuePairData, getSchemaProperty,
|
|
||||||
* installStarlarkApp, installPixlet, the GitHub token functions,
|
* installStarlarkApp, installPixlet, the GitHub token functions,
|
||||||
* window.installedPlugins and window.currentPluginConfig.
|
* window.installedPlugins and window.currentPluginConfig.
|
||||||
*/
|
*/
|
||||||
@@ -923,6 +920,12 @@ function loadInstalledPlugins(forceRefresh = false) {
|
|||||||
|
|
||||||
debugLog('[FETCH] Loading installed plugins...');
|
debugLog('[FETCH] Loading installed plugins...');
|
||||||
|
|
||||||
|
// A forced refresh must not be answered from PluginAPI's own GET cache
|
||||||
|
// (RequestThrottler, 5s) either, or it re-renders the list it replaces.
|
||||||
|
if (forceRefresh && window.PluginAPI && typeof window.PluginAPI.clearCache === 'function') {
|
||||||
|
window.PluginAPI.clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
// Use PluginAPI if available, otherwise fall back to direct fetch
|
// Use PluginAPI if available, otherwise fall back to direct fetch
|
||||||
const fetchPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ?
|
const fetchPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ?
|
||||||
window.PluginAPI.getInstalledPlugins().then(plugins => {
|
window.PluginAPI.getInstalledPlugins().then(plugins => {
|
||||||
@@ -1883,516 +1886,6 @@ function closeOnDemandModalOnBackdrop(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get the full property object from schema
|
|
||||||
// Uses greedy longest-match to handle schema keys containing dots (e.g., "eng.1")
|
|
||||||
function getSchemaProperty(schema, path) {
|
|
||||||
if (!schema || !schema.properties) return null;
|
|
||||||
|
|
||||||
const parts = path.split('.');
|
|
||||||
let current = schema.properties;
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < parts.length) {
|
|
||||||
let matched = false;
|
|
||||||
// Try progressively longer candidates, longest first
|
|
||||||
for (let j = parts.length; j > i; j--) {
|
|
||||||
const candidate = parts.slice(i, j).join('.');
|
|
||||||
if (current && current[candidate]) {
|
|
||||||
if (j === parts.length) {
|
|
||||||
// Consumed all remaining parts — done
|
|
||||||
return current[candidate];
|
|
||||||
}
|
|
||||||
if (current[candidate].properties) {
|
|
||||||
current = current[candidate].properties;
|
|
||||||
i = j;
|
|
||||||
matched = true;
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
return null; // Can't navigate deeper
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!matched) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// "x-display": "hidden" (or an object of nothing but hidden children).
|
|
||||||
// Mirrors prop_is_hidden in plugin_config.html.
|
|
||||||
function isHiddenSchemaProp(schema) {
|
|
||||||
if (!schema || typeof schema !== 'object') return false;
|
|
||||||
if (schema['x-display'] === 'hidden') return true;
|
|
||||||
const children = schema.properties;
|
|
||||||
if (children && typeof children === 'object') {
|
|
||||||
const values = Object.values(children);
|
|
||||||
return values.length > 0 && values.every(isHiddenSchemaProp);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to render a single item in an array of objects
|
|
||||||
function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, index, itemsSchema) {
|
|
||||||
const item = itemValue || {};
|
|
||||||
const itemId = `${escapeAttribute(fieldId)}_item_${index}`;
|
|
||||||
// Store original item data in data attribute to preserve non-editable properties after reindexing
|
|
||||||
const itemDataJson = JSON.stringify(item);
|
|
||||||
const itemDataBase64 = btoa(unescape(encodeURIComponent(itemDataJson)));
|
|
||||||
let html = `<div id="${itemId}" class="border border-gray-300 rounded-lg p-4 bg-gray-50 array-object-item" data-index="${index}" data-item-data="${escapeAttribute(itemDataBase64)}">`;
|
|
||||||
|
|
||||||
// Render each property of the object
|
|
||||||
const propertyOrder = itemsSchema['x-propertyOrder'] || Object.keys(itemProperties);
|
|
||||||
propertyOrder.forEach(propKey => {
|
|
||||||
if (!itemProperties[propKey]) return;
|
|
||||||
// "x-display": "hidden": no control. The stored value survives through
|
|
||||||
// data-item-data, which updateArrayObjectData overlays edits onto.
|
|
||||||
if (isHiddenSchemaProp(itemProperties[propKey])) return;
|
|
||||||
|
|
||||||
const propSchema = itemProperties[propKey];
|
|
||||||
const propValue = item[propKey] !== undefined ? item[propKey] : propSchema.default;
|
|
||||||
const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
||||||
const propDescription = propSchema.description || '';
|
|
||||||
const propFullKey = `${fullKey}[${index}].${propKey}`;
|
|
||||||
|
|
||||||
html += `<div class="mb-3">`;
|
|
||||||
|
|
||||||
// Handle file-upload widget (for logo field)
|
|
||||||
if (propSchema['x-widget'] === 'file-upload') {
|
|
||||||
html += `<label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(propLabel)}</label>`;
|
|
||||||
if (propDescription) {
|
|
||||||
html += `<p class="text-xs text-gray-500 mb-2">${escapeHtml(propDescription)}</p>`;
|
|
||||||
}
|
|
||||||
const uploadConfig = propSchema['x-upload-config'] || {};
|
|
||||||
// Derive pluginId strictly from uploadConfig or currentPluginConfig, no hard-coded fallback
|
|
||||||
const pluginId = uploadConfig.plugin_id || (typeof currentPluginConfig !== 'undefined' ? currentPluginConfig?.pluginId : null) || (typeof window.currentPluginConfig !== 'undefined' ? window.currentPluginConfig?.pluginId : null) || null;
|
|
||||||
const logoValue = propValue || {};
|
|
||||||
// Use base64 encoding for JSON in data attributes to safely handle all characters
|
|
||||||
const logoDataJson = logoValue && Object.keys(logoValue).length > 0 ? JSON.stringify(logoValue) : '';
|
|
||||||
const logoDataBase64 = logoDataJson ? btoa(unescape(encodeURIComponent(logoDataJson))) : '';
|
|
||||||
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp'];
|
|
||||||
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
||||||
const pluginIdParam = pluginId ? `'${escapeAttribute(pluginId)}'` : 'null';
|
|
||||||
const uploadConfigJson = JSON.stringify({ allowed_types: allowedTypes, max_size_mb: maxSizeMB });
|
|
||||||
const uploadConfigBase64 = btoa(unescape(encodeURIComponent(uploadConfigJson)));
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="file-upload-widget-inline"${logoDataBase64 ? ` data-file-data="${escapeAttribute(logoDataBase64)}" data-prop-key="${escapeAttribute(propKey)}"` : ` data-prop-key="${escapeAttribute(propKey)}"`} data-upload-config="${escapeAttribute(uploadConfigBase64)}">
|
|
||||||
<input type="file"
|
|
||||||
id="${escapeAttribute(itemId)}_logo_file"
|
|
||||||
accept="${escapeAttribute(allowedTypes.join(','))}"
|
|
||||||
style="display: none;"
|
|
||||||
onchange="handleArrayObjectFileUpload(event, '${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}', ${pluginIdParam})">
|
|
||||||
<button type="button"
|
|
||||||
onclick="document.getElementById('${escapeAttribute(itemId)}_logo_file').click()"
|
|
||||||
class="px-3 py-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md transition-colors">
|
|
||||||
<i class="fas fa-upload mr-1"></i> Upload Logo
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (logoValue.path) {
|
|
||||||
html += `
|
|
||||||
<div class="mt-2 flex items-center space-x-2 uploaded-image-container">
|
|
||||||
<img src="/${escapeAttribute(logoValue.path.replace(/^\/+/, ''))}" alt="Logo" loading="lazy" decoding="async" class="w-16 h-16 object-cover rounded border">
|
|
||||||
<button type="button"
|
|
||||||
onclick="removeArrayObjectFile('${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}')"
|
|
||||||
class="text-red-600 hover:text-red-800">
|
|
||||||
<i class="fas fa-trash"></i> Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `</div>`;
|
|
||||||
} else if (propSchema.type === 'boolean') {
|
|
||||||
// Boolean checkbox
|
|
||||||
html += `
|
|
||||||
<label class="flex items-center">
|
|
||||||
<input type="checkbox"
|
|
||||||
id="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}"
|
|
||||||
data-prop-key="${escapeAttribute(propKey)}"
|
|
||||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
|
||||||
${propValue ? 'checked' : ''}
|
|
||||||
onchange="updateArrayObjectData('${escapeAttribute(fieldId)}')">
|
|
||||||
<span class="ml-2 text-sm text-gray-700">${escapeHtml(propLabel)}</span>
|
|
||||||
</label>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
// Regular text/string input
|
|
||||||
html += `
|
|
||||||
<label for="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
${escapeHtml(propLabel)}
|
|
||||||
</label>
|
|
||||||
`;
|
|
||||||
if (propDescription) {
|
|
||||||
html += `<p class="text-xs text-gray-500 mb-1">${escapeHtml(propDescription)}</p>`;
|
|
||||||
}
|
|
||||||
const placeholder = propSchema.format === 'uri' ? 'https://example.com/feed' : '';
|
|
||||||
html += `
|
|
||||||
<input type="${propSchema.format === 'uri' ? 'url' : 'text'}"
|
|
||||||
id="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}"
|
|
||||||
data-prop-key="${escapeAttribute(propKey)}"
|
|
||||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-white text-black"
|
|
||||||
value="${escapeAttribute(propValue || '')}"
|
|
||||||
placeholder="${escapeAttribute(placeholder)}"
|
|
||||||
onchange="updateArrayObjectData('${escapeAttribute(fieldId)}')">
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `</div>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use schema-driven label for remove button, fallback to generic "Remove item"
|
|
||||||
const removeLabel = itemsSchema['x-removeLabel'] || 'Remove item';
|
|
||||||
html += `
|
|
||||||
<button type="button"
|
|
||||||
onclick="removeArrayObjectItem('${escapeAttribute(fieldId)}', ${index})"
|
|
||||||
class="mt-2 px-3 py-2 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors">
|
|
||||||
<i class="fas fa-trash mr-1"></i> ${escapeHtml(removeLabel)}
|
|
||||||
</button>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Functions to handle patternProperties key-value pairs
|
|
||||||
window.removeKeyValuePair = function(fieldId, index) {
|
|
||||||
const pairsContainer = document.getElementById(fieldId + '_pairs');
|
|
||||||
if (!pairsContainer) return;
|
|
||||||
|
|
||||||
const pair = pairsContainer.querySelector(`.key-value-pair[data-index="${index}"]`);
|
|
||||||
if (pair) {
|
|
||||||
pair.remove();
|
|
||||||
// Re-index remaining pairs
|
|
||||||
const remainingPairs = pairsContainer.querySelectorAll('.key-value-pair');
|
|
||||||
remainingPairs.forEach((p, newIndex) => {
|
|
||||||
p.setAttribute('data-index', newIndex);
|
|
||||||
const keyInput = p.querySelector('[data-key-index]');
|
|
||||||
const valueInput = p.querySelector('[data-value-index]');
|
|
||||||
if (keyInput) {
|
|
||||||
keyInput.setAttribute('name', keyInput.getAttribute('name').replace(/\[key_\d+\]/, `[key_${newIndex}]`));
|
|
||||||
keyInput.setAttribute('data-key-index', newIndex);
|
|
||||||
keyInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${keyInput.getAttribute('name').split('[')[0]}')`);
|
|
||||||
}
|
|
||||||
if (valueInput) {
|
|
||||||
valueInput.setAttribute('name', valueInput.getAttribute('name').replace(/\[value_\d+\]/, `[value_${newIndex}]`));
|
|
||||||
valueInput.setAttribute('data-value-index', newIndex);
|
|
||||||
valueInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${valueInput.getAttribute('name').split('[')[0]}')`);
|
|
||||||
}
|
|
||||||
const removeButton = p.querySelector('button[onclick*="removeKeyValuePair"]');
|
|
||||||
if (removeButton) {
|
|
||||||
removeButton.setAttribute('onclick', `removeKeyValuePair('${fieldId}', ${newIndex})`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const hiddenInput = pairsContainer.closest('.key-value-pairs-container').querySelector('input[type="hidden"]');
|
|
||||||
if (hiddenInput) {
|
|
||||||
const hiddenName = hiddenInput.getAttribute('name').replace(/_data$/, '');
|
|
||||||
updateKeyValuePairData(fieldId, hiddenName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update add button state
|
|
||||||
const addButton = pairsContainer.nextElementSibling;
|
|
||||||
if (addButton) {
|
|
||||||
const maxProperties = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]);
|
|
||||||
if (remainingPairs.length < maxProperties) {
|
|
||||||
addButton.disabled = false;
|
|
||||||
addButton.style.opacity = '1';
|
|
||||||
addButton.style.cursor = 'pointer';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.updateKeyValuePairData = function(fieldId, fullKey) {
|
|
||||||
const pairsContainer = document.getElementById(fieldId + '_pairs');
|
|
||||||
const hiddenInput = document.getElementById(fieldId + '_data');
|
|
||||||
if (!pairsContainer || !hiddenInput) return;
|
|
||||||
|
|
||||||
const pairs = {};
|
|
||||||
const keyInputs = pairsContainer.querySelectorAll('[data-key-index]');
|
|
||||||
const valueInputs = pairsContainer.querySelectorAll('[data-value-index]');
|
|
||||||
|
|
||||||
keyInputs.forEach((keyInput, idx) => {
|
|
||||||
const key = keyInput.value.trim();
|
|
||||||
const valueInput = Array.from(valueInputs).find(v => v.getAttribute('data-value-index') === keyInput.getAttribute('data-key-index'));
|
|
||||||
if (key && valueInput) {
|
|
||||||
const value = valueInput.value.trim();
|
|
||||||
if (value) {
|
|
||||||
pairs[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
hiddenInput.value = JSON.stringify(pairs);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.updateArrayObjectData = function(fieldId) {
|
|
||||||
const itemsContainer = document.getElementById(fieldId + '_items');
|
|
||||||
const hiddenInput = document.getElementById(fieldId + '_data');
|
|
||||||
if (!itemsContainer || !hiddenInput) return;
|
|
||||||
|
|
||||||
// Get existing items from hidden input to preserve non-editable properties
|
|
||||||
let existingItems = [];
|
|
||||||
try {
|
|
||||||
const existingData = hiddenInput.value.trim();
|
|
||||||
if (existingData) {
|
|
||||||
existingItems = JSON.parse(existingData);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing existing items data:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = [];
|
|
||||||
const itemElements = itemsContainer.querySelectorAll('.array-object-item');
|
|
||||||
|
|
||||||
itemElements.forEach((itemEl, index) => {
|
|
||||||
// Start with original item data from data attribute to preserve non-editable properties
|
|
||||||
// This avoids index-based corruption after deletions/reindexing
|
|
||||||
let existingItem = {};
|
|
||||||
const itemDataBase64 = itemEl.getAttribute('data-item-data');
|
|
||||||
if (itemDataBase64) {
|
|
||||||
try {
|
|
||||||
const itemDataJson = decodeURIComponent(escape(atob(itemDataBase64)));
|
|
||||||
existingItem = JSON.parse(itemDataJson);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing item data from data attribute:', e);
|
|
||||||
// Fallback to index-based lookup if data attribute is missing/corrupt
|
|
||||||
if (index < existingItems.length && existingItems[index]) {
|
|
||||||
existingItem = existingItems[index];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to index-based lookup if data attribute is missing
|
|
||||||
if (index < existingItems.length && existingItems[index]) {
|
|
||||||
existingItem = existingItems[index];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const item = Object.assign({}, existingItem); // Copy existing item
|
|
||||||
|
|
||||||
// Get all text inputs in this item and overlay their values with type coercion
|
|
||||||
itemEl.querySelectorAll('input[type="text"], input[type="url"], input[type="number"]').forEach(input => {
|
|
||||||
const propKey = input.getAttribute('data-prop-key');
|
|
||||||
if (propKey && propKey !== 'logo_file') {
|
|
||||||
let value = input.value.trim();
|
|
||||||
|
|
||||||
// Type coercion: check input type or data-prop-type attribute
|
|
||||||
const inputType = input.type;
|
|
||||||
const propType = input.getAttribute('data-prop-type');
|
|
||||||
|
|
||||||
if (inputType === 'number' || propType === 'number') {
|
|
||||||
// Use valueAsNumber if available, fallback to Number()
|
|
||||||
const numValue = input.valueAsNumber !== undefined && !isNaN(input.valueAsNumber)
|
|
||||||
? input.valueAsNumber
|
|
||||||
: Number(value);
|
|
||||||
item[propKey] = isNaN(numValue) ? value : numValue;
|
|
||||||
} else if (propType === 'array' || input.getAttribute('data-prop-is-list') === 'true') {
|
|
||||||
// Try to parse as JSON array, fallback to comma splitting
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
item[propKey] = Array.isArray(parsed) ? parsed : value;
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to comma-splitting for arrays
|
|
||||||
item[propKey] = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// String value - keep as-is
|
|
||||||
item[propKey] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Handle checkboxes
|
|
||||||
itemEl.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
|
|
||||||
const propKey = checkbox.getAttribute('data-prop-key');
|
|
||||||
if (propKey) {
|
|
||||||
item[propKey] = checkbox.checked;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Handle file upload data (stored in data attributes, base64-encoded)
|
|
||||||
itemEl.querySelectorAll('[data-file-data]').forEach(fileEl => {
|
|
||||||
const fileDataBase64 = fileEl.getAttribute('data-file-data');
|
|
||||||
if (fileDataBase64) {
|
|
||||||
try {
|
|
||||||
// Decode base64-encoded JSON
|
|
||||||
const fileDataJson = decodeURIComponent(escape(atob(fileDataBase64)));
|
|
||||||
const data = JSON.parse(fileDataJson);
|
|
||||||
const propKey = fileEl.getAttribute('data-prop-key');
|
|
||||||
if (propKey) {
|
|
||||||
item[propKey] = data;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing file data:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
items.push(item);
|
|
||||||
|
|
||||||
// Update data-item-data attribute with the merged item to keep it in sync
|
|
||||||
try {
|
|
||||||
const itemDataJson = JSON.stringify(item);
|
|
||||||
const itemDataBase64 = btoa(unescape(encodeURIComponent(itemDataJson)));
|
|
||||||
itemEl.setAttribute('data-item-data', itemDataBase64);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error updating data-item-data attribute:', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
hiddenInput.value = JSON.stringify(items);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.handleArrayObjectFileUpload = async function(event, fieldId, itemIndex, propKey, pluginId) {
|
|
||||||
const file = event.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// Derive item element from event instead of constructing ID (works after reindexing)
|
|
||||||
const itemEl = event.target.closest('.array-object-item');
|
|
||||||
if (!itemEl) {
|
|
||||||
console.error('Array object item element not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find file upload container within the item element, scoped to propKey
|
|
||||||
const fileUploadContainer = itemEl.querySelector(`.file-upload-widget-inline[data-prop-key="${propKey}"]`);
|
|
||||||
if (!fileUploadContainer) {
|
|
||||||
console.error('File upload container not found for propKey:', propKey);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get upload config from data attribute
|
|
||||||
let uploadConfig = { allowed_types: ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp'], max_size_mb: 5 };
|
|
||||||
const uploadConfigBase64 = fileUploadContainer.getAttribute('data-upload-config');
|
|
||||||
if (uploadConfigBase64) {
|
|
||||||
try {
|
|
||||||
const uploadConfigJson = decodeURIComponent(escape(atob(uploadConfigBase64)));
|
|
||||||
uploadConfig = JSON.parse(uploadConfigJson);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error parsing upload config from data attribute:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file type using uploadConfig
|
|
||||||
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp'];
|
|
||||||
if (!allowedTypes.includes(file.type)) {
|
|
||||||
showNotification(`File ${file.name} is not a valid image type`, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size using uploadConfig
|
|
||||||
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
||||||
if (file.size > maxSizeMB * 1024 * 1024) {
|
|
||||||
showNotification(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate pluginId before upload (fail fast)
|
|
||||||
if (!pluginId || pluginId === 'null' || pluginId === 'undefined' || (typeof pluginId === 'string' && pluginId.trim() === '')) {
|
|
||||||
showNotification('Plugin ID is required for file upload', 'error');
|
|
||||||
console.error('File upload failed: pluginId is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload file
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('plugin_id', pluginId);
|
|
||||||
formData.append('files', file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v3/plugins/assets/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check response.ok before parsing JSON to avoid parsing errors on HTTP errors
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
let errorMessage = `Upload failed: HTTP ${response.status}`;
|
|
||||||
try {
|
|
||||||
const errorData = JSON.parse(errorText);
|
|
||||||
errorMessage = errorData.message || errorMessage;
|
|
||||||
} catch (e) {
|
|
||||||
// If response isn't JSON, use the text or status
|
|
||||||
if (errorText) {
|
|
||||||
errorMessage = `Upload failed: ${errorText}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showNotification(errorMessage, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
|
|
||||||
const uploadedFile = data.uploaded_files[0];
|
|
||||||
|
|
||||||
// Store file data in data-file-data attribute on the container (base64-encoded)
|
|
||||||
const fileDataJson = JSON.stringify(uploadedFile);
|
|
||||||
const fileDataBase64 = btoa(unescape(encodeURIComponent(fileDataJson)));
|
|
||||||
fileUploadContainer.setAttribute('data-file-data', fileDataBase64);
|
|
||||||
fileUploadContainer.setAttribute('data-prop-key', propKey);
|
|
||||||
|
|
||||||
// Update the display to show the uploaded image
|
|
||||||
const existingImage = fileUploadContainer.querySelector('.uploaded-image-container');
|
|
||||||
if (existingImage) {
|
|
||||||
existingImage.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageContainer = document.createElement('div');
|
|
||||||
imageContainer.className = 'mt-2 flex items-center space-x-2 uploaded-image-container';
|
|
||||||
const escapedPath = escapeAttribute(uploadedFile.path.replace(/^\/+/, ''));
|
|
||||||
const escapedFieldId = escapeAttribute(fieldId);
|
|
||||||
const escapedPropKey = escapeAttribute(propKey);
|
|
||||||
// Get current item index from data-index attribute for remove button
|
|
||||||
const currentItemIndex = itemEl.getAttribute('data-index') || itemIndex;
|
|
||||||
imageContainer.innerHTML = `
|
|
||||||
<img src="/${escapedPath}" alt="Logo" loading="lazy" decoding="async" class="w-16 h-16 object-cover rounded border">
|
|
||||||
<button type="button"
|
|
||||||
onclick="removeArrayObjectFile('${escapedFieldId}', ${currentItemIndex}, '${escapedPropKey}')"
|
|
||||||
class="text-red-600 hover:text-red-800">
|
|
||||||
<i class="fas fa-trash"></i> Remove
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
fileUploadContainer.appendChild(imageContainer);
|
|
||||||
|
|
||||||
// Update the hidden input with the new file data
|
|
||||||
updateArrayObjectData(fieldId);
|
|
||||||
|
|
||||||
showNotification('Logo uploaded successfully', 'success');
|
|
||||||
} else {
|
|
||||||
showNotification(`Upload failed: ${data.message || 'Unknown error'}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
showNotification(`Upload error: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear file input
|
|
||||||
event.target.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) {
|
|
||||||
const itemId = `${fieldId}_item_${itemIndex}`;
|
|
||||||
const fileUploadContainer = document.querySelector(`#${itemId} .file-upload-widget-inline`);
|
|
||||||
if (!fileUploadContainer) {
|
|
||||||
console.error('File upload container not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove file data from data attribute
|
|
||||||
fileUploadContainer.removeAttribute('data-file-data');
|
|
||||||
|
|
||||||
// Remove the image display
|
|
||||||
const imageContainer = fileUploadContainer.querySelector('.uploaded-image-container');
|
|
||||||
if (imageContainer) {
|
|
||||||
imageContainer.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the hidden input to remove the file data
|
|
||||||
updateArrayObjectData(fieldId);
|
|
||||||
|
|
||||||
showNotification('Logo removed', 'success');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generic Plugin Action Handler
|
// Generic Plugin Action Handler
|
||||||
window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) {
|
window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) {
|
||||||
debugLog('[DEBUG] executePluginAction called - actionId:', actionId, 'actionIndex:', actionIndex, 'pluginIdParam:', pluginIdParam);
|
debugLog('[DEBUG] executePluginAction called - actionId:', actionId, 'actionIndex:', actionIndex, 'pluginIdParam:', pluginIdParam);
|
||||||
@@ -2770,9 +2263,12 @@ function handleUninstallSuccess(pluginId) {
|
|||||||
renderInstalledPlugins(updatedPlugins);
|
renderInstalledPlugins(updatedPlugins);
|
||||||
showNotification(`Plugin uninstalled successfully`, 'success');
|
showNotification(`Plugin uninstalled successfully`, 'success');
|
||||||
|
|
||||||
// Also refresh from server to ensure consistency
|
// Also refresh from server to ensure consistency. The uninstall went
|
||||||
|
// through a plain fetch, so both list caches still hold the plugin:
|
||||||
|
// refreshInstalledPlugins() bypasses them.
|
||||||
|
pluginLoadCache.invalidate();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loadInstalledPlugins();
|
refreshInstalledPlugins();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3978,10 +3474,6 @@ window.dismissGithubWarning = function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Used by the array-of-objects helpers below, which live outside this IIFE.
|
|
||||||
window.renderArrayObjectItem = renderArrayObjectItem;
|
|
||||||
window.getSchemaProperty = getSchemaProperty;
|
|
||||||
|
|
||||||
// Assigned here because the functions are IIFE-local; the load-time and
|
// Assigned here because the functions are IIFE-local; the load-time and
|
||||||
// htmx:afterSettle wiring below, outside the IIFE, reaches them through window.
|
// htmx:afterSettle wiring below, outside the IIFE, reaches them through window.
|
||||||
window.attachInstallButtonHandler = attachInstallButtonHandler;
|
window.attachInstallButtonHandler = attachInstallButtonHandler;
|
||||||
@@ -3989,134 +3481,6 @@ window.setupGitHubInstallHandlers = setupGitHubInstallHandlers;
|
|||||||
|
|
||||||
})(); // End IIFE
|
})(); // End IIFE
|
||||||
|
|
||||||
// Array-of-objects config fields: add and remove items (the inline onclick
|
|
||||||
// handlers that renderArrayObjectItem writes call these).
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.addArrayObjectItem = function(fieldId, fullKey, maxItems) {
|
|
||||||
const itemsContainer = document.getElementById(fieldId + '_items');
|
|
||||||
const hiddenInput = document.getElementById(fieldId + '_data');
|
|
||||||
if (!itemsContainer || !hiddenInput) return;
|
|
||||||
|
|
||||||
const currentItems = itemsContainer.querySelectorAll('.array-object-item');
|
|
||||||
if (currentItems.length >= maxItems) {
|
|
||||||
alert(`Maximum ${maxItems} items allowed`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get schema for item properties - ensure currentPluginConfig is available
|
|
||||||
// Try window.currentPluginConfig first (most reliable), then currentPluginConfig
|
|
||||||
const schema = (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema) ||
|
|
||||||
(typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema);
|
|
||||||
if (!schema) {
|
|
||||||
console.error('addArrayObjectItem: Schema not available. currentPluginConfig may not be set.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use getSchemaProperty to properly handle nested schemas (e.g., news.custom_feeds)
|
|
||||||
const arraySchema = window.getSchemaProperty(schema, fullKey);
|
|
||||||
if (!arraySchema || arraySchema.type !== 'array' || !arraySchema.items) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemsSchema = arraySchema.items;
|
|
||||||
if (!itemsSchema || !itemsSchema.properties) return;
|
|
||||||
|
|
||||||
const newIndex = currentItems.length;
|
|
||||||
// renderArrayObjectItem is exported by the plugin-manager IIFE above.
|
|
||||||
const itemHtml = window.renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema);
|
|
||||||
itemsContainer.insertAdjacentHTML('beforeend', itemHtml);
|
|
||||||
window.updateArrayObjectData(fieldId);
|
|
||||||
|
|
||||||
// Update add button state
|
|
||||||
const addButton = itemsContainer.nextElementSibling;
|
|
||||||
if (addButton && currentItems.length + 1 >= maxItems) {
|
|
||||||
addButton.disabled = true;
|
|
||||||
addButton.style.opacity = '0.5';
|
|
||||||
addButton.style.cursor = 'not-allowed';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.removeArrayObjectItem = function(fieldId, index) {
|
|
||||||
const itemsContainer = document.getElementById(fieldId + '_items');
|
|
||||||
if (!itemsContainer) return;
|
|
||||||
|
|
||||||
const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`);
|
|
||||||
if (item) {
|
|
||||||
item.remove();
|
|
||||||
// Re-index remaining items
|
|
||||||
// Use data-index for index storage - no need to encode index in onclick strings or IDs
|
|
||||||
const remainingItems = itemsContainer.querySelectorAll('.array-object-item');
|
|
||||||
remainingItems.forEach((itemEl, newIndex) => {
|
|
||||||
itemEl.setAttribute('data-index', newIndex);
|
|
||||||
// Update all inputs within this item - only update index in array bracket notation
|
|
||||||
itemEl.querySelectorAll('input, select, textarea').forEach(input => {
|
|
||||||
const name = input.getAttribute('name');
|
|
||||||
const id = input.id;
|
|
||||||
if (name) {
|
|
||||||
// Only replace index in bracket notation like [0], [1], etc.
|
|
||||||
// Match pattern: field_name[index] but not field_name123
|
|
||||||
const newName = name.replace(/\[(\d+)\]/, `[${newIndex}]`);
|
|
||||||
input.setAttribute('name', newName);
|
|
||||||
}
|
|
||||||
if (id) {
|
|
||||||
// Only update index in specific patterns like _item_0, _item_1
|
|
||||||
// Match pattern: _item_<digits> but be careful not to break other numeric IDs
|
|
||||||
const newId = id.replace(/_item_(\d+)/, `_item_${newIndex}`);
|
|
||||||
input.id = newId;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// The buttons' inline onclick handlers carry the item index, so
|
|
||||||
// rewrite it in each one.
|
|
||||||
itemEl.querySelectorAll('button[onclick]').forEach(button => {
|
|
||||||
const onclick = button.getAttribute('onclick');
|
|
||||||
if (onclick) {
|
|
||||||
// Match patterns like:
|
|
||||||
// removeArrayObjectItem('fieldId', 0)
|
|
||||||
// handleArrayObjectFileUpload(event, 'fieldId', 0, 'propKey', 'pluginId')
|
|
||||||
// removeArrayObjectFile('fieldId', 0, 'propKey')
|
|
||||||
// Only replace the numeric index parameter (second or third argument depending on function)
|
|
||||||
let newOnclick = onclick;
|
|
||||||
// For removeArrayObjectItem('fieldId', index) - second param
|
|
||||||
newOnclick = newOnclick.replace(
|
|
||||||
/removeArrayObjectItem\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*\)/g,
|
|
||||||
`removeArrayObjectItem('$1', ${newIndex})`
|
|
||||||
);
|
|
||||||
// For handleArrayObjectFileUpload(event, 'fieldId', index, ...) - third param
|
|
||||||
newOnclick = newOnclick.replace(
|
|
||||||
/handleArrayObjectFileUpload\s*\(\s*event\s*,\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
|
|
||||||
`handleArrayObjectFileUpload(event, '$1', ${newIndex},`
|
|
||||||
);
|
|
||||||
// For removeArrayObjectFile('fieldId', index, ...) - second param
|
|
||||||
newOnclick = newOnclick.replace(
|
|
||||||
/removeArrayObjectFile\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
|
|
||||||
`removeArrayObjectFile('$1', ${newIndex},`
|
|
||||||
);
|
|
||||||
button.setAttribute('onclick', newOnclick);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
window.updateArrayObjectData(fieldId);
|
|
||||||
|
|
||||||
// Update add button state
|
|
||||||
const addButton = itemsContainer.nextElementSibling;
|
|
||||||
if (addButton && addButton.getAttribute('onclick')) {
|
|
||||||
// Extract maxItems from onclick attribute more safely
|
|
||||||
// Pattern: addArrayObjectItem('fieldId', 'fullKey', maxItems)
|
|
||||||
const onclickMatch = addButton.getAttribute('onclick').match(/addArrayObjectItem\s*\([^,]+,\s*[^,]+,\s*(\d+)\)/);
|
|
||||||
if (onclickMatch && onclickMatch[1]) {
|
|
||||||
const maxItems = parseInt(onclickMatch[1]);
|
|
||||||
if (remainingItems.length < maxItems) {
|
|
||||||
addButton.disabled = false;
|
|
||||||
addButton.style.opacity = '1';
|
|
||||||
addButton.style.cursor = 'pointer';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force initialization immediately when script loads (for HTMX swapped content)
|
// Force initialization immediately when script loads (for HTMX swapped content)
|
||||||
debugLog('Plugins script loaded, checking for elements...');
|
debugLog('Plugins script loaded, checking for elements...');
|
||||||
|
|
||||||
|
|||||||
@@ -911,7 +911,7 @@
|
|||||||
LEDDialog, the widgets and app-shell.js's updatePlugin at load time
|
LEDDialog, the widgets and app-shell.js's updatePlugin at load time
|
||||||
or when a card is clicked, so it must follow all of them. No other
|
or when a card is clicked, so it must follow all of them. No other
|
||||||
script defines the same globals. -->
|
script defines the same globals. -->
|
||||||
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
|
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}" defer></script>
|
||||||
|
|
||||||
<!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the
|
<!-- Custom feeds table helpers live in js/widgets/custom-feeds.js (the
|
||||||
deferred widget's window assignments always shadowed the inline
|
deferred widget's window assignments always shadowed the inline
|
||||||
|
|||||||
@@ -282,7 +282,7 @@
|
|||||||
if (data.status === 'success' && data.data) {
|
if (data.status === 'success' && data.data) {
|
||||||
const select = document.getElementById('history-plugin-filter');
|
const select = document.getElementById('history-plugin-filter');
|
||||||
if (select) {
|
if (select) {
|
||||||
const plugins = Object.keys(data.data);
|
const plugins = (data.data.plugins || []).map(p => p.id).filter(Boolean);
|
||||||
plugins.forEach(pluginId => {
|
plugins.forEach(pluginId => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = pluginId;
|
option.value = pluginId;
|
||||||
|
|||||||
@@ -424,7 +424,7 @@
|
|||||||
<dd class="text-lg font-semibold text-gray-900" id="ledmatrix-version">Loading...</dd>
|
<dd class="text-lg font-semibold text-gray-900" id="ledmatrix-version">Loading...</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="checkForUpdates()"
|
<button onclick="checkForUpdates(this)"
|
||||||
class="inline-flex items-center px-3 py-2 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50">
|
class="inline-flex items-center px-3 py-2 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50">
|
||||||
<i class="fas fa-sync-alt mr-2"></i>
|
<i class="fas fa-sync-alt mr-2"></i>
|
||||||
Check Updates
|
Check Updates
|
||||||
@@ -458,7 +458,7 @@
|
|||||||
hx-vals='{"action": "git_pull"}'
|
hx-vals='{"action": "git_pull"}'
|
||||||
hx-confirm="This will stash any local changes and update the code. Continue?"
|
hx-confirm="This will stash any local changes and update the code. Continue?"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on:htmx:after-request="var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
hx-on:htmx:after-request="var m='Code update completed',s='info'; try { var d=JSON.parse(event.detail.xhr.responseText); m=d.message||m; s=d.status||s; if (d.restart_required) window.notifyCodeUpdateRestart(); } catch(e) { s=(event.detail.xhr&&event.detail.xhr.status>=400?'error':s); } showNotification(m,s);"
|
||||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-base font-semibold rounded-md text-gray-900 bg-white hover:bg-gray-50">
|
||||||
<i class="fas fa-download mr-2"></i>
|
<i class="fas fa-download mr-2"></i>
|
||||||
Update Code
|
Update Code
|
||||||
@@ -575,14 +575,27 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Check for updates function
|
// A pull only replaces files on disk; the running services keep the code
|
||||||
window.checkForUpdates = function() {
|
// they loaded at boot. Same wording as the update banner in base.html.
|
||||||
const btn = event.target.closest('button');
|
window.notifyCodeUpdateRestart = function() {
|
||||||
|
if (typeof window.showRestartPending === 'function') {
|
||||||
|
window.showRestartPending(
|
||||||
|
'Update installed \u2014 restart the display and web ' +
|
||||||
|
'services to run the new code');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for updates function: the same git_pull (and the same confirmation)
|
||||||
|
// as the Update Code quick action below.
|
||||||
|
window.checkForUpdates = function(btn) {
|
||||||
|
if (!confirm('This will stash any local changes and update the code. Continue?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (btn) {
|
if (btn) {
|
||||||
const originalContent = btn.innerHTML;
|
const originalContent = btn.innerHTML;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Checking...';
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Checking...';
|
||||||
|
|
||||||
fetch('/api/v3/system/action', {
|
fetch('/api/v3/system/action', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -592,9 +605,10 @@ window.checkForUpdates = function() {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
btn.innerHTML = originalContent;
|
btn.innerHTML = originalContent;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
showNotification('Update successful: ' + (data.stdout || 'Code updated'), 'success');
|
showNotification(data.message || 'Code updated', 'success');
|
||||||
|
if (data.restart_required) window.notifyCodeUpdateRestart();
|
||||||
// Reload version after a short delay
|
// Reload version after a short delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fetch('/api/v3/system/version')
|
fetch('/api/v3/system/version')
|
||||||
@@ -607,7 +621,7 @@ window.checkForUpdates = function() {
|
|||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
showNotification('Update failed: ' + (data.stderr || data.message || 'Unknown error'), 'error');
|
showNotification(data.message || 'Update failed', 'error');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ function manualValidateJson(editorId, validationDivId) {
|
|||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-red-800">✗ Invalid JSON syntax</div>
|
<div class="font-semibold text-red-800">✗ Invalid JSON syntax</div>
|
||||||
<div class="text-sm text-red-700 mt-1">
|
<div class="text-sm text-red-700 mt-1">
|
||||||
<strong>Error:</strong> ${e.message}
|
<strong>Error:</strong> ${window.LEDEscape.html(e.message)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -451,9 +451,11 @@
|
|||||||
})
|
})
|
||||||
.then(r => {
|
.then(r => {
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
|
// Only a non-JSON body falls back to the status code; the
|
||||||
|
// rejection carrying the server's message must not be caught.
|
||||||
return r.json()
|
return r.json()
|
||||||
.then(d => Promise.reject(new Error(d.message || `HTTP ${r.status}`)))
|
.catch(() => null)
|
||||||
.catch(() => Promise.reject(new Error(`HTTP ${r.status}`)));
|
.then(d => Promise.reject(new Error((d && d.message) || `HTTP ${r.status}`)));
|
||||||
}
|
}
|
||||||
return r.json();
|
return r.json();
|
||||||
})
|
})
|
||||||
@@ -465,6 +467,14 @@
|
|||||||
showOutput ? (data.output || '') : '',
|
showOutput ? (data.output || '') : '',
|
||||||
showPluginDetails ? (data.details || []) : null
|
showPluginDetails ? (data.details || []) : null
|
||||||
);
|
);
|
||||||
|
// A pull replaces files on disk; the services keep running the
|
||||||
|
// code they loaded at boot until restarted.
|
||||||
|
if (ok && action === 'git_pull' && data.restart_required &&
|
||||||
|
typeof window.showRestartPending === 'function') {
|
||||||
|
window.showRestartPending(
|
||||||
|
'Update installed \u2014 restart the display and web ' +
|
||||||
|
'services to run the new code');
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
showResult(resultId, false, 'Request failed: ' + err.message);
|
showResult(resultId, false, 'Request failed: ' + err.message);
|
||||||
@@ -717,8 +727,8 @@
|
|||||||
fetch('/api/v3/system/status')
|
fetch('/api/v3/system/status')
|
||||||
.then(r => {
|
.then(r => {
|
||||||
if (!r.ok) return r.json()
|
if (!r.ok) return r.json()
|
||||||
.then(d => Promise.reject(d.message || `HTTP ${r.status}`))
|
.catch(() => null)
|
||||||
.catch(() => Promise.reject(`HTTP ${r.status}`));
|
.then(d => Promise.reject((d && d.message) || `HTTP ${r.status}`));
|
||||||
return r.json();
|
return r.json();
|
||||||
})
|
})
|
||||||
.then(res => {
|
.then(res => {
|
||||||
|
|||||||
Reference in New Issue
Block a user