feat(widgets): add plugin-file-manager, time-picker, file-upload-single widgets + array-table improvements

## New widgets

### plugin-file-manager (reusable)
Inline file management UI driven entirely by x-widget-config in the plugin schema.
Any plugin can adopt it by declaring web_ui_actions in manifest.json and adding
x-widget: "plugin-file-manager" to their config schema.

Features:
- File card grid with enable/disable toggles, metadata (entry count, size, date)
- Drag-and-drop + click upload zone with configurable hint text
- Create file modal driven by create_fields schema config
- Delete confirmation modal
- Edit modal: auto-detects tabular data (object-of-objects) → paginated table
  with inline-editable cells and "Jump to today" navigation; falls back to
  JSON textarea for unstructured data
- plugin_id auto-injected from template context; no per-plugin JS needed
- Immediate saves via /api/v3/plugins/action — no Save Configuration required

### time-picker
Wraps native <input type="time">, returns HH:MM string. Generic, zero config.

### file-upload-single
Single-image upload for string fields. Shows thumbnail preview + clear button.
plugin_id auto-injected from template context.

## New route (pages_v3.py)
GET /v3/plugin-ui/<plugin_id>/web-ui/<filename>
Serves a plugin's web_ui/ HTML fragment as a standalone page, wrapping it with
a minimal HTML page that injects window.PLUGIN_ID and loads Tailwind CSS.
Enables the json-file-manager iframe fallback (Phase A) and future plugin UIs.

## plugin_config.html updates
- json-file-manager: renders plugin's web_ui/file_manager.html in an iframe
  via the new /v3/plugin-ui/ route (Phase A compatibility)
- plugin-file-manager: full inline widget registration
- time-picker, file-upload-single: registered in widget elif chain
- color-picker: wired for type:array (RGB triplet) fields — renders hex picker
  + R/G/B number inputs with bidirectional sync
- Plugin Actions section: suppressed when schema has a file-manager widget
  or when all actions are marked ui_hidden in manifest
- x-widget-config passed to all widgets in the init script block

## array-table.js improvements (v2.0.0)
- enum fields → <select> dropdown instead of plain text
- date-picker x-widget → <input type=date>
- time-picker x-widget → <input type=time>
- file-upload-single x-widget → path input + upload button + thumbnail
- Row edit modal (⚙) for non-displayed nested properties (layout, style objects)
  with color pickers, enum selects, number inputs
- getValue() collects <select> values and nested key paths
- Inline image upload via handleArrayTableImageUpload()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-05-30 17:58:02 -04:00
parent 4dc33c256c
commit b1af068f7a
7 changed files with 2135 additions and 216 deletions

View File

