mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-05-31 16:13:31 +00:00
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:
@@ -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)');
|
||||
})();
|
||||
|
||||
266
web_interface/static/v3/js/widgets/file-upload-single.js
Normal file
266
web_interface/static/v3/js/widgets/file-upload-single.js
Normal 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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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');
|
||||
})();
|
||||
692
web_interface/static/v3/js/widgets/plugin-file-manager.js
Normal file
692
web_interface/static/v3/js/widgets/plugin-file-manager.js
Normal 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 1–365 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' : ''} • ${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');
|
||||
})();
|
||||
157
web_interface/static/v3/js/widgets/time-picker.js
Normal file
157
web_interface/static/v3/js/widgets/time-picker.js
Normal 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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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');
|
||||
})();
|
||||
Reference in New Issue
Block a user