Add directory board widget renderer with scrolling, anti-burn-in, dark/light themes

Lobby-style tenant/room directory with vertical marquee, seamless loop via
content cloning, pixel shift + bg pulse for anti-burn-in, rotating background
images with crossfade. Supports logo, title, footer, subtitles per entry,
and Available (green) state. All user strings rendered via textContent in
browser — no server-side HTML escaping of entries needed.

Also refactors render dispatch into renderWidgetHtml() and adds a POST
/preview endpoint that inlines user-owned image content as base64 data
URIs so the editor can preview unsaved widgets. Preview is gated by:
- image/* MIME only
- 10 MB size cap
- user_id ownership check
- path traversal guard via basename + resolve

Unknown widget_type on /preview returns 400.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-21 22:28:37 -05:00
parent a981171c94
commit 08a83c9ba9

View file

@ -1,7 +1,34 @@
const express = require('express');
const router = express.Router();
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const appConfig = require('../config');
// For preview only: inline /api/content/:id/file and /thumbnail URLs as data URIs,
// scoped to the current user. Lets the srcdoc preview iframe show logos/bg images
// before the widget is saved (post-save they're reachable via the widget-reference gate).
const MAX_INLINE_BYTES = 10 * 1024 * 1024; // 10MB cap — base64 expands ~1.33x
const MIME_RE = /^image\/[a-zA-Z0-9.+-]+$/;
function inlineUserContent(html, userId) {
return html.replace(/\/api\/content\/([a-f0-9-]+)\/(file|thumbnail)/gi, (match, id, kind) => {
const c = db.prepare('SELECT filepath, thumbnail_path, mime_type, user_id FROM content WHERE id = ?').get(id);
if (!c || c.user_id !== userId) return match;
const filename = kind === 'thumbnail' ? c.thumbnail_path : c.filepath;
if (!filename) return match;
const mime = kind === 'thumbnail' ? 'image/jpeg' : c.mime_type;
if (!mime || !MIME_RE.test(mime)) return match;
const safe = path.resolve(appConfig.contentDir, path.basename(filename));
if (!safe.startsWith(path.resolve(appConfig.contentDir))) return match;
try {
const st = fs.statSync(safe);
if (!st.isFile() || st.size > MAX_INLINE_BYTES) return match;
const buf = fs.readFileSync(safe);
return `data:${mime};base64,${buf.toString('base64')}`;
} catch { return match; }
});
}
// Escape HTML to prevent XSS
function escapeHtml(str) {
@ -89,37 +116,37 @@ router.delete('/:id', (req, res) => {
res.json({ success: true });
});
const KNOWN_WIDGET_TYPES = new Set(['clock','weather','rss','text','webpage','social','directory-board']);
function renderWidgetHtml(type, config) {
config = config || {};
switch (type) {
case 'clock': return renderClock(config);
case 'weather': return renderWeather(config);
case 'rss': return renderRSS(config);
case 'text': return renderText(config);
case 'webpage': return renderWebpage(config);
case 'social': return renderSocial(config);
case 'directory-board': return renderDirectoryBoard(config);
default: return '<html><body style="color:white;background:black;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><h1>Unknown widget</h1></body></html>';
}
}
// Render widget as HTML page
router.get('/:id/render', (req, res) => {
const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id);
if (!widget) return res.status(404).send('Widget not found');
const config = JSON.parse(widget.config || '{}');
let html = '';
switch (widget.widget_type) {
case 'clock':
html = renderClock(config);
break;
case 'weather':
html = renderWeather(config);
break;
case 'rss':
html = renderRSS(config);
break;
case 'text':
html = renderText(config);
break;
case 'webpage':
html = renderWebpage(config);
break;
case 'social':
html = renderSocial(config);
break;
default:
html = '<html><body style="color:white;background:black;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><h1>Unknown widget</h1></body></html>';
}
res.setHeader('Content-Type', 'text/html');
res.send(renderWidgetHtml(widget.widget_type, config));
});
// Preview unsaved widget from config (used by editor Preview button)
router.post('/preview', (req, res) => {
const { widget_type, config } = req.body || {};
if (!widget_type || typeof widget_type !== 'string') return res.status(400).json({ error: 'widget_type required' });
if (!KNOWN_WIDGET_TYPES.has(widget_type)) return res.status(400).json({ error: 'Unknown widget_type' });
let html = renderWidgetHtml(widget_type, config || {});
if (req.user && req.user.id) html = inlineUserContent(html, req.user.id);
res.setHeader('Content-Type', 'text/html');
res.send(html);
});
@ -244,4 +271,306 @@ function renderSocial(c) {
</div></body></html>`;
}
// Directory Board — lobby tenant directory with scrolling content, header/footer,
// rotating background images, and anti-burn-in motion (pixel shift, bg pulse).
// All user-supplied strings are rendered via textContent in-browser, not inlined
// into HTML, so no server-side HTML escaping is needed for entries/categories.
function renderDirectoryBoard(c) {
const configJson = JSON.stringify(c || {}).replace(/</g, '\\u003c');
return `<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Directory</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { width:100%; height:100%; overflow:hidden; }
body {
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
color:#fff;
background:#1a1a2e;
animation: bg-pulse 60s ease-in-out infinite;
}
body.light { color:#1a1a2e; background:#f5f5f5; animation: bg-pulse-light 60s ease-in-out infinite; }
@keyframes bg-pulse { 0%,100% { background:#1a1a2e; } 50% { background:#1b1b30; } }
@keyframes bg-pulse-light { 0%,100% { background:#f5f5f5; } 50% { background:#ededf0; } }
.page { position:fixed; inset:0; overflow:hidden; transition: transform 1.5s ease; will-change: transform; }
.bg-layer { position:absolute; inset:0; z-index:0; }
.bg-img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; opacity:0; transition: opacity 2s ease-in-out; }
.bg-img.active { opacity:0.30; }
.header {
position:absolute; top:0; left:0; right:0; z-index:2;
padding:32px 48px 24px; text-align:center;
background: linear-gradient(to bottom, rgba(0,0,0,0.55), rgba(0,0,0,0));
}
body.light .header { background: linear-gradient(to bottom, rgba(255,255,255,0.75), rgba(255,255,255,0)); }
.header img.logo { max-height:160px; max-width:440px; object-fit:contain; margin-bottom:16px; }
.header h1 { font-size:72px; font-weight:600; letter-spacing:0.02em; }
.footer {
position:absolute; bottom:0; left:0; right:0; z-index:2;
padding:22px 48px; text-align:center;
background: linear-gradient(to top, rgba(0,0,0,0.65), rgba(0,0,0,0));
font-size:28px; color:#fff; line-height:1.3;
}
body.light .footer { color:#1a1a2e; background: linear-gradient(to top, rgba(255,255,255,0.85), rgba(255,255,255,0)); }
.scroller {
position:absolute; left:0; right:0; z-index:1;
overflow:hidden;
mask-image: linear-gradient(to bottom, transparent 0, #000 40px, #000 calc(100% - 40px), transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 40px, #000 calc(100% - 40px), transparent 100%);
}
.track { position:absolute; top:0; left:0; right:0; will-change: transform; }
.block { padding:0 48px 24px; }
.block + .block { padding-top:24px; }
.category { padding:36px 0 16px; }
.category h2 {
text-align:center;
font-size:52px;
font-weight:500;
letter-spacing:0.08em;
text-transform:uppercase;
opacity:0.9;
padding-bottom:14px;
border-bottom: 1px solid rgba(255,255,255,0.15);
margin-bottom:22px;
}
body.light .category h2 { border-bottom-color: rgba(0,0,0,0.12); }
.entries { display:grid; gap:14px 36px; }
.entries[data-cols="auto"] { grid-template-columns: repeat(auto-fit, minmax(440px, 1fr)); }
.entries[data-cols="1"] { grid-template-columns: 1fr; }
.entries[data-cols="2"] { grid-template-columns: repeat(2, 1fr); }
.entries[data-cols="3"] { grid-template-columns: repeat(3, 1fr); }
.entries[data-cols="4"] { grid-template-columns: repeat(4, 1fr); }
.entry { font-size:38px; line-height:1.35; color:#fff; display:flex; gap:14px; align-items:baseline; }
.entry .id { font-weight:600; min-width:3.5em; flex-shrink:0; }
.entry .text { display:flex; flex-direction:column; flex:1; min-width:0; }
.entry .nm { font-weight:400; }
.entry .sub { font-size:0.55em; opacity:0.65; margin-top:4px; line-height:1.3; font-weight:400; }
.entry.available { color:#00ff00; }
.entry.available .id { color:#00ff00; }
body.light .entry { color:#1a1a2e; }
body.light .entry.available, body.light .entry.available .id { color:#059669; }
.gap { height:120px; }
@media (max-width: 1280px) {
.header h1 { font-size:54px; }
.header img.logo { max-height:120px; }
.category h2 { font-size:40px; }
.entry { font-size:28px; }
.footer { font-size:22px; padding:16px 32px; }
.entries[data-cols="auto"] { grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); }
}
</style>
</head>
<body>
<div class="page" id="page">
<div class="bg-layer" id="bgLayer"></div>
<header class="header" id="header"></header>
<div class="scroller" id="scroller">
<div class="track" id="track"></div>
</div>
<footer class="footer" id="footer"></footer>
</div>
<script>
(function(){
var cfg = ${configJson};
var SPEEDS = { slow: 20, medium: 45, fast: 75 };
if (cfg.theme === 'light') document.body.classList.add('light');
var GAP_PX = 100;
var MIN_SCROLL_PX_SEC = 5; // anti-burn-in minimum when content fits
// ----- header -----
var header = document.getElementById('header');
function safeImgUrl(u) {
return typeof u === 'string' && (u.indexOf('/') === 0 || /^https?:\\/\\//.test(u) || /^data:image\\//.test(u)) ? u : '';
}
var logoSrc = safeImgUrl(cfg.logo_url);
if (logoSrc) {
var img = document.createElement('img');
img.className = 'logo';
img.src = logoSrc;
img.alt = '';
header.appendChild(img);
}
if (cfg.title) {
var h1 = document.createElement('h1');
h1.textContent = cfg.title;
header.appendChild(h1);
}
// ----- footer -----
var footer = document.getElementById('footer');
footer.textContent = cfg.footer_text || '';
// ----- background images crossfade -----
var bgLayer = document.getElementById('bgLayer');
var bgs = Array.isArray(cfg.background_images) ? cfg.background_images.map(safeImgUrl).filter(Boolean) : [];
var bgEls = [];
bgs.forEach(function(url){
var el = document.createElement('img');
el.className = 'bg-img';
el.src = url;
el.alt = '';
bgLayer.appendChild(el);
bgEls.push(el);
});
if (bgEls.length > 0) {
bgEls[0].classList.add('active');
if (bgEls.length > 1) {
var idx = 0;
setInterval(function(){
bgEls[idx].classList.remove('active');
idx = (idx + 1) % bgEls.length;
bgEls[idx].classList.add('active');
}, 15000);
}
}
// ----- layout the scroller between header and footer -----
var scroller = document.getElementById('scroller');
function layoutScroller() {
var headerH = header.getBoundingClientRect().height;
var footerH = footer.getBoundingClientRect().height;
scroller.style.top = headerH + 'px';
scroller.style.bottom = footerH + 'px';
}
layoutScroller();
window.addEventListener('resize', layoutScroller);
// ----- build directory content -----
var cols = cfg.columns || 'auto';
if (['auto','1','2','3','4'].indexOf(String(cols)) === -1) cols = 'auto';
function buildBlock() {
var block = document.createElement('div');
block.className = 'block';
var cats = Array.isArray(cfg.categories) ? cfg.categories : [];
cats.forEach(function(cat){
var catEl = document.createElement('div');
catEl.className = 'category';
var h2 = document.createElement('h2');
h2.textContent = cat.name || '';
catEl.appendChild(h2);
var entries = document.createElement('div');
entries.className = 'entries';
entries.setAttribute('data-cols', String(cols));
(cat.entries || []).forEach(function(e){
var row = document.createElement('div');
row.className = 'entry' + (e.available ? ' available' : '');
var id = document.createElement('span');
id.className = 'id';
id.textContent = (e.identifier || '') + ':';
var text = document.createElement('div');
text.className = 'text';
var nm = document.createElement('span');
nm.className = 'nm';
nm.textContent = e.name || '';
text.appendChild(nm);
if (e.subtitle) {
var sub = document.createElement('span');
sub.className = 'sub';
sub.textContent = e.subtitle;
text.appendChild(sub);
}
row.appendChild(id);
row.appendChild(text);
entries.appendChild(row);
});
catEl.appendChild(entries);
block.appendChild(catEl);
});
return block;
}
var track = document.getElementById('track');
var baseBlock = buildBlock();
track.appendChild(baseBlock);
// ----- measure + clone enough copies to fill (seamless loop) -----
function setupScroll() {
// remove any previous clones (on resize)
while (track.children.length > 1) track.removeChild(track.lastChild);
var gap = document.createElement('div');
gap.className = 'gap';
track.appendChild(gap);
var baseH = baseBlock.getBoundingClientRect().height;
var cycleH = baseH + GAP_PX; // distance to translate per loop
var viewH = scroller.getBoundingClientRect().height || window.innerHeight;
// Clone enough times so track fills scroller + at least one full cycle
// Minimum 1 clone (so we can loop). Target: track_height >= view + cycle.
var cloneCount = Math.max(1, Math.ceil((viewH + cycleH) / cycleH));
for (var i = 0; i < cloneCount; i++) {
track.appendChild(buildBlock());
if (i < cloneCount - 1) {
var g = document.createElement('div');
g.className = 'gap';
track.appendChild(g);
}
}
// speed
var contentFits = baseH <= viewH;
var speedName = cfg.scroll_speed || 'medium';
var speedPxSec = SPEEDS[speedName] || SPEEDS.medium;
if (contentFits) speedPxSec = MIN_SCROLL_PX_SEC;
var duration = cycleH / speedPxSec;
// inject keyframes
var oldStyle = document.getElementById('scroll-kf');
if (oldStyle) oldStyle.remove();
var style = document.createElement('style');
style.id = 'scroll-kf';
style.textContent =
'@keyframes dir-scroll { from { transform: translateY(0); } to { transform: translateY(-' + cycleH + 'px); } }' +
'.track { animation: dir-scroll ' + duration + 's linear infinite; }';
document.head.appendChild(style);
}
// wait for images (logo + bgs) to load before measuring, so heights are correct
var pendingImgs = Array.from(document.images).filter(function(i){ return !i.complete; });
if (pendingImgs.length === 0) {
setupScroll();
} else {
var done = 0;
pendingImgs.forEach(function(i){
var onDone = function(){ done++; if (done === pendingImgs.length) setupScroll(); };
i.addEventListener('load', onDone, { once:true });
i.addEventListener('error', onDone, { once:true });
});
// hard timeout so we never hang
setTimeout(function(){ if (document.getElementById('scroll-kf') == null) setupScroll(); }, 5000);
}
// re-layout on resize (debounced)
var rT;
window.addEventListener('resize', function(){
clearTimeout(rT);
rT = setTimeout(function(){ layoutScroller(); setupScroll(); }, 250);
});
// ----- pixel shift (anti-burn-in): every 5 min, shift .page 0-3px random dir -----
var page = document.getElementById('page');
setInterval(function(){
var dx = Math.floor(Math.random() * 7) - 3; // -3..+3
var dy = Math.floor(Math.random() * 7) - 3;
page.style.transform = 'translate(' + dx + 'px, ' + dy + 'px)';
}, 5 * 60 * 1000);
})();
</script>
</body></html>`;
}
module.exports = router;