/** * 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'; // Colors come from the app theme tokens (app.css :root / [data-theme="dark"]), // with the original light values as fallbacks for pages without app.css. // The few shades the tokens don't cover are widget-local variables with // a dark-theme override. style.textContent = ` .pfm-root, .pfm-overlay { --pfm-border-strong:#d1d5db; --pfm-subtle:#f3f4f6; --pfm-subtle-hover:#e5e7eb; --pfm-danger-border:#fecaca; --pfm-danger-text:#991b1b; --pfm-today:#fef9c3; --pfm-text-muted:#6b7280; } [data-theme="dark"] .pfm-root, [data-theme="dark"] .pfm-overlay { --pfm-border-strong:#4b5563; --pfm-subtle:#374151; --pfm-subtle-hover:#4b5563; --pfm-danger-border:#991b1b; --pfm-danger-text:#fecaca; --pfm-today:#422006; --pfm-text-muted:#9ca3af; } .pfm-root { font-family: inherit; color:var(--color-text-primary,#111827); } .pfm-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:.75rem; } .pfm-title { font-size:1rem; font-weight:600; color:var(--color-text-primary,#111827); } .pfm-dir { font-size:.75rem; color:var(--pfm-text-muted); margin-top:.125rem; } .pfm-upload { border:2px dashed var(--pfm-border-strong); 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:var(--color-primary,#3b82f6); background:var(--color-info-bg,#eff6ff); } .pfm-upload p { font-size:.875rem; color:var(--color-text-tertiary,#4b5563); margin:.25rem 0 0; } .pfm-upload small { font-size:.75rem; color:var(--pfm-text-muted); } .pfm-upload-icon { font-size:1.5rem; color:var(--pfm-text-muted); } .pfm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:.75rem; margin-top:.75rem; } .pfm-card { border:1px solid var(--color-border,#e5e7eb); border-radius:.5rem; padding:.875rem; background:var(--color-surface,#fff); transition:box-shadow .15s; } .pfm-card:hover { box-shadow:var(--shadow-md,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:var(--pfm-subtle); border-radius:.375rem; display:flex; align-items:center; justify-content:center; color:var(--pfm-text-muted); font-size:1rem; } .pfm-card-name { font-weight:600; color:var(--color-text-primary,#111827); font-size:.875rem; margin:.375rem 0 .125rem; } .pfm-card-meta { font-size:.75rem; color:var(--pfm-text-muted); 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:focus-visible { outline:2px solid var(--color-primary,#2563eb); outline-offset:2px; } .pfm-btn:disabled { opacity:.5; cursor:not-allowed; } .pfm-btn-primary { background:var(--color-primary,#2563eb); color:#fff; flex:1; justify-content:center; } .pfm-btn-primary:hover { background:var(--color-primary-hover,#1d4ed8); } /* Solid fills with white text: fixed shades read well on both themes. */ .pfm-btn-danger { background:#dc2626; color:#fff; } .pfm-btn-danger:hover { background:#b91c1c; } .pfm-btn-secondary { background:var(--pfm-subtle); color:var(--color-text-secondary,#374151); border:1px solid var(--pfm-border-strong); } .pfm-btn-secondary:hover { background:var(--pfm-subtle-hover); } .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:var(--pfm-text-muted); } .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:var(--pfm-border-strong); 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-toggle-cb input:focus-visible + .pfm-toggle-slider { outline:2px solid var(--color-primary,#2563eb); outline-offset:2px; } .pfm-empty { text-align:center; padding:2rem; color:var(--pfm-text-muted); } .pfm-empty i { font-size:2rem; margin-bottom:.5rem; display:block; } .pfm-error-text { color:var(--color-error,#dc2626); } /* 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:var(--color-surface,#fff); color:var(--color-text-primary,#111827); border-radius:.75rem; width:100%; max-width:56rem; box-shadow:0 20px 50px rgba(0,0,0,.3); margin:auto; } .pfm-modal:focus { outline:none; } .pfm-modal-header { display:flex; align-items:center; justify-content:space-between; padding:1rem 1.25rem; border-bottom:1px solid var(--color-border,#e5e7eb); } .pfm-modal-title { font-size:1rem; font-weight:600; color:var(--color-text-primary,#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 var(--color-border,#e5e7eb); background:var(--color-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:var(--color-background,#f9fafb); text-align:left; padding:.5rem .625rem; font-weight:600; color:var(--color-text-secondary,#374151); border-bottom:1px solid var(--color-border,#e5e7eb); white-space:nowrap; position:sticky; top:0; } .pfm-table td { padding:.375rem .625rem; border-bottom:1px solid var(--color-border-light,#f3f4f6); vertical-align:top; } .pfm-table tr.today-row td { background:var(--pfm-today); } .pfm-table td input, .pfm-table td textarea { width:100%; border:1px solid var(--pfm-border-strong); border-radius:.25rem; padding:.25rem .375rem; font-size:.8125rem; font-family:inherit; resize:vertical; background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); } .pfm-table td input:focus, .pfm-table td textarea:focus { outline:none; border-color:var(--color-primary,#3b82f6); box-shadow:0 0 0 2px var(--color-primary,#3b82f6); } .pfm-day-col { width:3rem; text-align:center; font-weight:600; color:var(--pfm-text-muted); white-space:nowrap; } .pfm-table-info { font-size:.75rem; color:var(--pfm-text-muted); margin-bottom:.375rem; } .pfm-pagination { display:flex; align-items:center; justify-content:space-between; margin-top:.75rem; font-size:.8125rem; color:var(--pfm-text-muted); } .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 var(--pfm-border-strong); border-radius:.25rem; text-align:center; background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); } .pfm-json-ta { width:100%; font-family:monospace; font-size:.75rem; border:1px solid var(--pfm-border-strong); border-radius:.375rem; padding:.5rem; background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); } .pfm-json-err { color:var(--color-error,#dc2626); font-size:.75rem; margin-top:.25rem; } /* Form in create modal */ .pfm-field { margin-bottom:.875rem; } .pfm-field label { display:block; font-size:.875rem; font-weight:500; color:var(--color-text-secondary,#374151); margin-bottom:.25rem; } .pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid var(--pfm-border-strong); border-radius:.375rem; font-size:.875rem; background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); } .pfm-field input:focus { outline:none; border-color:var(--color-primary,#3b82f6); box-shadow:0 0 0 2px var(--color-primary,#3b82f6); } .pfm-field-hint { font-size:.75rem; color:var(--pfm-text-muted); margin-top:.2rem; } .pfm-field-error { font-size:.75rem; color:var(--color-error,#dc2626); margin-top:.2rem; } /* Delete danger box */ .pfm-danger-box { background:var(--color-error-bg,#fef2f2); border:1px solid var(--pfm-danger-border); border-radius:.5rem; padding:.875rem; font-size:.875rem; color:var(--pfm-danger-text); } @media (prefers-reduced-motion: reduce) { .pfm-upload, .pfm-card, .pfm-btn, .pfm-toggle-slider, .pfm-toggle-slider:before { transition:none; } } `; 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}`); } // Quotes too: the result lands in quoted attribute values (id=, value=, // data-col=), and the textContent/innerHTML round-trip only escapes // &, < and >. function escHtml(s) { const d = document.createElement('div'); d.textContent = String(s ?? ''); return d.innerHTML .replace(/"/g, '"') .replace(/'/g, '''); } 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, '