mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
#254 lets an organization opt out of widget iframe isolation so that players can embed origin-strict third-party sites. It applied that opt-out to the widget editor's Preview as well. Preview is framed by the dashboard, from the dashboard's own origin, and the dashboard keeps its session JWT in localStorage. So with the setting on, anyone who can author a widget -- workspace_editor and up; viewers are refused at the create route -- could put script in a text widget and read the session of whichever admin clicked Preview. That is an editor -> admin escalation, and it is not the risk the confirmation modal asks the admin to accept: a player runs on a kiosk with a device token, an admin's dashboard session is a different thing entirely. The org setting is what makes players able to embed those sites, so the /render path keeps consulting it. Preview is pinned to allow-scripts in both places that build it -- the dashboard iframe and the server-side render -- so neither a frontend change nor a new server caller can re-grant it alone. Also correct the modal copy, which claimed same-origin would expose the session of anyone viewing "a display or preview". Preview is now excluded, and the display case is really the device token, so say that instead. widget-preview-stays-isolated.test.js fails if either half is reverted; both mutations were checked to fail before committing.
1385 lines
69 KiB
JavaScript
1385 lines
69 KiB
JavaScript
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');
|
|
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
|
|
// Phase 2.2d: workspace-aware access. Same pattern as devices.js / content.js.
|
|
const { accessContext } = require('../lib/tenancy');
|
|
|
|
// For preview only: inline /api/content/:id/file and /thumbnail URLs as data URIs,
|
|
// scoped to the caller's current workspace. 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, workspaceId) {
|
|
if (!workspaceId) return html;
|
|
return html.replace(/\/api\/content\/([a-f0-9-]+)\/(file|thumbnail)/gi, (match, id, kind) => {
|
|
const c = db.prepare('SELECT filepath, thumbnail_path, mime_type, workspace_id FROM content WHERE id = ?').get(id);
|
|
// Inline content only when it lives in the caller's workspace, or is a
|
|
// platform-template row (workspace_id IS NULL) shared with everyone.
|
|
if (!c) return match;
|
|
if (c.workspace_id && c.workspace_id !== workspaceId) return match;
|
|
const filename = kind === 'thumbnail' ? c.thumbnail_path : c.filepath;
|
|
if (!filename) return match;
|
|
// YouTube (and other remote-sourced) content stores thumbnail_path as a remote
|
|
// http(s) URL, not a local file. Don't try to read it from disk (would ENOENT the
|
|
// same way the serving route did) — leave the /api/content/:id/thumbnail reference
|
|
// in place; the thumbnail route proxies it same-origin and CSP img-src allows https:.
|
|
if (/^https?:\/\//i.test(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) {
|
|
if (typeof str !== 'string') return str;
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
// Validate timezone format (e.g. America/New_York, UTC, Etc/GMT+5)
|
|
function safeTimezone(tz) {
|
|
if (!tz) return 'UTC';
|
|
return /^[A-Za-z_\-\/+0-9]+$/.test(tz) ? tz : 'UTC';
|
|
}
|
|
|
|
// Validate ISO date string format
|
|
function safeDateString(d) {
|
|
if (!d) return '';
|
|
return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/.test(d) ? d : '';
|
|
}
|
|
|
|
// Validate URL is http/https
|
|
function safeUrl(url) {
|
|
if (!url) return 'about:blank';
|
|
try {
|
|
const parsed = new URL(url);
|
|
return ['http:', 'https:'].includes(parsed.protocol) ? url : 'about:blank';
|
|
} catch { return 'about:blank'; }
|
|
}
|
|
|
|
// Security: widget render output is public and CSP-exempt, so config values that
|
|
// get inlined into <style>/CSS must not be able to break out (a config field set
|
|
// via the API could otherwise carry `}</style><script>...`). safeCss allows
|
|
// colors/gradients but rejects breakout/exfil constructs; safeNumber coerces to
|
|
// a finite number (so e.g. font_size can't smuggle markup).
|
|
function safeCss(v, fallback) {
|
|
if (typeof v !== 'string') return fallback;
|
|
if (/[<>{}\\;]/.test(v) || /url\s*\(/i.test(v) || /@import/i.test(v) || /expression/i.test(v) || /javascript:/i.test(v)) return fallback;
|
|
return v.trim().slice(0, 200);
|
|
}
|
|
function safeNumber(v, fallback) {
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
}
|
|
|
|
// List widgets accessible to the caller's current workspace, plus any
|
|
// platform-template rows (workspace_id IS NULL) shared with all workspaces.
|
|
// Phase 2.2d: workspace-scoped. Cross-workspace visibility comes from
|
|
// switch-workspace, not a special list branch.
|
|
router.get('/', (req, res) => {
|
|
if (!req.workspaceId) return res.json([]);
|
|
const widgets = db.prepare(
|
|
'SELECT * FROM widgets WHERE (workspace_id = ? OR workspace_id IS NULL) ORDER BY created_at DESC'
|
|
).all(req.workspaceId);
|
|
res.json(widgets);
|
|
});
|
|
|
|
// Create widget in the caller's current workspace.
|
|
router.post('/', (req, res) => {
|
|
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before creating widgets.' });
|
|
const { widget_type, name, config } = req.body;
|
|
if (!widget_type || !name) return res.status(400).json({ error: 'widget_type and name required' });
|
|
|
|
const id = uuidv4();
|
|
db.prepare('INSERT INTO widgets (id, user_id, workspace_id, widget_type, name, config) VALUES (?, ?, ?, ?, ?, ?)')
|
|
.run(id, req.user.id, req.workspaceId, widget_type, name, JSON.stringify(config || {}));
|
|
|
|
res.status(201).json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(id));
|
|
});
|
|
|
|
// Phase 2.2d: workspace-aware access. Mirrors the device/content pattern.
|
|
// Platform-template widgets (workspace_id IS NULL) are readable by anyone
|
|
// authenticated and writable only by platform_admin.
|
|
function checkWidgetRead(req, res) {
|
|
const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id);
|
|
if (!widget) { res.status(404).json({ error: 'Widget not found' }); return null; }
|
|
if (!widget.workspace_id) return widget;
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(widget.workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
|
|
return widget;
|
|
}
|
|
|
|
function checkWidgetWrite(req, res) {
|
|
const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id);
|
|
if (!widget) { res.status(404).json({ error: 'Widget not found' }); return null; }
|
|
if (!widget.workspace_id) {
|
|
if (!PLATFORM_ROLES.includes(req.user.role)) {
|
|
res.status(403).json({ error: 'Platform admin required to modify shared widgets' }); return null;
|
|
}
|
|
return widget;
|
|
}
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(widget.workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
|
|
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
|
|
res.status(403).json({ error: 'Read-only access' }); return null;
|
|
}
|
|
return widget;
|
|
}
|
|
|
|
// Get widget
|
|
router.get('/:id', (req, res) => {
|
|
const widget = checkWidgetRead(req, res);
|
|
if (!widget) return;
|
|
res.json(widget);
|
|
});
|
|
|
|
// Update widget
|
|
router.put('/:id', (req, res) => {
|
|
const widget = checkWidgetWrite(req, res);
|
|
if (!widget) return;
|
|
|
|
const { name, config } = req.body;
|
|
if (name) db.prepare('UPDATE widgets SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id);
|
|
if (config) db.prepare('UPDATE widgets SET config = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(JSON.stringify(config), req.params.id);
|
|
|
|
// Push the change to any display currently showing this widget. Editing a widget used to
|
|
// notify nothing at all: the render endpoint serves live config, but a player that already has
|
|
// the widget on screen keeps its WebView (deliberately — re-navigating a widget every duration
|
|
// is a visible flash and destroys widget state). With no push and no change to the URL, an edit
|
|
// reached the screen only when the app was restarted. Reported on #234: "I changed the text and
|
|
// the new text did not appear on the screen. I had to close the app and then open again."
|
|
//
|
|
// The push is what makes it prompt; the rev in the payload is what makes the player reload.
|
|
try {
|
|
const io = req.app.get('io');
|
|
if (io) {
|
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
|
const commandQueue = require('../lib/command-queue');
|
|
const affected = db.prepare(`
|
|
SELECT DISTINCT d.id FROM devices d
|
|
JOIN playlist_items pi ON pi.playlist_id = d.playlist_id
|
|
WHERE pi.widget_id = ?
|
|
`).all(req.params.id);
|
|
for (const d of affected) {
|
|
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), d.id, buildPlaylistPayload);
|
|
}
|
|
}
|
|
} catch (e) { /* best-effort; the heartbeat refresh still picks it up */ }
|
|
|
|
res.json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id));
|
|
});
|
|
|
|
// Delete widget
|
|
router.delete('/:id', (req, res) => {
|
|
const widget = checkWidgetWrite(req, res);
|
|
if (!widget) return;
|
|
db.prepare('DELETE FROM widgets WHERE id = ?').run(req.params.id);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
const KNOWN_WIDGET_TYPES = new Set(['clock','weather','rss','text','webpage','social','directory-board','directory-search','diag-smoothness']);
|
|
function renderWidgetHtml(type, config, opts = {}) {
|
|
const iframeSandbox = opts.iframeSandbox || 'allow-scripts';
|
|
config = config || {};
|
|
switch (type) {
|
|
case 'clock': return renderClock(config);
|
|
case 'weather': return renderWeather(config);
|
|
case 'rss': return renderRSS(config);
|
|
case 'text': return renderText(config, iframeSandbox);
|
|
case 'webpage': return renderWebpage(config, iframeSandbox);
|
|
case 'social': return renderSocial(config);
|
|
case 'directory-board': return renderDirectoryBoard(config);
|
|
case 'directory-search': return renderDirectorySearch(config);
|
|
case 'diag-smoothness': return renderDiagSmoothness(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>';
|
|
}
|
|
}
|
|
|
|
// The widget editor's Preview is framed by the DASHBOARD, from the dashboard's own
|
|
// origin, and the dashboard keeps its session JWT in localStorage. So preview HTML is
|
|
// pinned to the isolating sandbox and never consults the org setting: otherwise anyone
|
|
// who can author a widget (workspace_editor and up) could run script in the dashboard
|
|
// origin and lift the session of whichever admin clicked Preview.
|
|
//
|
|
// The org setting exists so PLAYERS can embed origin-strict third-party sites. A player
|
|
// runs on a kiosk with a device token, which is the risk the confirmation modal
|
|
// describes; an admin's dashboard session is not.
|
|
const PREVIEW_IFRAME_SANDBOX = 'allow-scripts';
|
|
|
|
function widgetIframeSandboxForWorkspace(workspaceId) {
|
|
if (!workspaceId) return 'allow-scripts';
|
|
try {
|
|
const row = db.prepare(`
|
|
SELECT COALESCE(o.widget_sandbox_isolation_disabled, 0) AS disabled
|
|
FROM workspaces ws
|
|
LEFT JOIN organizations o ON o.id = ws.organization_id
|
|
WHERE ws.id = ?
|
|
`).get(workspaceId);
|
|
return Number(row?.disabled || 0) === 1
|
|
? 'allow-scripts allow-same-origin'
|
|
: 'allow-scripts';
|
|
} catch (_) {
|
|
return 'allow-scripts';
|
|
}
|
|
}
|
|
|
|
// 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 || '{}');
|
|
const iframeSandbox = widgetIframeSandboxForWorkspace(widget.workspace_id);
|
|
// This page is DESIGNED to be embedded by the player, which frames it in a
|
|
// sandboxed (allow-scripts, no allow-same-origin) iframe = a null origin. The
|
|
// global helmet X-Frame-Options: SAMEORIGIN refuses that (null != same), so
|
|
// widgets render blank in the web player. Drop it here; the sandbox - not
|
|
// X-Frame-Options - is what isolates the widget (it can't read the dashboard JWT).
|
|
res.removeHeader('X-Frame-Options');
|
|
// Caching is keyed on whether the caller pinned a revision.
|
|
//
|
|
// A URL carrying ?rev=<widget.updated_at> is content-addressed: those exact bytes cannot change
|
|
// without the rev changing, so it is safe to cache hard — and it NEEDS to be, because a player
|
|
// that loses its network must still be able to render its widgets. Offline resilience is the
|
|
// point of the player's cache, and no-store made widgets the one thing it could never keep.
|
|
//
|
|
// A URL with no rev is the old shape and stays uncacheable: nothing distinguishes one render
|
|
// from the next, so a cached copy could serve content the operator has already changed.
|
|
if (req.query.rev) {
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
} else {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
}
|
|
res.setHeader('Content-Type', 'text/html');
|
|
res.send(renderWidgetHtml(widget.widget_type, config, { iframeSandbox }));
|
|
});
|
|
|
|
// Public JSON feed of a directory board's entries. A directory-search page polls
|
|
// this to reflect board edits without a reload. It exposes only the same data
|
|
// already public via /render, and is CORS-open so a null-origin sandboxed widget
|
|
// iframe can read it. 404 (not empty) on a missing/wrong-type source so the
|
|
// polling page keeps its last-good data instead of blanking on a transient miss.
|
|
router.get('/:id/data.json', (req, res) => {
|
|
const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id);
|
|
if (!widget || widget.widget_type !== 'directory-board') return res.status(404).json({ error: 'Not a directory board' });
|
|
let categories = [];
|
|
try {
|
|
const cfg = JSON.parse(widget.config || '{}');
|
|
categories = Array.isArray(cfg.categories) ? cfg.categories : [];
|
|
} catch (e) { categories = []; }
|
|
res.removeHeader('X-Frame-Options');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.json({ categories });
|
|
});
|
|
|
|
// Latest frame-rate telemetry per widget, reported by the diag-smoothness widget running on a device.
|
|
// In-memory (diagnostic, not persisted) — a device page reads the snapshot for the widget it plays.
|
|
//
|
|
// BOUNDED, because the writer is unauthenticated (the widget runs in a null-origin sandboxed
|
|
// iframe and cannot carry a session) and the key comes from the request body. An uncapped map
|
|
// keyed on caller-supplied values is a remote memory-exhaustion path, and on this product a dead
|
|
// server means the whole fleet reconnects at once.
|
|
//
|
|
// The cap is GLOBAL rather than per-IP on purpose: signage sites egress through one NAT address,
|
|
// so a per-IP limit would punish an entire venue for one noisy panel while doing nothing about a
|
|
// distributed writer. Same reasoning as lib/ota-download-guard ("NEVER per-IP (SNAT)"). Eviction
|
|
// is least-recently-written, and a live panel rewrites its key every 2.5s, so only entries the
|
|
// dashboard would already call stale (>15s) are ever eligible.
|
|
const widgetTelemetry = require('../lib/bounded-snapshot-store').createStore({ max: 500, ttlMs: 60_000 });
|
|
widgetTelemetry.startSweep();
|
|
// Public POST from the widget: it runs in a null-origin sandboxed iframe, so this must be no-auth +
|
|
// CORS-open. The widget sends text/plain (a "simple" request → no CORS preflight); we JSON.parse it.
|
|
router.post('/:id/telemetry', express.text({ type: '*/*', limit: '16kb' }), (req, res) => {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
let t = {};
|
|
try { t = typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body || {}); } catch (e) { t = {}; }
|
|
t.receivedAt = Date.now();
|
|
// Key by the reporting device (player passes ?device=<id>) so multiple panels don't collide;
|
|
// fall back to a widget-scoped key for players that don't pass a device id yet.
|
|
const key = (t.device && String(t.device).slice(0, 64)) || ('w:' + req.params.id);
|
|
widgetTelemetry.set(key, t);
|
|
// 204, not res.json(): this is fire-and-forget diagnostic data and the reporting widget ignores
|
|
// the response entirely (routes/widgets.js renderDiagSmoothness -> fetch(...).catch()). It also
|
|
// keeps services/activity.js activityLogger — which wraps res.json — from writing an activity_log
|
|
// row per unauthenticated report, i.e. from letting an anonymous caller grow a DB table.
|
|
res.status(204).end();
|
|
});
|
|
// Public GET so the dashboard device page can display the snapshot. ?device=<id> reads that panel's
|
|
// report; without it (or if that panel hasn't reported) falls back to the widget-scoped snapshot.
|
|
router.get('/:id/telemetry', (req, res) => {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
const dev = req.query.device ? String(req.query.device) : null;
|
|
// Device-scoped request returns ONLY that device's report — NO widget-wide fallback, or one
|
|
// reporting panel's data would show on every other device's page (incl. offline ones). A request
|
|
// with no device id gets the widget-scoped snapshot (raw/debug view only).
|
|
// get() returns null for a missing OR expired entry, so a stale snapshot is never served
|
|
// as live even between sweeps.
|
|
const rec = dev ? widgetTelemetry.get(dev) : widgetTelemetry.get('w:' + req.params.id);
|
|
res.json(rec || null);
|
|
});
|
|
|
|
// 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' });
|
|
// Preview renders inside the DASHBOARD origin, so it never opts into same-origin —
|
|
// see PREVIEW_IFRAME_SANDBOX.
|
|
let html = renderWidgetHtml(widget_type, config || {}, { iframeSandbox: PREVIEW_IFRAME_SANDBOX });
|
|
if (req.workspaceId) html = inlineUserContent(html, req.workspaceId);
|
|
res.setHeader('Content-Type', 'text/html');
|
|
res.send(html);
|
|
});
|
|
|
|
// Preview sessions — ephemeral store so the preview iframe loads via src (not srcdoc)
|
|
// and bypasses the dashboard CSP that would block the widget's inline scripts.
|
|
const previewStore = new Map();
|
|
const PREVIEW_TTL = 5 * 60 * 1000;
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of previewStore) {
|
|
if (now - entry.created > PREVIEW_TTL) previewStore.delete(key);
|
|
}
|
|
}, 60 * 1000).unref();
|
|
|
|
router.post('/preview-session', (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' });
|
|
const id = uuidv4();
|
|
// Same reasoning as /preview — dashboard origin, never same-origin.
|
|
const html = renderWidgetHtml(widget_type, config || {}, { iframeSandbox: PREVIEW_IFRAME_SANDBOX });
|
|
previewStore.set(id, { html, widget_type, created: Date.now() });
|
|
res.json({ id, url: `/api/widgets/preview-session/${id}` });
|
|
});
|
|
|
|
router.get('/preview-session/:id', (req, res) => {
|
|
const entry = previewStore.get(req.params.id);
|
|
if (!entry) return res.status(410).send('Preview expired');
|
|
if (Date.now() - entry.created > PREVIEW_TTL) {
|
|
previewStore.delete(req.params.id);
|
|
return res.status(410).send('Preview expired');
|
|
}
|
|
let html = entry.html;
|
|
if (req.workspaceId) html = inlineUserContent(html, req.workspaceId);
|
|
res.removeHeader('X-Frame-Options');
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.setHeader('Content-Type', 'text/html');
|
|
res.send(html);
|
|
});
|
|
|
|
function renderClock(c) {
|
|
return `<!DOCTYPE html><html><head><style>
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
body { background:${safeCss(c.background, 'transparent')}; display:flex; flex-direction:column; align-items:center; justify-content:center; height:100vh; font-family:-apple-system,sans-serif; overflow:hidden; }
|
|
#time { font-size:${safeNumber(c.font_size, 64)}px; font-weight:700; color:${safeCss(c.color, '#FFFFFF')}; }
|
|
#date { font-size:${Math.max(16, safeNumber(c.font_size, 64) / 3)}px; color:${safeCss(c.color, '#FFFFFF')}; opacity:0.7; margin-top:8px; }
|
|
</style></head><body>
|
|
<div id="time"></div>
|
|
${c.show_date !== false ? '<div id="date"></div>' : ''}
|
|
<script>
|
|
function update() {
|
|
const opts = { hour12: ${c.format !== '24h'}, timeZone: '${safeTimezone(c.timezone)}', hour:'2-digit', minute:'2-digit', second:'2-digit' };
|
|
document.getElementById('time').textContent = new Date().toLocaleTimeString('en-US', opts);
|
|
${c.show_date !== false ? `document.getElementById('date').textContent = new Date().toLocaleDateString('en-US', { timeZone: '${safeTimezone(c.timezone)}', weekday:'long', year:'numeric', month:'long', day:'numeric' });` : ''}
|
|
}
|
|
setInterval(update, 1000); update();
|
|
</script></body></html>`;
|
|
}
|
|
|
|
function renderWeather(c) {
|
|
return `<!DOCTYPE html><html><head><style>
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
body { background:${safeCss(c.background, 'transparent')}; display:flex; align-items:center; justify-content:center; height:100vh; font-family:-apple-system,sans-serif; color:${safeCss(c.color, '#FFF')}; }
|
|
.weather { text-align:center; }
|
|
.temp { font-size:${safeNumber(c.font_size, 48)}px; font-weight:700; }
|
|
.location { font-size:18px; opacity:0.7; margin-top:4px; }
|
|
.desc { font-size:16px; opacity:0.6; margin-top:8px; }
|
|
.icon { font-size:64px; }
|
|
</style></head><body>
|
|
<div class="weather">
|
|
<div class="icon" id="icon"></div>
|
|
<div class="temp" id="temp">--</div>
|
|
<div class="location">${escapeHtml(c.location) || 'Unknown'}</div>
|
|
<div class="desc" id="desc"></div>
|
|
</div>
|
|
<script>
|
|
async function load() {
|
|
try {
|
|
const r = await fetch('https://wttr.in/${encodeURIComponent(c.location || 'New York')}?format=j1');
|
|
const d = await r.json();
|
|
const cur = d.current_condition[0];
|
|
const unit = '${c.units === 'metric' ? 'temp_C' : 'temp_F'}';
|
|
const deg = '${c.units === 'metric' ? '°C' : '°F'}';
|
|
document.getElementById('temp').textContent = cur[unit] + deg;
|
|
document.getElementById('desc').textContent = cur.weatherDesc[0].value;
|
|
const code = parseInt(cur.weatherCode);
|
|
const icons = {113:'☀️',116:'⛅',119:'☁️',122:'☁️',143:'🌫️',176:'🌧️',200:'⛈️',227:'🌨️',260:'🌫️',263:'🌧️',266:'🌧️',293:'🌧️',296:'🌧️',299:'🌧️',302:'🌧️',305:'🌧️',308:'🌧️',311:'🌧️',314:'🌧️',317:'🌧️',320:'🌨️',323:'🌨️',326:'🌨️',329:'🌨️',332:'🌨️',335:'🌨️',338:'🌨️',350:'🌧️',353:'🌧️',356:'🌧️',359:'🌧️',362:'🌨️',365:'🌨️',368:'🌨️',371:'🌨️',374:'🌨️',377:'🌨️',386:'⛈️',389:'⛈️',392:'⛈️',395:'🌨️'};
|
|
document.getElementById('icon').textContent = icons[code] || '🌡️';
|
|
} catch(e) { document.getElementById('desc').textContent = 'Weather unavailable'; }
|
|
}
|
|
load(); setInterval(load, 600000);
|
|
</script></body></html>`;
|
|
}
|
|
|
|
function renderRSS(c) {
|
|
// scroll_speed is authored in the UI as "seconds" (legacy field), but that used to be wired
|
|
// straight into animation-duration: a *fixed total time* for the whole strip to cross the
|
|
// screen. That makes the on-screen speed depend on how much content there is - a feed with
|
|
// many items gets dragged through in the same {scroll_speed}s as a feed with one, so it
|
|
// flies past far too fast, never lets the reader finish, and simply "jumps back to the
|
|
// start" once the fixed duration is up. Instead we treat scroll_speed as calibrating a
|
|
// constant px/sec rate (using one viewport-width per scroll_speed seconds as the reference,
|
|
// matching prior behaviour for content that fits in one screen), then measure the actual
|
|
// rendered width of the ticker and derive a duration long enough to move that full distance
|
|
// at the same constant speed - so more items simply take proportionally longer, and every
|
|
// item scrolls fully into and out of view before the loop restarts.
|
|
const scrollSpeedSec = safeNumber(c.scroll_speed, 30);
|
|
return `<!DOCTYPE html><html><head><style>
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
body { background:${safeCss(c.background, '#000')}; height:100vh; overflow:hidden; font-family:-apple-system,sans-serif; }
|
|
.ticker { display:flex; align-items:center; height:100%; white-space:nowrap; position:relative; will-change:transform; }
|
|
.item { display:inline-block; padding:0 40px; font-size:${safeNumber(c.font_size, 24)}px; color:${safeCss(c.color, '#FFF')}; }
|
|
.item .title { font-weight:600; }
|
|
.item .sep { margin:0 20px; opacity:0.3; }
|
|
</style></head><body>
|
|
<div class="ticker" id="ticker"><div class="item">Loading feed...</div></div>
|
|
<script>
|
|
var SCROLL_SPEED_SEC = ${scrollSpeedSec};
|
|
var ticker = document.getElementById('ticker');
|
|
var anim = null;
|
|
function restartAnimation() {
|
|
if (anim) { anim.cancel(); anim = null; }
|
|
var viewportW = window.innerWidth;
|
|
var tickerW = ticker.scrollWidth;
|
|
// Reference speed: one viewport-width travelled every SCROLL_SPEED_SEC seconds, so the
|
|
// default of 30s behaves the same as before for a feed that fits within one screen.
|
|
var pxPerSec = viewportW / SCROLL_SPEED_SEC;
|
|
var distance = viewportW + tickerW; // starts fully off-screen right, ends fully off-screen left
|
|
var durationMs = Math.max(1000, (distance / pxPerSec) * 1000);
|
|
anim = ticker.animate(
|
|
[
|
|
{ transform: 'translateX(' + viewportW + 'px)' },
|
|
{ transform: 'translateX(-' + tickerW + 'px)' },
|
|
],
|
|
{ duration: durationMs, iterations: Infinity, easing: 'linear' }
|
|
);
|
|
}
|
|
async function load() {
|
|
try {
|
|
const r = await fetch('https://api.rss2json.com/v1/api.json?rss_url=' + encodeURIComponent('${escapeHtml(c.feed_url) || ''}'));
|
|
const d = await r.json();
|
|
const items = d.items?.slice(0, ${safeNumber(c.max_items, 10)}) || [];
|
|
// NOTE: RSS feed titles are external content - using textContent instead of innerHTML to prevent XSS
|
|
ticker.innerHTML = items.map(i => {
|
|
const el = document.createElement('span'); el.textContent = i.title;
|
|
return '<div class="item"><span class="title">' + el.innerHTML + '</span></div><div class="item sep">•</div>';
|
|
}).join('') || '<div class="item">No items</div>';
|
|
} catch(e) { ticker.innerHTML = '<div class="item">Feed unavailable</div>'; }
|
|
requestAnimationFrame(restartAnimation);
|
|
}
|
|
window.addEventListener('resize', restartAnimation);
|
|
load(); setInterval(load, 300000);
|
|
</script></body></html>`;
|
|
}
|
|
|
|
function renderText(c, iframeSandbox = 'allow-scripts') {
|
|
let html = c.html || '<p style="color:white;padding:20px">Empty text widget</p>';
|
|
|
|
// LEGACY DESIGNER RESCUE — deliberately narrow.
|
|
//
|
|
// The Content Designer used to publish absolute font sizes as fontSize*10.8 px; today it emits
|
|
// cqw (see designer.js). Converting px/108 back to vw restores the author's intended size and
|
|
// makes those old widgets scale to any screen.
|
|
//
|
|
// It must NOT touch hand-authored HTML. This regex used to run over EVERY text widget, so
|
|
// someone writing `font-size:16px` in the Text/HTML editor got 0.15vw — 2.8px on a 1080p
|
|
// screen, and smaller still on anything narrower. Their text was not clipped or hidden; it was
|
|
// rendered too small to read, in the one widget whose whole purpose is hand-written HTML.
|
|
//
|
|
// Designer output is identified by its absolutely-positioned elements, the same signal the
|
|
// dashboard uses to decide whether a text widget can be reopened in the designer. Hand-written
|
|
// markup keeps its px exactly as typed.
|
|
const isDesignerAuthored = /position:\s*absolute;\s*left:/.test(html);
|
|
if (isDesignerAuthored) {
|
|
html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => {
|
|
return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`;
|
|
});
|
|
}
|
|
|
|
// What to do when the text is taller than the screen. It used to be clipped in silence: the
|
|
// document was overflow:hidden with no scrollbar and nothing to scroll it, so on a display
|
|
// shorter than the content the bottom simply vanished — reported as "text goes to bottom and
|
|
// disappears. It dont fit."
|
|
//
|
|
// fit (default) shrink until it fits. A no-op when the content already fits, so this
|
|
// rescues widgets that are currently losing text without altering ones that are fine.
|
|
// scroll pan through it on a loop, with a pause at each end. For content that is genuinely
|
|
// longer than a screen, where shrinking it would make it unreadable.
|
|
// clip the old behaviour, kept because a designer-positioned layout may deliberately run
|
|
// past the edge and must not be rescaled underneath the author.
|
|
const overflowMode = ['fit', 'scroll', 'clip'].includes(c.overflow) ? c.overflow : 'fit';
|
|
|
|
// Runs inside the sandboxed iframe (allow-scripts, null origin). Measures after layout, after
|
|
// web fonts settle, and on resize — a rotation or a resized zone changes the answer, and fonts
|
|
// loading late is the classic cause of a fit that was computed against the wrong height.
|
|
const fitScript = overflowMode === 'clip' ? '' : `<script>
|
|
(function () {
|
|
var mode = ${JSON.stringify(overflowMode)};
|
|
var wrap = document.getElementById('st-wrap');
|
|
if (!wrap) return;
|
|
var anim = null;
|
|
function apply() {
|
|
// Reset before measuring, or we measure the previous transform's result.
|
|
wrap.style.transform = '';
|
|
if (anim) { anim.cancel(); anim = null; }
|
|
var avail = document.documentElement.clientHeight;
|
|
var need = wrap.scrollHeight;
|
|
if (!avail || !need || need <= avail + 1) return; // already fits: leave it alone
|
|
if (mode === 'fit') {
|
|
var k = avail / need;
|
|
wrap.style.transformOrigin = 'top center';
|
|
wrap.style.transform = 'scale(' + k + ')';
|
|
return;
|
|
}
|
|
// scroll: hold, pan the overflow, hold, return. Speed is distance-based so a long
|
|
// document is not unreadably fast and a short one is not tediously slow.
|
|
var over = need - avail;
|
|
var panMs = Math.max(4000, (over / 40) * 1000);
|
|
var holdMs = 2000;
|
|
var total = panMs * 2 + holdMs * 2;
|
|
var p1 = holdMs / total, p2 = (holdMs + panMs) / total, p3 = (holdMs * 2 + panMs) / total;
|
|
anim = wrap.animate(
|
|
[
|
|
{ transform: 'translateY(0)', offset: 0 },
|
|
{ transform: 'translateY(0)', offset: p1 },
|
|
{ transform: 'translateY(' + (-over) + 'px)', offset: p2 },
|
|
{ transform: 'translateY(' + (-over) + 'px)', offset: p3 },
|
|
{ transform: 'translateY(0)', offset: 1 },
|
|
],
|
|
{ duration: total, iterations: Infinity, easing: 'linear' }
|
|
);
|
|
}
|
|
addEventListener('resize', apply);
|
|
if (document.fonts && document.fonts.ready) document.fonts.ready.then(apply).catch(function(){});
|
|
// Late images change the height too; rAF lets first layout finish before measuring.
|
|
addEventListener('load', function () { requestAnimationFrame(apply); });
|
|
requestAnimationFrame(apply);
|
|
})();
|
|
</script>`;
|
|
|
|
// Security: c.html / c.css are intentionally raw user-authored content, but the
|
|
// render is public and same-origin with the dashboard - injected <script> could
|
|
// otherwise read the dashboard's localStorage JWT. Render the user content inside
|
|
// a sandboxed iframe with NO allow-same-origin: scripts still run (so legit
|
|
// widget markup works) but in a null origin that can't touch the app's storage.
|
|
const inner = `<!DOCTYPE html><html><head><style>
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
html, body { width:100vw; height:100vh; overflow:hidden; }
|
|
/* The wrapper is what gets scaled or panned. It must be allowed to exceed the viewport,
|
|
otherwise there is nothing to measure and nothing to move. */
|
|
#st-wrap { width:100%; min-height:100%; will-change:transform; }
|
|
${c.css || ''}
|
|
</style></head><body><div id="st-wrap">${html}</div>${fitScript}</body></html>`;
|
|
return `<!DOCTYPE html><html><head><style>
|
|
* { margin:0; padding:0; }
|
|
html, body { width:100vw; height:100vh; overflow:hidden; background:${safeCss(c.background, 'transparent')}; }
|
|
iframe { width:100%; height:100%; border:0; display:block; }
|
|
</style></head><body><iframe sandbox="${escapeHtml(iframeSandbox)}" srcdoc="${escapeHtml(inner)}"></iframe></body></html>`;
|
|
}
|
|
|
|
function renderWebpage(c, iframeSandbox = 'allow-scripts') {
|
|
const zoom = (c.zoom || 100) / 100;
|
|
const invZoom = 100 / (c.zoom || 100) * 100;
|
|
return `<!DOCTYPE html><html><head><style>
|
|
* { margin:0; } body { height:100vh; overflow:hidden; }
|
|
iframe { width:${invZoom}%; height:${invZoom}%; border:0; transform:scale(${zoom}); transform-origin:0 0; }
|
|
</style></head><body>
|
|
<iframe src="${escapeHtml(safeUrl(c.url))}" sandbox="${escapeHtml(iframeSandbox)}"></iframe>
|
|
${c.refresh_interval > 0 ? `<script>setInterval(()=>document.querySelector('iframe').src=document.querySelector('iframe').src,${c.refresh_interval * 1000});</script>` : ''}
|
|
</body></html>`;
|
|
}
|
|
|
|
function renderSocial(c) {
|
|
return `<!DOCTYPE html><html><head><style>
|
|
body { background:${safeCss(c.background, '#000')}; color:${safeCss(c.color, '#FFF')}; font-family:-apple-system,sans-serif; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; }
|
|
</style></head><body>
|
|
<div style="text-align:center">
|
|
<p style="font-size:24px">Social Feed</p>
|
|
<p style="opacity:0.5;margin-top:8px">${escapeHtml(c.platform) || 'twitter'}: ${escapeHtml(c.query) || ''}</p>
|
|
<p style="opacity:0.3;margin-top:16px;font-size:13px">Configure API key in widget settings</p>
|
|
</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; }
|
|
|
|
.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; }
|
|
|
|
@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>
|
|
<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 = 120; // blank space between the end of the directory and where it repeats (loop seam)
|
|
var MIN_SCROLL_PX_SEC = 5; // anti-burn-in minimum when content fits
|
|
var REFRESH_MS = 60000; // poll data.json this often; re-render ONLY when entries changed
|
|
|
|
// ----- 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);
|
|
}
|
|
// A logo replaces the title text — showing both stacks the wordmark over the name.
|
|
if (cfg.title && !logoSrc) {
|
|
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 buildCategoryEl(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);
|
|
return catEl;
|
|
}
|
|
|
|
var stage = scroller; // the clip window between header & footer
|
|
var N = 4; // panels in the ring (2 tile the screen, 1 dwells below, 1 above)
|
|
var baseStyle = document.createElement('style');
|
|
baseStyle.textContent =
|
|
'.panel{ position:absolute; left:0; right:0; top:0; overflow:hidden; contain:paint; will-change:transform; backface-visibility:hidden; }' +
|
|
'.pcontent{ position:absolute; left:0; right:0; top:0; padding:0 48px; }';
|
|
document.head.appendChild(baseStyle);
|
|
var scrollStyle = document.createElement('style');
|
|
scrollStyle.id = 'dir-scroll-kf';
|
|
document.head.appendChild(scrollStyle);
|
|
|
|
// ----- scroll: a ring of compositor-animated, viewport-tall panels -----
|
|
// Animating one tall track fails on Firefox (it won't composite a transform bigger than ~1.1x the
|
|
// viewport / 4096px and falls back to a stuttering main-thread animation) and churns GPU tiles even
|
|
// on Chromium. Instead we run N panels, each exactly one stage-height tall (overflow:hidden +
|
|
// contain:paint clamp each compositor layer to that box). Each panel is a static window onto a full
|
|
// copy of the directory (positioned by a static inner translateY = -slice); the PANEL is slid
|
|
// rigidly upward by ONE CSS @keyframes animation, and the panels are phase-locked by negative
|
|
// animation-delay so two always tile the screen while one dwells off-screen below and one above.
|
|
// There is NO per-frame JS — "scrolling" is the compositor sliding pre-rasterized viewport-sized
|
|
// textures, so nothing on the main thread (GC, extensions, the host player) can stutter it. On each
|
|
// off-screen wrap a panel jumps its slice N screens ahead (content already built — nothing to load
|
|
// when it reappears) and, if a data refresh is pending, rebuilds its content THEN, safely off-screen.
|
|
var panels = []; // [{el, content, version, slice}]
|
|
var Sh = 0; // panel / stage height
|
|
var C = 0; // looped directory height (one full copy)
|
|
var speedPxSec = 0;
|
|
var contentVersion = 0;
|
|
var pending = null; // a queued data refresh, picked up per-panel while off-screen
|
|
|
|
function fillContent(el) { // full directory + a clone of the top (>= one screen) for the within-panel wrap
|
|
var arr = Array.isArray(cfg.categories) ? cfg.categories : [];
|
|
arr.forEach(function(c){ el.appendChild(buildCategoryEl(c)); });
|
|
var full = el.scrollHeight; // == C (one full directory)
|
|
var i = 0, guard = arr.length * 4 + 1;
|
|
while ((el.scrollHeight - full) < Sh + 4 && arr.length && i < guard) {
|
|
el.appendChild(buildCategoryEl(arr[i % arr.length])); i++;
|
|
}
|
|
return full;
|
|
}
|
|
|
|
function globalScroll() { return speedPxSec * ((document.timeline.currentTime || 0) / 1000); }
|
|
function mod(a, n) { return n > 0 ? ((a % n) + n) % n : 0; }
|
|
function setSlice(p, off) { p.slice = off; p.content.style.transform = 'translate3d(0,' + (-off) + 'px,0)'; }
|
|
|
|
function seedSlices() { // four consecutive screens, matching the lanes' physical phase (delays 0..-3T)
|
|
var base = globalScroll();
|
|
var laneStart = [2 * Sh, 1 * Sh, 0, -1 * Sh];
|
|
panels.forEach(function(p, i){ setSlice(p, mod(base + laneStart[i % 4], C)); });
|
|
}
|
|
|
|
function onWrap(p) { // fires as a panel wraps to the bottom (off-screen); rebuild + advance N screens
|
|
if (pending && p.version !== pending.version) {
|
|
p.content.replaceChildren();
|
|
C = fillContent(p.content); // all panels share the same data => same C
|
|
p.version = pending.version;
|
|
}
|
|
setSlice(p, mod(p.slice + N * Sh, C));
|
|
}
|
|
|
|
function setup() {
|
|
layoutScroller();
|
|
Sh = stage.getBoundingClientRect().height || window.innerHeight;
|
|
stage.replaceChildren();
|
|
panels = [];
|
|
for (var i = 0; i < N; i++) {
|
|
var el = document.createElement('div'); el.className = 'panel'; el.setAttribute('data-lane', i);
|
|
el.style.height = Sh + 'px';
|
|
var content = document.createElement('div'); content.className = 'pcontent';
|
|
el.appendChild(content);
|
|
stage.appendChild(el);
|
|
panels.push({ el: el, content: content, version: contentVersion, slice: 0 });
|
|
}
|
|
C = fillContent(panels[0].content);
|
|
for (var j = 1; j < N; j++) fillContent(panels[j].content);
|
|
speedPxSec = (C <= Sh) ? MIN_SCROLL_PX_SEC : (SPEEDS[cfg.scroll_speed] || SPEEDS.medium);
|
|
var T = Sh / speedPxSec, dur = N * T;
|
|
var kf = '@keyframes dir-pan { from { transform: translate3d(0,' + (2 * Sh) + 'px,0); } to { transform: translate3d(0,' + (-2 * Sh) + 'px,0); } }';
|
|
kf += '.panel{ animation: dir-pan ' + dur + 's linear infinite; }';
|
|
for (var k = 0; k < N; k++) kf += '.panel[data-lane="' + k + '"]{ animation-delay: ' + (-k * T).toFixed(4) + 's; }';
|
|
scrollStyle.textContent = kf;
|
|
seedSlices();
|
|
panels.forEach(function(p){ p.el.addEventListener('animationiteration', function(){ onWrap(p); }); });
|
|
}
|
|
|
|
// wait for images (logo + bgs) to load before the first layout, so heights are correct
|
|
var pendingImgs = Array.from(document.images).filter(function(i){ return !i.complete; });
|
|
if (pendingImgs.length === 0) {
|
|
setup();
|
|
} else {
|
|
var built = false, build = function(){ if (!built) { built = true; setup(); } };
|
|
pendingImgs.forEach(function(i){
|
|
i.addEventListener('load', build, { once:true });
|
|
i.addEventListener('error', build, { once:true });
|
|
});
|
|
setTimeout(build, 5000); // hard timeout so we never hang
|
|
}
|
|
|
|
// re-layout on resize (debounced) — rebuild the ring; globalScroll() keeps the same content position
|
|
var rT;
|
|
window.addEventListener('resize', function(){
|
|
clearTimeout(rT);
|
|
rT = setTimeout(setup, 250);
|
|
});
|
|
|
|
// ----- live data refresh: poll data.json; re-render ONLY when the entries changed -----
|
|
// Mirrors the directory-search poll. data.json is THIS board's own feed (relative URL,
|
|
// CORS-open, no-store). Diff the categories signature and rebuild IN PLACE only on a real
|
|
// change, so an unchanged poll never touches the running scroll (no periodic reset).
|
|
var lastSig = JSON.stringify(cfg.categories || []);
|
|
setInterval(function(){
|
|
if (document.hidden) return;
|
|
fetch('data.json', { cache: 'no-store' })
|
|
.then(function(r){ return r.ok ? r.json() : Promise.reject(r.status); })
|
|
.then(function(data){
|
|
var cats = data && Array.isArray(data.categories) ? data.categories : [];
|
|
var sig = JSON.stringify(cats);
|
|
if (sig === lastSig) return; // unchanged -> leave the scroll running untouched
|
|
lastSig = sig;
|
|
cfg.categories = cats;
|
|
contentVersion++; // queue it; each panel adopts it on its next off-screen wrap
|
|
pending = { version: contentVersion };
|
|
})
|
|
.catch(function(){ /* transient error -> keep last-good board */ });
|
|
}, REFRESH_MS);
|
|
|
|
// ----- 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>`;
|
|
}
|
|
|
|
// Friendly full-page fallback when a directory-search points at a missing or
|
|
// non-directory-board source. Matches the "Unknown widget" fallback tone.
|
|
function renderDirectorySearchMissing() {
|
|
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Directory Search</title></head>
|
|
<body style="margin:0;height:100vh;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px;box-sizing:border-box;color:#fff;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
|
|
<div style="max-width:640px"><h1 style="font-size:2.2em;font-weight:600;margin:0 0 14px">Directory source not found</h1><p style="opacity:0.7;font-size:1.2em;margin:0;line-height:1.4">Pick a directory board in the widget settings.</p></div>
|
|
</body></html>`;
|
|
}
|
|
|
|
// Interactive walk-up search over an existing directory-board's entries. It
|
|
// REFERENCES the source board by id (no data copy): the board scrolls on a main
|
|
// screen while this lets someone find an entry instantly on a tablet.
|
|
function renderDirectorySearch(c) {
|
|
c = c || {};
|
|
const src = db.prepare('SELECT * FROM widgets WHERE id = ?').get(c.source_widget_id);
|
|
if (!src || src.widget_type !== 'directory-board') return renderDirectorySearchMissing();
|
|
let categories = [];
|
|
try {
|
|
const sc = JSON.parse(src.config || '{}');
|
|
categories = Array.isArray(sc.categories) ? sc.categories : [];
|
|
} catch (e) { categories = []; }
|
|
|
|
// Inline everything the page needs as one JSON blob, guarded the same way the
|
|
// board does. All user text is rendered via textContent below — never concat.
|
|
const payload = {
|
|
categories: categories,
|
|
source_widget_id: src.id,
|
|
title: c.title || '',
|
|
logo_url: c.logo_url || '',
|
|
theme: c.theme === 'light' ? 'light' : 'dark',
|
|
placeholder_text: c.placeholder_text || 'Search…',
|
|
show_onscreen_keyboard: c.show_onscreen_keyboard !== false,
|
|
};
|
|
const configJson = JSON.stringify(payload).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 Search</title>
|
|
<style>
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
html, body { width:100%; height:100%; }
|
|
body {
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
|
color:#fff; background:#1a1a2e;
|
|
display:flex; flex-direction:column; height:100vh; overflow:hidden;
|
|
}
|
|
body.light { color:#1a1a2e; background:#f5f5f5; }
|
|
|
|
.header { flex:0 0 auto; text-align:center; padding:20px 24px 8px; }
|
|
.header img.logo { max-height:90px; max-width:320px; object-fit:contain; margin:0 auto 8px; display:block; }
|
|
.header h1 { font-size:40px; font-weight:600; letter-spacing:0.01em; }
|
|
|
|
.searchbar { flex:0 0 auto; padding:10px 24px; }
|
|
#q {
|
|
width:100%; font-size:34px; padding:18px 22px; border-radius:14px; color:inherit; outline:none;
|
|
border:2px solid rgba(255,255,255,0.2); background:rgba(255,255,255,0.08);
|
|
}
|
|
#q:focus { border-color:#4a9eff; }
|
|
#q::placeholder { color:rgba(255,255,255,0.4); }
|
|
body.light #q { border-color:rgba(0,0,0,0.15); background:#fff; }
|
|
body.light #q:focus { border-color:#2563eb; }
|
|
body.light #q::placeholder { color:rgba(0,0,0,0.4); }
|
|
|
|
.results { flex:1 1 auto; overflow-y:auto; padding:8px 24px 16px; -webkit-overflow-scrolling:touch; }
|
|
.msg { text-align:center; opacity:0.55; font-size:26px; padding:48px 16px; line-height:1.4; }
|
|
|
|
.group { margin-bottom:22px; }
|
|
.group h2 {
|
|
font-size:22px; font-weight:500; letter-spacing:0.06em; text-transform:uppercase; opacity:0.6;
|
|
padding:14px 0 8px; border-bottom:1px solid rgba(255,255,255,0.15); margin-bottom:10px;
|
|
}
|
|
body.light .group h2 { border-bottom-color:rgba(0,0,0,0.12); }
|
|
|
|
.entry { display:flex; gap:14px; align-items:baseline; padding:10px 8px; font-size:30px; line-height:1.3; border-radius:8px; }
|
|
.entry:nth-child(even) { background:rgba(255,255,255,0.03); }
|
|
body.light .entry:nth-child(even) { background:rgba(0,0,0,0.03); }
|
|
.entry .id { font-weight:700; min-width:2.6em; 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.6em; opacity:0.6; margin-top:3px; }
|
|
.entry.available, .entry.available .id { color:#00ff00; }
|
|
body.light .entry.available, body.light .entry.available .id { color:#059669; }
|
|
|
|
/* The keyboard is sized against the VIEWPORT, not in fixed px. A panel's CSS viewport is its
|
|
physical resolution divided by its density, so a 1080p screen at 240dpi presents only 1280x720
|
|
CSS px - and a keyboard laid out for 1920x1080 then eats ~37% of the height instead of ~24%.
|
|
The vh terms scale it down on short viewports; the clamp() maxima are the original values, so
|
|
a 1080-tall viewport renders pixel-identically to before (5.3vh and 2.3vh both exceed their
|
|
max at 1080 and clamp). The px minima keep the keys tappable on very short screens. */
|
|
.keyboard { flex:0 0 auto; padding:clamp(5px,0.8vh,8px) 12px clamp(8px,1.3vh,14px); background:rgba(0,0,0,0.25); user-select:none; }
|
|
body.light .keyboard { background:rgba(0,0,0,0.05); }
|
|
.krow { display:flex; gap:clamp(4px,0.6vh,6px); justify-content:center; margin-bottom:clamp(4px,0.6vh,6px); }
|
|
.key {
|
|
flex:1 1 0; max-width:96px; min-width:0;
|
|
height:clamp(34px,5.3vh,56px); font-size:clamp(15px,2.3vh,24px); text-transform:uppercase;
|
|
border:0; border-radius:8px; background:rgba(255,255,255,0.12); color:inherit; cursor:pointer;
|
|
}
|
|
.key:active { background:#4a9eff; color:#fff; }
|
|
body.light .key { background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.15); }
|
|
.key-space { flex:4 1 0; max-width:none; text-transform:none; }
|
|
.key-wide { flex:2 1 0; max-width:none; text-transform:none; }
|
|
|
|
@media (max-width:700px) {
|
|
.header h1 { font-size:30px; }
|
|
#q { font-size:26px; padding:14px 16px; }
|
|
.entry { font-size:24px; }
|
|
/* .key is viewport-scaled above - no fixed override here, it would undo the clamp. */
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="header" id="header"></header>
|
|
<div class="searchbar"><input id="q" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"></div>
|
|
<div class="results" id="results"></div>
|
|
<div class="keyboard" id="keyboard"></div>
|
|
<script>
|
|
(function(){
|
|
var cfg = ${configJson};
|
|
if (cfg.theme === 'light') document.body.classList.add('light');
|
|
|
|
function safeImgUrl(u) {
|
|
return typeof u === 'string' && (u.indexOf('/') === 0 || /^https?:\\/\\//.test(u) || /^data:image\\//.test(u)) ? u : '';
|
|
}
|
|
|
|
// ----- header -----
|
|
var header = document.getElementById('header');
|
|
var logoSrc = safeImgUrl(cfg.logo_url);
|
|
if (logoSrc) {
|
|
var img = document.createElement('img');
|
|
img.className = 'logo'; img.src = logoSrc; img.alt = '';
|
|
header.appendChild(img);
|
|
}
|
|
// A logo replaces the title text — showing both stacks the wordmark over the name.
|
|
if (cfg.title && !logoSrc) {
|
|
var h1 = document.createElement('h1');
|
|
h1.textContent = cfg.title;
|
|
header.appendChild(h1);
|
|
}
|
|
if (!logoSrc && !cfg.title) header.style.display = 'none';
|
|
|
|
// ----- flatten source entries (preserve category order) -----
|
|
function buildFlat(categories) {
|
|
var out = [];
|
|
(Array.isArray(categories) ? categories : []).forEach(function(cat){
|
|
var cn = cat && cat.name != null ? String(cat.name) : '';
|
|
var entries = cat && Array.isArray(cat.entries) ? cat.entries : [];
|
|
entries.forEach(function(e){
|
|
var item = {
|
|
cat: cn,
|
|
identifier: e && e.identifier != null ? String(e.identifier) : '',
|
|
name: e && e.name != null ? String(e.name) : '',
|
|
subtitle: e && e.subtitle != null ? String(e.subtitle) : '',
|
|
available: !!(e && e.available)
|
|
};
|
|
item._h = (item.identifier + ' ' + item.name + ' ' + item.subtitle).toLowerCase();
|
|
out.push(item);
|
|
});
|
|
});
|
|
return out;
|
|
}
|
|
var flat = buildFlat(cfg.categories);
|
|
|
|
var input = document.getElementById('q');
|
|
input.placeholder = cfg.placeholder_text || '';
|
|
var results = document.getElementById('results');
|
|
var HINT = 'Start typing to search the directory…';
|
|
var NO_MATCHES = 'No matches';
|
|
|
|
function showMessage(msg) {
|
|
results.textContent = '';
|
|
var d = document.createElement('div');
|
|
d.className = 'msg';
|
|
d.textContent = msg;
|
|
results.appendChild(d);
|
|
}
|
|
|
|
function render(q) {
|
|
q = (q || '').trim().toLowerCase();
|
|
if (!q) { showMessage(HINT); return; }
|
|
var matches = flat.filter(function(e){ return e._h.indexOf(q) !== -1; });
|
|
if (!matches.length) { showMessage(NO_MATCHES); return; }
|
|
var order = [], groups = {};
|
|
matches.forEach(function(e){
|
|
if (!groups[e.cat]) { groups[e.cat] = []; order.push(e.cat); }
|
|
groups[e.cat].push(e);
|
|
});
|
|
results.textContent = '';
|
|
order.forEach(function(cn){
|
|
var group = document.createElement('div');
|
|
group.className = 'group';
|
|
if (cn) {
|
|
var h2 = document.createElement('h2');
|
|
h2.textContent = cn;
|
|
group.appendChild(h2);
|
|
}
|
|
groups[cn].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);
|
|
group.appendChild(row);
|
|
});
|
|
results.appendChild(group);
|
|
});
|
|
results.scrollTop = 0;
|
|
}
|
|
|
|
// ----- debounced input (~120ms) -----
|
|
var dT;
|
|
function onInput() { clearTimeout(dT); dT = setTimeout(function(){ render(input.value); }, 120); }
|
|
input.addEventListener('input', onInput);
|
|
|
|
// ----- on-screen keyboard (drives the same filter path as typing) -----
|
|
if (cfg.show_onscreen_keyboard) {
|
|
var kb = document.getElementById('keyboard');
|
|
function press(ch) { input.value += ch; try { input.focus(); } catch(e){} onInput(); }
|
|
['1234567890','qwertyuiop','asdfghjkl','zxcvbnm'].forEach(function(r){
|
|
var rowEl = document.createElement('div');
|
|
rowEl.className = 'krow';
|
|
r.split('').forEach(function(ch){
|
|
var b = document.createElement('button');
|
|
b.type = 'button'; b.className = 'key'; b.textContent = ch;
|
|
b.addEventListener('click', function(){ press(ch); });
|
|
rowEl.appendChild(b);
|
|
});
|
|
kb.appendChild(rowEl);
|
|
});
|
|
var act = document.createElement('div');
|
|
act.className = 'krow';
|
|
var back = document.createElement('button');
|
|
back.type = 'button'; back.className = 'key key-wide'; back.textContent = '\\u232B';
|
|
back.addEventListener('click', function(){ input.value = input.value.slice(0, -1); try { input.focus(); } catch(e){} onInput(); });
|
|
var space = document.createElement('button');
|
|
space.type = 'button'; space.className = 'key key-space'; space.textContent = 'space';
|
|
space.addEventListener('click', function(){ press(' '); });
|
|
var clear = document.createElement('button');
|
|
clear.type = 'button'; clear.className = 'key key-wide'; clear.textContent = 'clear';
|
|
clear.addEventListener('click', function(){ input.value = ''; try { input.focus(); } catch(e){} onInput(); });
|
|
act.appendChild(back); act.appendChild(space); act.appendChild(clear);
|
|
kb.appendChild(act);
|
|
} else {
|
|
var kbOff = document.getElementById('keyboard');
|
|
if (kbOff) kbOff.style.display = 'none';
|
|
}
|
|
|
|
// ----- initial state + autofocus -----
|
|
render('');
|
|
try { input.focus(); } catch(e){}
|
|
|
|
// ----- live sync: poll the source board so edits appear without a reload -----
|
|
// The board's data.json sits next to this page (/api/widgets/<board>/data.json),
|
|
// reached with a relative URL so it works behind any proxy/base path and from a
|
|
// null-origin sandboxed iframe (data.json is CORS-open). We only rebuild + rerender
|
|
// when the data actually changed, so a mid-search view isn't disturbed every tick.
|
|
var SRC_ID = cfg.source_widget_id || '';
|
|
var POLL_MS = 30000;
|
|
var lastSig = JSON.stringify(cfg.categories || []);
|
|
if (SRC_ID) {
|
|
setInterval(function(){
|
|
if (document.hidden) return;
|
|
fetch('../' + encodeURIComponent(SRC_ID) + '/data.json', { cache: 'no-store' })
|
|
.then(function(r){ return r.ok ? r.json() : Promise.reject(r.status); })
|
|
.then(function(data){
|
|
var cats = data && Array.isArray(data.categories) ? data.categories : [];
|
|
var sig = JSON.stringify(cats);
|
|
if (sig === lastSig) return; // unchanged -> leave the view alone
|
|
lastSig = sig;
|
|
flat = buildFlat(cats);
|
|
render(input.value); // refresh results for the current query
|
|
})
|
|
.catch(function(){ /* transient error -> keep last-good data */ });
|
|
}, POLL_MS);
|
|
}
|
|
})();
|
|
</script>
|
|
</body></html>`;
|
|
}
|
|
|
|
// diag-smoothness: a self-contained frame-cadence tester for the ACTUAL panel. Two GPU-composited
|
|
// animations (a vertical scroll like the board + a fast sweep) plus a big on-screen HUD (FPS, refresh
|
|
// estimate, long-frame count, worst stall, SMOOTH/STALLING verdict) — so a stutter can be read off the
|
|
// panel screen with no console. If this stalls on real signage hardware, the hardware is the cause.
|
|
function renderDiagSmoothness(config) {
|
|
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Smoothness Diagnostic</title><style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
html,body{height:100%}
|
|
body{background:#0a0d13;color:#cbd4e4;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow:hidden;height:100vh}
|
|
.bar{position:absolute;top:0;left:0;right:0;padding:1.4vh 2vw;border-bottom:1px solid #20293a;background:#0f1420;z-index:5}
|
|
.bar h1{font-size:2.2vh;font-weight:700;letter-spacing:.02em}
|
|
.bar p{font-size:1.7vh;color:#6d7789;margin-top:.4vh}
|
|
.bar b{color:#54a6ff}
|
|
.stage{position:absolute;top:0;left:0;right:0;bottom:0}
|
|
.col{position:absolute;top:0;left:0;width:52%;height:100%;overflow:hidden;border-right:1px solid #20293a}
|
|
.roll{position:absolute;left:0;right:0;top:0;will-change:transform;animation:roll 30s linear infinite}
|
|
@keyframes roll{from{transform:translate3d(0,0,0)}to{transform:translate3d(0,-50%,0)}}
|
|
.row{display:flex;align-items:center;gap:1.4vw;padding:1.5vh 2vw;border-bottom:1px solid #20293a;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:2.6vh}
|
|
.row .n{color:#54a6ff;min-width:3.2em;font-variant-numeric:tabular-nums}
|
|
.row:nth-child(3n) .n{color:#37d391}
|
|
.sweep{position:absolute;top:0;right:0;width:48%;height:100%;background:repeating-linear-gradient(90deg,#0f1420 0 3vw,#182234 3vw 6vw)}
|
|
.marker{position:absolute;top:0;bottom:0;width:.5vw;background:#f5b451;box-shadow:0 0 3vw #f5b451;will-change:transform;animation:sweep 2s linear infinite}
|
|
@keyframes sweep{from{transform:translateX(0)}to{transform:translateX(calc(48vw - .5vw))}}
|
|
.tag{position:absolute;top:1.5vh;font-family:ui-monospace,monospace;font-size:1.6vh;color:#6d7789;z-index:2}
|
|
.col .tag{left:1.5vw;background:#0a0d13;padding:.4vh .8vw;border-radius:4px}
|
|
.sweep .tag{right:1.5vw}
|
|
.hud{position:absolute;left:50%;bottom:3vh;transform:translateX(-50%);background:rgba(12,16,24,.94);border:1px solid #20293a;border-radius:14px;padding:2.2vh 2.4vw;min-width:64vw;z-index:6;box-shadow:0 1.4vh 4vh rgba(0,0,0,.55)}
|
|
.verdict{display:flex;align-items:center;gap:1.4vw;margin-bottom:1.8vh}
|
|
.dot{width:1.8vh;height:1.8vh;border-radius:50%;background:#6d7789}
|
|
.verdict.smooth .dot{background:#37d391;box-shadow:0 0 0 .6vh rgba(55,211,145,.16)}
|
|
.verdict.stall .dot{background:#ff5d5d;box-shadow:0 0 0 .6vh rgba(255,93,93,.18)}
|
|
.verdict .txt{font-size:3.4vh;font-weight:750;letter-spacing:.01em}
|
|
.verdict.smooth .txt{color:#37d391}.verdict.stall .txt{color:#ff5d5d}
|
|
.verdict .sub{font-size:1.9vh;color:#6d7789;font-weight:400;margin-left:auto;text-align:right}
|
|
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1.2vw;margin-bottom:1.4vh}
|
|
.stat{background:#121826;border:1px solid #20293a;border-radius:10px;padding:1.2vh 1vw}
|
|
.stat .k{font-size:1.4vh;text-transform:uppercase;letter-spacing:.08em;color:#6d7789}
|
|
.stat .v{font-family:ui-monospace,Menlo,monospace;font-size:4vh;font-variant-numeric:tabular-nums;margin-top:.4vh}
|
|
.stat .v small{font-size:1.8vh;color:#6d7789}
|
|
.log{font-family:ui-monospace,Menlo,monospace;font-size:1.7vh;color:#6d7789;height:2.4vh;overflow:hidden}
|
|
.log b{color:#ff5d5d}
|
|
</style></head><body>
|
|
<div class="bar"><h1>Panel Smoothness Diagnostic</h1><p>Two GPU-composited animations, zero app logic. If the scroll or the yellow bar <b>skips</b> — or the HUD reads STALLING — this <b>panel/hardware</b> is dropping frames.</p></div>
|
|
<div class="stage">
|
|
<div class="col"><div class="tag">TEST 1 · vertical scroll</div><div class="roll" id="roll"></div></div>
|
|
<div class="sweep"><div class="tag">TEST 2 · fast sweep</div><div class="marker"></div></div>
|
|
<div class="hud">
|
|
<div class="verdict" id="verdict"><span class="dot"></span><span class="txt" id="vtxt">measuring…</span><span class="sub" id="vsub">collecting frames</span></div>
|
|
<div class="grid">
|
|
<div class="stat"><div class="k">FPS now</div><div class="v" id="fps">–</div></div>
|
|
<div class="stat"><div class="k">Refresh est.</div><div class="v" id="hz">–<small> Hz</small></div></div>
|
|
<div class="stat"><div class="k">Long frames</div><div class="v" id="long">0<small> >50ms</small></div></div>
|
|
<div class="stat"><div class="k">Worst stall</div><div class="v" id="worst">0<small> ms</small></div></div>
|
|
</div>
|
|
<div class="log" id="log">no stalls yet · a healthy panel shows 0 long frames</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function(){
|
|
var roll=document.getElementById('roll'),half='',i;
|
|
for(i=1;i<=26;i++){ half+='<div class="row"><span class="n">'+(100+i)+'</span><span class="t">Directory line '+i+'</span></div>'; }
|
|
roll.innerHTML=half+half;
|
|
var last=0,worst=0,longCount=0,recent=[],dts=[],started=0,lastPaint=0;
|
|
var elFps=document.getElementById('fps'),elHz=document.getElementById('hz'),elLong=document.getElementById('long'),
|
|
elWorst=document.getElementById('worst'),elLog=document.getElementById('log'),verdict=document.getElementById('verdict'),
|
|
vtxt=document.getElementById('vtxt'),vsub=document.getElementById('vsub');
|
|
function median(a){ var b=a.slice().sort(function(x,y){return x-y}); return b[Math.floor(b.length/2)]||0; }
|
|
function paint(ts){
|
|
var med=median(dts)||16.7;
|
|
elFps.innerHTML=(1000/med).toFixed(0);
|
|
elHz.innerHTML=(1000/med).toFixed(0)+'<small> Hz</small>';
|
|
elLong.innerHTML=longCount+'<small> >50ms</small>';
|
|
elWorst.innerHTML=worst.toFixed(0)+'<small> ms</small>';
|
|
if(recent.length) elLog.innerHTML=recent.join(' \\u00b7 ');
|
|
var el=ts-started;
|
|
if(el>4000){
|
|
if(longCount===0){ verdict.className='verdict smooth'; vtxt.innerHTML='SMOOTH'; vsub.innerHTML='0 long frames — this panel animates cleanly'; }
|
|
else { verdict.className='verdict stall'; vtxt.innerHTML='STALLING'; vsub.innerHTML=longCount+' long frame'+(longCount>1?'s':'')+' — this panel is dropping frames'; }
|
|
} else { vsub.innerHTML='collecting frames… '+(el/1000).toFixed(0)+'s'; }
|
|
}
|
|
function frame(ts){
|
|
if(!started) started=ts;
|
|
if(last){ var dt=ts-last; dts.push(dt); if(dts.length>180) dts.shift();
|
|
if(dt>50){ longCount++; if(dt>worst) worst=dt;
|
|
recent.unshift('<b>'+dt.toFixed(0)+'ms</b> @ '+((ts-started)/1000).toFixed(0)+'s'); if(recent.length>3) recent.pop(); }
|
|
}
|
|
last=ts;
|
|
if(ts-lastPaint>250){ lastPaint=ts; paint(ts); }
|
|
requestAnimationFrame(frame);
|
|
}
|
|
requestAnimationFrame(frame);
|
|
// the player appends ?device=<id> to the render URL so telemetry can be keyed to THIS panel.
|
|
var DEVID=''; try{ var mm=(location.search||'').match(/[?&](?:device|d)=([^&]+)/); if(mm) DEVID=decodeURIComponent(mm[1]); }catch(e){}
|
|
// report the snapshot back to the server (relative 'telemetry' -> /api/widgets/<id>/telemetry).
|
|
// text/plain keeps it a CORS-simple request (no preflight) from the null-origin sandboxed iframe.
|
|
function report(){
|
|
try{
|
|
var med=median(dts)||16.7, now=(typeof performance!=='undefined'&&performance.now)?performance.now():Date.now();
|
|
var payload={device:DEVID,fps:Math.round(1000/med),refreshHz:Math.round(1000/med),longFrames:longCount,worstStallMs:Math.round(worst),
|
|
elapsedS:started?Math.round((now-started)/1000):0,
|
|
verdict:(started&&(now-started>4000))?(longCount?'STALLING':'SMOOTH'):'measuring',
|
|
recent:recent.slice(0,3).map(function(s){return s.replace(/<[^>]+>/g,'');}),
|
|
vp:window.innerWidth+'x'+window.innerHeight,dpr:window.devicePixelRatio||1,ua:(navigator.userAgent||'').slice(0,180)};
|
|
fetch('telemetry',{method:'POST',headers:{'Content-Type':'text/plain'},body:JSON.stringify(payload),keepalive:true})['catch'](function(){});
|
|
}catch(e){}
|
|
}
|
|
setInterval(report,2500);
|
|
})();
|
|
</script></body></html>`;
|
|
}
|
|
|
|
module.exports = router;
|