diff --git a/docs/widget-guide.md b/docs/widget-guide.md index 2a88291f..be310fb4 100644 --- a/docs/widget-guide.md +++ b/docs/widget-guide.md @@ -10,6 +10,98 @@ The LEDMatrix Widget Registry system allows plugins to use reusable UI component ## Available Core Widgets +### Plugin File Manager Widget (`plugin-file-manager`) + +Full inline file management UI for plugins that manage files via the `web_ui_actions` system. Renders a card grid, upload zone, create/delete modals, and an entry table editor — entirely inline, no iframe. + +`plugin_id` is **automatically injected** from template context. File operations call `/api/v3/plugins/action` immediately on user action; no Save Configuration needed. + +**Schema Configuration:** +```json +{ + "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": "my_data/", + "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" } + ] + } + } +} +``` + +Not all 7 actions are required — omit any key to hide the corresponding UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch). + +The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea. + +**Used by:** of-the-day + +--- + +### Time Picker Widget (`time-picker`) + +Single time selection using the browser's native time input. Returns a string in `HH:MM` (24-hour) format. Generic — works in any plugin without configuration. + +**Schema Configuration:** +```json +{ + "target_time": { + "type": "string", + "x-widget": "time-picker", + "default": "00:00", + "x-options": { + "placeholder": "Select time", + "clearable": true + } + } +} +``` + +**Used by:** countdown + +--- + +### File Upload Single Widget (`file-upload-single`) + +Single-image upload for string fields. Uploads to the plugin's asset folder (`assets/plugins//uploads/`) and sets the string field value to the returned relative path. Shows a thumbnail preview and a clear button. The `plugin_id` is **automatically injected** from the template context — no need to specify it in the schema. + +**Schema Configuration:** +```json +{ + "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 + } + } +} +``` + +Note: Unlike `file-upload` (array-level), this widget is for a single `string` field. It is ideal for per-item images inside `array-table` rows. + +**Used by:** countdown + +--- + ### File Upload Widget (`file-upload`) Upload and manage image files with drag-and-drop support, preview, delete, and scheduling. diff --git a/web_interface/blueprints/pages_v3.py b/web_interface/blueprints/pages_v3.py index fc5b80a5..e3d0976e 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -102,6 +102,59 @@ def load_plugin_config_partial(plugin_id): logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True) return '
Error loading plugin config; see logs for details
', 500 + +@pages_v3.route('/plugin-ui//web-ui/') +def serve_plugin_web_ui(plugin_id, filename): + """Serve a plugin's web_ui/ HTML fragment as a standalone page. + + Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID + and loads Tailwind CSS so the fragment runs correctly in a sandboxed iframe. + """ + try: + _plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve() + _plugin_dir = (_plugins_base / plugin_id).resolve() + # Path traversal guard — plugin_dir must be inside plugins base + _plugin_dir.relative_to(_plugins_base) + + web_ui_path = (_plugin_dir / 'web_ui' / filename).resolve() + # Second guard — web_ui_path must stay inside web_ui/ + web_ui_path.relative_to(_plugin_dir / 'web_ui') + + if not web_ui_path.exists(): + return f'web_ui file not found: {filename}', 404 + if web_ui_path.suffix.lower() != '.html': + return 'Only .html files may be served here', 403 + + fragment = web_ui_path.read_text(encoding='utf-8') + + page = ( + '\n' + '\n' + '\n' + '\n' + '\n' + '\n' + # Tailwind v2 CDN — same version used by the parent LEDMatrix UI + '\n' + '\n' + '\n' + '\n' + + fragment + + '\n\n' + ) + return page, 200, {'Content-Type': 'text/html; charset=utf-8'} + + except ValueError: + return 'Forbidden', 403 + except Exception: + logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True) + return 'Error serving file', 500 + def _load_overview_partial(): """Load overview partial with system stats""" try: diff --git a/web_interface/static/v3/js/widgets/array-table.js b/web_interface/static/v3/js/widgets/array-table.js index d0fcf617..20f10858 100644 --- a/web_interface/static/v3/js/widgets/array-table.js +++ b/web_interface/static/v3/js/widgets/array-table.js @@ -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 → + * time-picker → + * file-upload-single → compact path input + upload button + * (enum values always render as + 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 = ''; + + 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 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 = ''; + 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 = ''; + 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 = ` +
+

Advanced Properties

+ +
`; + + 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 = `

${escapeHtml(label)}

`; + + 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 = ``; + 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 = ``; + 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 = ` + + `; + + // 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)'); })(); diff --git a/web_interface/static/v3/js/widgets/file-upload-single.js b/web_interface/static/v3/js/widgets/file-upload-single.js new file mode 100644 index 00000000..35956c8f --- /dev/null +++ b/web_interface/static/v3/js/widgets/file-upload-single.js @@ -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 = `
`; + + // Hidden input carries the actual string value + html += ``; + + // Preview area (shown when a value is set) + html += `
`; + html += `Preview`; + html += ``; + html += `
+

