mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-05-31 08:03:32 +00:00
Compare commits
1 Commits
feat/plugi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cac9644b6d |
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
exclude_paths:
|
|
||||||
- "plugin-repos/**"
|
|
||||||
- "plugins/**"
|
|
||||||
- "assets/**"
|
|
||||||
- "test/**"
|
|
||||||
- "scripts/debug/**"
|
|
||||||
@@ -10,98 +10,6 @@ The LEDMatrix Widget Registry system allows plugins to use reusable UI component
|
|||||||
|
|
||||||
## Available Core Widgets
|
## 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/<plugin_id>/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`)
|
### File Upload Widget (`file-upload`)
|
||||||
|
|
||||||
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
|
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
|
||||||
|
|||||||
@@ -3,13 +3,8 @@ from markupsafe import escape
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import os.path
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
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
|
from src.web_interface.secret_helpers import mask_secret_fields
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -107,99 +102,6 @@ def load_plugin_config_partial(plugin_id):
|
|||||||
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
logger.error("Error loading plugin config partial for %s", plugin_id, exc_info=True)
|
||||||
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
return '<div class="text-red-500 p-4">Error loading plugin config; see logs for details</div>', 500
|
||||||
|
|
||||||
|
|
||||||
@pages_v3.route('/plugin-ui/<plugin_id>/web-ui/<path:filename>')
|
|
||||||
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 <script> tag.
|
|
||||||
# r'<' is the 6-char literal string <, which JavaScript
|
|
||||||
# interprets as <. This is the standard JSON-in-HTML hardening pattern.
|
|
||||||
safe_plugin_id_js = (
|
|
||||||
json.dumps(safe_id)
|
|
||||||
.replace('<', '\\u003c')
|
|
||||||
.replace('>', '\\u003e')
|
|
||||||
.replace('&', '\\u0026')
|
|
||||||
)
|
|
||||||
|
|
||||||
page = (
|
|
||||||
'<!DOCTYPE html>\n'
|
|
||||||
'<html lang="en">\n'
|
|
||||||
'<head>\n'
|
|
||||||
'<meta charset="UTF-8">\n'
|
|
||||||
'<meta name="viewport" content="width=device-width,initial-scale=1">\n'
|
|
||||||
'<script>\n'
|
|
||||||
# Inject plugin context before the fragment runs.
|
|
||||||
# plugin_id is validated to [a-zA-Z0-9_-] above, so this is safe,
|
|
||||||
# but we also Unicode-escape HTML meta-chars as defence in depth.
|
|
||||||
f' window.PLUGIN_ID = {safe_plugin_id_js};\n'
|
|
||||||
'</script>\n'
|
|
||||||
# Tailwind v2 CDN — same version used by the parent LEDMatrix UI
|
|
||||||
'<link rel="stylesheet" '
|
|
||||||
'href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" '
|
|
||||||
'crossorigin="anonymous">\n'
|
|
||||||
'<style>body{margin:0;padding:0;background:#fff;}</style>\n'
|
|
||||||
'</head>\n'
|
|
||||||
'<body>\n'
|
|
||||||
+ fragment +
|
|
||||||
'\n</body>\n</html>'
|
|
||||||
)
|
|
||||||
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():
|
def _load_overview_partial():
|
||||||
"""Load overview partial with system stats"""
|
"""Load overview partial with system stats"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,14 +5,21 @@
|
|||||||
* Handles adding, removing, and editing array items with object properties.
|
* Handles adding, removing, and editing array items with object properties.
|
||||||
* Reads column definitions from the schema's items.properties.
|
* Reads column definitions from the schema's items.properties.
|
||||||
*
|
*
|
||||||
* Supported x-widget hints on item properties:
|
* Usage in config_schema.json:
|
||||||
* date-picker → <input type="date">
|
* "my_array": {
|
||||||
* time-picker → <input type="time">
|
* "type": "array",
|
||||||
* file-upload-single → compact path input + upload button
|
* "x-widget": "array-table",
|
||||||
* (enum values always render as <select>)
|
* "x-columns": ["name", "code", "priority", "enabled"], // optional
|
||||||
*
|
* "items": {
|
||||||
* Non-displayed properties (objects like layout/style) are stored in a hidden
|
* "type": "object",
|
||||||
* cell and editable via the ⚙ row editor modal.
|
* "properties": {
|
||||||
|
* "name": { "type": "string" },
|
||||||
|
* "code": { "type": "string" },
|
||||||
|
* "priority": { "type": "integer", "default": 50 },
|
||||||
|
* "enabled": { "type": "boolean", "default": true }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
*
|
*
|
||||||
* @module ArrayTableWidget
|
* @module ArrayTableWidget
|
||||||
*/
|
*/
|
||||||
@@ -20,16 +27,18 @@
|
|||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
// Ensure LEDMatrixWidgets registry exists
|
||||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||||
console.error('[ArrayTableWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
|
console.error('[ArrayTableWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Widget registration ────────────────────────────────────────────────
|
/**
|
||||||
|
* Register the array-table widget
|
||||||
|
*/
|
||||||
window.LEDMatrixWidgets.register('array-table', {
|
window.LEDMatrixWidgets.register('array-table', {
|
||||||
name: 'Array Table Widget',
|
name: 'Array Table Widget',
|
||||||
version: '2.0.0',
|
version: '1.0.0',
|
||||||
|
|
||||||
render: function(container, config, value, options) {
|
render: function(container, config, value, options) {
|
||||||
console.log('[ArrayTableWidget] Render called (server-side rendered)');
|
console.log('[ArrayTableWidget] Render called (server-side rendered)');
|
||||||
@@ -44,39 +53,24 @@
|
|||||||
|
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
const item = {};
|
const item = {};
|
||||||
|
row.querySelectorAll('input').forEach(input => {
|
||||||
// Collect all named form controls (input + select), skip type=hidden except
|
const name = input.getAttribute('name');
|
||||||
// for boolean hidden sentinels (those end in the field name only, not .enabled).
|
if (!name || name.endsWith('.enabled') || input.type === 'hidden') return;
|
||||||
row.querySelectorAll('input, select').forEach(el => {
|
const match = name.match(/\.\d+\.([^.]+)$/);
|
||||||
const name = el.getAttribute('name');
|
if (match) {
|
||||||
if (!name) return;
|
const propName = match[1];
|
||||||
// Skip hidden inputs that are boolean sentinels (they duplicate checkboxes)
|
if (input.type === 'checkbox') {
|
||||||
if (el.type === 'hidden' && !el.dataset.nestedProp) return;
|
item[propName] = input.checked;
|
||||||
|
} else if (input.type === 'number') {
|
||||||
// Nested advanced props stored in hidden cell
|
item[propName] = input.value ? parseFloat(input.value) : null;
|
||||||
if (el.dataset.nestedProp) {
|
} else {
|
||||||
const propPath = el.dataset.nestedProp;
|
item[propName] = input.value;
|
||||||
setNestedValue(item, propPath, coerceValue(el.value, el.dataset.propType || 'string'));
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard display-column props: name matches fullKey.index.propName[.subKey...]
|
|
||||||
const match = name.match(/\.\d+\.(.+)$/);
|
|
||||||
if (!match) return;
|
|
||||||
const propPath = match[1];
|
|
||||||
|
|
||||||
if (el.tagName === 'SELECT') {
|
|
||||||
setNestedValue(item, propPath, el.value);
|
|
||||||
} else if (el.type === 'checkbox') {
|
|
||||||
setNestedValue(item, propPath, el.checked);
|
|
||||||
} else if (el.type === 'number') {
|
|
||||||
setNestedValue(item, propPath, el.value !== '' ? parseFloat(el.value) : null);
|
|
||||||
} else {
|
|
||||||
setNestedValue(item, propPath, el.value);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (Object.keys(item).length > 0) {
|
||||||
if (Object.keys(item).length > 0) items.push(item);
|
items.push(item);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
@@ -87,733 +81,236 @@
|
|||||||
console.error('[ArrayTableWidget] setValue expects an array');
|
console.error('[ArrayTableWidget] setValue expects an array');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options || !options.fullKey || !options.pluginId) {
|
if (!options || !options.fullKey || !options.pluginId) {
|
||||||
throw new Error('ArrayTableWidget.setValue requires options.fullKey and options.pluginId');
|
throw new Error('ArrayTableWidget.setValue requires options.fullKey and options.pluginId');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tbody = document.getElementById(`${fieldId}_tbody`);
|
const tbody = document.getElementById(`${fieldId}_tbody`);
|
||||||
if (!tbody) return;
|
if (!tbody) {
|
||||||
|
console.warn(`[ArrayTableWidget] tbody not found for fieldId: ${fieldId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
const row = createArrayTableRow(
|
const row = createArrayTableRow(
|
||||||
fieldId, options.fullKey, index, options.pluginId,
|
fieldId,
|
||||||
item, options.itemProperties || {}, options.displayColumns || [],
|
options.fullKey,
|
||||||
options.fullItemProperties || options.itemProperties || {}
|
index,
|
||||||
|
options.pluginId,
|
||||||
|
item,
|
||||||
|
options.itemProperties || {},
|
||||||
|
options.displayColumns || []
|
||||||
);
|
);
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refresh Add button state after repopulating rows
|
||||||
updateAddButtonState(fieldId);
|
updateAddButtonState(fieldId);
|
||||||
},
|
},
|
||||||
|
|
||||||
handlers: {}
|
handlers: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keys that must never be assigned to prevent prototype pollution.
|
|
||||||
const _FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
||||||
|
|
||||||
function setNestedValue(obj, path, value) {
|
|
||||||
const parts = path.split('.');
|
|
||||||
let cur = obj;
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
const key = parts[i];
|
|
||||||
if (_FORBIDDEN_KEYS.has(key)) return;
|
|
||||||
// Use hasOwnProperty to avoid reading inherited prototype properties,
|
|
||||||
// and defineProperty to write without triggering prototype setters.
|
|
||||||
if (!Object.hasOwn(cur, key) ||
|
|
||||||
typeof Object.getOwnPropertyDescriptor(cur, key).value !== 'object') {
|
|
||||||
Object.defineProperty(cur, key, {
|
|
||||||
value: Object.create(null), writable: true,
|
|
||||||
enumerable: true, configurable: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
cur = Object.getOwnPropertyDescriptor(cur, key).value;
|
|
||||||
}
|
|
||||||
const lastKey = parts[parts.length - 1];
|
|
||||||
if (!_FORBIDDEN_KEYS.has(lastKey)) {
|
|
||||||
Object.defineProperty(cur, lastKey, {
|
|
||||||
value: value, writable: true, enumerable: true, configurable: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function coerceValue(strVal, typeHint) {
|
|
||||||
if (strVal === '' || strVal === null || strVal === undefined) return null;
|
|
||||||
if (typeHint === 'integer') return parseInt(strVal, 10);
|
|
||||||
if (typeHint === 'number') return parseFloat(strVal);
|
|
||||||
if (typeHint === 'boolean') return strVal === 'true' || strVal === '1';
|
|
||||||
// nullable integer/number: "integer|null"
|
|
||||||
if (typeHint && typeHint.includes('integer')) return strVal !== '' ? parseInt(strVal, 10) : null;
|
|
||||||
if (typeHint && typeHint.includes('number')) return strVal !== '' ? parseFloat(strVal) : null;
|
|
||||||
return strVal;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Cell rendering ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create one <td> for a display column.
|
* Create a table row element for array item
|
||||||
*/
|
*/
|
||||||
function createCell(fullKey, index, colName, colDef, colValue, pluginId) {
|
function createArrayTableRow(fieldId, fullKey, index, pluginId, item, itemProperties, displayColumns) {
|
||||||
const colType = Array.isArray(colDef.type) ? colDef.type.find(t => t !== 'null') || 'string' : (colDef.type || 'string');
|
item = item || {};
|
||||||
const xWidget = colDef['x-widget'] || colDef['x_widget'];
|
|
||||||
const enumVals = colDef.enum;
|
|
||||||
const inputName = `${fullKey}.${index}.${colName}`;
|
|
||||||
|
|
||||||
const cell = document.createElement('td');
|
|
||||||
cell.className = 'px-3 py-3 whitespace-nowrap';
|
|
||||||
cell.style.verticalAlign = 'middle';
|
|
||||||
|
|
||||||
if (colType === 'boolean') {
|
|
||||||
// Boolean: hidden sentinel + visible checkbox
|
|
||||||
const hidden = document.createElement('input');
|
|
||||||
hidden.type = 'hidden';
|
|
||||||
hidden.name = inputName;
|
|
||||||
hidden.value = 'false';
|
|
||||||
cell.appendChild(hidden);
|
|
||||||
|
|
||||||
const cb = document.createElement('input');
|
|
||||||
cb.type = 'checkbox';
|
|
||||||
cb.name = inputName;
|
|
||||||
cb.checked = Boolean(colValue);
|
|
||||||
cb.value = 'true';
|
|
||||||
cb.className = 'h-4 w-4 text-blue-600';
|
|
||||||
cell.appendChild(cb);
|
|
||||||
|
|
||||||
} else if (colType === 'integer' || colType === 'number') {
|
|
||||||
const inp = document.createElement('input');
|
|
||||||
inp.type = 'number';
|
|
||||||
inp.name = inputName;
|
|
||||||
inp.value = colValue !== null && colValue !== undefined ? colValue : '';
|
|
||||||
if (colDef.minimum !== undefined) inp.min = colDef.minimum;
|
|
||||||
if (colDef.maximum !== undefined) inp.max = colDef.maximum;
|
|
||||||
inp.step = colType === 'integer' ? '1' : 'any';
|
|
||||||
inp.className = 'block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center';
|
|
||||||
if (colDef.description) inp.title = colDef.description;
|
|
||||||
cell.appendChild(inp);
|
|
||||||
|
|
||||||
} else if (Array.isArray(enumVals) && enumVals.length > 0) {
|
|
||||||
// Enum: render <select>
|
|
||||||
cell.style.minWidth = '90px';
|
|
||||||
const sel = document.createElement('select');
|
|
||||||
sel.name = inputName;
|
|
||||||
sel.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white';
|
|
||||||
enumVals.forEach(opt => {
|
|
||||||
if (opt === null) return;
|
|
||||||
const o = document.createElement('option');
|
|
||||||
o.value = opt;
|
|
||||||
o.textContent = opt;
|
|
||||||
if (String(colValue) === String(opt)) o.selected = true;
|
|
||||||
sel.appendChild(o);
|
|
||||||
});
|
|
||||||
// If current value didn't match any option, set to first
|
|
||||||
if (!sel.value && enumVals.length > 0) sel.value = enumVals[0];
|
|
||||||
cell.appendChild(sel);
|
|
||||||
|
|
||||||
} else if (xWidget === 'date-picker') {
|
|
||||||
cell.style.minWidth = '140px';
|
|
||||||
const inp = document.createElement('input');
|
|
||||||
inp.type = 'date';
|
|
||||||
inp.name = inputName;
|
|
||||||
inp.value = colValue || '';
|
|
||||||
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
||||||
inp.style.minWidth = '128px';
|
|
||||||
if (colDef.description) inp.title = colDef.description;
|
|
||||||
cell.appendChild(inp);
|
|
||||||
|
|
||||||
} else if (xWidget === 'time-picker') {
|
|
||||||
cell.style.minWidth = '115px';
|
|
||||||
const inp = document.createElement('input');
|
|
||||||
inp.type = 'time';
|
|
||||||
inp.name = inputName;
|
|
||||||
inp.value = colValue || '00:00';
|
|
||||||
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
||||||
inp.style.minWidth = '100px';
|
|
||||||
cell.appendChild(inp);
|
|
||||||
|
|
||||||
} else if (xWidget === 'file-upload-single') {
|
|
||||||
// Compact: text input (stores path) + upload button
|
|
||||||
cell.style.minWidth = '200px';
|
|
||||||
const wrap = document.createElement('div');
|
|
||||||
wrap.className = 'flex items-center gap-1';
|
|
||||||
|
|
||||||
const pathInput = document.createElement('input');
|
|
||||||
pathInput.type = 'text';
|
|
||||||
pathInput.name = inputName;
|
|
||||||
pathInput.id = `${fullKey}_${index}_${colName}`.replace(/\./g,'_');
|
|
||||||
pathInput.value = colValue || '';
|
|
||||||
pathInput.className = 'block px-1 py-1 border border-gray-300 rounded text-xs flex-1';
|
|
||||||
pathInput.style.minWidth = '100px';
|
|
||||||
pathInput.placeholder = 'path…';
|
|
||||||
|
|
||||||
const preview = document.createElement('img');
|
|
||||||
preview.className = 'w-6 h-6 object-cover rounded flex-shrink-0';
|
|
||||||
preview.style.display = colValue ? 'inline' : 'none';
|
|
||||||
if (colValue) { preview.src = '/' + colValue; preview.onerror = () => { preview.style.display = 'none'; }; }
|
|
||||||
|
|
||||||
const labelEl = document.createElement('label');
|
|
||||||
labelEl.className = 'cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100';
|
|
||||||
labelEl.title = 'Upload image';
|
|
||||||
labelEl.innerHTML = '<i class="fas fa-upload"></i>';
|
|
||||||
|
|
||||||
const fileInput = document.createElement('input');
|
|
||||||
fileInput.type = 'file';
|
|
||||||
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
|
|
||||||
fileInput.style.display = 'none';
|
|
||||||
fileInput.dataset.pluginId = pluginId;
|
|
||||||
fileInput.dataset.targetInput = pathInput.id;
|
|
||||||
fileInput.dataset.previewImg = preview.id || '';
|
|
||||||
fileInput.onchange = function(e) {
|
|
||||||
window.handleArrayTableImageUpload(e, pathInput, preview, pluginId);
|
|
||||||
};
|
|
||||||
labelEl.appendChild(fileInput);
|
|
||||||
|
|
||||||
wrap.appendChild(preview);
|
|
||||||
wrap.appendChild(pathInput);
|
|
||||||
wrap.appendChild(labelEl);
|
|
||||||
cell.appendChild(wrap);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Default: text input
|
|
||||||
const inp = document.createElement('input');
|
|
||||||
inp.type = 'text';
|
|
||||||
inp.name = inputName;
|
|
||||||
inp.value = colValue !== null && colValue !== undefined ? colValue : '';
|
|
||||||
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
||||||
if (colDef.description) inp.placeholder = colDef.description;
|
|
||||||
if (colDef.pattern) inp.pattern = colDef.pattern;
|
|
||||||
if (colDef.minLength) inp.minLength = colDef.minLength;
|
|
||||||
if (colDef.maxLength) inp.maxLength = colDef.maxLength;
|
|
||||||
cell.appendChild(inp);
|
|
||||||
}
|
|
||||||
|
|
||||||
return cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a hidden <td> holding flat hidden inputs for non-displayed properties
|
|
||||||
* (including nested objects like layout/style).
|
|
||||||
*/
|
|
||||||
function createAdvancedCell(fullKey, index, nonDisplayedProps, item) {
|
|
||||||
const cell = document.createElement('td');
|
|
||||||
cell.style.display = 'none';
|
|
||||||
cell.className = 'array-table-advanced-data';
|
|
||||||
cell.dataset.propSchema = JSON.stringify(nonDisplayedProps);
|
|
||||||
|
|
||||||
Object.entries(nonDisplayedProps).forEach(([propName, propSchema]) => {
|
|
||||||
const propType = Array.isArray(propSchema.type)
|
|
||||||
? propSchema.type.find(t => t !== 'null') || 'string'
|
|
||||||
: (propSchema.type || 'string');
|
|
||||||
|
|
||||||
if (propType === 'object' && propSchema.properties) {
|
|
||||||
const nestedVal = (item && item[propName]) || {};
|
|
||||||
Object.entries(propSchema.properties).forEach(([subName, subSchema]) => {
|
|
||||||
const subType = Array.isArray(subSchema.type)
|
|
||||||
? subSchema.type.find(t => t !== 'null') || 'string'
|
|
||||||
: (subSchema.type || 'string');
|
|
||||||
const defaultVal = subSchema.default !== undefined ? subSchema.default : null;
|
|
||||||
const currentVal = nestedVal[subName] !== undefined ? nestedVal[subName] : defaultVal;
|
|
||||||
|
|
||||||
const hidden = document.createElement('input');
|
|
||||||
hidden.type = 'hidden';
|
|
||||||
hidden.name = `${fullKey}.${index}.${propName}.${subName}`;
|
|
||||||
hidden.value = currentVal !== null && currentVal !== undefined ? String(currentVal) : '';
|
|
||||||
hidden.dataset.nestedProp = `${propName}.${subName}`;
|
|
||||||
hidden.dataset.propType = subType;
|
|
||||||
hidden.dataset.propSchema = JSON.stringify(subSchema);
|
|
||||||
cell.appendChild(hidden);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const defaultVal = propSchema.default !== undefined ? propSchema.default : null;
|
|
||||||
const currentVal = item && item[propName] !== undefined ? item[propName] : defaultVal;
|
|
||||||
|
|
||||||
const hidden = document.createElement('input');
|
|
||||||
hidden.type = 'hidden';
|
|
||||||
hidden.name = `${fullKey}.${index}.${propName}`;
|
|
||||||
hidden.value = currentVal !== null && currentVal !== undefined ? String(currentVal) : '';
|
|
||||||
hidden.dataset.nestedProp = propName;
|
|
||||||
hidden.dataset.propType = propType;
|
|
||||||
hidden.dataset.propSchema = JSON.stringify(propSchema);
|
|
||||||
cell.appendChild(hidden);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Row creation ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function createArrayTableRow(fieldId, fullKey, index, pluginId, item, itemProperties, displayColumns, fullItemProperties) {
|
|
||||||
item = item || {};
|
|
||||||
fullItemProperties = fullItemProperties || itemProperties;
|
|
||||||
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.className = 'array-table-row';
|
row.className = 'array-table-row';
|
||||||
row.setAttribute('data-index', index);
|
row.setAttribute('data-index', index);
|
||||||
|
|
||||||
// Visible column cells
|
|
||||||
displayColumns.forEach(colName => {
|
displayColumns.forEach(colName => {
|
||||||
const colDef = itemProperties[colName] || {};
|
const colDef = itemProperties[colName] || {};
|
||||||
const colType = Array.isArray(colDef.type) ? colDef.type.find(t => t !== 'null') || 'string' : (colDef.type || 'string');
|
const colType = colDef.type || 'string';
|
||||||
const colDefault = colDef.default !== undefined ? colDef.default
|
const colDefault = colDef.default !== undefined ? colDef.default : (colType === 'boolean' ? false : '');
|
||||||
: (colType === 'boolean' ? false : colType === 'time-picker' ? '00:00' : '');
|
|
||||||
const colValue = item[colName] !== undefined ? item[colName] : colDefault;
|
const colValue = item[colName] !== undefined ? item[colName] : colDefault;
|
||||||
row.appendChild(createCell(fullKey, index, colName, colDef, colValue, pluginId));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine non-displayed properties (these go into the advanced cell + edit modal)
|
const cell = document.createElement('td');
|
||||||
const nonDisplayed = {};
|
cell.className = 'px-4 py-3 whitespace-nowrap';
|
||||||
Object.keys(fullItemProperties).forEach(k => {
|
|
||||||
if (!displayColumns.includes(k) && k !== 'id') {
|
if (colType === 'boolean') {
|
||||||
nonDisplayed[k] = fullItemProperties[k];
|
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);
|
||||||
});
|
});
|
||||||
const hasAdvanced = Object.keys(nonDisplayed).length > 0;
|
|
||||||
|
|
||||||
// Actions cell
|
// Actions cell
|
||||||
const actionsCell = document.createElement('td');
|
const actionsCell = document.createElement('td');
|
||||||
actionsCell.className = 'px-3 py-3 whitespace-nowrap text-center';
|
actionsCell.className = 'px-4 py-3 whitespace-nowrap text-center';
|
||||||
actionsCell.style.minWidth = '90px';
|
const removeButton = document.createElement('button');
|
||||||
actionsCell.style.verticalAlign = 'middle';
|
removeButton.type = 'button';
|
||||||
|
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||||
const removeBtn = document.createElement('button');
|
removeButton.onclick = function() { window.removeArrayTableRow(this); };
|
||||||
removeBtn.type = 'button';
|
const removeIcon = document.createElement('i');
|
||||||
removeBtn.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
removeIcon.className = 'fas fa-trash';
|
||||||
removeBtn.onclick = function() { window.removeArrayTableRow(this); };
|
removeButton.appendChild(removeIcon);
|
||||||
removeBtn.innerHTML = '<i class="fas fa-trash"></i>';
|
actionsCell.appendChild(removeButton);
|
||||||
actionsCell.appendChild(removeBtn);
|
|
||||||
|
|
||||||
if (hasAdvanced) {
|
|
||||||
const editBtn = document.createElement('button');
|
|
||||||
editBtn.type = 'button';
|
|
||||||
editBtn.className = 'text-blue-500 hover:text-blue-700 px-2 py-1 ml-1';
|
|
||||||
editBtn.title = 'Edit advanced properties (layout, style…)';
|
|
||||||
editBtn.onclick = function() { window.openArrayTableRowEditor(this); };
|
|
||||||
editBtn.innerHTML = '<i class="fas fa-sliders-h"></i>';
|
|
||||||
actionsCell.appendChild(editBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.appendChild(actionsCell);
|
row.appendChild(actionsCell);
|
||||||
|
|
||||||
// Hidden advanced data cell
|
|
||||||
if (hasAdvanced) {
|
|
||||||
row.appendChild(createAdvancedCell(fullKey, index, nonDisplayed, item));
|
|
||||||
}
|
|
||||||
|
|
||||||
return row;
|
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, `
|
|
||||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
|
||||||
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
|
||||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
|
||||||
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const body = document.createElement('div');
|
|
||||||
body.className = 'px-5 py-4 space-y-4';
|
|
||||||
|
|
||||||
// Render a field for each advanced property
|
|
||||||
Object.entries(schema).forEach(([propName, propSchema]) => {
|
|
||||||
const propType = Array.isArray(propSchema.type)
|
|
||||||
? propSchema.type.find(t => t !== 'null') || 'string'
|
|
||||||
: (propSchema.type || 'string');
|
|
||||||
const label = propSchema.title || propName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
||||||
const desc = propSchema.description || '';
|
|
||||||
|
|
||||||
if (propType === 'object' && propSchema.properties) {
|
|
||||||
// Section for nested object
|
|
||||||
const section = document.createElement('div');
|
|
||||||
section.className = 'border border-gray-200 rounded-lg p-3';
|
|
||||||
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, `
|
|
||||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
|
||||||
class="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-md hover:bg-gray-100">Cancel</button>
|
|
||||||
<button type="button" id="array-row-editor-save"
|
|
||||||
class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md">Save</button>`;
|
|
||||||
|
|
||||||
// Save handler
|
|
||||||
footer.querySelector('#array-row-editor-save').onclick = function() {
|
|
||||||
body.querySelectorAll('[data-modal-prop]').forEach(el => {
|
|
||||||
const propPath = el.dataset.modalProp;
|
|
||||||
const targetInput = advancedCell.querySelector(`[data-nested-prop="${propPath}"]`);
|
|
||||||
if (!targetInput) return;
|
|
||||||
if (el.type === 'checkbox') {
|
|
||||||
targetInput.value = el.checked ? 'true' : 'false';
|
|
||||||
} else {
|
|
||||||
targetInput.value = el.value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.closeArrayTableRowEditor();
|
|
||||||
};
|
|
||||||
|
|
||||||
dialog.appendChild(footer);
|
|
||||||
overlay.appendChild(dialog);
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.closeArrayTableRowEditor = function() {
|
|
||||||
const modal = document.getElementById('array-row-editor-modal');
|
|
||||||
if (modal) modal.remove();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a single form control for the row editor modal.
|
* Update the Add button's disabled state based on current row count
|
||||||
|
* @param {string} fieldId - Field ID to find the tbody and button
|
||||||
*/
|
*/
|
||||||
function buildModalInput(propPath, schema, propType, currentVal) {
|
|
||||||
const xWidget = schema['x-widget'] || schema['x_widget'];
|
|
||||||
const enumVals = schema.enum;
|
|
||||||
const wrap = document.createElement('div');
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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] || '';
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ─── In-cell image upload ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from file-upload-single cells inside array-table rows.
|
|
||||||
* Uploads the selected file and updates the path text input.
|
|
||||||
*/
|
|
||||||
window.handleArrayTableImageUpload = async function(event, pathInput, previewImg, pluginId) {
|
|
||||||
const file = event.target.files && event.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
|
||||||
notifyFn('File exceeds 5MB limit', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('plugin_id', pluginId);
|
|
||||||
formData.append('files', file);
|
|
||||||
|
|
||||||
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 = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Button helpers ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function updateAddButtonState(fieldId) {
|
function updateAddButtonState(fieldId) {
|
||||||
const tbody = document.getElementById(fieldId + '_tbody');
|
const tbody = document.getElementById(fieldId + '_tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
// Find the add button by looking for the button with matching data-field-id
|
||||||
const addButton = document.querySelector(`button[data-field-id="${fieldId}"]`);
|
const addButton = document.querySelector(`button[data-field-id="${fieldId}"]`);
|
||||||
if (!tbody || !addButton) return;
|
if (!addButton) return;
|
||||||
const maxItems = parseInt(addButton.getAttribute('data-max-items'), 10);
|
|
||||||
const currentRows = tbody.querySelectorAll('.array-table-row').length;
|
const maxItems = parseInt(addButton.getAttribute('data-max-items'), 10);
|
||||||
const isAtMax = currentRows >= maxItems;
|
const currentRows = tbody.querySelectorAll('.array-table-row');
|
||||||
addButton.disabled = isAtMax;
|
const isAtMax = currentRows.length >= maxItems;
|
||||||
|
|
||||||
|
addButton.disabled = isAtMax;
|
||||||
addButton.style.opacity = isAtMax ? '0.5' : '';
|
addButton.style.opacity = isAtMax ? '0.5' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose for external use if needed
|
||||||
window.updateArrayTableAddButtonState = updateAddButtonState;
|
window.updateArrayTableAddButtonState = updateAddButtonState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new row to the array table
|
||||||
|
* @param {HTMLElement} button - The button element with data attributes
|
||||||
|
*/
|
||||||
window.addArrayTableRow = function(button) {
|
window.addArrayTableRow = function(button) {
|
||||||
const fieldId = button.getAttribute('data-field-id');
|
const fieldId = button.getAttribute('data-field-id');
|
||||||
const fullKey = button.getAttribute('data-full-key');
|
const fullKey = button.getAttribute('data-full-key');
|
||||||
const maxItems = parseInt(button.getAttribute('data-max-items'), 10);
|
const maxItems = parseInt(button.getAttribute('data-max-items'), 10);
|
||||||
const pluginId = button.getAttribute('data-plugin-id');
|
const pluginId = button.getAttribute('data-plugin-id');
|
||||||
|
|
||||||
let itemProperties = {};
|
// Parse JSON with fallback on error
|
||||||
let displayColumns = [];
|
let itemProperties = {};
|
||||||
let fullItemProperties = {};
|
let displayColumns = [];
|
||||||
|
const rawItemProps = button.getAttribute('data-item-properties') || '{}';
|
||||||
|
const rawDisplayCols = button.getAttribute('data-display-columns') || '[]';
|
||||||
|
|
||||||
try { itemProperties = JSON.parse(button.getAttribute('data-item-properties') || '{}'); } catch(_e) {}
|
try {
|
||||||
try { displayColumns = JSON.parse(button.getAttribute('data-display-columns') || '[]'); } catch(_e) {}
|
itemProperties = JSON.parse(rawItemProps);
|
||||||
try { fullItemProperties = JSON.parse(button.getAttribute('data-full-item-properties') || '{}'); } catch(_e) { fullItemProperties = itemProperties; }
|
} catch (e) {
|
||||||
|
console.error('[ArrayTableWidget] Failed to parse data-item-properties:', rawItemProps, e);
|
||||||
|
itemProperties = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
const tbody = document.getElementById(fieldId + '_tbody');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|
||||||
const currentRows = tbody.querySelectorAll('.array-table-row').length;
|
const currentRows = tbody.querySelectorAll('.array-table-row');
|
||||||
if (currentRows >= maxItems) {
|
if (currentRows.length >= maxItems) {
|
||||||
(window.showNotification || alert)(`Maximum ${maxItems} items allowed`, 'error');
|
const notifyFn = window.showNotification || alert;
|
||||||
|
notifyFn(`Maximum ${maxItems} items allowed`, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newIndex = currentRows;
|
const newIndex = currentRows.length;
|
||||||
const row = createArrayTableRow(fieldId, fullKey, newIndex, pluginId, {}, itemProperties, displayColumns, fullItemProperties);
|
const row = createArrayTableRow(fieldId, fullKey, newIndex, pluginId, {}, itemProperties, displayColumns);
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
|
|
||||||
|
// Update button state after adding
|
||||||
updateAddButtonState(fieldId);
|
updateAddButtonState(fieldId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a row from the array table
|
||||||
|
* @param {HTMLElement} button - The remove button element
|
||||||
|
*/
|
||||||
window.removeArrayTableRow = function(button) {
|
window.removeArrayTableRow = function(button) {
|
||||||
const row = button.closest('tr');
|
const row = button.closest('tr');
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
if (!confirm('Remove this item?')) return;
|
|
||||||
|
|
||||||
const tbody = row.parentElement;
|
if (confirm('Remove this item?')) {
|
||||||
if (!tbody) return;
|
const tbody = row.parentElement;
|
||||||
const fieldId = tbody.id.replace('_tbody', '');
|
if (!tbody) return;
|
||||||
row.remove();
|
|
||||||
|
|
||||||
// Re-index remaining rows
|
// Get fieldId from tbody id (format: {fieldId}_tbody)
|
||||||
tbody.querySelectorAll('.array-table-row').forEach((r, index) => {
|
const fieldId = tbody.id.replace('_tbody', '');
|
||||||
r.setAttribute('data-index', index);
|
|
||||||
r.querySelectorAll('input, select').forEach(el => {
|
row.remove();
|
||||||
const name = el.getAttribute('name');
|
|
||||||
if (name) el.setAttribute('name', name.replace(/\.\d+\./, '.' + index + '.'));
|
// Re-index remaining rows
|
||||||
// Also update data-nested-prop-based inputs (they don't have regular names needing re-index)
|
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 + '.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
updateAddButtonState(fieldId);
|
// Update button state after removing
|
||||||
|
updateAddButtonState(fieldId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all array table add buttons on page load
|
||||||
|
*/
|
||||||
function initArrayTableButtons() {
|
function initArrayTableButtons() {
|
||||||
document.querySelectorAll('button[data-field-id][data-max-items]').forEach(button => {
|
const addButtons = document.querySelectorAll('button[data-field-id][data-max-items]');
|
||||||
updateAddButtonState(button.getAttribute('data-field-id'));
|
addButtons.forEach(function(button) {
|
||||||
|
const fieldId = button.getAttribute('data-field-id');
|
||||||
|
updateAddButtonState(fieldId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize on DOM ready
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initArrayTableButtons);
|
document.addEventListener('DOMContentLoaded', initArrayTableButtons);
|
||||||
} else {
|
} else {
|
||||||
initArrayTableButtons();
|
initArrayTableButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[ArrayTableWidget] Array table widget registered (v2.0.0)');
|
console.log('[ArrayTableWidget] Array table widget registered');
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 = `<div id="${fieldId}_widget" class="file-upload-single-widget" data-field-id="${fieldId}" data-plugin-id="${escapeHtml(pluginId)}">`;
|
|
||||||
|
|
||||||
// Hidden input carries the actual string value
|
|
||||||
html += `<input type="hidden" id="${fieldId}" name="${escapeHtml(options.name || fieldId)}" value="${escapeHtml(currentValue)}">`;
|
|
||||||
|
|
||||||
// Preview area (shown when a value is set)
|
|
||||||
html += `<div id="${fieldId}_preview" class="${hasImage ? '' : 'hidden'} flex items-center space-x-3 mb-2 p-2 bg-gray-50 rounded border border-gray-200">`;
|
|
||||||
html += `<img id="${fieldId}_thumb" src="/${escapeHtml(currentValue)}" alt="Preview"
|
|
||||||
class="w-12 h-12 object-cover rounded"
|
|
||||||
onerror="this.style.display='none';document.getElementById('${fieldId}_thumb_placeholder').style.display='flex'">`;
|
|
||||||
html += `<div id="${fieldId}_thumb_placeholder" style="display:none" class="w-12 h-12 bg-gray-200 rounded flex items-center justify-center">
|
|
||||||
<i class="fas fa-image text-gray-400 text-lg"></i>
|
|
||||||
</div>`;
|
|
||||||
html += `<div class="flex-1 min-w-0">
|
|
||||||
<p id="${fieldId}_filename" class="text-xs text-gray-600 truncate">${escapeHtml(currentValue.split('/').pop() || '')}</p>
|
|
||||||
<p id="${fieldId}_fullpath" class="text-xs text-gray-400">${escapeHtml(currentValue)}</p>
|
|
||||||
</div>`;
|
|
||||||
html += `<button type="button"
|
|
||||||
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
|
|
||||||
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>`;
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
// Upload drop zone — keyboard accessible via tabindex + Enter/Space
|
|
||||||
html += `<div id="${fieldId}_drop_zone"
|
|
||||||
class="border-2 border-dashed border-gray-300 rounded-lg p-3 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
|
||||||
role="button" tabindex="0"
|
|
||||||
aria-label="${hasImage ? 'Replace image' : 'Upload image'}"
|
|
||||||
ondrop="window.LEDMatrixWidgets.getHandlers('file-upload-single').onDrop(event, '${fieldId}')"
|
|
||||||
ondragover="event.preventDefault()"
|
|
||||||
onclick="document.getElementById('${fieldId}_file_input').click()"
|
|
||||||
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('${fieldId}_file_input').click();}">
|
|
||||||
<input type="file"
|
|
||||||
id="${fieldId}_file_input"
|
|
||||||
accept="${escapeHtml(allowedTypes)}"
|
|
||||||
style="display:none"
|
|
||||||
data-field-id="${fieldId}"
|
|
||||||
data-plugin-id="${escapeHtml(pluginId)}"
|
|
||||||
data-max-size-mb="${maxSizeMb}"
|
|
||||||
data-allowed-types="${escapeHtml(allowedTypes)}"
|
|
||||||
onchange="window.LEDMatrixWidgets.getHandlers('file-upload-single').onFileSelect(event, '${fieldId}')">
|
|
||||||
<i class="fas fa-cloud-upload-alt text-xl text-gray-400 mb-1"></i>
|
|
||||||
<p class="text-xs text-gray-500">${hasImage ? 'Click to replace image' : 'Click or drag to upload image'}</p>
|
|
||||||
<p class="text-xs text-gray-400">Max ${maxSizeMb}MB</p>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// Status area for upload feedback
|
|
||||||
html += `<div id="${fieldId}_status" class="mt-1 text-xs hidden"></div>`;
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
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');
|
|
||||||
})();
|
|
||||||
@@ -1,797 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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, '<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>');
|
|
||||||
|
|
||||||
const data = await callAction(st.pluginId, st.actions.list).catch(() => null);
|
|
||||||
if (!data || data.status !== 'success') {
|
|
||||||
if (grid) safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-exclamation-circle"></i>Failed to load files.</div>');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
st.files = data.files || [];
|
|
||||||
renderCards(fieldId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Card grid ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function renderCards(fieldId) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
const root = document.getElementById(`${fieldId}_pfm`);
|
|
||||||
if (!root) return;
|
|
||||||
const grid = root.querySelector('.pfm-grid');
|
|
||||||
if (!grid) return;
|
|
||||||
|
|
||||||
if (!st.files.length) {
|
|
||||||
safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-folder-open"></i>No files yet. Create or upload one.</div>');
|
|
||||||
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 = '<i class="fas fa-file-code"></i>';
|
|
||||||
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 = '<i class="fas fa-edit"></i> 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 = '<i class="fas fa-trash"></i>'; // 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, `
|
|
||||||
<div class="pfm-modal-header">
|
|
||||||
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
|
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="pfm-modal-body" id="${escHtml(fieldId)}_edit_body">
|
|
||||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
|
||||||
</div>
|
|
||||||
<div class="pfm-modal-footer">
|
|
||||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
|
|
||||||
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
|
||||||
<i class="fas fa-save mr-1"></i>Save
|
|
||||||
</button>
|
|
||||||
</div>`;
|
|
||||||
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, '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>');
|
|
||||||
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 <textarea>
|
|
||||||
st._editData = null;
|
|
||||||
safeSetHTML(body, `
|
|
||||||
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
|
|
||||||
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
|
||||||
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
|
||||||
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function isTabular(data) {
|
|
||||||
if (typeof data !== 'object' || Array.isArray(data)) return false;
|
|
||||||
const keys = Object.keys(data);
|
|
||||||
if (!keys.length) return false;
|
|
||||||
const first = data[keys[0]];
|
|
||||||
if (typeof first !== 'object' || Array.isArray(first)) return false;
|
|
||||||
const entryKeys = Object.keys(first);
|
|
||||||
return entryKeys.length > 0 && entryKeys.length <= 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEntryTable(fieldId, container, content) {
|
|
||||||
const st = getState(fieldId);
|
|
||||||
const entries = Object.entries(content).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
|
|
||||||
if (!entries.length) { container.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, `
|
|
||||||
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;">
|
|
||||||
${total} entries total
|
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem"
|
|
||||||
onclick="(function(){const targetPage=Math.ceil(${todayDoy}/${perPage});window._pfmTablePage('${fieldId}',targetPage);setTimeout(function(){const row=document.querySelector('tr[data-day=\\'${todayDoy}\\']');if(row)row.scrollIntoView({block:'center'});},60);})()">
|
|
||||||
<i class="fas fa-calendar-day"></i> Jump to today (day ${todayDoy})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="${fieldId}_tbl_wrap" class="pfm-table-wrap" style="max-height:52vh;overflow-y:auto;">
|
|
||||||
<table class="pfm-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="pfm-day-col">Day</th>
|
|
||||||
${cols.map(c => `<th>${escHtml(c.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()))}</th>`).join('')}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${pageEntries.map(([day, val]) => `
|
|
||||||
<tr data-day="${day}" class="${parseInt(day) === todayDoy ? 'today-row' : ''}">
|
|
||||||
<td class="pfm-day-col" style="user-select:none;">${escHtml(day)}</td>
|
|
||||||
${cols.map(col => {
|
|
||||||
const v = val[col] ?? '';
|
|
||||||
const isLong = String(v).length > 60 || col === 'description' || col === 'definition' || col === 'content';
|
|
||||||
return isLong
|
|
||||||
? `<td><textarea data-day="${day}" data-col="${escHtml(col)}" rows="2"
|
|
||||||
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"
|
|
||||||
>${escHtml(String(v))}</textarea></td>`
|
|
||||||
: `<td><input type="text" data-day="${day}" data-col="${escHtml(col)}"
|
|
||||||
value="${escHtml(String(v))}"
|
|
||||||
oninput="window._pfmCellEdit('${fieldId}','${day}','${escHtml(col)}',this.value)"></td>`;
|
|
||||||
}).join('')}
|
|
||||||
</tr>`).join('')}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="pfm-pagination">
|
|
||||||
<span>Page ${page} of ${totalPages}</span>
|
|
||||||
<div class="pfm-page-jump">
|
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
|
||||||
${page <= 1 ? 'disabled' : ''}
|
|
||||||
onclick="window._pfmTablePage('${fieldId}',${page - 1})">‹ Prev</button>
|
|
||||||
<span>Go to</span>
|
|
||||||
<input type="number" min="1" max="${totalPages}" value="${page}"
|
|
||||||
onchange="window._pfmTablePage('${fieldId}',+this.value)">
|
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
|
||||||
${page >= totalPages ? 'disabled' : ''}
|
|
||||||
onclick="window._pfmTablePage('${fieldId}',${page + 1})">Next ›</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
st._tablePage = page;
|
|
||||||
st._tableEntries = entries;
|
|
||||||
st._tableCols = cols;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, `
|
|
||||||
<div class="pfm-modal-header">
|
|
||||||
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
|
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="pfm-modal-body">
|
|
||||||
<div class="pfm-danger-box">
|
|
||||||
<strong>${escHtml(filename)}</strong> will be permanently deleted and removed
|
|
||||||
from the plugin configuration. This cannot be undone.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="pfm-modal-footer">
|
|
||||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
|
|
||||||
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
|
||||||
<i class="fas fa-trash mr-1"></i>Delete
|
|
||||||
</button>
|
|
||||||
</div>`;
|
|
||||||
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, `
|
|
||||||
<div class="pfm-modal-header">
|
|
||||||
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
|
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="pfm-modal-body">
|
|
||||||
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
|
|
||||||
${fields.map(f => `
|
|
||||||
<div class="pfm-field">
|
|
||||||
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
|
|
||||||
<input type="text" id="${escHtml(fieldId)}_cf_${escHtml(f.key)}"
|
|
||||||
placeholder="${escHtml(f.placeholder || '')}"
|
|
||||||
${f.pattern ? `pattern="${escHtml(f.pattern)}"` : ''}>
|
|
||||||
${f.hint ? `<div class="pfm-field-hint">${escHtml(f.hint)}</div>` : ''}
|
|
||||||
</div>`).join('')}
|
|
||||||
</div>
|
|
||||||
<div class="pfm-modal-footer">
|
|
||||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
|
|
||||||
<button class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
|
|
||||||
<i class="fas fa-plus mr-1"></i>Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
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, `
|
|
||||||
<div class="pfm-root" id="${fieldId}_pfm">
|
|
||||||
<div class="pfm-header">
|
|
||||||
<div>
|
|
||||||
<div class="pfm-title">File Explorer</div>
|
|
||||||
${st.directoryLabel ? `<div class="pfm-dir">Manage files in <code>${escHtml(st.directoryLabel)}</code></div>` : ''}
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:.375rem;">
|
|
||||||
${actions.create ? `
|
|
||||||
<button class="pfm-btn pfm-btn-create"
|
|
||||||
onclick="window._pfmOpenCreate('${fieldId}')">
|
|
||||||
<i class="fas fa-plus mr-1"></i>New File
|
|
||||||
</button>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${actions.upload ? `
|
|
||||||
<div class="pfm-upload" id="${fieldId}_upload_zone"
|
|
||||||
onclick="document.getElementById('${fieldId}_file_input').click()"
|
|
||||||
ondragover="event.preventDefault();this.classList.add('dragover')"
|
|
||||||
ondragleave="this.classList.remove('dragover')"
|
|
||||||
ondrop="this.classList.remove('dragover');event.preventDefault();
|
|
||||||
if(event.dataTransfer.files[0])window._pfmUpload('${fieldId}',event.dataTransfer.files[0])">
|
|
||||||
<input type="file" id="${fieldId}_file_input" accept=".json"
|
|
||||||
style="display:none"
|
|
||||||
onchange="if(this.files[0])window._pfmUpload('${fieldId}',this.files[0]);this.value=''">
|
|
||||||
<i class="fas fa-cloud-upload-alt" style="font-size:1.5rem;color:#9ca3af;"></i>
|
|
||||||
<p>Drag and drop or click to upload</p>
|
|
||||||
<small>${escHtml(st.uploadHint)}</small>
|
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
<div class="pfm-grid">
|
|
||||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
loadFiles(fieldId);
|
|
||||||
},
|
|
||||||
|
|
||||||
getValue: function () { return null; }, // file ops are immediate; nothing to submit
|
|
||||||
setValue: function (fieldId) { loadFiles(fieldId); }
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[PluginFileManager] plugin-file-manager widget registered');
|
|
||||||
})();
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 = `<div id="${fieldId}_widget" class="time-picker-widget" data-field-id="${fieldId}">`;
|
|
||||||
html += '<div class="flex items-center">';
|
|
||||||
html += `
|
|
||||||
<div class="relative flex-1">
|
|
||||||
<input type="time"
|
|
||||||
id="${fieldId}_input"
|
|
||||||
name="${escapeHtml(options.name || fieldId)}"
|
|
||||||
value="${escapeHtml(currentValue)}"
|
|
||||||
${placeholder ? `placeholder="${escapeHtml(placeholder)}"` : ''}
|
|
||||||
${disabled ? 'disabled' : ''}
|
|
||||||
${required ? 'required' : ''}
|
|
||||||
onchange="window.LEDMatrixWidgets.getHandlers('time-picker').onChange('${fieldId}')"
|
|
||||||
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
|
|
||||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
|
||||||
<i class="fas fa-clock text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (clearable && !disabled) {
|
|
||||||
html += `
|
|
||||||
<button type="button"
|
|
||||||
id="${fieldId}_clear"
|
|
||||||
onclick="window.LEDMatrixWidgets.getHandlers('time-picker').onClear('${fieldId}')"
|
|
||||||
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
|
|
||||||
title="Clear">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
html += `<div id="${fieldId}_error" class="text-sm text-red-600 mt-1 hidden"></div>`;
|
|
||||||
html += '</div>';
|
|
||||||
|
|
||||||
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');
|
|
||||||
})();
|
|
||||||
@@ -497,31 +497,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="array-table-container mt-1" data-field-id="{{ field_id }}" data-full-key="{{ full_key }}" data-max-items="{{ max_items }}" data-plugin-id="{{ plugin_id }}">
|
<div class="array-table-container mt-1" data-field-id="{{ field_id }}" data-full-key="{{ full_key }}" data-max-items="{{ max_items }}" data-plugin-id="{{ plugin_id }}">
|
||||||
<div style="overflow-x:auto">
|
<table class="min-w-full divide-y divide-gray-200 border border-gray-300 rounded-lg">
|
||||||
<table class="divide-y divide-gray-200 border border-gray-300 rounded-lg" style="min-width:max-content;width:100%">
|
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
{% for col_name in display_columns %}
|
{% for col_name in display_columns %}
|
||||||
{% set col_def = item_properties.get(col_name, {}) %}
|
{% set col_def = item_properties.get(col_name, {}) %}
|
||||||
{% set col_title = col_def.get('title', col_name|replace('_', ' ')|title) %}
|
{% 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', '') %}
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ col_title }}</th>
|
||||||
{% 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 %}
|
|
||||||
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" style="min-width:{{ col_min_w }}">{{ col_title }}</th>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider" style="min-width:90px">Actions</th>
|
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider w-20">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="{{ field_id }}_tbody" class="bg-white divide-y divide-gray-200">
|
<tbody id="{{ field_id }}_tbody" class="bg-white divide-y divide-gray-200">
|
||||||
@@ -530,24 +514,9 @@
|
|||||||
<tr class="array-table-row" data-index="{{ item_index }}">
|
<tr class="array-table-row" data-index="{{ item_index }}">
|
||||||
{% for col_name in display_columns %}
|
{% for col_name in display_columns %}
|
||||||
{% set col_def = item_properties.get(col_name, {}) %}
|
{% set col_def = item_properties.get(col_name, {}) %}
|
||||||
{# Normalize nullable types e.g. ["null","integer"] → "integer" #}
|
{% set col_type = col_def.get('type', 'string') %}
|
||||||
{% 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', '')) %}
|
{% set col_value = item.get(col_name, col_def.get('default', '')) %}
|
||||||
{% if col_xwidget == 'date-picker' %}{% set td_min_w = '140px' %}
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
{% 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 %}
|
|
||||||
<td class="px-3 py-3 whitespace-nowrap" style="min-width:{{ td_min_w }};vertical-align:middle">
|
|
||||||
{% if col_type == 'boolean' %}
|
{% if col_type == 'boolean' %}
|
||||||
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="false">
|
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="false">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
@@ -564,43 +533,6 @@
|
|||||||
{% if col_type == 'integer' %}step="1"{% else %}step="any"{% endif %}
|
{% if col_type == 'integer' %}step="1"{% else %}step="any"{% endif %}
|
||||||
class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
||||||
{% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}>
|
{% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}>
|
||||||
{% elif col_enum %}
|
|
||||||
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
|
||||||
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
|
|
||||||
{% for opt in col_enum %}{% if opt is not none %}
|
|
||||||
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ opt }}</option>
|
|
||||||
{% endif %}{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% elif col_xwidget == 'date-picker' %}
|
|
||||||
<input type="date"
|
|
||||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
|
||||||
value="{{ col_value if col_value is not none else '' }}"
|
|
||||||
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
|
|
||||||
{% elif col_xwidget == 'time-picker' %}
|
|
||||||
<input type="time"
|
|
||||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
|
||||||
value="{{ col_value if col_value is not none else '00:00' }}"
|
|
||||||
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
|
|
||||||
{% elif col_xwidget == 'file-upload-single' %}
|
|
||||||
{% set cell_input_id = field_id ~ '_' ~ item_index ~ '_' ~ col_name %}
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
{% if col_value %}<img src="/{{ col_value }}" class="w-6 h-6 object-cover rounded flex-shrink-0" onerror="this.style.display='none'">{% endif %}
|
|
||||||
<input type="text"
|
|
||||||
id="{{ cell_input_id }}"
|
|
||||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
|
||||||
value="{{ col_value if col_value is not none else '' }}"
|
|
||||||
class="block w-20 px-1 py-1 border border-gray-300 rounded text-xs"
|
|
||||||
placeholder="path…">
|
|
||||||
<label class="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" title="Upload image">
|
|
||||||
<i class="fas fa-upload"></i>
|
|
||||||
<input type="file"
|
|
||||||
accept="image/png,image/jpeg,image/bmp,image/gif"
|
|
||||||
style="display:none"
|
|
||||||
data-plugin-id="{{ plugin_id }}"
|
|
||||||
data-target-input="{{ cell_input_id }}"
|
|
||||||
onchange="(function(e){ const t=document.getElementById('{{ cell_input_id }}'); const p=t.previousElementSibling && t.previousElementSibling.tagName==='IMG' ? t.previousElementSibling : null; window.handleArrayTableImageUpload(e,t,p,'{{ plugin_id }}'); })(event)">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<input type="text"
|
<input type="text"
|
||||||
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
||||||
@@ -613,60 +545,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||||
{# 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 %}
|
|
||||||
<td class="px-3 py-3 whitespace-nowrap text-center" style="min-width:90px;vertical-align:middle">
|
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onclick="removeArrayTableRow(this)"
|
onclick="removeArrayTableRow(this)"
|
||||||
class="text-red-600 hover:text-red-800 px-2 py-1">
|
class="text-red-600 hover:text-red-800 px-2 py-1">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
{% if has_advanced.value %}
|
|
||||||
<button type="button"
|
|
||||||
onclick="openArrayTableRowEditor(this)"
|
|
||||||
class="text-blue-500 hover:text-blue-700 px-2 py-1 ml-1"
|
|
||||||
title="Edit layout, style and other advanced properties">
|
|
||||||
<i class="fas fa-sliders-h"></i>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{# 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 %}
|
|
||||||
<td style="display:none" class="array-table-advanced-data"
|
|
||||||
data-prop-schema='{{ adv_schema.d|tojson }}'>
|
|
||||||
{% for prop_name, prop_schema in adv_schema.d.items() %}
|
|
||||||
{% set prop_type = prop_schema.get('type', 'string') %}
|
|
||||||
{% if prop_type == 'object' and prop_schema.get('properties') %}
|
|
||||||
{% for sub_name, sub_schema in prop_schema.get('properties', {}).items() %}
|
|
||||||
{% set sub_val = item.get(prop_name, {}).get(sub_name) %}
|
|
||||||
{% set sub_default = sub_schema.get('default') %}
|
|
||||||
{% set final_val = sub_val if sub_val is not none else sub_default %}
|
|
||||||
<input type="hidden"
|
|
||||||
name="{{ full_key }}.{{ item_index }}.{{ prop_name }}.{{ sub_name }}"
|
|
||||||
data-nested-prop="{{ prop_name }}.{{ sub_name }}"
|
|
||||||
data-prop-type="{{ sub_schema.get('type', 'string') }}"
|
|
||||||
data-prop-schema='{{ sub_schema|tojson }}'
|
|
||||||
value="{{ final_val if final_val is not none else '' }}">
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
{% set prop_val = item.get(prop_name) %}
|
|
||||||
{% set prop_default = prop_schema.get('default') %}
|
|
||||||
{% set final_val = prop_val if prop_val is not none else prop_default %}
|
|
||||||
<input type="hidden"
|
|
||||||
name="{{ full_key }}.{{ item_index }}.{{ prop_name }}"
|
|
||||||
data-nested-prop="{{ prop_name }}"
|
|
||||||
data-prop-type="{{ prop_schema.get('type', 'string') }}"
|
|
||||||
data-prop-schema='{{ prop_schema|tojson }}'
|
|
||||||
value="{{ final_val if final_val is not none else '' }}">
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -678,58 +563,11 @@
|
|||||||
data-max-items="{{ max_items }}"
|
data-max-items="{{ max_items }}"
|
||||||
data-plugin-id="{{ plugin_id }}"
|
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-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 }}'
|
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"
|
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 %}>
|
{% if array_value|length >= max_items %}disabled style="opacity: 0.5;"{% endif %}>
|
||||||
<i class="fas fa-plus mr-1"></i> Add Item
|
<i class="fas fa-plus mr-1"></i> Add Item
|
||||||
</button>
|
</button>
|
||||||
</div>{# end overflow-x:auto wrapper #}
|
|
||||||
</div>
|
|
||||||
{% 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) %}
|
|
||||||
<div class="flex items-center gap-3 flex-wrap mt-1" id="{{ field_id }}_color_row">
|
|
||||||
<input type="color"
|
|
||||||
id="{{ field_id }}_hex"
|
|
||||||
value="{{ hex_val }}"
|
|
||||||
class="h-9 w-12 cursor-pointer rounded border border-gray-300"
|
|
||||||
title="Color picker"
|
|
||||||
oninput="(function(h){var r=parseInt(h.slice(1,3),16),g=parseInt(h.slice(3,5),16),b=parseInt(h.slice(5,7),16);document.getElementById('{{ field_id }}_r').value=r;document.getElementById('{{ field_id }}_g').value=g;document.getElementById('{{ field_id }}_b').value=b;})(this.value)">
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<label class="text-xs text-gray-500 font-medium">R</label>
|
|
||||||
<input type="number" min="0" max="255" step="1"
|
|
||||||
id="{{ field_id }}_r"
|
|
||||||
name="{{ full_key }}.0"
|
|
||||||
value="{{ r_val }}"
|
|
||||||
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
|
||||||
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<label class="text-xs text-gray-500 font-medium">G</label>
|
|
||||||
<input type="number" min="0" max="255" step="1"
|
|
||||||
id="{{ field_id }}_g"
|
|
||||||
name="{{ full_key }}.1"
|
|
||||||
value="{{ g_val }}"
|
|
||||||
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
|
||||||
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<label class="text-xs text-gray-500 font-medium">B</label>
|
|
||||||
<input type="number" min="0" max="255" step="1"
|
|
||||||
id="{{ field_id }}_b"
|
|
||||||
name="{{ full_key }}.2"
|
|
||||||
value="{{ b_val }}"
|
|
||||||
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
|
||||||
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
|
|
||||||
</div>
|
|
||||||
<div class="w-8 h-8 rounded border border-gray-300 flex-shrink-0"
|
|
||||||
style="background-color: rgb({{ r_val }}, {{ g_val }}, {{ b_val }})"
|
|
||||||
title="Color preview"></div>
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Generic array-of-objects would go here if needed in the future #}
|
{# Generic array-of-objects would go here if needed in the future #}
|
||||||
@@ -788,19 +626,7 @@
|
|||||||
name="{{ full_key }}"
|
name="{{ full_key }}"
|
||||||
value="{{ str_value }}">
|
value="{{ str_value }}">
|
||||||
</div>
|
</div>
|
||||||
{% elif str_widget == 'json-file-manager' %}
|
{% 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'] %}
|
||||||
{# Embedded file manager — plugin's web_ui/file_manager.html served via /v3/plugin-ui/ route #}
|
|
||||||
<div class="mt-1 rounded-lg border border-gray-200 overflow-hidden">
|
|
||||||
<iframe id="{{ field_id }}_frame"
|
|
||||||
src="/v3/plugin-ui/{{ plugin_id }}/web-ui/file_manager.html"
|
|
||||||
style="width:100%;height:640px;border:none;"
|
|
||||||
title="File Manager for {{ plugin_id }}"></iframe>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-amber-600 mt-2 flex items-center">
|
|
||||||
<i class="fas fa-info-circle mr-1"></i>
|
|
||||||
Changes in the file manager save immediately — no need to click Save Configuration.
|
|
||||||
</p>
|
|
||||||
{% 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 #}
|
{# Render widget container #}
|
||||||
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
||||||
<script>
|
<script>
|
||||||
@@ -817,9 +643,7 @@
|
|||||||
'enum': {{ (prop.enum or [])|tojson|safe }},
|
'enum': {{ (prop.enum or [])|tojson|safe }},
|
||||||
'minimum': {{ prop.minimum|tojson if prop.minimum is defined else 'null' }},
|
'minimum': {{ prop.minimum|tojson if prop.minimum is defined else 'null' }},
|
||||||
'maximum': {{ prop.maximum|tojson if prop.maximum is defined else 'null' }},
|
'maximum': {{ prop.maximum|tojson if prop.maximum is defined else 'null' }},
|
||||||
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }},
|
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }}
|
||||||
'x-upload-config': {{ (prop.get('x-upload-config') or prop.get('x_upload_config') or {})|tojson|safe }},
|
|
||||||
'x-widget-config': {{ (prop.get('x-widget-config') or prop.get('x_widget_config') or {})|tojson|safe }}
|
|
||||||
};
|
};
|
||||||
widget.render(container, config, value, { fieldId: '{{ field_id }}', name: '{{ full_key }}', pluginId: '{{ plugin_id }}' });
|
widget.render(container, config, value, { fieldId: '{{ field_id }}', name: '{{ full_key }}', pluginId: '{{ plugin_id }}' });
|
||||||
}
|
}
|
||||||
@@ -1040,28 +864,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Web UI Actions — hide if schema has a dedicated file-manager widget,
|
{# Web UI Actions (if any) #}
|
||||||
or if every action is marked ui_hidden in the manifest. #}
|
{% if web_ui_actions %}
|
||||||
{% set has_file_manager_widget = namespace(value=false) %}
|
|
||||||
{% for _fk, _fp in schema.get('properties', {}).items() %}
|
|
||||||
{% if (_fp.get('x-widget') or _fp.get('x_widget')) in ('json-file-manager', 'plugin-file-manager') %}
|
|
||||||
{% set has_file_manager_widget.value = true %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% set visible_actions = [] %}
|
|
||||||
{% for _a in web_ui_actions %}
|
|
||||||
{% if not _a.get('ui_hidden', false) %}
|
|
||||||
{% set _ = visible_actions.append(_a) %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if visible_actions and not has_file_manager_widget.value %}
|
|
||||||
<div class="mt-6 pt-4 border-t border-gray-200">
|
<div class="mt-6 pt-4 border-t border-gray-200">
|
||||||
<h3 class="text-md font-medium text-gray-900 mb-3">Plugin Actions</h3>
|
<h3 class="text-md font-medium text-gray-900 mb-3">Plugin Actions</h3>
|
||||||
{% if visible_actions[0].section_description %}
|
{% if web_ui_actions[0].section_description %}
|
||||||
<p class="text-sm text-gray-600 mb-4">{{ visible_actions[0].section_description }}</p>
|
<p class="text-sm text-gray-600 mb-4">{{ web_ui_actions[0].section_description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{% for action in visible_actions %}
|
{% for action in web_ui_actions %}
|
||||||
{% set action_id = "action-" ~ action.id ~ "-" ~ loop.index0 %}
|
{% set action_id = "action-" ~ action.id ~ "-" ~ loop.index0 %}
|
||||||
{% set status_id = "action-status-" ~ action.id ~ "-" ~ loop.index0 %}
|
{% set status_id = "action-status-" ~ action.id ~ "-" ~ loop.index0 %}
|
||||||
{% set bg_color = action.color or 'blue' %}
|
{% set bg_color = action.color or 'blue' %}
|
||||||
|
|||||||
Reference in New Issue
Block a user