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) + ` +
+ +
+
+ Data Files + ${this.dirLabel ? `${this._esc(this.dirLabel)}` : ''} +
+
+ ${hasCreate ? `` : ''} + +
+
+ +
+
Loading…
+
+ + ${hasUpload ? ` +
+ +
+ 📁 +

Drop a JSON file here, or click to browse

+ ${this.uploadHint ? `

${this._esc(this.uploadHint)}

` : ''} +
+
` : ''} + + + + + + ${hasDelete ? ` + ` : ''} + + + ${hasCreate ? ` + ` : ''} + +
`; // 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 @@ + + +