mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
refactor(web): drop the two dead client-side config-form renderers
generateConfigForm and generateSimpleConfigForm (580 lines) were defined on the Alpine component and never called: server-side Jinja replaced them, as pages_v3.py:641 records. Nothing in any template invokes them -- there is no x-html in the templates and no bracket access on the component. They carried their own x-widget dispatch, which made them an active trap: the next person adding a widget would reasonably think both renderers needed updating. plugins/config_manager.js (PluginConfigManager, 133 lines) goes for the same reason -- loaded on every page from base.html, referenced only by itself and by an archived doc. Kept, having checked them: widgets/example-color-picker.js is the worked example docs/widget-guide.md points plugin authors at, and widgets/plugin-loader.js is the client half of a documented feature (manifest-declared plugin widgets) whose server route is missing -- soccer-scoreboard already ships a widgets/custom-leagues.js that this loader is meant to fetch. That is an unfinished feature to complete, not dead code to delete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -747,586 +747,6 @@
|
||||
}
|
||||
},
|
||||
|
||||
generateConfigForm(pluginId, config, schema, webUiActions = []) {
|
||||
// Safety check - if schema/config not ready, return empty
|
||||
if (!pluginId || !config) {
|
||||
return '<div class="text-gray-500">Loading configuration...</div>';
|
||||
}
|
||||
|
||||
// Only log once per plugin to avoid spam (Alpine.js may call this multiple times during rendering)
|
||||
if (!this._configFormLogged || this._configFormLogged !== pluginId) {
|
||||
debugLog('[DEBUG] generateConfigForm called for', pluginId, 'with', webUiActions?.length || 0, 'actions');
|
||||
// Debug: Check if image_config.images has x-widget in schema
|
||||
if (schema && schema.properties && schema.properties.image_config) {
|
||||
const imgConfig = schema.properties.image_config;
|
||||
if (imgConfig.properties && imgConfig.properties.images) {
|
||||
const imagesProp = imgConfig.properties.images;
|
||||
debugLog('[DEBUG] Schema check - image_config.images:', {
|
||||
type: imagesProp.type,
|
||||
'x-widget': imagesProp['x-widget'],
|
||||
'has x-widget': 'x-widget' in imagesProp,
|
||||
keys: Object.keys(imagesProp)
|
||||
});
|
||||
}
|
||||
}
|
||||
this._configFormLogged = pluginId;
|
||||
}
|
||||
if (!schema || !schema.properties) {
|
||||
return this.generateSimpleConfigForm(config, webUiActions, pluginId);
|
||||
}
|
||||
|
||||
// Helper function to get schema property by full key path
|
||||
const getSchemaProperty = (schemaObj, keyPath) => {
|
||||
if (!schemaObj || !schemaObj.properties) return null;
|
||||
const keys = keyPath.split('.');
|
||||
let current = schemaObj.properties;
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k = keys[i];
|
||||
if (!current || !current[k]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prop = current[k];
|
||||
// If this is the last key, return the property
|
||||
if (i === keys.length - 1) {
|
||||
return prop;
|
||||
}
|
||||
|
||||
// If this property has nested properties, navigate deeper
|
||||
if (prop && typeof prop === 'object' && prop.properties) {
|
||||
current = prop.properties;
|
||||
} else {
|
||||
// Can't navigate deeper
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const generateFieldHtml = (key, prop, value, prefix = '') => {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
const label = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
const description = prop.description || '';
|
||||
let html = '';
|
||||
|
||||
// Debug: Log property structure for arrays to help diagnose file-upload widget issues
|
||||
if (prop.type === 'array') {
|
||||
// Also check schema directly as fallback
|
||||
const schemaProp = getSchemaProperty(schema, fullKey);
|
||||
const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
|
||||
|
||||
debugLog('[DEBUG generateFieldHtml] Array property:', fullKey, {
|
||||
'prop.x-widget': prop['x-widget'],
|
||||
'prop.x_widget': prop['x_widget'],
|
||||
'schema.x-widget': xWidgetFromSchema,
|
||||
'hasOwnProperty(x-widget)': prop.hasOwnProperty('x-widget'),
|
||||
'x-widget in prop': 'x-widget' in prop,
|
||||
'all prop keys': Object.keys(prop),
|
||||
'schemaProp keys': schemaProp ? Object.keys(schemaProp) : 'null'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle nested objects
|
||||
if (prop.type === 'object' && prop.properties) {
|
||||
const sectionId = `section-${fullKey.replace(/\./g, '-')}`;
|
||||
const nestedConfig = value || {};
|
||||
const sectionLabel = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
// Calculate nesting depth for better spacing
|
||||
const nestingDepth = (fullKey.match(/\./g) || []).length;
|
||||
const marginClass = nestingDepth > 1 ? 'mb-6' : 'mb-4';
|
||||
|
||||
html += `
|
||||
<div class="nested-section border border-gray-300 rounded-lg ${marginClass}">
|
||||
<button type="button"
|
||||
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors"
|
||||
onclick="toggleNestedSection('${sectionId}', event); return false;">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-900">${sectionLabel}</h4>
|
||||
${description ? `<p class="text-sm text-gray-600 mt-1">${description}</p>` : ''}
|
||||
</div>
|
||||
<i id="${sectionId}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
||||
</button>
|
||||
<div id="${sectionId}" class="nested-content collapsed bg-gray-50 px-4 py-4 space-y-3" style="max-height: 0; display: none;">
|
||||
`;
|
||||
|
||||
// Recursively generate fields for nested properties
|
||||
// Get ordered properties if x-propertyOrder is defined
|
||||
let nestedPropertyEntries = Object.entries(prop.properties);
|
||||
if (prop['x-propertyOrder'] && Array.isArray(prop['x-propertyOrder'])) {
|
||||
const order = prop['x-propertyOrder'];
|
||||
const orderedEntries = [];
|
||||
const unorderedEntries = [];
|
||||
|
||||
// Separate ordered and unordered properties
|
||||
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
|
||||
const index = order.indexOf(nestedKey);
|
||||
if (index !== -1) {
|
||||
orderedEntries[index] = [nestedKey, nestedProp];
|
||||
} else {
|
||||
unorderedEntries.push([nestedKey, nestedProp]);
|
||||
}
|
||||
});
|
||||
|
||||
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
|
||||
nestedPropertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
|
||||
}
|
||||
|
||||
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
|
||||
// Use config value if it exists and is not null (including false), otherwise use schema default
|
||||
// Check if key exists in config and value is not null/undefined
|
||||
const hasValue = nestedKey in nestedConfig && nestedConfig[nestedKey] !== null && nestedConfig[nestedKey] !== undefined;
|
||||
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
|
||||
const isNestedObject = nestedProp.type === 'object' && nestedProp.properties;
|
||||
const nestedValue = hasValue ? nestedConfig[nestedKey] :
|
||||
(nestedProp.default !== undefined ? nestedProp.default :
|
||||
(isNestedObject ? {} : (nestedProp.type === 'array' ? [] : (nestedProp.type === 'boolean' ? false : ''))));
|
||||
|
||||
// Debug logging for file-upload widgets
|
||||
if (nestedProp.type === 'array' && (nestedProp['x-widget'] === 'file-upload' || nestedProp['x_widget'] === 'file-upload')) {
|
||||
debugLog('[DEBUG] Found file-upload widget in nested property:', nestedKey, 'fullKey:', fullKey + '.' + nestedKey, 'prop:', nestedProp);
|
||||
}
|
||||
|
||||
html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey);
|
||||
});
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add extra spacing after nested sections to prevent overlap with next section
|
||||
if (nestingDepth > 0) {
|
||||
html += `<div class="mb-2"></div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Regular (non-nested) field
|
||||
html += `<div class="form-group">`;
|
||||
html += `<label class="block text-sm font-medium text-gray-700 mb-1">${label}</label>`;
|
||||
|
||||
if (description) {
|
||||
html += `<p class="text-sm text-gray-600 mb-2">${description}</p>`;
|
||||
}
|
||||
|
||||
// Generate appropriate input based on type
|
||||
if (prop.type === 'boolean') {
|
||||
html += `<label class="flex items-center">`;
|
||||
html += `<input type="checkbox" name="${fullKey}" ${value ? 'checked' : ''} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">`;
|
||||
html += `<span class="ml-2 text-sm">Enabled</span>`;
|
||||
html += `</label>`;
|
||||
} else if (prop.type === 'number' || prop.type === 'integer' ||
|
||||
(Array.isArray(prop.type) && (prop.type.includes('number') || prop.type.includes('integer')))) {
|
||||
// Handle union types like ["integer", "null"]
|
||||
const isUnionType = Array.isArray(prop.type);
|
||||
const allowsNull = isUnionType && prop.type.includes('null');
|
||||
const isInteger = prop.type === 'integer' || (isUnionType && prop.type.includes('integer'));
|
||||
const isNumber = prop.type === 'number' || (isUnionType && prop.type.includes('number'));
|
||||
const min = prop.minimum !== undefined ? `min="${prop.minimum}"` : '';
|
||||
const max = prop.maximum !== undefined ? `max="${prop.maximum}"` : '';
|
||||
const step = isInteger ? 'step="1"' : 'step="any"';
|
||||
|
||||
// For union types with null, don't show default if value is null (leave empty)
|
||||
// This allows users to explicitly set null by leaving it empty
|
||||
let fieldValue = '';
|
||||
if (value !== undefined && value !== null) {
|
||||
fieldValue = value;
|
||||
} else if (!allowsNull && prop.default !== undefined) {
|
||||
// Only use default if null is not allowed
|
||||
fieldValue = prop.default;
|
||||
}
|
||||
|
||||
// Ensure value respects min/max constraints
|
||||
if (fieldValue !== '' && fieldValue !== undefined && fieldValue !== null) {
|
||||
const numValue = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue;
|
||||
if (!isNaN(numValue)) {
|
||||
// Clamp value to min/max if constraints exist
|
||||
if (prop.minimum !== undefined && numValue < prop.minimum) {
|
||||
fieldValue = prop.minimum;
|
||||
} else if (prop.maximum !== undefined && numValue > prop.maximum) {
|
||||
fieldValue = prop.maximum;
|
||||
} else {
|
||||
fieldValue = numValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add placeholder/help text for null-able fields
|
||||
const placeholder = allowsNull ? 'Leave empty to use current time (random)' : '';
|
||||
const helpText = allowsNull && description && description.includes('null') ?
|
||||
`<p class="text-xs text-gray-500 mt-1">${description}</p>` : '';
|
||||
|
||||
html += `<input type="number" name="${fullKey}" value="${fieldValue}" ${min} ${max} ${step} placeholder="${placeholder}" class="mt-1 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">`;
|
||||
if (helpText) {
|
||||
html += helpText;
|
||||
}
|
||||
} else if (prop.type === 'array') {
|
||||
// AGGRESSIVE file upload widget detection
|
||||
// For 'images' field in static-image plugin, always check schema directly
|
||||
let isFileUpload = false;
|
||||
let uploadConfig = {};
|
||||
|
||||
// Direct check: if this is the 'images' field and schema has it with x-widget
|
||||
if (fullKey === 'images' && schema && schema.properties && schema.properties.images) {
|
||||
const imagesSchema = schema.properties.images;
|
||||
if (imagesSchema['x-widget'] === 'file-upload' || imagesSchema['x_widget'] === 'file-upload') {
|
||||
isFileUpload = true;
|
||||
uploadConfig = imagesSchema['x-upload-config'] || imagesSchema['x_upload_config'] || {};
|
||||
debugLog('[DEBUG] ✅ Direct detection: images field has file-upload widget', uploadConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check prop object (should have x-widget if schema loaded correctly)
|
||||
if (!isFileUpload) {
|
||||
const xWidgetFromProp = prop['x-widget'] || prop['x_widget'] || prop.xWidget;
|
||||
if (xWidgetFromProp === 'file-upload') {
|
||||
isFileUpload = true;
|
||||
uploadConfig = prop['x-upload-config'] || prop['x_upload_config'] || {};
|
||||
debugLog('[DEBUG] ✅ Detection via prop object');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: schema property lookup
|
||||
if (!isFileUpload) {
|
||||
let schemaProp = getSchemaProperty(schema, fullKey);
|
||||
if (!schemaProp && fullKey === 'images' && schema && schema.properties && schema.properties.images) {
|
||||
schemaProp = schema.properties.images;
|
||||
}
|
||||
const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
|
||||
if (xWidgetFromSchema === 'file-upload') {
|
||||
isFileUpload = true;
|
||||
uploadConfig = schemaProp['x-upload-config'] || schemaProp['x_upload_config'] || {};
|
||||
debugLog('[DEBUG] ✅ Detection via schema lookup');
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging for ALL array fields to diagnose
|
||||
debugLog('[DEBUG] Array field check:', fullKey, {
|
||||
'isFileUpload': isFileUpload,
|
||||
'prop keys': Object.keys(prop),
|
||||
'prop.x-widget': prop['x-widget'],
|
||||
'schema.properties.images exists': !!(schema && schema.properties && schema.properties.images),
|
||||
'schema.properties.images.x-widget': (schema && schema.properties && schema.properties.images) ? schema.properties.images['x-widget'] : null,
|
||||
'uploadConfig': uploadConfig
|
||||
});
|
||||
|
||||
if (isFileUpload) {
|
||||
debugLog('[DEBUG] ✅ Rendering file-upload widget for', fullKey, 'with config:', uploadConfig);
|
||||
// Use the file upload widget from plugins.html
|
||||
// We'll need to call a function that exists in the global scope
|
||||
const maxFiles = uploadConfig.max_files || 10;
|
||||
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
|
||||
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
||||
|
||||
const currentImages = Array.isArray(value) ? value : [];
|
||||
const fieldId = fullKey.replace(/\./g, '_');
|
||||
const safePluginId = (uploadConfig.plugin_id || pluginId || 'static-image').toString().replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
html += `
|
||||
<div id="${fieldId}_upload_widget" class="mt-1">
|
||||
<!-- File Upload Drop Zone -->
|
||||
<div id="${fieldId}_drop_zone"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
||||
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
|
||||
ondragover="event.preventDefault()"
|
||||
data-field-id="${fieldId}"
|
||||
onclick="document.getElementById(this.dataset.fieldId + '_file_input').click()">
|
||||
<input type="file"
|
||||
id="${fieldId}_file_input"
|
||||
multiple
|
||||
accept="${allowedTypes.join(',')}"
|
||||
style="display: none;"
|
||||
data-field-id="${fieldId}"
|
||||
onchange="window.handleFileSelect(event, this.dataset.fieldId)">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
||||
<p class="text-sm text-gray-600">Drag and drop images here or click to browse</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Max ${maxFiles} files, ${maxSizeMB}MB each (PNG, JPG, GIF, BMP)</p>
|
||||
</div>
|
||||
|
||||
<!-- Uploaded Images List -->
|
||||
<div id="${fieldId}_image_list" class="mt-4 space-y-2">
|
||||
${currentImages.map((img, idx) => {
|
||||
const imgSchedule = img.schedule || {};
|
||||
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
|
||||
let scheduleSummary = 'Always shown';
|
||||
if (hasSchedule && window.getScheduleSummary) {
|
||||
try {
|
||||
scheduleSummary = window.getScheduleSummary(imgSchedule) || 'Scheduled';
|
||||
} catch (e) {
|
||||
scheduleSummary = 'Scheduled';
|
||||
}
|
||||
} else if (hasSchedule) {
|
||||
scheduleSummary = 'Scheduled';
|
||||
}
|
||||
// Escape the summary for HTML
|
||||
scheduleSummary = String(scheduleSummary).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
return `
|
||||
<div id="img_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-3 flex-1">
|
||||
<img src="/${(img.path || '').replace(/&/g, '&').replace(/"/g, '"')}"
|
||||
alt="${(img.filename || '').replace(/"/g, '"')}"
|
||||
class="w-16 h-16 object-cover rounded"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
||||
<div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
|
||||
<i class="fas fa-image text-gray-400"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">${String(img.original_filename || img.filename || 'Image').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</p>
|
||||
<p class="text-xs text-gray-500">${img.size ? (Math.round(img.size / 1024) + ' KB') : ''} • ${(img.uploaded_at || '').replace(/&/g, '&')}</p>
|
||||
<p class="text-xs text-blue-600 mt-1">
|
||||
<i class="fas fa-clock mr-1"></i>${scheduleSummary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button type="button"
|
||||
data-field-id="${fieldId}"
|
||||
data-image-id="${img.id || ''}"
|
||||
data-image-idx="${idx}"
|
||||
onclick="window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId || null, parseInt(this.dataset.imageIdx))"
|
||||
class="text-blue-600 hover:text-blue-800 p-2"
|
||||
title="Schedule this image">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</button>
|
||||
<button type="button"
|
||||
data-field-id="${fieldId}"
|
||||
data-image-id="${img.id || ''}"
|
||||
data-plugin-id="${safePluginId}"
|
||||
onclick="window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId)"
|
||||
class="text-red-600 hover:text-red-800 p-2"
|
||||
title="Delete image">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Schedule widget will be inserted here when opened -->
|
||||
<div id="schedule_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Hidden input to store image data -->
|
||||
<input type="hidden" id="${fieldId}_images_data" name="${fullKey}" value="${JSON.stringify(currentImages).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')}">
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Regular array input
|
||||
const arrayValue = Array.isArray(value) ? value.join(', ') : '';
|
||||
html += `<input type="text" name="${fullKey}" value="${arrayValue}" placeholder="Enter values separated by commas" class="mt-1 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">`;
|
||||
html += `<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>`;
|
||||
}
|
||||
} else if (prop.enum) {
|
||||
html += `<select name="${fullKey}" class="mt-1 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">`;
|
||||
prop.enum.forEach(option => {
|
||||
const selected = value === option ? 'selected' : '';
|
||||
html += `<option value="${option}" ${selected}>${option}</option>`;
|
||||
});
|
||||
html += `</select>`;
|
||||
} else if (prop.type === 'string' && prop['x-widget'] === 'file-upload') {
|
||||
// File upload widget for string fields (e.g., credentials.json)
|
||||
const uploadConfig = prop['x-upload-config'] || {};
|
||||
const uploadEndpoint = uploadConfig.upload_endpoint || '/api/v3/plugins/assets/upload';
|
||||
const maxSizeMB = uploadConfig.max_size_mb || 1;
|
||||
const allowedExtensions = uploadConfig.allowed_extensions || ['.json'];
|
||||
const targetFilename = uploadConfig.target_filename || 'file.json';
|
||||
const fieldId = fullKey.replace(/\./g, '_');
|
||||
const hasFile = value && value !== '';
|
||||
|
||||
html += `
|
||||
<div id="${fieldId}_upload_widget" class="mt-1">
|
||||
<div id="${fieldId}_file_upload"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
||||
onclick="document.getElementById('${fieldId}_file_input').click()">
|
||||
<input type="file"
|
||||
id="${fieldId}_file_input"
|
||||
accept="${allowedExtensions.join(',')}"
|
||||
style="display: none;"
|
||||
data-field-id="${fieldId}"
|
||||
data-upload-endpoint="${uploadEndpoint}"
|
||||
data-target-filename="${targetFilename}"
|
||||
onchange="window.handleCredentialsUpload(event, this.dataset.fieldId, this.dataset.uploadEndpoint, this.dataset.targetFilename)">
|
||||
<i class="fas fa-file-upload text-2xl text-gray-400 mb-2"></i>
|
||||
<p class="text-sm text-gray-600" id="${fieldId}_status">
|
||||
${hasFile ? `Current file: ${value}` : 'Click to upload ' + targetFilename}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Max ${maxSizeMB}MB (${allowedExtensions.join(', ')})</p>
|
||||
</div>
|
||||
<input type="hidden" name="${fullKey}" value="${value || ''}" id="${fieldId}_hidden">
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Default to text input
|
||||
const maxLength = prop.maxLength || '';
|
||||
const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : '';
|
||||
html += `<input type="text" name="${fullKey}" value="${value !== undefined ? value : ''}" ${maxLengthAttr} class="mt-1 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">`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
};
|
||||
|
||||
let formHtml = '';
|
||||
// Get ordered properties if x-propertyOrder is defined
|
||||
let propertyEntries = Object.entries(schema.properties);
|
||||
if (schema['x-propertyOrder'] && Array.isArray(schema['x-propertyOrder'])) {
|
||||
const order = schema['x-propertyOrder'];
|
||||
const orderedEntries = [];
|
||||
const unorderedEntries = [];
|
||||
|
||||
// Separate ordered and unordered properties
|
||||
propertyEntries.forEach(([key, prop]) => {
|
||||
const index = order.indexOf(key);
|
||||
if (index !== -1) {
|
||||
orderedEntries[index] = [key, prop];
|
||||
} else {
|
||||
unorderedEntries.push([key, prop]);
|
||||
}
|
||||
});
|
||||
|
||||
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
|
||||
propertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
|
||||
}
|
||||
|
||||
propertyEntries.forEach(([key, prop]) => {
|
||||
// Skip the 'enabled' property - it's managed separately via the header toggle
|
||||
if (key === 'enabled') return;
|
||||
// Use config value if key exists and is not null/undefined, otherwise use schema default
|
||||
// Check if key exists in config and value is not null/undefined
|
||||
const hasValue = key in config && config[key] !== null && config[key] !== undefined;
|
||||
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
|
||||
const isNestedObject = prop.type === 'object' && prop.properties;
|
||||
const value = hasValue ? config[key] :
|
||||
(prop.default !== undefined ? prop.default :
|
||||
(isNestedObject ? {} : (prop.type === 'array' ? [] : (prop.type === 'boolean' ? false : ''))));
|
||||
formHtml += generateFieldHtml(key, prop, value);
|
||||
});
|
||||
|
||||
// Add web UI actions section if plugin defines any
|
||||
if (webUiActions && webUiActions.length > 0) {
|
||||
debugLog('[DEBUG] Rendering', webUiActions.length, 'actions in tab form');
|
||||
|
||||
// Map color names to explicit Tailwind classes
|
||||
const colorMap = {
|
||||
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
|
||||
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
|
||||
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
|
||||
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
|
||||
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
|
||||
};
|
||||
|
||||
formHtml += `
|
||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">${webUiActions[0].section_description || 'Perform actions for this plugin'}</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
`;
|
||||
|
||||
webUiActions.forEach((action, index) => {
|
||||
const actionId = `action-${action.id}-${index}`;
|
||||
const statusId = `action-status-${action.id}-${index}`;
|
||||
const bgColor = action.color || 'blue';
|
||||
const colors = colorMap[bgColor] || colorMap['blue'];
|
||||
// Ensure pluginId is valid for template interpolation
|
||||
const safePluginId = pluginId || '';
|
||||
|
||||
formHtml += `
|
||||
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium ${colors.text} mb-1">
|
||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
|
||||
</h4>
|
||||
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
id="${actionId}"
|
||||
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
||||
data-plugin-id="${safePluginId}"
|
||||
data-action-id="${action.id}"
|
||||
class="btn ${colors.btn} text-white px-4 py-2 rounded-md whitespace-nowrap">
|
||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
||||
</button>
|
||||
</div>
|
||||
<div id="${statusId}" class="mt-3 hidden"></div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
formHtml += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return formHtml;
|
||||
},
|
||||
|
||||
generateSimpleConfigForm(config, webUiActions = [], pluginId = '') {
|
||||
let actionsHtml = '';
|
||||
if (webUiActions && webUiActions.length > 0) {
|
||||
const colorMap = {
|
||||
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
|
||||
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
|
||||
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
|
||||
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
|
||||
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
|
||||
};
|
||||
|
||||
actionsHtml = `
|
||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
|
||||
<div class="space-y-3">
|
||||
`;
|
||||
webUiActions.forEach((action, index) => {
|
||||
const actionId = `action-${action.id}-${index}`;
|
||||
const statusId = `action-status-${action.id}-${index}`;
|
||||
const bgColor = action.color || 'blue';
|
||||
const colors = colorMap[bgColor] || colorMap['blue'];
|
||||
// Ensure pluginId is valid for template interpolation
|
||||
const safePluginId = pluginId || '';
|
||||
actionsHtml += `
|
||||
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium ${colors.text} mb-1">
|
||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
|
||||
</h4>
|
||||
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
id="${actionId}"
|
||||
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
||||
data-plugin-id="${safePluginId}"
|
||||
data-action-id="${action.id}"
|
||||
class="btn ${colors.btn} text-white px-4 py-2 rounded-md">
|
||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
||||
</button>
|
||||
</div>
|
||||
<div id="${statusId}" class="mt-3 hidden"></div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
actionsHtml += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
|
||||
<input type="number" name="display_duration" value="${Math.max(5, Math.min(300, config.display_duration || 30))}" min="5" max="300" class="mt-1 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">
|
||||
<p class="text-sm text-gray-600 mt-1">How long to show this plugin's content</p>
|
||||
</div>
|
||||
${actionsHtml}
|
||||
`;
|
||||
},
|
||||
|
||||
// Helper function to get schema property type for a field path
|
||||
getSchemaPropertyType(schema, path) {
|
||||
if (!schema || !schema.properties) return null;
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Plugin configuration form management.
|
||||
*
|
||||
* Handles configuration form generation, validation, and submission.
|
||||
*/
|
||||
|
||||
const PluginConfigManager = {
|
||||
/**
|
||||
* Current plugin configuration state.
|
||||
*/
|
||||
currentState: {
|
||||
pluginId: null,
|
||||
config: null,
|
||||
schema: null,
|
||||
jsonEditor: null
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize configuration for a plugin.
|
||||
*
|
||||
* @param {string} pluginId - Plugin identifier
|
||||
* @returns {Promise<Object>} Configuration and schema
|
||||
*/
|
||||
async initialize(pluginId) {
|
||||
try {
|
||||
const [config, schema] = await Promise.all([
|
||||
window.PluginAPI.getPluginConfig(pluginId),
|
||||
window.PluginAPI.getPluginSchema(pluginId)
|
||||
]);
|
||||
|
||||
this.currentState = {
|
||||
pluginId,
|
||||
config,
|
||||
schema,
|
||||
jsonEditor: null
|
||||
};
|
||||
|
||||
return { config, schema };
|
||||
} catch (error) {
|
||||
if (window.errorHandler) {
|
||||
window.errorHandler.displayError(error, `Failed to initialize config for ${pluginId}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset configuration to defaults.
|
||||
*
|
||||
* @param {string} pluginId - Plugin identifier
|
||||
* @returns {Promise<Object>} Default configuration
|
||||
*/
|
||||
async resetToDefaults(pluginId) {
|
||||
try {
|
||||
const result = await window.PluginAPI.resetPluginConfig(pluginId);
|
||||
|
||||
// Reload configuration
|
||||
if (this.currentState.pluginId === pluginId) {
|
||||
await this.initialize(pluginId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (window.errorHandler) {
|
||||
window.errorHandler.displayError(error, `Failed to reset config for ${pluginId}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save configuration.
|
||||
*
|
||||
* @param {string} pluginId - Plugin identifier
|
||||
* @param {Object} config - Configuration data
|
||||
* @returns {Promise<Object>} Save result
|
||||
*/
|
||||
async save(pluginId, config) {
|
||||
try {
|
||||
const result = await window.PluginAPI.savePluginConfig(pluginId, config);
|
||||
|
||||
// Update local state
|
||||
if (this.currentState.pluginId === pluginId) {
|
||||
this.currentState.config = config;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (window.errorHandler) {
|
||||
window.errorHandler.displayError(error, `Failed to save config for ${pluginId}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate configuration against schema.
|
||||
*
|
||||
* @param {Object} config - Configuration data
|
||||
* @param {Object} schema - JSON schema
|
||||
* @returns {Object} Validation result with errors
|
||||
*/
|
||||
validate(config, schema) {
|
||||
// Basic validation - full validation happens on server
|
||||
const errors = [];
|
||||
|
||||
if (!schema || !schema.properties) {
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if (schema.required) {
|
||||
for (const field of schema.required) {
|
||||
if (!(field in config)) {
|
||||
errors.push(`Required field '${field}' is missing`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Export
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = PluginConfigManager;
|
||||
} else {
|
||||
window.PluginConfigManager = PluginConfigManager;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user