diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 00000000..441240fe --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,7 @@ +--- +exclude_paths: + - "plugin-repos/**" + - "plugins/**" + - "assets/**" + - "test/**" + - "scripts/debug/**" diff --git a/docs/widget-guide.md b/docs/widget-guide.md index 2a88291f..dadda4cf 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" } + ] + } + } +} +``` + +**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its 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..73939f52 100644 --- a/web_interface/blueprints/pages_v3.py +++ b/web_interface/blueprints/pages_v3.py @@ -3,8 +3,13 @@ from markupsafe import escape import json import logging import os +import os.path import re from pathlib import Path + +# Strict allowlists for URL-derived values used in path and script operations. +_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$') +_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$') from src.web_interface.secret_helpers import mask_secret_fields logger = logging.getLogger(__name__) @@ -102,6 +107,99 @@ 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. + """ + # Validate URL-derived values against strict allowlists before any path or + # script operations. + if not _SAFE_PLUGIN_ID_RE.match(plugin_id): + return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'} + if not _SAFE_WEB_UI_FILE_RE.match(filename): + return 'Invalid filename', 400, {'Content-Type': 'text/plain'} + + # os.path.basename() is the CodeQL-recognised path sanitizer used throughout + # this codebase (see plugin_loader.py). Applying it here breaks the taint + # chain even though the allowlist above already prevents path separators. + safe_id = os.path.basename(plugin_id) + safe_fn = os.path.basename(filename) + if not safe_id or not safe_fn: + return 'Invalid path component', 400, {'Content-Type': 'text/plain'} + + if not pages_v3.plugin_manager: + return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'} + + try: + _plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve() + + # Reconstruct from sanitised basename — CodeQL-approved pattern. + _plugin_dir = (_plugins_base / safe_id).resolve() + _plugin_dir.relative_to(_plugins_base) # containment guard + + # Mirror PluginManager's ledmatrix- prefix fallback. + if not _plugin_dir.exists(): + _alt_id = os.path.basename(f'ledmatrix-{safe_id}') + _alt = (_plugins_base / _alt_id).resolve() + try: + _alt.relative_to(_plugins_base) + _plugin_dir = _alt + except ValueError: + pass + + web_ui_path = (_plugin_dir / 'web_ui' / safe_fn).resolve() + web_ui_path.relative_to(_plugin_dir / 'web_ui') # second guard + + if not web_ui_path.exists(): + return 'Not found', 404, {'Content-Type': 'text/plain'} + + fragment = web_ui_path.read_text(encoding='utf-8') + + # json.dumps wraps the value in quotes. Replace HTML meta-chars with + # their JS Unicode escape sequences so the value cannot close or escape + # the enclosing \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, {'Content-Type': 'text/plain'} + except Exception: + logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True) + return 'Error serving file', 500, {'Content-Type': 'text/plain'} + 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..20078fa2 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 || '{}'); + // 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 + safeSetHTML(dialog, ` +
+

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'; + const _secH4 = document.createElement('h4'); + _secH4.className = 'text-sm font-medium text-gray-700 mb-3'; + _secH4.textContent = label; + section.appendChild(_secH4); + + 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'); + const _subLbl = document.createElement('label'); + _subLbl.className = 'block text-xs font-medium text-gray-600 mb-1'; + _subLbl.title = subDesc; + _subLbl.textContent = subLabel; + fieldDiv.appendChild(_subLbl); + 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'); + const _flatLbl = document.createElement('label'); + _flatLbl.className = 'block text-sm font-medium text-gray-700 mb-1'; + _flatLbl.title = desc; + _flatLbl.textContent = label; + fieldDiv.appendChild(_flatLbl); + 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'; + safeSetHTML(footer, ` + + `; + + // 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; + + // ─── 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..52255d0d --- /dev/null +++ b/web_interface/static/v3/js/widgets/file-upload-single.js @@ -0,0 +1,291 @@ +/** + * 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); + } + + 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('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 — keyboard accessible via tabindex + Enter/Space + html += `
+ + +

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

+

Max ${maxSizeMb}MB

+
`; + + // Status area for upload feedback + html += ``; + + html += '
'; + safeSetHTML(container, 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() : ''; + const fullpath = document.getElementById(`${safeId}_fullpath`); + if (fullpath) fullpath.textContent = value || ''; + + // 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 — use DOM methods to avoid innerHTML with dynamic data + if (statusDiv) { + statusDiv.className = 'mt-1 text-xs text-gray-500'; + statusDiv.textContent = ''; + const spinner = document.createElement('i'); + spinner.className = 'fas fa-spinner fa-spin mr-1'; + statusDiv.appendChild(spinner); + statusDiv.appendChild(document.createTextNode('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.textContent = ''; + const icon = document.createElement('i'); + icon.className = 'fas fa-check-circle mr-1'; + statusDiv.appendChild(icon); + statusDiv.appendChild(document.createTextNode('Uploaded successfully')); + setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.textContent = ''; }, 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.textContent = ''; + const errIcon = document.createElement('i'); + errIcon.className = 'fas fa-exclamation-circle mr-1'; + statusDiv.appendChild(errIcon); + statusDiv.appendChild(document.createTextNode(error.message || 'Upload failed')); + } + 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..5cc54154 --- /dev/null +++ b/web_interface/static/v3/js/widgets/plugin-file-manager.js @@ -0,0 +1,797 @@ +/** + * 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); + } + + // ─── Safe HTML helper ───────────────────────────────────────────────────── + + /** + * Parse html in a sandboxed DOMParser document (scripts never execute) and + * replace target's children with the result. All dynamic values in html + * must be escaped by the caller before passing here. + */ + 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); + } + + // ─── 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) safeSetHTML(grid, '
Loading…
'); + + const data = await callAction(st.pluginId, st.actions.list).catch(() => null); + if (!data || data.status !== 'success') { + if (grid) safeSetHTML(grid, '
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) { + safeSetHTML(grid, '
No files yet. Create or upload one.
'); + return; + } + + // Remove any existing delegated listener before re-render + if (st._gridClickHandler) grid.removeEventListener('click', st._gridClickHandler); + if (st._gridChangeHandler) grid.removeEventListener('change', st._gridChangeHandler); + + // Event delegation: handles edit/delete/toggle via data attributes so + // filenames and category names are never interpolated into JS string literals. + st._gridClickHandler = function(e) { + const btn = e.target.closest('[data-pfm-action]'); + if (!btn) return; + const action = btn.dataset.pfmAction; + const fId = btn.dataset.pfmField; + if (action === 'edit') window._pfmOpenEdit(fId, btn.dataset.pfmFile); + if (action === 'delete') window._pfmOpenDelete(fId, btn.dataset.pfmFile); + }; + st._gridChangeHandler = function(e) { + const inp = e.target.closest('[data-pfm-action="toggle"]'); + if (!inp) return; + window._pfmToggle(inp.dataset.pfmField, inp.dataset.pfmCategory, inp.checked); + }; + grid.addEventListener('click', st._gridClickHandler); + grid.addEventListener('change', st._gridChangeHandler); + + // Build cards with DOM methods so no user-derived data flows through innerHTML. + grid.textContent = ''; + const frag = document.createDocumentFragment(); + st.files.forEach(function(f) { + const card = document.createElement('div'); + card.className = 'pfm-card' + (f.enabled === false ? ' disabled' : ''); + card.dataset.filename = f.filename; + card.dataset.category = f.category_name; + + // Top row: label + optional toggle + const top = document.createElement('div'); + top.className = 'pfm-card-top'; + const lbl = document.createElement('span'); + lbl.className = 'pfm-toggle-label'; + lbl.textContent = f.enabled !== false ? 'Enabled' : 'Disabled'; + top.appendChild(lbl); + if (st.actions.toggle) { + const tglLabel = document.createElement('label'); + tglLabel.className = 'pfm-toggle-cb'; + tglLabel.title = f.enabled !== false ? 'Click to disable' : 'Click to enable'; + const tglInput = document.createElement('input'); + tglInput.type = 'checkbox'; + tglInput.checked = f.enabled !== false; + tglInput.dataset.pfmAction = 'toggle'; + tglInput.dataset.pfmField = fieldId; + tglInput.dataset.pfmCategory = f.category_name; + const tglSlider = document.createElement('span'); + tglSlider.className = 'pfm-toggle-slider'; + tglLabel.appendChild(tglInput); + tglLabel.appendChild(tglSlider); + top.appendChild(tglLabel); + } + card.appendChild(top); + + // Icon (static markup) + const icon = document.createElement('div'); + icon.className = 'pfm-card-icon'; + icon.innerHTML = ''; + card.appendChild(icon); + + // Name & meta — textContent avoids any HTML injection + const name = document.createElement('div'); + name.className = 'pfm-card-name'; + name.textContent = f.display_name || f.filename; + card.appendChild(name); + + const meta = document.createElement('div'); + meta.className = 'pfm-card-meta'; + meta.appendChild(document.createTextNode(f.filename)); + meta.appendChild(document.createElement('br')); + if (f.entry_count != null) { + meta.appendChild(document.createTextNode(f.entry_count + ' entries · ' + formatSize(f.size))); + } + meta.appendChild(document.createElement('br')); + meta.appendChild(document.createTextNode(formatDate(f.modified))); + card.appendChild(meta); + + // Action buttons + const actions = document.createElement('div'); + actions.className = 'pfm-card-actions'; + if (st.actions.get && st.actions.save) { + const editBtn = document.createElement('button'); + editBtn.className = 'pfm-btn pfm-btn-primary'; + editBtn.dataset.pfmAction = 'edit'; + editBtn.dataset.pfmField = fieldId; + editBtn.dataset.pfmFile = f.filename; + editBtn.innerHTML = ' Edit'; // static + actions.appendChild(editBtn); + } + if (st.actions.delete) { + const delBtn = document.createElement('button'); + delBtn.className = 'pfm-btn pfm-btn-danger pfm-btn-sm'; + delBtn.dataset.pfmAction = 'delete'; + delBtn.dataset.pfmField = fieldId; + delBtn.dataset.pfmFile = f.filename; + delBtn.innerHTML = ''; // static + actions.appendChild(delBtn); + } + card.appendChild(actions); + frag.appendChild(card); + }); + grid.appendChild(frag); + } + + // ─── Edit modal ─────────────────────────────────────────────────────────── + + window._pfmOpenEdit = async function (fieldId, filename) { + const st = getState(fieldId); + const overlay = createOverlay(fieldId); + // Build modal using DOM methods so filename never enters a JS string literal. + const modal = document.createElement('div'); + modal.className = 'pfm-modal'; + safeSetHTML(modal, ` +
+ ${escHtml(filename)} + +
+
+
Loading…
+
+ `; + overlay.appendChild(modal); + // Bind events after DOM insertion — filename captured in closure, not in HTML. + modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId)); + modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId)); + modal.querySelector(`#${CSS.escape(fieldId)}_save_btn`).addEventListener('click', () => window._pfmSave(fieldId, filename)); + + 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) safeSetHTML(body, '
Failed to load file.
'); + return; + } + + const content = data.content || data.data || {}; + st._editFilename = filename; + + if (isTabular(content)) { + // Table path: track cell edits live in _editData + st._editData = content; + renderEntryTable(fieldId, body, content); + } else { + // Textarea path: _editData stays null; save() reads from the +
`; + } + }; + + 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 %}