feat(widgets): directory-search widget (interactive search of a directory board, live-sync) (#188)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run

* feat(widgets): add directory-search widget

An interactive, walk-up search view of an existing directory-board. It
references a source board by id (no data copy), so a venue can run the
scrolling board on a main screen and a search view on a tablet, letting
people find an entry instantly.

Server (routes/widgets.js):
- register 'directory-search' + renderDirectorySearch(): resolves the source
  board, inlines its categories as one \u003c-guarded JSON blob, renders all
  text via textContent (XSS-safe), live case-insensitive filter over
  identifier/name/subtitle (debounced), grouped results, available styling,
  optional touch on-screen QWERTY keyboard that drives the same filter path.
- missing / non-directory-board source -> friendly full-page fallback, not a 500.
- live-sync while open is out of scope; left a // TODO for a poll hook.

Frontend editor (views/widgets.js): type + magnifier icon, source-board
dropdown (from loaded widgets, filtered to directory-board), title, logo
(reuses the board's picker), placeholder text, theme, on-screen-keyboard toggle;
getConfigFromForm case. i18n: widget.dirsearch.* + type keys in en/es/it/de/pt/fr.
docs: openapi widget_type enum. Tests: server/test/directory-search.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(widgets): live-sync for directory-search (poll source board, no reload)

Reflect directory-board edits on an open directory-search page without a reload.

- New public GET /api/widgets/:id/data.json returns { categories } for a
  directory-board (404 for missing/wrong-type so the page keeps last-good data
  on a transient miss). CORS-open (ACAO:*) + no-store so a null-origin sandboxed
  widget iframe can read it; exposes only data already public via /render.
  Exempted from CSP + auth in server.js alongside /render.
- directory-search page inlines its source_widget_id and polls the board's
  data.json every 30s via a relative URL (works behind a proxy/base path and
  from a null-origin iframe). Only rebuilds + rerenders when the data actually
  changed, so a mid-search view isn't disturbed; skips while document.hidden;
  keeps last-good data on any fetch error. Flatten logic factored into
  buildFlat() and reused by the poll.

Tests: data.json feed (categories, CORS header, 404s) + poll wiring in
directory-search.test.js (13/13). Verified live in a browser (page.clock
fast-forward): editing the board updates the search page with no reload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): let player WebViews take touch focus for interactive widgets

directory-search is served through the existing generic widget path
(loadUrl <server>/api/widgets/:id/render), so it already renders on Android
with JS + DOM storage + mixed-content enabled, same-origin (so its live-sync
fetch of the source board's data.json works), and no touch blocking.

Add isFocusable/isFocusableInTouchMode to the shared WebView config so the
search field reliably takes a tap/cursor inside the kiosk lock-task WebView.
Harmless for passive widgets (board/YouTube have no focusable inputs); the
widget's own on-screen keyboard still drives the filter when the system IME
is suppressed. Verified with :app:compileDebugKotlin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-15 08:00:22 -05:00 committed by GitHub
parent 84ad89b06d
commit a15086540f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 563 additions and 3 deletions

View file

@ -38,6 +38,14 @@ object WebViewSupport {
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
}
webView.setBackgroundColor(android.graphics.Color.TRANSPARENT)
// Interactive widgets (e.g. directory-search) need the WebView to take
// touch focus so the search field accepts a tap/cursor inside the kiosk
// lock-task WebView. Harmless for passive widgets (board/YouTube): they
// have no focusable inputs, so nothing steals focus or pops the IME. The
// widget's own on-screen keyboard drives the filter even when the system
// IME is suppressed (it mutates the input value directly, no focus needed).
webView.isFocusable = true
webView.isFocusableInTouchMode = true
webView.webViewClient = object : WebViewClient() {
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
if (request?.isForMainFrame == true) {

View file

@ -222,7 +222,7 @@ components:
type: string
widget_type:
type: string
description: clock | weather | rss | text | webpage | social | directory-board
description: clock | weather | rss | text | webpage | social | directory-board | directory-search
name:
type: string
config:

View file

@ -533,6 +533,21 @@ export default {
'widget.type.social.desc': 'Social-Media-Feed',
'widget.type.directory_board.name': 'Verzeichnistafel',
'widget.type.directory_board.desc': 'Scrollendes Mieter-/Raumverzeichnis für Lobbys',
'widget.type.directory_search.name': 'Verzeichnissuche',
'widget.type.directory_search.desc': 'Interaktive Suche in einer Verzeichnistafel',
// directory-search widget
'widget.dirsearch.source_label': 'Verzeichnistafel',
'widget.dirsearch.source_hint': 'Liest die Einträge live aus der ausgewählten Tafel; nichts wird kopiert.',
'widget.dirsearch.source_empty': 'Erstellen Sie zuerst eine Verzeichnistafel: Dieses Widget durchsucht eine vorhandene Tafel.',
'widget.dirsearch.title_label': 'Titel',
'widget.dirsearch.title_placeholder': 'Mieter finden',
'widget.dirsearch.logo_label': 'Logo (optional)',
'widget.dirsearch.placeholder_label': 'Platzhalter des Suchfelds',
'widget.dirsearch.placeholder_hint': 'Name, Büro oder Abteilung eingeben…',
'widget.dirsearch.theme': 'Design',
'widget.dirsearch.keyboard_label': 'Bildschirmtastatur anzeigen',
'widget.dirsearch.no_matches': 'Keine Treffer',
'widget.dirsearch.hint': 'Tippen Sie, um das Verzeichnis zu durchsuchen…',
'widget.field.name': 'Widget-Name',
'widget.field.format': 'Format',
'widget.field.format_12h': '12 Stunden',

View file

@ -702,6 +702,21 @@ export default {
'widget.type.social.desc': 'Social media feed',
'widget.type.directory_board.name': 'Directory Board',
'widget.type.directory_board.desc': 'Scrolling tenant/room directory for lobbies',
'widget.type.directory_search.name': 'Directory Search',
'widget.type.directory_search.desc': 'Walk-up search of a directory board',
// directory-search widget
'widget.dirsearch.source_label': 'Directory board',
'widget.dirsearch.source_hint': 'Reads entries live from the selected board — nothing is copied.',
'widget.dirsearch.source_empty': 'Create a directory board first — this widget searches an existing board.',
'widget.dirsearch.title_label': 'Title',
'widget.dirsearch.title_placeholder': 'Find a Tenant',
'widget.dirsearch.logo_label': 'Logo (optional)',
'widget.dirsearch.placeholder_label': 'Search box placeholder',
'widget.dirsearch.placeholder_hint': 'Type a name, suite, or department…',
'widget.dirsearch.theme': 'Theme',
'widget.dirsearch.keyboard_label': 'Show on-screen keyboard',
'widget.dirsearch.no_matches': 'No matches',
'widget.dirsearch.hint': 'Start typing to search the directory…',
// Widget config form fields
'widget.field.name': 'Widget Name',
'widget.field.format': 'Format',

View file

@ -533,6 +533,21 @@ export default {
'widget.type.social.desc': 'Feed de redes sociales',
'widget.type.directory_board.name': 'Directorio',
'widget.type.directory_board.desc': 'Directorio de inquilinos/salas con desplazamiento para vestíbulos',
'widget.type.directory_search.name': 'Búsqueda de directorio',
'widget.type.directory_search.desc': 'Búsqueda interactiva de un panel de directorio',
// directory-search widget
'widget.dirsearch.source_label': 'Panel de directorio',
'widget.dirsearch.source_hint': 'Lee las entradas en vivo del panel seleccionado; no se copia nada.',
'widget.dirsearch.source_empty': 'Cree primero un panel de directorio: este widget busca en un panel existente.',
'widget.dirsearch.title_label': 'Título',
'widget.dirsearch.title_placeholder': 'Buscar un inquilino',
'widget.dirsearch.logo_label': 'Logotipo (opcional)',
'widget.dirsearch.placeholder_label': 'Texto del cuadro de búsqueda',
'widget.dirsearch.placeholder_hint': 'Escriba un nombre, oficina o departamento…',
'widget.dirsearch.theme': 'Tema',
'widget.dirsearch.keyboard_label': 'Mostrar teclado en pantalla',
'widget.dirsearch.no_matches': 'Sin coincidencias',
'widget.dirsearch.hint': 'Empiece a escribir para buscar en el directorio…',
'widget.field.name': 'Nombre del widget',
'widget.field.format': 'Formato',
'widget.field.format_12h': '12 horas',

View file

@ -533,6 +533,21 @@ export default {
'widget.type.social.desc': 'Flux de réseaux sociaux',
'widget.type.directory_board.name': 'Annuaire',
'widget.type.directory_board.desc': 'Annuaire défilant des locataires/salles pour halls',
'widget.type.directory_search.name': 'Recherche dannuaire',
'widget.type.directory_search.desc': 'Recherche interactive dun panneau dannuaire',
// directory-search widget
'widget.dirsearch.source_label': 'Panneau dannuaire',
'widget.dirsearch.source_hint': 'Lit les entrées en direct depuis le panneau sélectionné ; rien nest copié.',
'widget.dirsearch.source_empty': 'Créez dabord un panneau dannuaire : ce widget recherche dans un panneau existant.',
'widget.dirsearch.title_label': 'Titre',
'widget.dirsearch.title_placeholder': 'Trouver un locataire',
'widget.dirsearch.logo_label': 'Logo (facultatif)',
'widget.dirsearch.placeholder_label': 'Texte du champ de recherche',
'widget.dirsearch.placeholder_hint': 'Saisissez un nom, un bureau ou un service…',
'widget.dirsearch.theme': 'Thème',
'widget.dirsearch.keyboard_label': 'Afficher le clavier à lécran',
'widget.dirsearch.no_matches': 'Aucun résultat',
'widget.dirsearch.hint': 'Commencez à taper pour rechercher dans lannuaire…',
'widget.field.name': 'Nom du widget',
'widget.field.format': 'Format',
'widget.field.format_12h': '12 heures',

View file

@ -513,6 +513,21 @@ export default {
'widget.type.social.desc': 'Feed dei social media',
'widget.type.directory_board.name': 'Albo Fornitori/Directory',
'widget.type.directory_board.desc': 'Elenco scorrevole di inquilini/uffici per hall',
'widget.type.directory_search.name': 'Ricerca directory',
'widget.type.directory_search.desc': 'Ricerca interattiva di un pannello directory',
// directory-search widget
'widget.dirsearch.source_label': 'Pannello directory',
'widget.dirsearch.source_hint': 'Legge le voci in tempo reale dal pannello selezionato; nulla viene copiato.',
'widget.dirsearch.source_empty': 'Crea prima un pannello directory: questo widget cerca in un pannello esistente.',
'widget.dirsearch.title_label': 'Titolo',
'widget.dirsearch.title_placeholder': 'Trova un inquilino',
'widget.dirsearch.logo_label': 'Logo (opzionale)',
'widget.dirsearch.placeholder_label': 'Segnaposto casella di ricerca',
'widget.dirsearch.placeholder_hint': 'Digita un nome, ufficio o reparto…',
'widget.dirsearch.theme': 'Tema',
'widget.dirsearch.keyboard_label': 'Mostra tastiera a schermo',
'widget.dirsearch.no_matches': 'Nessuna corrispondenza',
'widget.dirsearch.hint': 'Inizia a digitare per cercare nella directory…',
// Widget config form fields
'widget.field.name': 'Nome Widget',
'widget.field.format': 'Formato',

View file

@ -533,6 +533,21 @@ export default {
'widget.type.social.desc': 'Feed de redes sociais',
'widget.type.directory_board.name': 'Diretório',
'widget.type.directory_board.desc': 'Diretório rolante de inquilinos/salas para lobbies',
'widget.type.directory_search.name': 'Pesquisa de diretório',
'widget.type.directory_search.desc': 'Pesquisa interativa de um painel de diretório',
// directory-search widget
'widget.dirsearch.source_label': 'Painel de diretório',
'widget.dirsearch.source_hint': 'Lê as entradas ao vivo do painel selecionado; nada é copiado.',
'widget.dirsearch.source_empty': 'Crie primeiro um painel de diretório: este widget pesquisa em um painel existente.',
'widget.dirsearch.title_label': 'Título',
'widget.dirsearch.title_placeholder': 'Encontrar um inquilino',
'widget.dirsearch.logo_label': 'Logotipo (opcional)',
'widget.dirsearch.placeholder_label': 'Texto do campo de pesquisa',
'widget.dirsearch.placeholder_hint': 'Digite um nome, sala ou departamento…',
'widget.dirsearch.theme': 'Tema',
'widget.dirsearch.keyboard_label': 'Mostrar teclado na tela',
'widget.dirsearch.no_matches': 'Sem correspondências',
'widget.dirsearch.hint': 'Comece a digitar para pesquisar no diretório…',
'widget.field.name': 'Nome do widget',
'widget.field.format': 'Formato',
'widget.field.format_12h': '12 horas',

View file

@ -6,7 +6,7 @@ const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type':
// Widget type ids only — name + desc are looked up via t() so they switch
// language with the rest of the UI.
const WIDGET_TYPES = ['clock', 'weather', 'rss', 'text', 'webpage', 'social', 'directory-board'];
const WIDGET_TYPES = ['clock', 'weather', 'rss', 'text', 'webpage', 'social', 'directory-board', 'directory-search'];
const WIDGET_ICONS = {
clock: '&#128339;',
weather: '&#9925;',
@ -15,6 +15,7 @@ const WIDGET_ICONS = {
webpage: '&#127760;',
social: '&#128172;',
'directory-board': '&#127970;',
'directory-search': '&#128269;',
};
const widgetTypeName = (id) => t(`widget.type.${id.replace(/-/g, '_')}.name`);
const widgetTypeDesc = (id) => t(`widget.type.${id.replace(/-/g, '_')}.desc`);
@ -179,6 +180,9 @@ export async function render(container) {
let editingWidget = null;
let creatingType = null;
let dirState = { categories: [], logo_url: '', background_images: [] };
// Cached widget list from the last load — used to populate the directory-search
// source-board dropdown without a second fetch.
let loadedWidgets = [];
document.getElementById('newWidgetBtn').onclick = () => {
const grid = document.getElementById('widgetTypeGrid');
@ -279,6 +283,26 @@ export async function render(container) {
<button type="button" class="btn btn-secondary btn-sm" id="dbAddCategory" style="margin-top:10px">${t('widget.dir.add_category')}</button>
</div>`;
break;
case 'directory-search': {
const boards = (loadedWidgets || []).filter(w => w.widget_type === 'directory-board');
const sourceField = boards.length
? `<select id="wSource" class="input" style="background:var(--bg-input)">
${boards.map(w => `<option value="${escAttr(w.id)}" ${config.source_widget_id === w.id ? 'selected' : ''}>${escAttr(w.name)}</option>`).join('')}
</select>
<div style="font-size:11px;color:var(--text-muted);margin-top:6px">${t('widget.dirsearch.source_hint')}</div>`
: `<div style="font-size:13px;color:var(--text-muted);padding:10px;border:1px dashed var(--border);border-radius:6px">${t('widget.dirsearch.source_empty')}</div>`;
html += `
<div class="form-group"><label>${t('widget.dirsearch.source_label')}</label>${sourceField}</div>
<div class="form-group"><label>${t('widget.dirsearch.title_label')}</label><input type="text" id="wTitle" class="input" value="${escAttr(config.title)}" placeholder="${t('widget.dirsearch.title_placeholder')}"></div>
<div class="form-group"><label>${t('widget.dirsearch.logo_label')}</label><div id="wLogoBox"></div></div>
<div class="form-group"><label>${t('widget.dirsearch.placeholder_label')}</label><input type="text" id="wPlaceholder" class="input" value="${escAttr(config.placeholder_text)}" placeholder="${t('widget.dirsearch.placeholder_hint')}"></div>
<div class="form-group" style="max-width:220px"><label>${t('widget.dirsearch.theme')}</label><select id="wTheme" class="input" style="background:var(--bg-input)">
<option value="dark" ${!config.theme || config.theme === 'dark' ? 'selected' : ''}>${t('widget.dir.theme_dark')}</option>
<option value="light" ${config.theme === 'light' ? 'selected' : ''}>${t('widget.dir.theme_light')}</option>
</select></div>
<div class="form-group"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="wKeyboard" ${config.show_onscreen_keyboard === false ? '' : 'checked'}> ${t('widget.dirsearch.keyboard_label')}</label></div>`;
break;
}
}
document.getElementById('widgetConfigForm').innerHTML = html;
@ -308,6 +332,12 @@ export async function render(container) {
};
document.getElementById('wBgAdd').onclick = pickBgImages;
}
if (type === 'directory-search') {
// Reuse the board's logo picker box (#wLogoBox + dirState.logo_url).
dirState.logo_url = config.logo_url || '';
renderLogoPicker();
}
}
function renderDirCategories(opts = {}) {
@ -526,6 +556,14 @@ export async function render(container) {
})),
})),
}); break;
case 'directory-search': Object.assign(config, {
source_widget_id: val('wSource') || '',
title: val('wTitle') || '',
logo_url: dirState.logo_url || '',
theme: val('wTheme') || 'dark',
placeholder_text: val('wPlaceholder') || '',
show_onscreen_keyboard: document.getElementById('wKeyboard') ? document.getElementById('wKeyboard').checked : true,
}); break;
}
return config;
}
@ -564,6 +602,7 @@ export async function render(container) {
async function loadWidgets() {
const widgets = await API('/widgets');
loadedWidgets = Array.isArray(widgets) ? widgets : [];
const grid = document.getElementById('widgetGrid');
if (!widgets.length) {
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>${t('widget.empty_title')}</h3><p>${t('widget.empty_desc')}</p></div>`;

View file

@ -168,7 +168,7 @@ router.delete('/:id', (req, res) => {
res.json({ success: true });
});
const KNOWN_WIDGET_TYPES = new Set(['clock','weather','rss','text','webpage','social','directory-board']);
const KNOWN_WIDGET_TYPES = new Set(['clock','weather','rss','text','webpage','social','directory-board','directory-search']);
function renderWidgetHtml(type, config) {
config = config || {};
switch (type) {
@ -179,6 +179,7 @@ function renderWidgetHtml(type, config) {
case 'webpage': return renderWebpage(config);
case 'social': return renderSocial(config);
case 'directory-board': return renderDirectoryBoard(config);
case 'directory-search': return renderDirectorySearch(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>';
}
}
@ -201,6 +202,25 @@ router.get('/:id/render', (req, res) => {
res.send(renderWidgetHtml(widget.widget_type, config));
});
// 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 });
});
// Preview unsaved widget from config (used by editor Preview button)
router.post('/preview', (req, res) => {
const { widget_type, config } = req.body || {};
@ -679,4 +699,289 @@ function renderDirectoryBoard(c) {
</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; }
.keyboard { flex:0 0 auto; padding:8px 12px 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:6px; justify-content:center; margin-bottom:6px; }
.key {
flex:1 1 0; max-width:96px; min-width:0; height:56px; font-size: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 { height:46px; font-size:20px; }
}
</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);
}
if (cfg.title) {
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>`;
}
module.exports = router;

View file

@ -130,6 +130,7 @@ app.use((req, res, next) => {
if (req.path.startsWith('/player')) return next();
if (req.path === '/docs') return next(); // Redoc API reference needs a relaxed CSP
if (req.path.startsWith('/api/widgets/') && req.path.endsWith('/render')) return next();
if (req.path.startsWith('/api/widgets/') && req.path.endsWith('/data.json')) return next();
if (req.path.startsWith('/api/widgets/preview-session/')) return next();
if (req.path.startsWith('/api/kiosk/') && req.path.endsWith('/render')) return next();
return dashboardCsp(req, res, next);
@ -546,6 +547,7 @@ const { PUBLIC_ROUTERS, JWT_ONLY_ROUTERS, AGENCY_ROUTERS } = require('./config/a
// Public device-render endpoints + the memory-heavy preview limiter must be registered
// BEFORE their parent router mount so the _skipAuth bypass / the limiter fire first.
app.get('/api/widgets/:id/render', (req, res, next) => { req._skipAuth = true; next(); });
app.get('/api/widgets/:id/data.json', (req, res, next) => { req._skipAuth = true; next(); });
app.get('/api/widgets/preview-session/:id', (req, res, next) => { req._skipAuth = true; next(); });
app.use('/api/widgets/preview', rateLimit(60000, 30)); // base64 inline = memory-intensive
app.use('/api/widgets/preview-session', rateLimit(60000, 30)); // preview session creation retains rendered HTML in memory for 5min

View file

@ -0,0 +1,116 @@
'use strict';
// directory-search widget: references a directory-board by id and renders an
// interactive walk-up search page. Verifies the source board's entries are
// safely inlined for client-side filtering, that a missing/wrong source shows a
// friendly fallback (not a 500), and that entry/category text can't break out
// of the inlined <script> (it's set via textContent at runtime).
const test = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');
process.env.JWT_SECRET = 'test-secret-dirsearch';
const db = new Database(':memory:');
db.exec(`CREATE TABLE widgets (id TEXT PRIMARY KEY, widget_type TEXT, config TEXT, workspace_id TEXT);`);
const dbModulePath = require.resolve('../db/database');
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db } };
const express = require('express');
const widgetsRouter = require('../routes/widgets');
const app = express();
app.use('/api/widgets', widgetsRouter);
const server = app.listen(0);
let base;
test.before(async () => { await new Promise(r => server.listening ? r() : server.once('listening', r)); base = `http://127.0.0.1:${server.address().port}`; });
test.after(() => { server.close(); db.close(); });
const seed = (id, type, config) => db.prepare('INSERT INTO widgets (id, widget_type, config, workspace_id) VALUES (?,?,?,?)').run(id, type, JSON.stringify(config), 'ws1');
const fetchRender = async (id) => { const r = await fetch(`${base}/api/widgets/${id}/render`); return { status: r.status, html: await r.text() }; };
const BOARD = {
title: 'Lincoln Warehouse',
categories: [
{ name: 'First Floor', entries: [
{ identifier: '101', name: 'Acme Co', subtitle: 'Suite A', available: false },
{ identifier: '102', name: 'Available Unit', subtitle: '', available: true },
] },
{ name: 'Second Floor', entries: [
{ identifier: '201', name: 'Globex', subtitle: 'Logistics', available: false },
] },
],
};
test('directory-search renders a search page and inlines the source board entries', async () => {
seed('board1', 'directory-board', BOARD);
seed('search1', 'directory-search', { source_widget_id: 'board1', title: 'Find a Tenant', show_onscreen_keyboard: true });
const { status, html } = await fetchRender('search1');
assert.equal(status, 200);
assert.ok(html.includes('id="q"'), 'has a search input');
assert.ok(html.includes('id="results"'), 'has a results container');
assert.ok(html.includes('Acme Co') && html.includes('Globex') && html.includes('101'), 'source entries embedded for client-side filtering');
assert.ok(html.includes('Find a Tenant'), 'search title present');
});
test('show_onscreen_keyboard flag is carried into the page config', async () => {
seed('search_kb_off', 'directory-search', { source_widget_id: 'board1', show_onscreen_keyboard: false });
const { html } = await fetchRender('search_kb_off');
assert.ok(html.includes('"show_onscreen_keyboard":false'), 'keyboard flag inlined (page hides the keyboard when false)');
});
test('missing source -> friendly fallback page, not a 500', async () => {
seed('search_missing', 'directory-search', { source_widget_id: 'does-not-exist' });
const { status, html } = await fetchRender('search_missing');
assert.equal(status, 200);
assert.ok(html.includes('Directory source not found'), 'friendly message shown instead of an error');
});
test('non-directory-board source -> friendly fallback page', async () => {
seed('clockX', 'clock', {});
seed('search_wrongtype', 'directory-search', { source_widget_id: 'clockX' });
const { status, html } = await fetchRender('search_wrongtype');
assert.equal(status, 200);
assert.ok(html.includes('Directory source not found'), 'friendly message for a wrong source type');
});
test('XSS: entry/category text cannot break out of the inlined script', async () => {
seed('board_xss', 'directory-board', {
categories: [{
name: '</script><script>window.__pwned=1</script>',
entries: [{ identifier: '<img src=x onerror=alert(1)>', name: '"><b>bold</b>', subtitle: 'amp & lt < gt >', available: false }],
}],
});
seed('search_xss', 'directory-search', { source_widget_id: 'board_xss' });
const { status, html } = await fetchRender('search_xss');
assert.equal(status, 200);
assert.ok(!html.includes('</script><script>window.__pwned'), 'raw </script> breakout neutralized');
assert.ok(html.includes('\\u003c/script>'), 'angle brackets escaped in the inlined JSON blob');
});
// ---- live sync: GET /:id/data.json feed the search page polls ----
const fetchData = async (id) => fetch(`${base}/api/widgets/${id}/data.json`);
test('data.json returns the source board categories, CORS-open for polling', async () => {
const r = await fetchData('board1'); // seeded in the first test
assert.equal(r.status, 200);
assert.equal(r.headers.get('access-control-allow-origin'), '*', 'readable from a null-origin sandboxed iframe');
assert.equal(r.headers.get('cache-control'), 'no-store');
const body = await r.json();
assert.ok(Array.isArray(body.categories) && body.categories.length === 2, 'returns the categories array');
assert.equal(body.categories[0].entries[0].name, 'Acme Co');
});
test('data.json 404s for a missing widget (poll keeps last-good data)', async () => {
assert.equal((await fetchData('does-not-exist')).status, 404);
});
test('data.json 404s for a non-directory-board widget', async () => {
assert.equal((await fetchData('clockX')).status, 404); // clockX seeded earlier
});
test('search page wires the live-sync poll to its source board', async () => {
const { html } = await fetchRender('search1');
assert.ok(html.includes('"source_widget_id":"board1"'), 'source board id inlined into the page');
assert.ok(html.includes('/data.json'), 'page polls the data.json feed');
});