diff --git a/web_interface/static/v3/js/widgets/json-file-manager.js b/web_interface/static/v3/js/widgets/json-file-manager.js
new file mode 100644
index 00000000..7deae904
--- /dev/null
+++ b/web_interface/static/v3/js/widgets/json-file-manager.js
@@ -0,0 +1,782 @@
+/**
+ * JsonFileManager — reusable JSON file management widget for LEDMatrix plugins.
+ *
+ * Usage via config_schema.json:
+ * "file_manager": {
+ * "type": "null",
+ * "title": "Data Files",
+ * "x-widget": "json-file-manager",
+ * "x-widget-config": {
+ * "actions": {
+ * "list": "list-files", // required
+ * "get": "get-file", // required for editing
+ * "save": "save-file", // required for editing
+ * "upload": "upload-file", // optional
+ * "delete": "delete-file", // optional
+ * "create": "create-file", // optional
+ * "toggle": "toggle-category" // optional
+ * },
+ * "upload_hint": "Hint text under the drop zone",
+ * "directory_label": "of_the_day/",
+ * "create_fields": [
+ * { "key": "category_name", "label": "Category Name",
+ * "placeholder": "my_words", "pattern": "^[a-z0-9_]+$",
+ * "hint": "Used as filename" },
+ * { "key": "display_name", "label": "Display Name",
+ * "placeholder": "My Words" }
+ * ],
+ * "toggle_key": "category_name"
+ * }
+ * }
+ *
+ * No CDN dependencies. Works on all modern browsers.
+ */
+(function () {
+ 'use strict';
+
+ class JsonFileManager {
+ constructor(container, config, pluginId) {
+ // Prevent duplicate instances on the same container
+ if (container._jfmInstance) {
+ container._jfmInstance._destroy();
+ }
+ container._jfmInstance = this;
+
+ this.el = container;
+ this.pluginId = pluginId;
+ this.actions = config.actions || {};
+ this.uploadHint = config.upload_hint || '';
+ this.dirLabel = config.directory_label || '';
+ this.createFields = config.create_fields || [];
+ this.toggleKey = config.toggle_key || null;
+
+ // Unique prefix for all DOM IDs in this instance
+ this._uid = 'jfm_' + Math.random().toString(36).slice(2, 9);
+
+ // Mutable state
+ this._editFile = null;
+ this._deleteFile = null;
+ this._keyHandler = this._onKey.bind(this);
+
+ this._inject();
+ this._bind();
+ this._loadList();
+ }
+
+ // ── Lifecycle ────────────────────────────────────────────────────────
+
+ _destroy() {
+ document.removeEventListener('keydown', this._keyHandler);
+ this.el._jfmInstance = null;
+ }
+
+ // ── DOM Injection ────────────────────────────────────────────────────
+
+ _inject() {
+ const u = this._uid;
+ const hasUpload = !!this.actions.upload;
+ const hasCreate = !!this.actions.create;
+ const hasDelete = !!this.actions.delete;
+
+ this.el.innerHTML = this._css(u) + `
+
+
+
+
+
+
+ ${hasUpload ? `
+
+
+
+
📁
+
Drop a JSON file here, or click to browse
+ ${this.uploadHint ? `
${this._esc(this.uploadHint)}
` : ''}
+
+
` : ''}
+
+
+
+
+
+
Edit file
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${hasDelete ? `
+
+
+
+ Delete file
+
+
+
+
Delete ?
+
This permanently removes the file and its entry from the plugin configuration.
+
+
+
+
` : ''}
+
+
+ ${hasCreate ? `
+
+
+
+ Create new file
+
+
+
+ ${this.createFields.map(f => `
+
+
+
+ ${f.hint ? `${this._esc(f.hint)}` : ''}
+
`).join('')}
+
+
+
+
` : ''}
+
+
`; // end #${u}
+
+ // Cache frequently-used elements
+ this._root = document.getElementById(u);
+ this._listEl = document.getElementById(`${u}-list`);
+ this._editorEl = document.getElementById(`${u}-editor`);
+ this._editModal = document.getElementById(`${u}-edit-modal`);
+ this._delModal = document.getElementById(`${u}-del-modal`);
+ this._createModal = document.getElementById(`${u}-create-modal`);
+ this._dropzone = document.getElementById(`${u}-dropzone`);
+ this._fileInput = document.getElementById(`${u}-fileinput`);
+ }
+
+ _css(u) {
+ return ``;
+ }
+
+ // ── Event Binding ────────────────────────────────────────────────────
+
+ _bind() {
+ // Delegated clicks on the widget root
+ this._root.addEventListener('click', this._onClick.bind(this));
+ this._root.addEventListener('change', this._onChange.bind(this));
+
+ // Drag-and-drop on the dropzone
+ if (this._dropzone) {
+ this._dropzone.addEventListener('dragover', e => {
+ e.preventDefault();
+ this._dropzone.classList.add('jfm-over');
+ });
+ this._dropzone.addEventListener('dragleave', () => {
+ this._dropzone.classList.remove('jfm-over');
+ });
+ this._dropzone.addEventListener('drop', e => {
+ e.preventDefault();
+ this._dropzone.classList.remove('jfm-over');
+ const file = e.dataTransfer?.files[0];
+ if (file) this._uploadFile(file);
+ });
+ // Keyboard activation of drop zone
+ this._dropzone.addEventListener('keydown', e => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ this._fileInput?.click();
+ }
+ });
+ }
+
+ // Modal backdrop clicks
+ [this._editModal, this._delModal, this._createModal].forEach(m => {
+ if (m) m.addEventListener('click', e => { if (e.target === m) this._closeAll(); });
+ });
+
+ // Editor: char count + Tab indent
+ if (this._editorEl) {
+ this._editorEl.addEventListener('input', () => this._updateStat());
+ this._editorEl.addEventListener('keydown', e => {
+ if (e.key === 'Tab') {
+ e.preventDefault();
+ const s = this._editorEl.selectionStart;
+ const end = this._editorEl.selectionEnd;
+ const v = this._editorEl.value;
+ this._editorEl.value = v.slice(0, s) + ' ' + v.slice(end);
+ this._editorEl.selectionStart = this._editorEl.selectionEnd = s + 2;
+ this._updateStat();
+ }
+ });
+ }
+
+ // Global keyboard shortcuts
+ document.addEventListener('keydown', this._keyHandler);
+ }
+
+ _onKey(e) {
+ const editOpen = this._editModal && !this._editModal.hidden;
+ const delOpen = this._delModal && !this._delModal.hidden;
+ const createOpen = this._createModal && !this._createModal.hidden;
+
+ if (e.key === 'Escape') {
+ if (editOpen) { this._closeEdit(); return; }
+ if (delOpen) { this._closeDel(); return; }
+ if (createOpen) { this._closeCreate(); return; }
+ }
+ if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
+ e.preventDefault();
+ this._doSave();
+ }
+ }
+
+ _onClick(e) {
+ const btn = e.target.closest('[data-jfm]');
+ if (!btn) return;
+ const action = btn.dataset.jfm;
+
+ switch (action) {
+ case 'refresh': this._loadList(); break;
+ case 'open-picker': this._fileInput?.click(); break;
+ case 'open-create': this._openCreate(); break;
+ case 'close-edit': this._closeEdit(); break;
+ case 'close-del': this._closeDel(); break;
+ case 'close-create': this._closeCreate(); break;
+ case 'fmt': this._formatJson(); break;
+ case 'validate': this._validateJson(); break;
+ case 'save': this._doSave(); break;
+ case 'confirm-del': this._doDelete(); break;
+ case 'do-create': this._doCreate(); break;
+ case 'edit-file': {
+ const card = btn.closest('[data-jfm-file]');
+ if (card) this._openEdit(card.dataset.jfmFile);
+ break;
+ }
+ case 'del-file': {
+ const card = btn.closest('[data-jfm-file]');
+ if (card) this._openDel(card.dataset.jfmFile);
+ break;
+ }
+ }
+ }
+
+ _onChange(e) {
+ // Toggle checkbox
+ if (e.target.classList.contains('jfm-toggle-cb')) {
+ const catName = e.target.dataset.cat;
+ const enabled = e.target.checked;
+ this._doToggle(catName, enabled, e.target);
+ }
+ // File input
+ if (e.target === this._fileInput) {
+ const file = e.target.files?.[0];
+ if (file) this._uploadFile(file);
+ e.target.value = '';
+ }
+ }
+
+ // ── API helper ───────────────────────────────────────────────────────
+
+ async _api(actionKey, params) {
+ const actionId = this.actions[actionKey];
+ if (!actionId) throw new Error(`Action "${actionKey}" not configured`);
+ const body = { plugin_id: this.pluginId, action_id: actionId };
+ if (params !== undefined) body.params = params;
+ const r = await fetch('/api/v3/plugins/action', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ });
+ if (!r.ok) throw new Error('Server error ' + r.status);
+ const ct = r.headers.get('content-type') || '';
+ if (!ct.includes('application/json')) {
+ const txt = await r.text();
+ throw new Error('Unexpected response: ' + txt.slice(0, 120));
+ }
+ return r.json();
+ }
+
+ // ── File List ────────────────────────────────────────────────────────
+
+ async _loadList() {
+ this._listEl.innerHTML = ` Loading…
`;
+ try {
+ const data = await this._api('list');
+ if (data.status !== 'success') throw new Error(data.message || 'Load failed');
+ this._renderList(data.files || []);
+ } catch (err) {
+ this._listEl.innerHTML = `
+
+
⚠
+
Failed to load files
+
${this._esc(err.message)}
+
`;
+ }
+ }
+
+ _renderList(files) {
+ if (!files.length) {
+ this._listEl.innerHTML = `
+
+
📁
+
No files yet
+
Upload or create a JSON file to get started
+
`;
+ return;
+ }
+ this._listEl.innerHTML = files.map(f => this._card(f)).join('');
+ }
+
+ _card(f) {
+ const u = this._uid;
+ const enabled = f.enabled !== false;
+ const displayName = this._esc(f.display_name || f.filename);
+ const filename = this._esc(f.filename);
+ const catName = this.toggleKey ? this._esc(f[this.toggleKey] || '') : '';
+ const showToggle = !!(this.actions.toggle && this.toggleKey && f[this.toggleKey]);
+ const hasEdit = !!this.actions.get && !!this.actions.save;
+ const hasDelete = !!this.actions.delete;
+
+ return `
+
+
+ ${displayName}
+ ${showToggle ? `
+ ` : ''}
+
+
+ 📄 ${filename}
+ 📊 ${f.entry_count ?? 0} entries · ${this._fmtSize(f.size || 0)}
+ 🕑 ${this._fmtDate(f.modified)}
+
+
+ ${hasEdit ? `` : ''}
+ ${hasDelete ? `` : ''}
+
+
`;
+ }
+
+ // ── Edit flow ────────────────────────────────────────────────────────
+
+ async _openEdit(filename) {
+ this._editFile = filename;
+ document.getElementById(`${this._uid}-edit-title`).textContent = `Edit: ${filename}`;
+ this._clearErr();
+ this._editorEl.value = 'Loading…';
+ this._updateStat();
+ this._editModal.hidden = false;
+
+ try {
+ const data = await this._api('get', { filename });
+ if (data.status !== 'success') throw new Error(data.message || 'Load failed');
+ this._editorEl.value = JSON.stringify(data.content, null, 2);
+ this._updateStat();
+ this._editorEl.focus();
+ this._editorEl.setSelectionRange(0, 0);
+ this._editorEl.scrollTop = 0;
+ } catch (err) {
+ this._showErr('Failed to load file: ' + err.message);
+ this._editorEl.value = '';
+ }
+ }
+
+ _closeEdit() {
+ if (this._editModal) this._editModal.hidden = true;
+ this._editFile = null;
+ this._clearErr();
+ }
+
+ _formatJson() {
+ try {
+ const parsed = JSON.parse(this._editorEl.value);
+ this._editorEl.value = JSON.stringify(parsed, null, 2);
+ this._updateStat();
+ this._clearErr();
+ } catch (err) {
+ this._showErr('Invalid JSON — ' + err.message);
+ }
+ }
+
+ _validateJson() {
+ try {
+ const parsed = JSON.parse(this._editorEl.value);
+ const n = (typeof parsed === 'object' && parsed !== null) ? Object.keys(parsed).length : '?';
+ this._clearErr();
+ this._notify(`Valid JSON — ${n} top-level keys`, 'success');
+ } catch (err) {
+ this._showErr('Invalid JSON — ' + err.message);
+ }
+ }
+
+ async _doSave() {
+ if (!this._editFile) return;
+ let contentStr;
+ try {
+ const parsed = JSON.parse(this._editorEl.value);
+ contentStr = JSON.stringify(parsed, null, 2);
+ } catch (err) {
+ this._showErr('Cannot save — fix JSON first: ' + err.message);
+ return;
+ }
+ const btn = document.getElementById(`${this._uid}-save-btn`);
+ this._busy(btn, 'Saving…');
+ try {
+ const data = await this._api('save', { filename: this._editFile, content: contentStr });
+ if (data.status !== 'success') throw new Error(data.message || 'Save failed');
+ this._notify('File saved', 'success');
+ this._closeEdit();
+ this._loadList();
+ } catch (err) {
+ this._showErr('Save failed: ' + err.message);
+ this._idle(btn, 'Save');
+ }
+ }
+
+ // ── Delete flow ──────────────────────────────────────────────────────
+
+ _openDel(filename) {
+ this._deleteFile = filename;
+ const el = document.getElementById(`${this._uid}-del-name`);
+ if (el) el.textContent = filename;
+ if (this._delModal) this._delModal.hidden = false;
+ }
+
+ _closeDel() {
+ if (this._delModal) this._delModal.hidden = true;
+ this._deleteFile = null;
+ }
+
+ async _doDelete() {
+ if (!this._deleteFile) return;
+ const btn = document.getElementById(`${this._uid}-del-btn`);
+ this._busy(btn, 'Deleting…');
+ try {
+ const data = await this._api('delete', { filename: this._deleteFile });
+ if (data.status !== 'success') throw new Error(data.message || 'Delete failed');
+ this._notify('File deleted', 'success');
+ this._closeDel();
+ this._loadList();
+ } catch (err) {
+ this._notify('Delete failed: ' + err.message, 'error');
+ this._idle(btn, 'Delete');
+ }
+ }
+
+ // ── Create flow ──────────────────────────────────────────────────────
+
+ _openCreate() {
+ if (!this._createModal) return;
+ this.createFields.forEach(f => {
+ const el = document.getElementById(`${this._uid}-cf-${f.key}`);
+ if (el) el.value = '';
+ });
+ this._createModal.hidden = false;
+ const first = this.createFields[0];
+ if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
+ }
+
+ _closeCreate() {
+ if (this._createModal) this._createModal.hidden = true;
+ }
+
+ async _doCreate() {
+ const params = {};
+ for (const f of this.createFields) {
+ const el = document.getElementById(`${this._uid}-cf-${f.key}`);
+ const val = (el?.value || '').trim();
+ if (!val) {
+ this._notify(`"${f.label}" is required`, 'error');
+ el?.focus();
+ return;
+ }
+ if (f.pattern && !new RegExp(f.pattern).test(val)) {
+ this._notify(`"${f.label}" format is invalid`, 'error');
+ el?.focus();
+ return;
+ }
+ params[f.key] = val;
+ }
+ // Auto-derive display_name from category_name if left blank
+ const nameField = this.createFields.find(f => f.key === 'display_name');
+ if (nameField) {
+ const el = document.getElementById(`${this._uid}-cf-display_name`);
+ if (el && !el.value.trim()) {
+ const catEl = document.getElementById(`${this._uid}-cf-category_name`);
+ if (catEl?.value) {
+ params.display_name = catEl.value.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
+ }
+ }
+ }
+ const btn = document.getElementById(`${this._uid}-create-btn`);
+ this._busy(btn, 'Creating…');
+ try {
+ const data = await this._api('create', params);
+ if (data.status !== 'success') throw new Error(data.message || 'Create failed');
+ this._notify('File created', 'success');
+ this._closeCreate();
+ this._loadList();
+ } catch (err) {
+ this._notify('Create failed: ' + err.message, 'error');
+ this._idle(btn, 'Create');
+ }
+ }
+
+ // ── Upload ───────────────────────────────────────────────────────────
+
+ async _uploadFile(file) {
+ if (!file.name.endsWith('.json')) {
+ this._notify('Please select a .json file', 'error');
+ return;
+ }
+ let content;
+ try {
+ content = await file.text();
+ JSON.parse(content); // client-side validation
+ } catch (err) {
+ this._notify('Invalid JSON: ' + err.message, 'error');
+ return;
+ }
+ if (this._dropzone) this._dropzone.style.opacity = '.5';
+ try {
+ const data = await this._api('upload', { filename: file.name, content });
+ if (data.status !== 'success') throw new Error(data.message || 'Upload failed');
+ this._notify(`"${file.name}" uploaded`, 'success');
+ this._loadList();
+ } catch (err) {
+ this._notify('Upload failed: ' + err.message, 'error');
+ } finally {
+ if (this._dropzone) this._dropzone.style.opacity = '';
+ }
+ }
+
+ // ── Toggle ───────────────────────────────────────────────────────────
+
+ async _doToggle(catName, enabled, checkbox) {
+ checkbox.disabled = true;
+ try {
+ const params = { enabled };
+ if (this.toggleKey) params[this.toggleKey] = catName;
+ const data = await this._api('toggle', params);
+ if (data.status !== 'success') throw new Error(data.message || 'Toggle failed');
+ this._notify(enabled ? 'Category enabled' : 'Category disabled', 'success');
+ this._loadList();
+ } catch (err) {
+ this._notify('Toggle failed: ' + err.message, 'error');
+ checkbox.checked = !enabled; // revert
+ checkbox.disabled = false;
+ }
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────
+
+ _closeAll() {
+ this._closeEdit();
+ this._closeDel();
+ this._closeCreate();
+ }
+
+ _updateStat() {
+ const v = this._editorEl?.value || '';
+ const lines = v ? v.split('\n').length : 0;
+ const el = document.getElementById(`${this._uid}-charcount`);
+ if (el) el.textContent = `${lines.toLocaleString()} lines · ${v.length.toLocaleString()} chars`;
+ }
+
+ _showErr(msg) {
+ const el = document.getElementById(`${this._uid}-edit-err`);
+ if (el) { el.textContent = msg; el.hidden = false; }
+ }
+
+ _clearErr() {
+ const el = document.getElementById(`${this._uid}-edit-err`);
+ if (el) { el.textContent = ''; el.hidden = true; }
+ }
+
+ _notify(msg, type) {
+ if (typeof window.showNotification === 'function') {
+ window.showNotification(msg, type || 'info');
+ } else {
+ console.info(`[JsonFileManager] ${type || 'info'}: ${msg}`);
+ }
+ }
+
+ _busy(btn, label) {
+ if (!btn) return;
+ btn._jfmOriginal = btn.innerHTML;
+ btn.disabled = true;
+ btn.innerHTML = ` ${this._esc(label)}`;
+ }
+
+ _idle(btn, label) {
+ if (!btn) return;
+ btn.disabled = false;
+ btn.innerHTML = btn._jfmOriginal || this._esc(label);
+ }
+
+ _esc(str) {
+ const d = document.createElement('div');
+ d.textContent = String(str ?? '');
+ return d.innerHTML;
+ }
+
+ _fmtSize(bytes) {
+ if (!bytes) return '0 B';
+ const i = Math.min(Math.floor(Math.log2(bytes + 1) / 10), 2);
+ const unit = ['B', 'KB', 'MB'][i];
+ const val = bytes / Math.pow(1024, i);
+ return (i ? val.toFixed(1) : val) + ' ' + unit;
+ }
+
+ _fmtDate(str) {
+ if (!str) return '—';
+ try {
+ return new Date(str).toLocaleDateString(undefined, {
+ month: 'short', day: 'numeric', year: 'numeric'
+ });
+ } catch { return str; }
+ }
+ }
+
+ // ── Widget registry integration ──────────────────────────────────────────
+
+ window.JsonFileManager = JsonFileManager;
+
+ if (typeof window.LEDMatrixWidgets !== 'undefined') {
+ window.LEDMatrixWidgets.register('json-file-manager', {
+ name: 'JSON File Manager',
+ version: '1.0.0',
+ render(container, config, _value, options) {
+ new JsonFileManager(container, config || {}, options?.pluginId || '');
+ },
+ getValue() { return null; },
+ setValue() {}
+ });
+ console.log('[JsonFileManager] Registered with LEDMatrixWidgets');
+ } else {
+ console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)');
+ }
+})();
diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js
index c34c45fc..773f73d6 100644
--- a/web_interface/static/v3/plugins_manager.js
+++ b/web_interface/static/v3/plugins_manager.js
@@ -3446,6 +3446,23 @@ function generateFieldHtml(key, prop, value, prefix = '') {
html += ``;
});
html += ``;
+ } else if (prop['x-widget'] === 'json-file-manager') {
+ // Reusable JSON file manager widget (no CDN, keyboard shortcuts, configurable actions)
+ const widgetConfig = prop['x-widget-config'] || {};
+ const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || '';
+ const safeFieldId = (fullKey || 'file_manager').replace(/[^a-zA-Z0-9_-]/g, '_');
+
+ html += ``;
+
+ setTimeout(() => {
+ const mount = document.getElementById(`${safeFieldId}_jfm_mount`);
+ if (!mount) return;
+ if (typeof JsonFileManager !== 'undefined') {
+ new JsonFileManager(mount, widgetConfig, pluginId);
+ } else {
+ mount.innerHTML = 'json-file-manager widget not loaded. Check base.html includes json-file-manager.js.
';
+ }
+ }, 150);
} else if (prop['x-widget'] === 'custom-html') {
// Custom HTML widget - load HTML from plugin directory
const htmlFile = prop['x-html-file'];
diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html
index bca338d1..922dab42 100644
--- a/web_interface/templates/v3/base.html
+++ b/web_interface/templates/v3/base.html
@@ -4625,6 +4625,9 @@
+
+
+