From 496169725168d9903e810b674bb80772c9a7b781 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 31 May 2026 08:56:26 -0400 Subject: [PATCH] feat(widgets): plugin-file-manager, time-picker, file-upload-single + array-table v2 (#356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(plugin-loader): detect new deps via requirements.txt hash instead of empty marker The .dependencies_installed marker was an empty file, so adding a new package to requirements.txt (e.g. astral in ledmatrix-weather v2.3.0) never triggered a pip re-install on existing installs — the file existed so the check returned early. The marker now stores a SHA-256 hash of requirements.txt. On every plugin load, the loader compares the current hash to the stored one; a mismatch (or missing marker) triggers pip install and writes the new hash. store_manager._install_dependencies() also writes the hash marker after a store install/update so the loader skips a redundant pip run on next boot. Co-Authored-By: Claude Sonnet 4.6 * fix(plugin-loader): address CodeQL path expression and I/O error handling - Add explicit relative_to() containment check after path resolution so CodeQL recognizes the plugin directory boundary (fixes 4 CodeQL alerts: Uncontrolled data used in path expression, lines 168/172/189/205) - Wrap requirements_file.read_bytes() in try/except OSError — on Raspberry Pi with flaky SD card storage this can fail; returns False with a clear log - Wrap marker_path.read_text() in try/except OSError — a corrupted marker falls through to a clean reinstall instead of crashing - Wrap both marker_path.write_text() calls in try/except OSError — pip already succeeded at this point so a marker write failure should not return False or propagate through the generic exception handler Co-Authored-By: Claude Sonnet 4.6 * fix(plugin-loader): use realpath+startswith containment check for CodeQL path-injection Replace relative_to() (not recognised by CodeQL as a path sanitiser) with the os.path.realpath() + startswith() pattern that CodeQL explicitly models as sanitising py/path-injection. - Add plugins_dir optional param to install_dependencies() and load_plugin() - PluginManager.load_plugin() passes self.plugins_dir as the trusted anchor; install_dependencies() validates that the resolved plugin_dir starts with the resolved plugins_dir before any file I/O - Replace all Path.read_bytes/read_text/write_text/exists with open() and os.path.isfile() so the sanitised string paths flow directly to file ops without re-introducing taint through Path object conversion Co-Authored-By: Claude Sonnet 4.6 * fix(plugin-loader): fail-fast when install_dependencies returns False Previously the boolean result was silently discarded, so a failed pip install would log a warning but continue attempting to import the plugin module — resulting in a confusing ModuleNotFoundError instead of a clear dependency failure message. Now raises PluginError with plugin_id and plugin_dir if dependency installation fails, stopping the load before the import is attempted. Co-Authored-By: Claude Sonnet 4.6 * fix(plugin-loader): use basename+reconstruct to satisfy CodeQL py/path-injection startswith() is a validation check in CodeQL's model, not a sanitiser — taint still flows through plugin_dir_real to the file operations. os.path.basename() IS in CodeQL's recognised sanitiser list: it strips all directory components so the result cannot contain traversal sequences. Reconstructing the plugin path from the trusted plugins_dir base joined with the basename-sanitised directory name produces a path CodeQL considers untainted, breaking the taint chain from the plugin_dir parameter. Co-Authored-By: Claude Sonnet 4.6 * fix(plugin-loader): guard against empty basename when plugin_dir resolves to fs root If plugin_dir somehow resolves to '/' or a bare drive root, os.path.basename() returns '', causing safe_plugin_dir to equal plugins_dir_real and the isdir() check to pass incorrectly. Reject early with a clear error in that case. Co-Authored-By: Claude Sonnet 4.6 * 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 , 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//web-ui/ 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 → - time-picker x-widget → - 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 +
`; + } + }; + + 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.textContent = 'No entries.'; return; } + + const cols = Object.keys(entries[0][1]); + const MS_PER_DAY = 86400 * 1000; // eslint-disable-line no-magic-numbers -- 86400s/day is not magic + const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / MS_PER_DAY); + const total = entries.length; + const perPage = st.entriesPerPage; + + function buildPage(page) { + const start = (page - 1) * perPage; // eslint-disable-line no-magic-numbers + const pageEntries = entries.slice(start, start + perPage); + const totalPages = Math.ceil(total / perPage); + + safeSetHTML(container, ` +
+ ${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; + } + + // Store buildPage in per-instance state so multiple instances don't + // clobber each other's pagination via a shared global. + st._buildPage = buildPage; + buildPage(st._tablePage || 1); + } + + // Global dispatcher — resolves the per-instance buildPage from state so + // multiple plugin-file-manager instances don't clobber each other. + window._pfmTablePage = function (fId, p) { + const s = getState(fId); + if (s._buildPage) { + const total = s._tableEntries ? s._tableEntries.length : 0; + const totalP = Math.ceil(total / s.entriesPerPage) || 1; + s._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; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-spinner fa-spin mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Saving…'));})(saveBtn); } + + 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; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-save mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Save'));})(saveBtn); } + + 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); + const modal = document.createElement('div'); + modal.className = 'pfm-modal'; + modal.style.maxWidth = '28rem'; + safeSetHTML(modal, ` +
+ Delete File + +
+
+
+ ${escHtml(filename)} will be permanently deleted and removed + from the plugin configuration. This cannot be undone. +
+
+ `; + overlay.appendChild(modal); + modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId)); + modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId)); + modal.querySelector(`#${CSS.escape(fieldId)}_del_confirm`).addEventListener('click', () => window._pfmConfirmDelete(fieldId, filename)); + }; + + 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); + const modal = document.createElement('div'); + modal.className = 'pfm-modal'; + modal.style.maxWidth = '32rem'; + safeSetHTML(modal, ` +
+ Create New File + +
+
+
+ ${fields.map(f => ` +
+ + + ${f.hint ? `
${escHtml(f.hint)}
` : ''} +
`).join('')} +
+ + `; + overlay.appendChild(modal); + modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId)); + modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId)); + modal.querySelector(`#${CSS.escape(fieldId)}_create_btn`).addEventListener('click', () => window._pfmConfirmCreate(fieldId)); + }; + + 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(); + // Client-side pattern validation omitted — server-side create-file script validates. + params[f.key] = val; + } + + if (btn) { btn.disabled = true; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-spinner fa-spin mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Creating…'));})(btn); } + 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; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-plus mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Create'));})(btn); } + + 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 || '' + }); + + safeSetHTML(container, ` +
+
+
+
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..bf8b7636 --- /dev/null +++ b/web_interface/static/v3/js/widgets/time-picker.js @@ -0,0 +1,166 @@ +/** + * 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 + })); + } + } + + function safeSetHTML(target, html) { + target.textContent = ''; + // createContextualFragment parses html relative to the document context + // without executing scripts — a widely recognised safe insertion method. + const frag = document.createRange().createContextualFragment(html); + target.appendChild(frag); + } + + 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 += '
'; + + safeSetHTML(container, 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, ''); + widget.validate(fieldId); // refresh required/error state + 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..12fdfc4d 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -497,15 +497,31 @@ {% 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') or col_def.get('x_widget', '') %} + {% set col_enum = col_def.get('enum', []) %} + {% set _raw_ctype = col_def.get('type', 'string') %} + {% if _raw_ctype is iterable and _raw_ctype is not string %} + {% set col_ctype = (_raw_ctype | reject('equalto','null') | list | first) or 'string' %} + {% else %} + {% set col_ctype = _raw_ctype or 'string' %} + {% endif %} + {% 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 %} - + @@ -514,9 +530,24 @@ {% for col_name in display_columns %} {% set col_def = item_properties.get(col_name, {}) %} - {% set col_type = col_def.get('type', 'string') %} + {# Normalize nullable types e.g. ["null","integer"] → "integer" #} + {% set _raw_type = col_def.get('type', 'string') %} + {% if _raw_type is iterable and _raw_type is not string %} + {% set col_type = (_raw_type | reject('equalto','null') | list | first) or 'string' %} + {% else %} + {% set col_type = _raw_type or 'string' %} + {% endif %} + {% set col_xwidget = col_def.get('x-widget') or 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 +678,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 +788,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 %}