From 4e4664b603bf49f38edaf71cf0dc96831233a1f4 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 21 Apr 2026 22:28:47 -0500 Subject: [PATCH] Add directory board editor UI with content picker, category/entry management Inline editor with: - Collapsible categories, reorder up/down, delete - Entries with identifier, name, subtitle, available toggle - Add/remove with auto-focus on new row - Empty state prompts first category - Theme, scroll speed, column count selectors - Reusable content picker (single/multi-select) against user's image library - Logo picker + background image picker (multi) via that picker - Preview button posts unsaved config to /widgets/preview and shows the returned HTML in a modal iframe (srcdoc + injected so relative content URLs resolve against our origin) - Delete confirms with widget name Also escapes w.name / typeMeta.name / w.id in the widget grid to prevent stored XSS against admins viewing other users' widgets. Co-Authored-By: Claude Opus 4.7 --- frontend/js/views/widgets.js | 407 ++++++++++++++++++++++++++++++++++- 1 file changed, 397 insertions(+), 10 deletions(-) diff --git a/frontend/js/views/widgets.js b/frontend/js/views/widgets.js index 3e5cfa4..c4796c7 100644 --- a/frontend/js/views/widgets.js +++ b/frontend/js/views/widgets.js @@ -9,8 +9,122 @@ const WIDGET_TYPES = [ { id: 'text', name: 'Text/HTML', icon: '📝', desc: 'Custom text or HTML content' }, { id: 'webpage', name: 'Webpage', icon: '🌐', desc: 'Embed a webpage' }, { id: 'social', name: 'Social Feed', icon: '💬', desc: 'Social media feed' }, + { id: 'directory-board', name: 'Directory Board', icon: '🏢', desc: 'Scrolling tenant/room directory for lobbies' }, ]; +function escAttr(s) { + return String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +} + +function openContentPicker({ multiple = false, title = 'Select Image' } = {}) { + return new Promise(async (resolve) => { + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px'; + overlay.innerHTML = ` +
+

${title}

+ +
+
+
+
+ + ${multiple ? '' : ''} +
+
+
`; + document.body.appendChild(overlay); + + let items = []; + try { items = await API('/content'); } catch {} + items = (items || []).filter(i => (i.mime_type || '').startsWith('image/')); + + const selected = new Set(); + const resolveUrl = (item) => item.remote_url || `/api/content/${item.id}/file`; + const updateCount = () => { + const el = overlay.querySelector('#cpSelCount'); + if (el && multiple) el.textContent = `${selected.size} selected`; + }; + + function renderList() { + const q = (overlay.querySelector('#cpSearch').value || '').toLowerCase(); + const filtered = items.filter(i => (i.filename || '').toLowerCase().includes(q)); + const list = overlay.querySelector('#cpList'); + if (!filtered.length) { + list.innerHTML = `
${items.length ? 'No matches.' : 'No images in your content library. Upload images first from Content Library.'}
`; + return; + } + list.innerHTML = `
${ + filtered.map(c => { + const isSel = selected.has(c.id); + const thumb = c.remote_url || `/api/content/${c.id}/thumbnail`; + return ` +
+ +
${escAttr(c.filename)}
+ ${isSel ? '
' : ''} +
`; + }).join('') + }
`; + list.querySelectorAll('[data-pick-id]').forEach(el => el.onclick = () => { + const id = el.dataset.pickId; + if (multiple) { + if (selected.has(id)) selected.delete(id); else selected.add(id); + updateCount(); + renderList(); + } else { + const item = items.find(x => String(x.id) === id); + if (item) { cleanup(); resolve(resolveUrl(item)); } + } + }); + } + + function cleanup() { overlay.remove(); } + + overlay.querySelector('#cpSearch').oninput = renderList; + overlay.querySelector('#cpCancel').onclick = () => { cleanup(); resolve(multiple ? [] : null); }; + if (multiple) { + overlay.querySelector('#cpDone').onclick = () => { + const urls = Array.from(selected).map(id => { + const item = items.find(x => String(x.id) === id); + return item ? resolveUrl(item) : null; + }).filter(Boolean); + cleanup(); + resolve(urls); + }; + } + overlay.onclick = (e) => { if (e.target === overlay) { cleanup(); resolve(multiple ? [] : null); } }; + updateCount(); + renderList(); + }); +} + +function showPreviewModal(html) { + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px'; + overlay.innerHTML = ` +
+
+ Preview + +
+ +
`; + document.body.appendChild(overlay); + // srcdoc resolves relative URLs against about:srcdoc, so inject pointing to our origin + const baseTag = ``; + const withBase = /]*>/i.test(html) + ? html.replace(/]*)>/i, `${baseTag}`) + : html.replace(/]*)>/i, `${baseTag}`); + overlay.querySelector('#pvIframe').srcdoc = withBase; + const close = () => overlay.remove(); + overlay.querySelector('#pvClose').onclick = close; + overlay.onclick = (e) => { if (e.target === overlay) close(); }; + document.addEventListener('keydown', function esc(ev) { + if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc); } + }); +} + export async function render(container) { container.innerHTML = `
-
${w.name}
-
${typeMeta.name || w.widget_type}
+
${escAttr(w.name)}
+
${escAttr(typeMeta.name || w.widget_type)}
- - + +
`; @@ -201,6 +585,9 @@ export async function render(container) { } const deleteBtn = e.target.closest('[data-delete-widget]'); if (deleteBtn) { + const w = widgets.find(x => x.id === deleteBtn.dataset.deleteWidget); + const label = w ? w.name : 'this widget'; + if (!confirm(`Delete "${label}"? This cannot be undone.`)) return; try { await API(`/widgets/${deleteBtn.dataset.deleteWidget}`, { method: 'DELETE' }); showToast('Widget deleted', 'success');