mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
feat(widgets): directory-search widget (interactive search of a directory board, live-sync) (#188)
* 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:
parent
84ad89b06d
commit
a15086540f
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 d’annuaire',
|
||||
'widget.type.directory_search.desc': 'Recherche interactive d’un panneau d’annuaire',
|
||||
// directory-search widget
|
||||
'widget.dirsearch.source_label': 'Panneau d’annuaire',
|
||||
'widget.dirsearch.source_hint': 'Lit les entrées en direct depuis le panneau sélectionné ; rien n’est copié.',
|
||||
'widget.dirsearch.source_empty': 'Créez d’abord un panneau d’annuaire : 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 l’annuaire…',
|
||||
'widget.field.name': 'Nom du widget',
|
||||
'widget.field.format': 'Format',
|
||||
'widget.field.format_12h': '12 heures',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '🕓',
|
||||
weather: '⛅',
|
||||
|
|
@ -15,6 +15,7 @@ const WIDGET_ICONS = {
|
|||
webpage: '🌐',
|
||||
social: '💬',
|
||||
'directory-board': '🏢',
|
||||
'directory-search': '🔍',
|
||||
};
|
||||
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>`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
116
server/test/directory-search.test.js
Normal file
116
server/test/directory-search.test.js
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue