/** * 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)'); } })();