${escapeHtml(currentValue.split('/').pop() || '')}

+

${escapeHtml(currentValue)}

+
`; + html += ``; + html += '
'; + + // Upload drop zone (always shown, acts as change button when value is set) + html += `
+ + +

${hasImage ? 'Click to replace image' : 'Click or drag to upload image'}

+

Max ${maxSizeMb}MB

+
`; + + // Status area for upload feedback + html += ``; + + html += '
'; + 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 = '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 = '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 = `${escapeHtml(error.message)}`; + } + notifyFn(`Upload error: ${error.message}`, 'error'); + } finally { + if (fileInput) fileInput.value = ''; + } + } + } + }); + + console.log('[FileUploadSingleWidget] File upload single widget registered'); +})(); diff --git a/web_interface/static/v3/js/widgets/plugin-file-manager.js b/web_interface/static/v3/js/widgets/plugin-file-manager.js new file mode 100644 index 00000000..0cc6c972 --- /dev/null +++ b/web_interface/static/v3/js/widgets/plugin-file-manager.js @@ -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 = '
Loading…
'; + + const data = await callAction(st.pluginId, st.actions.list).catch(() => null); + if (!data || data.status !== 'success') { + if (grid) grid.innerHTML = '
Failed to load files.
'; + 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 = '
No files yet. Create or upload one.
'; + return; + } + + grid.innerHTML = st.files.map(f => ` +
+
+ ${f.enabled !== false ? 'Enabled' : 'Disabled'} + ${st.actions.toggle ? ` + ` : ''} +
+
+
${escHtml(f.display_name || f.filename)}
+
+ ${escHtml(f.filename)}
+ ${f.entry_count != null ? escHtml(f.entry_count) + ' entries' : ''} • ${formatSize(f.size)}
+ ${formatDate(f.modified)} +
+
+ ${st.actions.get && st.actions.save ? ` + ` : ''} + ${st.actions.delete ? ` + ` : ''} +
+
`).join(''); + } + + // ─── Edit modal ─────────────────────────────────────────────────────────── + + window._pfmOpenEdit = async function (fieldId, filename) { + const st = getState(fieldId); + const overlay = createOverlay(fieldId); + overlay.innerHTML = ` +
+
+ ${escHtml(filename)} + +
+
+
Loading…
+
+ +
`; + + 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 = '
Failed to load file.
'; + 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 = ` + +
`; + } + }; + + 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 = '
No entries.
'; 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 = ` +
+ ${total} entries total + +
+
+ + + + + ${cols.map(c => ``).join('')} + + + + ${pageEntries.map(([day, val]) => ` + + + ${cols.map(col => { + const v = val[col] ?? ''; + const isLong = String(v).length > 60 || col === 'description' || col === 'definition' || col === 'content'; + return isLong + ? `` + : ``; + }).join('')} + `).join('')} + +
Day${escHtml(c.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()))}
${escHtml(day)}
+
+
+ Page ${page} of ${totalPages} +
+ + Go to + + +
+
`; + 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 = '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 = '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 = ` +
+
+ Delete File + +
+
+
+ ${escHtml(filename)} will be permanently deleted and removed + from the plugin configuration. This cannot be undone. +
+
+ +
`; + }; + + 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 = ` +
+
+ Create New File + +
+
+
+ ${fields.map(f => ` +
+ + + ${f.hint ? `
${escHtml(f.hint)}
` : ''} +
`).join('')} +
+ +
`; + }; + + 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 = '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 = '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 = ` +
+
+
+
File Explorer
+ ${st.directoryLabel ? `
Manage files in ${escHtml(st.directoryLabel)}
` : ''} +
+
+ ${actions.create ? ` + ` : ''} +
+
+ + ${actions.upload ? ` +
+ + +

Drag and drop or click to upload

+ ${escHtml(st.uploadHint)} +
` : ''} + +
+
Loading…
+
+
`; + + 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'); +})(); diff --git a/web_interface/static/v3/js/widgets/time-picker.js b/web_interface/static/v3/js/widgets/time-picker.js new file mode 100644 index 00000000..613a15d6 --- /dev/null +++ b/web_interface/static/v3/js/widgets/time-picker.js @@ -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 = `
`; + html += '
'; + html += ` +
+ +
+ +
+
+ `; + + if (clearable && !disabled) { + html += ` + + `; + } + + html += '
'; + html += ``; + html += '
'; + + 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'); +})(); diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 9072497d..6859e9c7 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -497,15 +497,26 @@ {% endif %}
- +
+
{% for col_name in display_columns %} {% set col_def = item_properties.get(col_name, {}) %} {% set col_title = col_def.get('title', col_name|replace('_', ' ')|title) %} - + {% set col_xwidget = col_def.get('x-widget', '') %} + {% set col_enum = col_def.get('enum', []) %} + {% set col_ctype = col_def.get('type', 'string') %} + {% if col_xwidget == 'date-picker' %}{% set col_min_w = '140px' %} + {% elif col_xwidget == 'time-picker' %}{% set col_min_w = '115px' %} + {% elif col_xwidget == 'file-upload-single' %}{% set col_min_w = '200px' %} + {% elif col_enum %}{% set col_min_w = '90px' %} + {% elif col_ctype == 'boolean' %}{% set col_min_w = '60px' %} + {% elif col_ctype in ['integer', 'number'] %}{% set col_min_w = '80px' %} + {% else %}{% set col_min_w = '110px' %}{% endif %} + {% endfor %} - + @@ -515,8 +526,17 @@ {% for col_name in display_columns %} {% set col_def = item_properties.get(col_name, {}) %} {% set col_type = col_def.get('type', 'string') %} + {% set col_xwidget = col_def.get('x-widget', '') %} + {% set col_enum = col_def.get('enum', []) %} {% set col_value = item.get(col_name, col_def.get('default', '')) %} - + + {# Hidden cell: flat hidden inputs for non-displayed props (layout, style, etc.) #} + {% if has_advanced.value %} + {% set adv_schema = namespace(d={}) %} + {% for k, v in item_properties.items() %}{% if k not in display_columns and k != 'id' %}{% set _ = adv_schema.d.update({k: v}) %}{% endif %}{% endfor %} + + {% endif %} {% endfor %} @@ -563,11 +667,58 @@ data-max-items="{{ max_items }}" data-plugin-id="{{ plugin_id }}" data-item-properties='{% set ns = namespace(d={}) %}{% for k in display_columns %}{% if k in item_properties %}{% set _ = ns.d.update({k: item_properties[k]}) %}{% endif %}{% endfor %}{{ ns.d|tojson }}' + data-full-item-properties='{{ item_properties|tojson }}' data-display-columns='{{ display_columns|tojson }}' class="mt-3 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md" {% if array_value|length >= max_items %}disabled style="opacity: 0.5;"{% endif %}> Add Item + {# end overflow-x:auto wrapper #} + + {% elif x_widget == 'color-picker' %} + {# RGB color array: R / G / B number inputs + visual swatch + sync'd hex picker #} + {% set color_arr = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else [255, 255, 255]) %} + {% set r_val = color_arr[0] if color_arr|length > 0 else 255 %} + {% set g_val = color_arr[1] if color_arr|length > 1 else 255 %} + {% set b_val = color_arr[2] if color_arr|length > 2 else 255 %} + {% set hex_val = '#%02x%02x%02x' % (r_val|int, g_val|int, b_val|int) %} +
+ +
+ + +
+
+ + +
+
+ + +
+
{% else %} {# Generic array-of-objects would go here if needed in the future #} @@ -626,7 +777,19 @@ name="{{ full_key }}" value="{{ str_value }}"> - {% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector'] %} + {% elif str_widget == 'json-file-manager' %} + {# Embedded file manager — plugin's web_ui/file_manager.html served via /v3/plugin-ui/ route #} +
+ +
+

+ + Changes in the file manager save immediately — no need to click Save Configuration. +

+ {% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager'] %} {# Render widget container #}
{{ col_title }}{{ col_title }}ActionsActions
+ {% if col_xwidget == 'date-picker' %}{% set td_min_w = '140px' %} + {% elif col_xwidget == 'time-picker' %}{% set td_min_w = '115px' %} + {% elif col_xwidget == 'file-upload-single' %}{% set td_min_w = '200px' %} + {% elif col_enum %}{% set td_min_w = '90px' %} + {% elif col_type == 'boolean' %}{% set td_min_w = '60px' %} + {% elif col_type in ['integer', 'number'] %}{% set td_min_w = '80px' %} + {% else %}{% set td_min_w = '110px' %}{% endif %} + {% if col_type == 'boolean' %} + {% elif col_enum %} + + {% elif col_xwidget == 'date-picker' %} + + {% elif col_xwidget == 'time-picker' %} + + {% elif col_xwidget == 'file-upload-single' %} + {% set cell_input_id = field_id ~ '_' ~ item_index ~ '_' ~ col_name %} +
+ {% if col_value %}{% endif %} + + +
{% else %} {% endfor %} -
+ + {# Actions cell: delete + optional edit button for advanced props #} + {% set has_advanced = namespace(value=false) %} + {% for k in item_properties.keys() %}{% if k not in display_columns and k != 'id' %}{% set has_advanced.value = true %}{% endif %}{% endfor %} + + {% if has_advanced.value %} + + {% endif %}