@@ -5,21 +5,14 @@
* Handles adding, removing, and editing array items with object properties.
* Reads column definitions from the schema's items.properties.
*
* Usage in config_schema.json:
* "my_array": {
* "type": "array",
* "x-widget": "array-table",
* "x-columns": ["name", "code", "priority", "enabled"], // optional
* "items": {
* "type": "object",
* "properties": {
* "name": { "type": "string" },
* "code": { "type": "string" },
* "priority": { "type": "integer", "default": 50 },
* "enabled": { "type": "boolean", "default": true }
* }
* }
* }
* Supported x-widget hints on item properties:
* date-picker → <input type="date">
* time-picker → <input type="time">
* file-upload-single → compact path input + upload button
* (enum values always render as <select>)
*
* Non-displayed properties (objects like layout/style) are stored in a hidden
* cell and editable via the ⚙ row editor modal.
*
* @module ArrayTableWidget
*/
@@ -27,18 +20,16 @@
(function() {
'use strict';
// Ensure LEDMatrixWidgets registry exists
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[ArrayTableWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
return;
}
/**
* Register the array-table widget
*/
// ─── Widget registration ────────────────────────────────────────────────
window.LEDMatrixWidgets.register('array-table', {
name: 'Array Table Widget',
version: '1.0.0',
version: '2.0.0',
render: function(container, config, value, options) {
console.log('[ArrayTableWidget] Render called (server-side rendered)');
@@ -53,24 +44,39 @@
rows.forEach((row) => {
const item = {};
row.querySelectorAll('input').forEach(input => {
const name = input.getAttribute('name');
if (!name || name.endsWith('.enabled') || input.type === 'hidden') return;
const match = name.match(/\.\d+\.([^.]+)$/);
if (match) {
const propName = match[1];
if (input.type === 'checkbox') {
item[propName] = input.checked;
} else if (input.type === 'number') {
item[propName] = input.value ? parseFloat(input.value) : null;
} else {
item[propName] = input.value;
}
// Collect all named form controls (input + select), skip type=hidden except
// for boolean hidden sentinels (those end in the field name only, not .enabled).
row.querySelectorAll('input, select').forEach(el => {
const name = el.getAttribute('name');
if (!name) return;
// Skip hidden inputs that are boolean sentinels (they duplicate checkboxes)
if (el.type === 'hidden' && !el.dataset.nestedProp) return;
// Nested advanced props stored in hidden cell
if (el.dataset.nestedProp) {
const propPath = el.dataset.nestedProp;
setNestedValue(item, propPath, coerceValue(el.value, el.dataset.propType || 'string'));
return;
}
// Standard display-column props: name matches fullKey.index.propName[.subKey...]
const match = name.match(/\.\d+\.(.+)$/);
if (!match) return;
const propPath = match[1];
if (el.tagName === 'SELECT') {
setNestedValue(item, propPath, el.value);
} else if (el.type === 'checkbox') {
setNestedValue(item, propPath, el.checked);
} else if (el.type === 'number') {
setNestedValue(item, propPath, el.value !== '' ? parseFloat(el.value) : null);
} else {
setNestedValue(item, propPath, el.value);
}
});
if (Object.keys(item).length > 0) {
items.push(item);
}
if (Object.keys(item).length > 0) items.push(item);
});
return items;
@@ -81,236 +87,711 @@
console.error('[ArrayTableWidget] setValue expects an array');
return;
}
if (!options || !options.fullKey || !options.pluginId) {
throw new Error('ArrayTableWidget.setValue requires options.fullKey and options.pluginId');
}
const tbody = document.getElementById(`${fieldId}_tbody`);
if (!tbody) {
console.warn(`[ArrayTableWidget] tbody not found for fieldId: ${fieldId}`);
return;
}
if (!tbody) return;
tbody.innerHTML = '';
items.forEach((item, index) => {
const row = createArrayTableRow(
fieldId,
options.fullKey,
index,
options.pluginId,
item,
options.itemProperties || {},
options.displayColumns || []
fieldId, options.fullKey, index, options.pluginId,
item, options.itemProperties || {}, options.displayColumns || [],
options.fullItemProperties || options.itemProperties || {}
);
tbody.appendChild(row);
});
// Refresh Add button state after repopulating rows
updateAddButtonState(fieldId);
},
handlers: {}
});
// ─── Helpers ────────────────────────────────────────────────────────────
function setNestedValue(obj, path, value) {
const parts = path.split('.');
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (cur[parts[i]] === undefined || typeof cur[parts[i]] !== 'object') {
cur[parts[i]] = {};
}
cur = cur[parts[i]];
}
cur[parts[parts.length - 1]] = value;
}
function getNestedValue(obj, path) {
return path.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
}
function coerceValue(strVal, typeHint) {
if (strVal === '' || strVal === null || strVal === undefined) return null;
if (typeHint === 'integer') return parseInt(strVal, 10);
if (typeHint === 'number') return parseFloat(strVal);
if (typeHint === 'boolean') return strVal === 'true' || strVal === '1';
// nullable integer/number: "integer|null"
if (typeHint && typeHint.includes('integer')) return strVal !== '' ? parseInt(strVal, 10) : null;
if (typeHint && typeHint.includes('number')) return strVal !== '' ? parseFloat(strVal) : null;
return strVal;
}
// ─── Cell rendering ─────────────────────────────────────────────────────
/**
* Create a table row element for array item
* Create one <td> for a display column.
*/
function createArrayTableRow(fieldId, fullKey, index, pluginId, item, itemProperties, displayColumns) {
item = item || {};
function createCell(fullKey, index, colName, colDef, colValue, pluginId) {
const colType = Array.isArray(colDef.type) ? colDef.type.find(t => t !== 'null') || 'string' : (colDef.type || 'string');
const xWidget = colDef['x-widget'] || colDef['x_widget'];
const enumVals = colDef.enum;
const inputName = `${fullKey}.${index}.${colName}`;
const cell = document.createElement('td');
cell.className = 'px-3 py-3 whitespace-nowrap';
cell.style.verticalAlign = 'middle';
if (colType === 'boolean') {
// Boolean: hidden sentinel + visible checkbox
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = inputName;
hidden.value = 'false';
cell.appendChild(hidden);
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.name = inputName;
cb.checked = Boolean(colValue);
cb.value = 'true';
cb.className = 'h-4 w-4 text-blue-600';
cell.appendChild(cb);
} else if (colType === 'integer' || colType === 'number') {
const inp = document.createElement('input');
inp.type = 'number';
inp.name = inputName;
inp.value = colValue !== null && colValue !== undefined ? colValue : '';
if (colDef.minimum !== undefined) inp.min = colDef.minimum;
if (colDef.maximum !== undefined) inp.max = colDef.maximum;
inp.step = colType === 'integer' ? '1' : 'any';
inp.className = 'block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center';
if (colDef.description) inp.title = colDef.description;
cell.appendChild(inp);
} else if (Array.isArray(enumVals) && enumVals.length > 0) {
// Enum: render <select>
cell.style.minWidth = '90px';
const sel = document.createElement('select');
sel.name = inputName;
sel.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white';
enumVals.forEach(opt => {
if (opt === null) return;
const o = document.createElement('option');
o.value = opt;
o.textContent = opt;
if (String(colValue) === String(opt)) o.selected = true;
sel.appendChild(o);
});
// If current value didn't match any option, set to first
if (!sel.value && enumVals.length > 0) sel.value = enumVals[0];
cell.appendChild(sel);
} else if (xWidget === 'date-picker') {
cell.style.minWidth = '140px';
const inp = document.createElement('input');
inp.type = 'date';
inp.name = inputName;
inp.value = colValue || '';
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
inp.style.minWidth = '128px';
if (colDef.description) inp.title = colDef.description;
cell.appendChild(inp);
} else if (xWidget === 'time-picker') {
cell.style.minWidth = '115px';
const inp = document.createElement('input');
inp.type = 'time';
inp.name = inputName;
inp.value = colValue || '00:00';
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
inp.style.minWidth = '100px';
cell.appendChild(inp);
} else if (xWidget === 'file-upload-single') {
// Compact: text input (stores path) + upload button
cell.style.minWidth = '200px';
const wrap = document.createElement('div');
wrap.className = 'flex items-center gap-1';
const pathInput = document.createElement('input');
pathInput.type = 'text';
pathInput.name = inputName;
pathInput.id = `${fullKey}_${index}_${colName}`.replace(/\./g,'_');
pathInput.value = colValue || '';
pathInput.className = 'block px-1 py-1 border border-gray-300 rounded text-xs flex-1';
pathInput.style.minWidth = '100px';
pathInput.placeholder = 'path…';
const preview = document.createElement('img');
preview.className = 'w-6 h-6 object-cover rounded flex-shrink-0';
preview.style.display = colValue ? 'inline' : 'none';
if (colValue) { preview.src = '/' + colValue; preview.onerror = () => { preview.style.display = 'none'; }; }
const labelEl = document.createElement('label');
labelEl.className = 'cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100';
labelEl.title = 'Upload image';
labelEl.innerHTML = '<i class="fas fa-upload"></i>';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
fileInput.style.display = 'none';
fileInput.dataset.pluginId = pluginId;
fileInput.dataset.targetInput = pathInput.id;
fileInput.dataset.previewImg = preview.id || '';
fileInput.onchange = function(e) {
window.handleArrayTableImageUpload(e, pathInput, preview, pluginId);
};
labelEl.appendChild(fileInput);
wrap.appendChild(preview);
wrap.appendChild(pathInput);
wrap.appendChild(labelEl);
cell.appendChild(wrap);
} else {
// Default: text input
const inp = document.createElement('input');
inp.type = 'text';
inp.name = inputName;
inp.value = colValue !== null && colValue !== undefined ? colValue : '';
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
if (colDef.description) inp.placeholder = colDef.description;
if (colDef.pattern) inp.pattern = colDef.pattern;
if (colDef.minLength) inp.minLength = colDef.minLength;
if (colDef.maxLength) inp.maxLength = colDef.maxLength;
cell.appendChild(inp);
}
return cell;
}
/**
* Create a hidden <td> holding flat hidden inputs for non-displayed properties
* (including nested objects like layout/style).
*/
function createAdvancedCell(fullKey, index, nonDisplayedProps, item) {
const cell = document.createElement('td');
cell.style.display = 'none';
cell.className = 'array-table-advanced-data';
cell.dataset.propSchema = JSON.stringify(nonDisplayedProps);
Object.entries(nonDisplayedProps).forEach(([propName, propSchema]) => {
const propType = Array.isArray(propSchema.type)
? propSchema.type.find(t => t !== 'null') || 'string'
: (propSchema.type || 'string');
if (propType === 'object' && propSchema.properties) {
const nestedVal = (item && item[propName]) || {};
Object.entries(propSchema.properties).forEach(([subName, subSchema]) => {
const subType = Array.isArray(subSchema.type)
? subSchema.type.find(t => t !== 'null') || 'string'
: (subSchema.type || 'string');
const defaultVal = subSchema.default !== undefined ? subSchema.default : null;
const currentVal = nestedVal[subName] !== undefined ? nestedVal[subName] : defaultVal;
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = `${fullKey}.${index}.${propName}.${subName}`;
hidden.value = currentVal !== null && currentVal !== undefined ? String(currentVal) : '';
hidden.dataset.nestedProp = `${propName}.${subName}`;
hidden.dataset.propType = subType;
hidden.dataset.propSchema = JSON.stringify(subSchema);
cell.appendChild(hidden);
});
} else {
const defaultVal = propSchema.default !== undefined ? propSchema.default : null;
const currentVal = item && item[propName] !== undefined ? item[propName] : defaultVal;
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = `${fullKey}.${index}.${propName}`;
hidden.value = currentVal !== null && currentVal !== undefined ? String(currentVal) : '';
hidden.dataset.nestedProp = propName;
hidden.dataset.propType = propType;
hidden.dataset.propSchema = JSON.stringify(propSchema);
cell.appendChild(hidden);
}
});
return cell;
}
// ─── Row creation ────────────────────────────────────────────────────────
function createArrayTableRow(fieldId, fullKey, index, pluginId, item, itemProperties, displayColumns, fullItemProperties) {
item = item || {};
fullItemProperties = fullItemProperties || itemProperties;
const row = document.createElement('tr');
row.className = 'array-table-row';
row.setAttribute('data-index', index);
// Visible column cells
displayColumns.forEach(colName => {
const colDef = itemProperties[colName] || {};
const colType = colDef.type || 'string';
const colDefault = colDef.default !== undefined ? colDef.default : (colType === 'boolean' ? false : '');
const colDef = itemProperties[colName] || {};
const colType = Array.isArray(colDef.type) ? colDef.type.find(t => t !== 'null') || 'string' : (colDef.type || 'string');
const colDefault = colDef.default !== undefined ? colDef.default
: (colType === 'boolean' ? false : colType === 'time-picker' ? '00:00' : '');
const colValue = item[colName] !== undefined ? item[colName] : colDefault;
const cell = document.createElement('td');
cell.className = 'px-4 py-3 whitespace-nowrap';
if (colType === 'boolean') {
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = `${fullKey}.${index}.${colName}`;
hiddenInput.value = 'false';
cell.appendChild(hiddenInput);
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.name = `${fullKey}.${index}.${colName}`;
checkbox.checked = Boolean(colValue);
checkbox.value = 'true';
checkbox.className = 'h-4 w-4 text-blue-600';
cell.appendChild(checkbox);
} else if (colType === 'integer' || colType === 'number') {
const input = document.createElement('input');
input.type = 'number';
input.name = `${fullKey}.${index}.${colName}`;
input.value = colValue !== null && colValue !== undefined ? colValue : '';
if (colDef.minimum !== undefined) input.min = colDef.minimum;
if (colDef.maximum !== undefined) input.max = colDef.maximum;
input.step = colType === 'integer' ? '1' : 'any';
input.className = 'block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center';
if (colDef.description) input.title = colDef.description;
cell.appendChild(input);
} else {
const input = document.createElement('input');
input.type = 'text';
input.name = `${fullKey}.${index}.${colName}`;
input.value = colValue !== null && colValue !== undefined ? colValue : '';
input.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
if (colDef.description) input.placeholder = colDef.description;
if (colDef.pattern) input.pattern = colDef.pattern;
if (colDef.minLength) input.minLength = colDef.minLength;
if (colDef.maxLength) input.maxLength = colDef.maxLength;
cell.appendChild(input);
}
row.appendChild(cell);
row.appendChild(createCell(fullKey, index, colName, colDef, colValue, pluginId));
});
// Determine non-displayed properties (these go into the advanced cell + edit modal)
const nonDisplayed = {};
Object.keys(fullItemProperties).forEach(k => {
if (!displayColumns.includes(k) && k !== 'id') {
nonDisplayed[k] = fullItemProperties[k];
}
});
const hasAdvanced = Object.keys(nonDisplayed).length > 0;
// Actions cell
const actionsCell = document.createElement('td');
actionsCell.className = 'px-4 py-3 whitespace-nowrap text-center';
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeButton.onclick = function() { window.removeArrayTableRow(this); };
const removeIcon = document.createElement('i');
removeIcon.className = 'fas fa-trash';
removeButton.appendChild(removeIcon);
actionsCell.appendChild(removeButton);
actionsCell.className = 'px-3 py-3 whitespace-nowrap text-center';
actionsCell.style.minWidth = '90px';
actionsCell.style.verticalAlign = 'middle';
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeBtn.onclick = function() { window.removeArrayTableRow(this); };
removeBtn.innerHTML = '<i class="fas fa-trash"></i>';
actionsCell.appendChild(removeBtn);
if (hasAdvanced) {
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'text-blue-500 hover:text-blue-700 px-2 py-1 ml-1';
editBtn.title = 'Edit advanced properties (layout, style…)';
editBtn.onclick = function() { window.openArrayTableRowEditor(this); };
editBtn.innerHTML = '<i class="fas fa-sliders-h"></i>';
actionsCell.appendChild(editBtn);
}
row.appendChild(actionsCell);
// Hidden advanced data cell
if (hasAdvanced) {
row.appendChild(createAdvancedCell(fullKey, index, nonDisplayed, item));
}
return row;
}
// ─── Row editor modal ────────────────────────────────────────────────────
window.openArrayTableRowEditor = function(button) {
const row = button.closest('tr');
const advancedCell = row.querySelector('.array-table-advanced-data');
if (!advancedCell) return;
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
const tbody = row.closest('tbody');
const fieldId = tbody ? tbody.id.replace('_tbody', '') : '';
const rowIndex = parseInt(row.dataset.index, 10);
// Close any existing modal
const existing = document.getElementById('array-row-editor-modal');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'array-row-editor-modal';
// Use inline styles for position/dimensions — inset-0 may be purged from the CSS bundle
// since it only appears in JS-generated markup, not in scanned templates.
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(0,0,0,0.5);';
overlay.onclick = function(e) { if (e.target === overlay) window.closeArrayTableRowEditor(); };
const dialog = document.createElement('div');
dialog.className = 'bg-white rounded-lg shadow-xl max-w-lg w-full max-h-screen overflow-y-auto';
// Header
dialog.innerHTML = `
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
<button type="button" onclick="window.closeArrayTableRowEditor()"
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>`;
const body = document.createElement('div');
body.className = 'px-5 py-4 space-y-4';
// Render a field for each advanced property
Object.entries(schema).forEach(([propName, propSchema]) => {
const propType = Array.isArray(propSchema.type)
? propSchema.type.find(t => t !== 'null') || 'string'
: (propSchema.type || 'string');
const label = propSchema.title || propName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const desc = propSchema.description || '';
if (propType === 'object' && propSchema.properties) {
// Section for nested object
const section = document.createElement('div');
section.className = 'border border-gray-200 rounded-lg p-3';
section.innerHTML = `<h4 class="text-sm font-medium text-gray-700 mb-3">${escapeHtml(label)}</h4>`;
const grid = document.createElement('div');
grid.className = 'grid grid-cols-2 gap-3';
Object.entries(propSchema.properties).forEach(([subName, subSchema]) => {
const subType = Array.isArray(subSchema.type) ? subSchema.type.find(t => t !== 'null') || 'string' : (subSchema.type || 'string');
const subLabel = subSchema.title || subName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const subDesc = subSchema.description || '';
const nestedPath = `${propName}.${subName}`;
// Read current value from hidden input
const hiddenInput = advancedCell.querySelector(`[data-nested-prop="${nestedPath}"]`);
const currentVal = hiddenInput ? hiddenInput.value : (subSchema.default !== undefined ? subSchema.default : '');
const fieldDiv = document.createElement('div');
fieldDiv.innerHTML = `<label class="block text-xs font-medium text-gray-600 mb-1" title="${escapeHtml(subDesc)}">${escapeHtml(subLabel)}</label>`;
fieldDiv.appendChild(buildModalInput(nestedPath, subSchema, subType, currentVal));
grid.appendChild(fieldDiv);
});
section.appendChild(grid);
body.appendChild(section);
} else {
// Flat property
const hiddenInput = advancedCell.querySelector(`[data-nested-prop="${propName}"]`);
const currentVal = hiddenInput ? hiddenInput.value : (propSchema.default !== undefined ? propSchema.default : '');
const fieldDiv = document.createElement('div');
fieldDiv.innerHTML = `<label class="block text-sm font-medium text-gray-700 mb-1" title="${escapeHtml(desc)}">${escapeHtml(label)}</label>`;
fieldDiv.appendChild(buildModalInput(propName, propSchema, propType, currentVal));
body.appendChild(fieldDiv);
}
});
dialog.appendChild(body);
// Footer
const footer = document.createElement('div');
footer.className = 'flex justify-end gap-3 px-5 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg';
footer.innerHTML = `
<button type="button" onclick="window.closeArrayTableRowEditor()"
class="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-md hover:bg-gray-100">Cancel</button>
<button type="button" id="array-row-editor-save"
class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md">Save</button>`;
// Save handler
footer.querySelector('#array-row-editor-save').onclick = function() {
body.querySelectorAll('[data-modal-prop]').forEach(el => {
const propPath = el.dataset.modalProp;
const targetInput = advancedCell.querySelector(`[data-nested-prop="${propPath}"]`);
if (!targetInput) return;
if (el.type === 'checkbox') {
targetInput.value = el.checked ? 'true' : 'false';
} else {
targetInput.value = el.value;
}
});
window.closeArrayTableRowEditor();
};
dialog.appendChild(footer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
};
window.closeArrayTableRowEditor = function() {
const modal = document.getElementById('array-row-editor-modal');
if (modal) modal.remove();
};
/**
* Update the Add button's disabled state based on current row count
* @param {string} fieldId - Field ID to find the tbody and button
* Build a single form control for the row editor modal.
*/
function updateAddButtonState(fieldId) {
const tbody = document.getElementById(fieldId + '_tbody');
if (!tbody) return;
function buildModalInput(propPath, schema, propType, currentVal) {
const xWidget = schema['x-widget'] || schema['x_widget'];
const enumVals = schema.enum;
const wrap = document.createElement('div');
// Find the add button by looking for the button with matching data-field-id
const addButton = document.querySelector(`button[data-field-id="${fieldId}"]`);
if (!addButton) return;
if (propType === 'boolean') {
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'h-4 w-4 text-blue-600';
cb.checked = currentVal === 'true' || currentVal === true || currentVal === 1;
cb.dataset.modalProp = propPath;
wrap.appendChild(cb);
return wrap;
}
const maxItems = parseInt(addButton.getAttribute('data-max-items'), 10);
const currentRows = tbody.querySelectorAll('.array-table-row');
const isAtMax = currentRows.length >= maxItems;
// Array[3] with x-widget color-picker → R/G/B row
if ((propType === 'array' || xWidget === 'color-picker') &&
(schema.minItems === 3 || schema.maxItems === 3 || xWidget === 'color-picker')) {
const parts = currentVal ? String(currentVal).split(',').map(s => s.trim()) : ['', '', ''];
const rVal = parts[0] || '';
const gVal = parts[1] || '';
const bVal = parts[2] || '';
addButton.disabled = isAtMax;
addButton.style.opacity = isAtMax ? '0.5' : '';
// Hex color picker for visual selection
const hexVal = (rVal && gVal && bVal)
? '#' + [rVal, gVal, bVal].map(n => parseInt(n, 10).toString(16).padStart(2, '0')).join('')
: '#ffffff';
const colorRow = document.createElement('div');
colorRow.className = 'flex items-center gap-2 flex-wrap';
const colorPick = document.createElement('input');
colorPick.type = 'color';
colorPick.value = hexVal;
colorPick.className = 'h-8 w-10 cursor-pointer rounded border';
colorRow.appendChild(colorPick);
['R', 'G', 'B'].forEach((ch, i) => {
const lbl = document.createElement('label');
lbl.className = 'text-xs text-gray-500';
lbl.textContent = ch;
const numInp = document.createElement('input');
numInp.type = 'number';
numInp.min = '0';
numInp.max = '255';
numInp.step = '1';
numInp.value = [rVal, gVal, bVal][i];
numInp.className = 'w-14 px-1 py-1 border border-gray-300 rounded text-sm text-center';
numInp.dataset.colorChannel = i;
colorRow.appendChild(lbl);
colorRow.appendChild(numInp);
});
// Hidden aggregate input that the save handler reads
const agg = document.createElement('input');
agg.type = 'hidden';
agg.value = `${rVal},${gVal},${bVal}`;
agg.dataset.modalProp = propPath;
colorRow.appendChild(agg);
// Sync: color picker → R/G/B numbers + agg
colorPick.oninput = function() {
const hex = colorPick.value;
const r = parseInt(hex.slice(1,3), 16);
const g = parseInt(hex.slice(3,5), 16);
const b = parseInt(hex.slice(5,7), 16);
const nums = colorRow.querySelectorAll('input[data-color-channel]');
if (nums[0]) nums[0].value = r;
if (nums[1]) nums[1].value = g;
if (nums[2]) nums[2].value = b;
agg.value = `${r},${g},${b}`;
};
// Sync: R/G/B numbers → color picker + agg
colorRow.querySelectorAll('input[data-color-channel]').forEach(inp => {
inp.oninput = function() {
const nums = colorRow.querySelectorAll('input[data-color-channel]');
const r = parseInt(nums[0] ? nums[0].value : 0, 10) || 0;
const g = parseInt(nums[1] ? nums[1].value : 0, 10) || 0;
const b = parseInt(nums[2] ? nums[2].value : 0, 10) || 0;
colorPick.value = '#' + [r,g,b].map(n => n.toString(16).padStart(2,'0')).join('');
agg.value = `${r},${g},${b}`;
};
});
wrap.appendChild(colorRow);
return wrap;
}
if (Array.isArray(enumVals) && enumVals.length > 0) {
const sel = document.createElement('select');
sel.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white';
sel.dataset.modalProp = propPath;
enumVals.forEach(opt => {
if (opt === null) return;
const o = document.createElement('option');
o.value = opt; o.textContent = opt;
if (String(currentVal) === String(opt)) o.selected = true;
sel.appendChild(o);
});
wrap.appendChild(sel);
return wrap;
}
if (xWidget === 'date-picker') {
const inp = document.createElement('input');
inp.type = 'date';
inp.value = currentVal || '';
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
inp.dataset.modalProp = propPath;
wrap.appendChild(inp);
return wrap;
}
if (xWidget === 'time-picker') {
const inp = document.createElement('input');
inp.type = 'time';
inp.value = currentVal || '00:00';
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
inp.dataset.modalProp = propPath;
wrap.appendChild(inp);
return wrap;
}
if (propType === 'integer' || propType === 'number') {
const inp = document.createElement('input');
inp.type = 'number';
inp.value = currentVal !== '' && currentVal !== null ? currentVal : '';
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
inp.dataset.modalProp = propPath;
if (schema.minimum !== undefined) inp.min = schema.minimum;
if (schema.maximum !== undefined) inp.max = schema.maximum;
inp.step = propType === 'integer' ? '1' : 'any';
if (schema.description) inp.placeholder = schema.description;
wrap.appendChild(inp);
return wrap;
}
// Default: text
const inp = document.createElement('input');
inp.type = 'text';
inp.value = currentVal || '';
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
inp.dataset.modalProp = propPath;
if (schema.description) inp.placeholder = schema.description;
wrap.appendChild(inp);
return wrap;
}
// Expose for external use if needed
window.updateArrayTableAddButtonState = updateAddButtonState;
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = String(str || '');
return d.innerHTML;
}
// ─── In-cell image upload ────────────────────────────────────────────────
/**
* Add a new row to the array table
* @param {HTMLElement} button - The button element with data attributes
* Called from file-upload-single cells inside array-table rows.
* Uploads the selected file and updates the path text input.
*/
window.addArrayTableRow = function(button) {
const fieldId = button.getAttribute('data-field-id');
const fullKey = button.getAttribute('data-full-key');
const maxItems = parseInt(button.getAttribute('data-max-items'), 10);
const pluginId = button.getAttribute('data-plugin-id');
window.handleArrayTableImageUpload = async function(event, pathInput, previewImg, pluginId) {
const file = event.target.files && event.target.files[0];
if (!file) return;
// Parse JSON with fallback on error
let itemProperties = {};
let displayColumns = [];
const rawItemProps = button.getAttribute('data-item-properties') || '{}';
const rawDisplayCols = button.getAttribute('data-display-columns') || '[]';
try {
itemProperties = JSON.parse(rawItemProps);
} catch (e) {
console.error('[ArrayTableWidget] Failed to parse data-item-properties:', rawItemProps, e);
itemProperties = {};
const notifyFn = window.showNotification || console.log;
const allowed = ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
if (!allowed.includes(file.type)) {
notifyFn(`File type "${file.type}" not allowed`, 'error');
return;
}
try {
displayColumns = JSON.parse(rawDisplayCols);
} catch (e) {
console.error('[ArrayTableWidget] Failed to parse data-display-columns:', rawDisplayCols, e);
displayColumns = [];
}
const tbody = document.getElementById(fieldId + '_tbody');
if (!tbody) return;
const currentRows = tbody.querySelectorAll('.array-table-row');
if (currentRows.length >= maxItems) {
const notifyFn = window.showNotification || alert;
notifyFn(`Maximum ${maxItems} items allowed`, 'error');
if (file.size > 5 * 1024 * 1024) {
notifyFn('File exceeds 5MB limit', 'error');
return;
}
const newIndex = currentRows.length;
const row = createArrayTableRow(fieldId, fullKey, newIndex, pluginId, {}, itemProperties, displayColumns);
tbody.appendChild(row);
const formData = new FormData();
formData.append('plugin_id', pluginId);
formData.append('files', file);
// Update button state after adding
updateAddButtonState(fieldId);
};
/**
* Remove a row from the array table
* @param {HTMLElement} button - The remove button element
*/
window.removeArrayTableRow = function(button) {
const row = button.closest('tr');
if (!row) return;
if (confirm('Remove this item?')) {
const tbody = row.parentElement;
if (!tbody) return;
// Get fieldId from tbody id (format: {fieldId}_tbody)
const fieldId = tbody.id.replace('_tbody', '');
row.remove();
// Re-index remaining rows
const rows = tbody.querySelectorAll('.array-table-row');
rows.forEach(function(r, index) {
r.setAttribute('data-index', index);
r.querySelectorAll('input').forEach(function(input) {
const name = input.getAttribute('name');
if (name) {
input.setAttribute('name', name.replace(/\.\d+\./, '.' + index + '.'));
}
});
});
// Update button state after removing
updateAddButtonState(fieldId);
try {
const resp = await fetch('/api/v3/plugins/assets/upload', { method: 'POST', body: formData });
if (!resp.ok) throw new Error(`Server error ${resp.status}`);
const data = await resp.json();
if (data.status === 'success' && data.uploaded_files && data.uploaded_files[0]) {
const path = data.uploaded_files[0].path;
pathInput.value = path;
if (previewImg) { previewImg.src = '/' + path; previewImg.style.display = 'inline'; }
notifyFn('Image uploaded', 'success');
} else {
throw new Error(data.message || 'Upload failed');
}
} catch (err) {
notifyFn('Upload error: ' + err.message, 'error');
} finally {
event.target.value = '';
}
};
/**
* Initialize all array table add buttons on page load
*/
// ─── Button helpers ──────────────────────────────────────────────────────
function updateAddButtonState(fieldId) {
const tbody = document.getElementById(fieldId + '_tbody');
const addButton = document.querySelector(`button[data-field-id="${fieldId}"]`);
if (!tbody || !addButton) return;
const maxItems = parseInt(addButton.getAttribute('data-max-items'), 10);
const currentRows = tbody.querySelectorAll('.array-table-row').length;
const isAtMax = currentRows >= maxItems;
addButton.disabled = isAtMax;
addButton.style.opacity = isAtMax ? '0.5' : '';
}
window.updateArrayTableAddButtonState = updateAddButtonState;
window.addArrayTableRow = function(button) {
const fieldId = button.getAttribute('data-field-id');
const fullKey = button.getAttribute('data-full-key');
const maxItems = parseInt(button.getAttribute('data-max-items'), 10);
const pluginId = button.getAttribute('data-plugin-id');
let itemProperties = {};
let displayColumns = [];
let fullItemProperties = {};
try { itemProperties = JSON.parse(button.getAttribute('data-item-properties') || '{}'); } catch(e) {}
try { displayColumns = JSON.parse(button.getAttribute('data-display-columns') || '[]'); } catch(e) {}
try { fullItemProperties = JSON.parse(button.getAttribute('data-full-item-properties') || '{}'); } catch(e) { fullItemProperties = itemProperties; }
const tbody = document.getElementById(fieldId + '_tbody');
if (!tbody) return;
const currentRows = tbody.querySelectorAll('.array-table-row').length;
if (currentRows >= maxItems) {
(window.showNotification || alert)(`Maximum ${maxItems} items allowed`, 'error');
return;
}
const newIndex = currentRows;
const row = createArrayTableRow(fieldId, fullKey, newIndex, pluginId, {}, itemProperties, displayColumns, fullItemProperties);
tbody.appendChild(row);
updateAddButtonState(fieldId);
};
window.removeArrayTableRow = function(button) {
const row = button.closest('tr');
if (!row) return;
if (!confirm('Remove this item?')) return;
const tbody = row.parentElement;
if (!tbody) return;
const fieldId = tbody.id.replace('_tbody', '');
row.remove();
// Re-index remaining rows
tbody.querySelectorAll('.array-table-row').forEach((r, index) => {
r.setAttribute('data-index', index);
r.querySelectorAll('input, select').forEach(el => {
const name = el.getAttribute('name');
if (name) el.setAttribute('name', name.replace(/\.\d+\./, '.' + index + '.'));
// Also update data-nested-prop-based inputs (they don't have regular names needing re-index)
});
});
updateAddButtonState(fieldId);
};
function initArrayTableButtons() {
const addButtons = document.querySelectorAll('button[data-field-id][data-max-items]');
addButtons.forEach(function(button) {
const fieldId = button.getAttribute('data-field-id');
updateAddButtonState(fieldId);
document.querySelectorAll('button[data-field-id][data-max-items]').forEach(button => {
updateAddButtonState(button.getAttribute('data-field-id'));
});
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initArrayTableButtons);
} else {
initArrayTableButtons();
}
console.log('[ArrayTableWidget] Array table widget registered');
console.log('[ArrayTableWidget] Array table widget registered (v2.0.0)');
})();

View File

@@ -0,0 +1,266 @@
/**
* LEDMatrix File Upload Single Widget
*
* Single-image upload for string fields. Uploads to the plugin's asset folder
* and sets the string field value to the returned relative path.
* Designed for per-item image fields within array-table rows.
*
* The plugin_id is injected automatically from the template context
* via options.pluginId — no need to specify it in the schema.
*
* Schema example (any plugin):
* {
* "image_path": {
* "type": "string",
* "x-widget": "file-upload-single",
* "x-upload-config": {
* "allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
* "max_size_mb": 5
* }
* }
* }
*
* @module FileUploadSingleWidget
*/
(function() {
'use strict';
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[FileUploadSingleWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
return;
}
const base = window.BaseWidget ? new window.BaseWidget('FileUploadSingle', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
}
function triggerChange(fieldId, value) {
if (base) {
base.triggerChange(fieldId, value);
} else {
document.dispatchEvent(new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true,
cancelable: true
}));
}
}
function isImagePath(path) {
if (!path) return false;
return /\.(png|jpg|jpeg|bmp|gif)$/i.test(path);
}
window.LEDMatrixWidgets.register('file-upload-single', {
name: 'File Upload Single Widget',
version: '1.0.0',
render: function(container, config, value, options) {
const fieldId = sanitizeId(options.fieldId || container.id || 'file_upload_single');
const uploadConfig = config['x-upload-config'] || config['x_upload_config'] || {};
const allowedTypes = (uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']).join(',');
const maxSizeMb = uploadConfig.max_size_mb || 5;
const pluginId = options.pluginId || '';
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)}">`;
// Hidden input carries the actual string value
html += `<input type="hidden" id="${fieldId}" name="${escapeHtml(options.name || fieldId)}" value="${escapeHtml(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"
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 class="text-xs text-gray-400">${escapeHtml(currentValue)}</p>
</div>`;
html += `<button type="button"
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image">
<i class="fas fa-times"></i>
</button>`;
html += '</div>';
// Upload drop zone (always shown, acts as change button when value is set)
html += `<div id="${fieldId}_drop_zone"
class="border-2 border-dashed border-gray-300 rounded-lg p-3 text-center hover:border-blue-400 transition-colors cursor-pointer"
ondrop="window.LEDMatrixWidgets.getHandlers('file-upload-single').onDrop(event, '${fieldId}')"
ondragover="event.preventDefault()"
onclick="document.getElementById('${fieldId}_file_input').click()">
<input type="file"
id="${fieldId}_file_input"
accept="${escapeHtml(allowedTypes)}"
style="display:none"
data-field-id="${fieldId}"
data-plugin-id="${escapeHtml(pluginId)}"
data-max-size-mb="${maxSizeMb}"
data-allowed-types="${escapeHtml(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>
<p class="text-xs text-gray-400">Max ${maxSizeMb}MB</p>
</div>`;
// Status area for upload feedback
html += `<div id="${fieldId}_status" class="mt-1 text-xs hidden"></div>`;
html += '</div>';
container.innerHTML = html;
},
getValue: function(fieldId) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(safeId);
return input ? input.value : '';
},
setValue: function(fieldId, value) {
const safeId = sanitizeId(fieldId);
const hidden = document.getElementById(safeId);
const preview = document.getElementById(`${safeId}_preview`);
const thumb = document.getElementById(`${safeId}_thumb`);
const thumbPlaceholder = document.getElementById(`${safeId}_thumb_placeholder`);
const filename = document.getElementById(`${safeId}_filename`);
const dropZone = document.getElementById(`${safeId}_drop_zone`);
if (hidden) hidden.value = value || '';
const hasImage = isImagePath(value);
if (preview) preview.classList.toggle('hidden', !hasImage);
if (thumb && hasImage) {
thumb.src = `/${value}`;
thumb.style.display = '';
if (thumbPlaceholder) thumbPlaceholder.style.display = 'none';
}
if (filename) filename.textContent = hasImage ? value.split('/').pop() : '';
// Update drop zone hint text
const hint = dropZone ? dropZone.querySelector('p') : null;
if (hint) hint.textContent = hasImage ? 'Click to replace image' : 'Click or drag to upload image';
},
handlers: {
onFileSelect: function(event, fieldId) {
const files = event.target.files;
if (files && files.length > 0) {
window.LEDMatrixWidgets.getHandlers('file-upload-single').uploadFile(fieldId, files[0]);
}
},
onDrop: function(event, fieldId) {
event.preventDefault();
const files = event.dataTransfer.files;
if (files && files.length > 0) {
window.LEDMatrixWidgets.getHandlers('file-upload-single').uploadFile(fieldId, files[0]);
}
},
onClear: function(fieldId) {
const widget = window.LEDMatrixWidgets.get('file-upload-single');
widget.setValue(fieldId, '');
triggerChange(fieldId, '');
// Reset file input so the same file can be re-selected
const fileInput = document.getElementById(`${sanitizeId(fieldId)}_file_input`);
if (fileInput) fileInput.value = '';
},
uploadFile: async function(fieldId, file) {
const safeId = sanitizeId(fieldId);
const fileInput = document.getElementById(`${safeId}_file_input`);
const statusDiv = document.getElementById(`${safeId}_status`);
const notifyFn = window.showNotification || console.log;
// Read config from the file input data attributes
const pluginId = (fileInput && fileInput.dataset.pluginId) || '';
const maxSizeMb = parseFloat((fileInput && fileInput.dataset.maxSizeMb) || '5');
const allowedTypes = ((fileInput && fileInput.dataset.allowedTypes) || 'image/png,image/jpeg,image/bmp,image/gif')
.split(',').map(t => t.trim());
if (!pluginId) {
notifyFn('Plugin ID not set — cannot upload', 'error');
return;
}
// Validate type
if (!allowedTypes.includes(file.type)) {
notifyFn(`File type "${file.type}" not allowed`, 'error');
return;
}
// Validate size
if (file.size > maxSizeMb * 1024 * 1024) {
notifyFn(`File exceeds ${maxSizeMb}MB limit`, 'error');
return;
}
// Show uploading status
if (statusDiv) {
statusDiv.className = 'mt-1 text-xs text-gray-500';
statusDiv.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Uploading...';
}
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
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Server error ${response.status}: ${body}`);
}
const data = await response.json();
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
const uploadedPath = data.uploaded_files[0].path;
const widget = window.LEDMatrixWidgets.get('file-upload-single');
widget.setValue(fieldId, uploadedPath);
triggerChange(fieldId, uploadedPath);
if (statusDiv) {
statusDiv.className = 'mt-1 text-xs text-green-600';
statusDiv.innerHTML = '<i class="fas fa-check-circle mr-1"></i>Uploaded successfully';
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.innerHTML = ''; }, 3000);
}
notifyFn('Image uploaded successfully', 'success');
} else {
throw new Error(data.message || 'Upload failed');
}
} catch (error) {
if (statusDiv) {
statusDiv.className = 'mt-1 text-xs text-red-600';
statusDiv.innerHTML = `<i class="fas fa-exclamation-circle mr-1"></i>${escapeHtml(error.message)}`;
}
notifyFn(`Upload error: ${error.message}`, 'error');
} finally {
if (fileInput) fileInput.value = '';
}
}
}
});
console.log('[FileUploadSingleWidget] File upload single widget registered');
})();

View File

@@ -0,0 +1,692 @@
/**
* Plugin File Manager Widget
*
* Reusable inline file manager for plugins that manage files via the
* web_ui_actions system. Driven entirely by x-widget-config in the schema —
* no external HTML file or iframe needed.
*
* Any plugin can adopt this widget by:
* 1. Defining web_ui_actions in manifest.json (list, get, save, upload,
* delete, create, toggle) with ui_hidden: true
* 2. Adding x-widget: "plugin-file-manager" to a field in config_schema.json
* with x-widget-config mapping the action IDs
*
* Schema example:
* {
* "file_manager": {
* "type": "null",
* "title": "Data Files",
* "x-widget": "plugin-file-manager",
* "x-widget-config": {
* "actions": {
* "list": "list-files",
* "get": "get-file",
* "save": "save-file",
* "upload": "upload-file",
* "delete": "delete-file",
* "create": "create-file",
* "toggle": "toggle-category"
* },
* "upload_hint": "JSON files with day numbers 1365 as keys",
* "directory_label": "of_the_day/",
* "create_fields": [
* { "key": "category_name", "label": "Category Name",
* "placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
* "hint": "Lowercase letters, numbers, underscores" },
* { "key": "display_name", "label": "Display Name",
* "placeholder": "e.g., My Words", "hint": "Optional — auto-generated if blank" }
* ]
* }
* }
* }
*
* @module PluginFileManagerWidget
*/
(function () {
'use strict';
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[PluginFileManager] LEDMatrixWidgets registry not found.');
return;
}
// ─── Inject widget-scoped styles once ────────────────────────────────────
if (!document.getElementById('pfm-styles')) {
const style = document.createElement('style');
style.id = 'pfm-styles';
style.textContent = `
.pfm-root { font-family: inherit; }
.pfm-header { display:flex; align-items:center; justify-content:space-between;
margin-bottom:.75rem; }
.pfm-title { font-size:1rem; font-weight:600; color:#111827; }
.pfm-dir { font-size:.75rem; color:#6b7280; margin-top:.125rem; }
.pfm-upload { border:2px dashed #d1d5db; border-radius:.5rem; padding:1.25rem;
text-align:center; cursor:pointer; transition:border-color .15s,background .15s; }
.pfm-upload:hover,.pfm-upload.dragover { border-color:#3b82f6; background:#eff6ff; }
.pfm-upload p { font-size:.875rem; color:#4b5563; margin:.25rem 0 0; }
.pfm-upload small { font-size:.75rem; color:#9ca3af; }
.pfm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
gap:.75rem; margin-top:.75rem; }
.pfm-card { border:1px solid #e5e7eb; border-radius:.5rem; padding:.875rem;
background:#fff; transition:box-shadow .15s; }
.pfm-card:hover { box-shadow:0 1px 4px rgba(0,0,0,.1); }
.pfm-card.disabled { opacity:.55; }
.pfm-card-top { display:flex; align-items:center; justify-content:space-between;
margin-bottom:.5rem; }
.pfm-card-icon { width:2rem; height:2rem; background:#f3f4f6; border-radius:.375rem;
display:flex; align-items:center; justify-content:center;
color:#6b7280; font-size:1rem; }
.pfm-card-name { font-weight:600; color:#111827; font-size:.875rem; margin:.375rem 0 .125rem; }
.pfm-card-meta { font-size:.75rem; color:#6b7280; line-height:1.5; }
.pfm-card-actions { display:flex; gap:.375rem; margin-top:.625rem; }
.pfm-btn { display:inline-flex; align-items:center; gap:.25rem; padding:.375rem .75rem;
border-radius:.375rem; font-size:.8125rem; font-weight:500;
border:none; cursor:pointer; transition:background .15s; }
.pfm-btn-primary { background:#2563eb; color:#fff; flex:1; justify-content:center; }
.pfm-btn-primary:hover { background:#1d4ed8; }
.pfm-btn-danger { background:#dc2626; color:#fff; }
.pfm-btn-danger:hover { background:#b91c1c; }
.pfm-btn-secondary { background:#f3f4f6; color:#374151; border:1px solid #d1d5db; }
.pfm-btn-secondary:hover { background:#e5e7eb; }
.pfm-btn-sm { padding:.25rem .5rem; font-size:.75rem; }
.pfm-btn-create { background:#059669; color:#fff; }
.pfm-btn-create:hover { background:#047857; }
.pfm-toggle-wrap { display:flex; align-items:center; gap:.375rem; }
.pfm-toggle-label { font-size:.75rem; color:#6b7280; }
.pfm-toggle-cb { position:relative; display:inline-block; width:2rem; height:1.125rem; }
.pfm-toggle-cb input { opacity:0; width:0; height:0; }
.pfm-toggle-slider { position:absolute; inset:0; background:#d1d5db; border-radius:9999px;
cursor:pointer; transition:background .2s; }
.pfm-toggle-slider:before { content:''; position:absolute; height:.75rem; width:.75rem;
left:.1875rem; bottom:.1875rem; background:#fff;
border-radius:50%; transition:transform .2s; }
.pfm-toggle-cb input:checked + .pfm-toggle-slider { background:#10b981; }
.pfm-toggle-cb input:checked + .pfm-toggle-slider:before { transform:translateX(.875rem); }
.pfm-empty { text-align:center; padding:2rem; color:#9ca3af; }
.pfm-empty i { font-size:2rem; margin-bottom:.5rem; display:block; }
/* Modal */
.pfm-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5);
display:flex; align-items:flex-start; justify-content:center;
z-index:9999; padding:2rem 1rem; overflow-y:auto; }
.pfm-modal { background:#fff; border-radius:.75rem; width:100%; max-width:56rem;
box-shadow:0 20px 50px rgba(0,0,0,.3); margin:auto; }
.pfm-modal-header { display:flex; align-items:center; justify-content:space-between;
padding:1rem 1.25rem; border-bottom:1px solid #e5e7eb; }
.pfm-modal-title { font-size:1rem; font-weight:600; color:#111827; }
.pfm-modal-body { padding:1.25rem; overflow-y:auto; max-height:70vh; }
.pfm-modal-footer { display:flex; justify-content:flex-end; gap:.5rem;
padding:.875rem 1.25rem; border-top:1px solid #e5e7eb;
background:#f9fafb; border-radius:0 0 .75rem .75rem; }
/* Entry table */
.pfm-table-wrap { overflow-x:auto; }
.pfm-table { width:100%; border-collapse:collapse; font-size:.8125rem; }
.pfm-table th { background:#f9fafb; text-align:left; padding:.5rem .625rem;
font-weight:600; color:#374151; border-bottom:1px solid #e5e7eb;
white-space:nowrap; position:sticky; top:0; }
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid #f3f4f6;
vertical-align:top; }
.pfm-table tr.today-row td { background:#fef9c3; }
.pfm-table td input, .pfm-table td textarea {
width:100%; border:1px solid #d1d5db; border-radius:.25rem;
padding:.25rem .375rem; font-size:.8125rem; font-family:inherit;
resize:vertical; background:#fff; }
.pfm-table td input:focus, .pfm-table td textarea:focus {
outline:none; border-color:#3b82f6; }
.pfm-day-col { width:3rem; text-align:center; font-weight:600;
color:#6b7280; white-space:nowrap; }
.pfm-pagination { display:flex; align-items:center; justify-content:space-between;
margin-top:.75rem; font-size:.8125rem; color:#6b7280; }
.pfm-page-jump { display:flex; align-items:center; gap:.375rem; font-size:.8125rem; }
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid #d1d5db;
border-radius:.25rem; text-align:center; }
/* Form in create modal */
.pfm-field { margin-bottom:.875rem; }
.pfm-field label { display:block; font-size:.875rem; font-weight:500;
color:#374151; margin-bottom:.25rem; }
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid #d1d5db;
border-radius:.375rem; font-size:.875rem; }
.pfm-field input:focus { outline:none; border-color:#3b82f6; }
.pfm-field-hint { font-size:.75rem; color:#9ca3af; margin-top:.2rem; }
.pfm-field-error { font-size:.75rem; color:#dc2626; margin-top:.2rem; }
/* Delete danger box */
.pfm-danger-box { background:#fef2f2; border:1px solid #fecaca;
border-radius:.5rem; padding:.875rem; font-size:.875rem;
color:#991b1b; }
`;
document.head.appendChild(style);
}
// ─── Per-instance state ───────────────────────────────────────────────────
const _state = new Map(); // fieldId → { pluginId, actions, createFields, files, page, entriesPerPage, modal }
function getState(fieldId) {
if (!_state.has(fieldId)) _state.set(fieldId, {
pluginId: '', actions: {}, createFields: [], uploadHint: '',
directoryLabel: '', files: [], page: 1, entriesPerPage: 20,
currentModal: null
});
return _state.get(fieldId);
}
// ─── API helper ───────────────────────────────────────────────────────────
async function callAction(pluginId, actionId, params = {}) {
const resp = await fetch('/api/v3/plugins/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId, action_id: actionId, params })
});
return resp.json();
}
function notify(msg, type) {
if (window.showNotification) window.showNotification(msg, type);
else console.log(`[PFM][${type}] ${msg}`);
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = String(s ?? '');
return d.innerHTML;
}
function formatSize(bytes) {
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1024).toFixed(2) + ' KB';
}
function formatDate(iso) {
try { return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); }
catch { return iso; }
}
// ─── Core: load files ─────────────────────────────────────────────────────
async function loadFiles(fieldId) {
const st = getState(fieldId);
const root = document.getElementById(`${fieldId}_pfm`);
if (!root) return;
const grid = root.querySelector('.pfm-grid');
if (grid) grid.innerHTML = '<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>';
const data = await callAction(st.pluginId, st.actions.list).catch(() => null);
if (!data || data.status !== 'success') {
if (grid) grid.innerHTML = '<div class="pfm-empty"><i class="fas fa-exclamation-circle"></i>Failed to load files.</div>';
return;
}
st.files = data.files || [];
renderCards(fieldId);
}
// ─── Card grid ────────────────────────────────────────────────────────────
function renderCards(fieldId) {
const st = getState(fieldId);
const root = document.getElementById(`${fieldId}_pfm`);
if (!root) return;
const grid = root.querySelector('.pfm-grid');
if (!grid) return;
if (!st.files.length) {
grid.innerHTML = '<div class="pfm-empty"><i class="fas fa-folder-open"></i>No files yet. Create or upload one.</div>';
return;
}
grid.innerHTML = st.files.map(f => `
<div class="pfm-card${f.enabled === false ? ' disabled' : ''}" data-filename="${escHtml(f.filename)}" data-category="${escHtml(f.category_name)}">
<div class="pfm-card-top">
<span class="pfm-toggle-label">${f.enabled !== false ? 'Enabled' : 'Disabled'}</span>
${st.actions.toggle ? `
<label class="pfm-toggle-cb" title="${f.enabled !== false ? 'Click to disable' : 'Click to enable'}">
<input type="checkbox" ${f.enabled !== false ? 'checked' : ''}
onchange="window._pfmToggle('${fieldId}','${escHtml(f.category_name)}',this.checked)">
<span class="pfm-toggle-slider"></span>
</label>` : ''}
</div>
<div class="pfm-card-icon"><i class="fas fa-file-code"></i></div>
<div class="pfm-card-name">${escHtml(f.display_name || f.filename)}</div>
<div class="pfm-card-meta">
${escHtml(f.filename)}<br>
${f.entry_count != null ? escHtml(f.entry_count) + ' entries' : ''}&nbsp;•&nbsp;${formatSize(f.size)}<br>
${formatDate(f.modified)}
</div>
<div class="pfm-card-actions">
${st.actions.get && st.actions.save ? `
<button class="pfm-btn pfm-btn-primary"
onclick="window._pfmOpenEdit('${fieldId}','${escHtml(f.filename)}')">
<i class="fas fa-edit"></i> Edit
</button>` : ''}
${st.actions.delete ? `
<button class="pfm-btn pfm-btn-danger pfm-btn-sm"
onclick="window._pfmOpenDelete('${fieldId}','${escHtml(f.filename)}')">
<i class="fas fa-trash"></i>
</button>` : ''}
</div>
</div>`).join('');
}
// ─── Edit modal ───────────────────────────────────────────────────────────
window._pfmOpenEdit = async function (fieldId, filename) {
const st = getState(fieldId);
const overlay = createOverlay(fieldId);
overlay.innerHTML = `
<div class="pfm-modal">
<div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
onclick="window._pfmCloseModal('${fieldId}')">
<i class="fas fa-times"></i>
</button>
</div>
<div class="pfm-modal-body" id="${fieldId}_edit_body">
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
</div>
<div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary"
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
<button class="pfm-btn pfm-btn-primary" id="${fieldId}_save_btn"
onclick="window._pfmSave('${fieldId}','${escHtml(filename)}')">
<i class="fas fa-save mr-1"></i>Save
</button>
</div>
</div>`;
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) body.innerHTML = '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>';
return;
}
const content = data.content || data.data || {};
st._editData = content;
st._editFilename = filename;
if (isTabular(content)) {
renderEntryTable(fieldId, body, content);
} else {
// Fallback: JSON textarea
body.innerHTML = `
<textarea id="${fieldId}_json_ta" rows="20"
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
<div id="${fieldId}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
}
};
function isTabular(data) {
if (typeof data !== 'object' || Array.isArray(data)) return false;
const keys = Object.keys(data);
if (!keys.length) return false;
const first = data[keys[0]];
if (typeof first !== 'object' || Array.isArray(first)) return false;
const entryKeys = Object.keys(first);
return entryKeys.length > 0 && entryKeys.length <= 8;
}
function renderEntryTable(fieldId, container, content) {
const st = getState(fieldId);
const entries = Object.entries(content).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
if (!entries.length) { container.innerHTML = '<div class="pfm-empty">No entries.</div>'; return; }
const cols = Object.keys(entries[0][1]);
const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / 86400000);
const total = entries.length;
const perPage = st.entriesPerPage;
function buildPage(page) {
const start = (page - 1) * perPage;
const pageEntries = entries.slice(start, start + perPage);
const totalPages = Math.ceil(total / perPage);
container.innerHTML = `
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;">
${total} entries total
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem"
onclick="(function(){const targetPage=Math.ceil(${todayDoy}/${perPage});window._pfmTablePage('${fieldId}',targetPage);setTimeout(function(){const row=document.querySelector('tr[data-day=\\'${todayDoy}\\']');if(row)row.scrollIntoView({block:'center'});},60);})()">
<i class="fas fa-calendar-day"></i> Jump to today (day ${todayDoy})
</button>
</div>
<div id="${fieldId}_tbl_wrap" class="pfm-table-wrap" style="max-height:52vh;overflow-y:auto;">
<table class="pfm-table">
<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('')}
</tr>
</thead>
<tbody>
${pageEntries.map(([day, val]) => `
<tr data-day="${day}" class="${parseInt(day) === todayDoy ? 'today-row' : ''}">
<td class="pfm-day-col" style="user-select:none;">${escHtml(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="${day}" data-col="${escHtml(col)}" rows="2"
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"
>${escHtml(String(v))}</textarea></td>`
: `<td><input type="text" data-day="${day}" data-col="${escHtml(col)}"
value="${escHtml(String(v))}"
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"></td>`;
}).join('')}
</tr>`).join('')}
</tbody>
</table>
</div>
<div class="pfm-pagination">
<span>Page ${page} of ${totalPages}</span>
<div class="pfm-page-jump">
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
${page <= 1 ? 'disabled' : ''}
onclick="window._pfmTablePage('${fieldId}',${page - 1})"> Prev</button>
<span>Go to</span>
<input type="number" min="1" max="${totalPages}" value="${page}"
onchange="window._pfmTablePage('${fieldId}',+this.value)">
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
${page >= totalPages ? 'disabled' : ''}
onclick="window._pfmTablePage('${fieldId}',${page + 1})">Next </button>
</div>
</div>`;
st._tablePage = page;
st._tableEntries = entries;
st._tableCols = cols;
}
buildPage(st._tablePage || 1);
window._pfmTablePage = function (fId, p) {
const s = getState(fId);
const totalP = Math.ceil(s._tableEntries.length / s.entriesPerPage);
buildPage(Math.max(1, Math.min(p, totalP)));
};
}
window._pfmCellEdit = function (fieldId, day, col, value) {
const st = getState(fieldId);
if (st._editData && st._editData[day]) st._editData[day][col] = value;
};
window._pfmSave = async function (fieldId, filename) {
const st = getState(fieldId);
const saveBtn = document.getElementById(`${fieldId}_save_btn`);
let content;
// Try getting from inline table data first, then textarea fallback
if (st._editData) {
content = st._editData;
} else {
const ta = document.getElementById(`${fieldId}_json_ta`);
if (!ta) return;
try { content = JSON.parse(ta.value); }
catch (e) {
const errEl = document.getElementById(`${fieldId}_json_err`);
if (errEl) errEl.textContent = 'Invalid JSON: ' + e.message;
return;
}
}
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Saving…'; }
const result = await callAction(st.pluginId, st.actions.save, {
filename, content: JSON.stringify(content)
}).catch(() => ({ status: 'error', message: 'Network error' }));
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = '<i class="fas fa-save mr-1"></i>Save'; }
if (result.status === 'success') {
notify('File saved successfully', 'success');
window._pfmCloseModal(fieldId);
await loadFiles(fieldId);
} else {
notify('Save failed: ' + (result.message || 'Unknown error'), 'error');
}
};
// ─── Delete modal ─────────────────────────────────────────────────────────
window._pfmOpenDelete = function (fieldId, filename) {
const overlay = createOverlay(fieldId);
overlay.innerHTML = `
<div class="pfm-modal" style="max-width:28rem;">
<div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
onclick="window._pfmCloseModal('${fieldId}')">
<i class="fas fa-times"></i>
</button>
</div>
<div class="pfm-modal-body">
<div class="pfm-danger-box">
<strong>${escHtml(filename)}</strong> will be permanently deleted and removed
from the plugin configuration. This cannot be undone.
</div>
</div>
<div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary"
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
<button class="pfm-btn pfm-btn-danger"
onclick="window._pfmConfirmDelete('${fieldId}','${escHtml(filename)}')">
<i class="fas fa-trash mr-1"></i>Delete
</button>
</div>
</div>`;
};
window._pfmConfirmDelete = async function (fieldId, filename) {
const st = getState(fieldId);
const result = await callAction(st.pluginId, st.actions.delete, { filename })
.catch(() => ({ status: 'error', message: 'Network error' }));
if (result.status === 'success') {
notify('File deleted', 'success');
window._pfmCloseModal(fieldId);
await loadFiles(fieldId);
} else {
notify('Delete failed: ' + (result.message || ''), 'error');
}
};
// ─── Create modal ─────────────────────────────────────────────────────────
window._pfmOpenCreate = function (fieldId) {
const st = getState(fieldId);
const fields = st.createFields;
const overlay = createOverlay(fieldId);
overlay.innerHTML = `
<div class="pfm-modal" style="max-width:32rem;">
<div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
onclick="window._pfmCloseModal('${fieldId}')">
<i class="fas fa-times"></i>
</button>
</div>
<div class="pfm-modal-body">
<div id="${fieldId}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
${fields.map(f => `
<div class="pfm-field">
<label for="${fieldId}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
<input type="text" id="${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>` : ''}
</div>`).join('')}
</div>
<div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary"
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
<button class="pfm-btn pfm-btn-create" id="${fieldId}_create_btn"
onclick="window._pfmConfirmCreate('${fieldId}')">
<i class="fas fa-plus mr-1"></i>Create
</button>
</div>
</div>`;
};
window._pfmConfirmCreate = async function (fieldId) {
const st = getState(fieldId);
const errEl = document.getElementById(`${fieldId}_create_err`);
const btn = document.getElementById(`${fieldId}_create_btn`);
const params = {};
for (const f of st.createFields) {
const inp = document.getElementById(`${fieldId}_cf_${f.key}`);
if (!inp) continue;
const val = inp.value.trim();
if (f.pattern && val && !new RegExp(f.pattern).test(val)) {
if (errEl) errEl.textContent = `${f.label || f.key}: invalid format — ${f.hint || ''}`;
inp.focus(); return;
}
params[f.key] = val;
}
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Creating…'; }
if (errEl) errEl.textContent = '';
const result = await callAction(st.pluginId, st.actions.create, params)
.catch(() => ({ status: 'error', message: 'Network error' }));
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-plus mr-1"></i>Create'; }
if (result.status === 'success') {
notify('File created', 'success');
window._pfmCloseModal(fieldId);
await loadFiles(fieldId);
} else {
if (errEl) errEl.textContent = result.message || 'Create failed';
}
};
// ─── Toggle ───────────────────────────────────────────────────────────────
window._pfmToggle = async function (fieldId, categoryName, enabled) {
const st = getState(fieldId);
const result = await callAction(st.pluginId, st.actions.toggle, { category_name: categoryName, enabled })
.catch(() => ({ status: 'error' }));
if (result.status === 'success') {
notify(enabled ? `${categoryName} enabled` : `${categoryName} disabled`, 'success');
await loadFiles(fieldId);
} else {
notify('Toggle failed', 'error');
await loadFiles(fieldId); // revert UI
}
};
// ─── Upload ───────────────────────────────────────────────────────────────
window._pfmUpload = async function (fieldId, file) {
const st = getState(fieldId);
const notifyFn = window.showNotification || console.log;
if (!file.name.toLowerCase().endsWith('.json')) {
notifyFn('Only .json files can be uploaded', 'error'); return;
}
let content;
try { content = await file.text(); JSON.parse(content); }
catch { notifyFn('File contains invalid JSON', 'error'); return; }
const result = await callAction(st.pluginId, st.actions.upload, {
filename: file.name, content
}).catch(() => ({ status: 'error', message: 'Network error' }));
if (result.status === 'success') {
notify('File uploaded: ' + (result.filename || file.name), 'success');
await loadFiles(fieldId);
} else {
notify('Upload failed: ' + (result.message || ''), 'error');
}
};
// ─── Modal helpers ────────────────────────────────────────────────────────
function createOverlay(fieldId) {
window._pfmCloseModal(fieldId); // close any open modal first
const overlay = document.createElement('div');
overlay.className = 'pfm-overlay';
overlay.id = `${fieldId}_pfm_overlay`;
// Close on backdrop click
overlay.addEventListener('click', e => { if (e.target === overlay) window._pfmCloseModal(fieldId); });
document.body.appendChild(overlay);
getState(fieldId).currentModal = overlay;
return overlay;
}
window._pfmCloseModal = function (fieldId) {
const st = getState(fieldId);
if (st.currentModal) { st.currentModal.remove(); st.currentModal = null; }
st._editData = null;
st._editFilename = null;
};
// ─── Widget registration ──────────────────────────────────────────────────
window.LEDMatrixWidgets.register('plugin-file-manager', {
name: 'Plugin File Manager Widget',
version: '1.0.0',
render: function (container, config, value, options) {
const fieldId = (options.fieldId || container.id || 'pfm').replace(/[^a-zA-Z0-9_-]/g, '_');
const wc = config['x-widget-config'] || {};
const actions = wc.actions || {};
const pluginId = options.pluginId || '';
const st = getState(fieldId);
Object.assign(st, {
pluginId,
actions,
createFields: wc.create_fields || [],
uploadHint: wc.upload_hint || 'Upload JSON files',
directoryLabel: wc.directory_label || ''
});
container.innerHTML = `
<div class="pfm-root" id="${fieldId}_pfm">
<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>` : ''}
</div>
<div style="display:flex;gap:.375rem;">
${actions.create ? `
<button class="pfm-btn pfm-btn-create"
onclick="window._pfmOpenCreate('${fieldId}')">
<i class="fas fa-plus mr-1"></i>New File
</button>` : ''}
</div>
</div>
${actions.upload ? `
<div class="pfm-upload" id="${fieldId}_upload_zone"
onclick="document.getElementById('${fieldId}_file_input').click()"
ondragover="event.preventDefault();this.classList.add('dragover')"
ondragleave="this.classList.remove('dragover')"
ondrop="this.classList.remove('dragover');event.preventDefault();
if(event.dataTransfer.files[0])window._pfmUpload('${fieldId}',event.dataTransfer.files[0])">
<input type="file" id="${fieldId}_file_input" accept=".json"
style="display:none"
onchange="if(this.files[0])window._pfmUpload('${fieldId}',this.files[0]);this.value=''">
<i class="fas fa-cloud-upload-alt" style="font-size:1.5rem;color:#9ca3af;"></i>
<p>Drag and drop or click to upload</p>
<small>${escHtml(st.uploadHint)}</small>
</div>` : ''}
<div class="pfm-grid">
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
</div>
</div>`;
loadFiles(fieldId);
},
getValue: function () { return null; }, // file ops are immediate; nothing to submit
setValue: function (fieldId) { loadFiles(fieldId); }
});
console.log('[PluginFileManager] plugin-file-manager widget registered');
})();

View File

@@ -0,0 +1,157 @@
/**
* LEDMatrix Time Picker Widget
*
* Single time selection using the browser's native time input.
* Returns a string in HH:MM (24-hour) format.
*
* Schema example:
* {
* "target_time": {
* "type": "string",
* "x-widget": "time-picker",
* "default": "00:00",
* "x-options": {
* "placeholder": "Select time",
* "clearable": true
* }
* }
* }
*
* @module TimePickerWidget
*/
(function() {
'use strict';
const base = window.BaseWidget ? new window.BaseWidget('TimePicker', '1.0.0') : null;
function escapeHtml(text) {
if (base) return base.escapeHtml(text);
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function sanitizeId(id) {
if (base) return base.sanitizeId(id);
return String(id).replace(/[^a-zA-Z0-9_-]/g, '_');
}
function triggerChange(fieldId, value) {
if (base) {
base.triggerChange(fieldId, value);
} else {
document.dispatchEvent(new CustomEvent('widget-change', {
detail: { fieldId, value },
bubbles: true,
cancelable: true
}));
}
}
window.LEDMatrixWidgets.register('time-picker', {
name: 'Time Picker Widget',
version: '1.0.0',
render: function(container, config, value, options) {
const fieldId = sanitizeId(options.fieldId || container.id || 'time_picker');
const xOptions = config['x-options'] || config['x_options'] || {};
const placeholder = xOptions.placeholder || '';
const clearable = xOptions.clearable === true;
const disabled = xOptions.disabled === true;
const required = xOptions.required === true;
const currentValue = value || '';
let html = `<div id="${fieldId}_widget" class="time-picker-widget" data-field-id="${fieldId}">`;
html += '<div class="flex items-center">';
html += `
<div class="relative flex-1">
<input type="time"
id="${fieldId}_input"
name="${escapeHtml(options.name || fieldId)}"
value="${escapeHtml(currentValue)}"
${placeholder ? `placeholder="${escapeHtml(placeholder)}"` : ''}
${disabled ? 'disabled' : ''}
${required ? 'required' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('time-picker').onChange('${fieldId}')"
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<i class="fas fa-clock text-gray-400"></i>
</div>
</div>
`;
if (clearable && !disabled) {
html += `
<button type="button"
id="${fieldId}_clear"
onclick="window.LEDMatrixWidgets.getHandlers('time-picker').onClear('${fieldId}')"
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
title="Clear">
<i class="fas fa-times"></i>
</button>
`;
}
html += '</div>';
html += `<div id="${fieldId}_error" class="text-sm text-red-600 mt-1 hidden"></div>`;
html += '</div>';
container.innerHTML = html;
},
getValue: function(fieldId) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
return input ? input.value : '';
},
setValue: function(fieldId, value) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
const clearBtn = document.getElementById(`${safeId}_clear`);
if (input) input.value = value || '';
if (clearBtn) clearBtn.classList.toggle('hidden', !value);
},
validate: function(fieldId) {
const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`);
const errorEl = document.getElementById(`${safeId}_error`);
if (!input) return { valid: true, errors: [] };
const isValid = input.checkValidity();
if (errorEl) {
if (!isValid) {
errorEl.textContent = input.validationMessage;
errorEl.classList.remove('hidden');
input.classList.add('border-red-500');
} else {
errorEl.classList.add('hidden');
input.classList.remove('border-red-500');
}
}
return { valid: isValid, errors: isValid ? [] : [input.validationMessage] };
},
handlers: {
onChange: function(fieldId) {
const widget = window.LEDMatrixWidgets.get('time-picker');
const safeId = sanitizeId(fieldId);
const clearBtn = document.getElementById(`${safeId}_clear`);
const value = widget.getValue(fieldId);
if (clearBtn) clearBtn.classList.toggle('hidden', !value);
widget.validate(fieldId);
triggerChange(fieldId, value);
},
onClear: function(fieldId) {
const widget = window.LEDMatrixWidgets.get('time-picker');
widget.setValue(fieldId, '');
triggerChange(fieldId, '');
}
}
});
console.log('[TimePickerWidget] Time picker widget registered');
})();