diff --git a/android/app/src/main/java/com/remotedisplay/player/util/WebViewSupport.kt b/android/app/src/main/java/com/remotedisplay/player/util/WebViewSupport.kt index cb7baac..392604e 100644 --- a/android/app/src/main/java/com/remotedisplay/player/util/WebViewSupport.kt +++ b/android/app/src/main/java/com/remotedisplay/player/util/WebViewSupport.kt @@ -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) { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 23062e6..43d5045 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 919d570..3d13074 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -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', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 4babdca..492e540 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -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', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index fd64d95..7e5e3f6 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -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', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 1525925..4664fc2 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -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', diff --git a/frontend/js/i18n/it.js b/frontend/js/i18n/it.js index bbaee53..c35a90a 100644 --- a/frontend/js/i18n/it.js +++ b/frontend/js/i18n/it.js @@ -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', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index 3747a32..6e78036 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -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', diff --git a/frontend/js/views/widgets.js b/frontend/js/views/widgets.js index 413b7ca..c283bfb 100644 --- a/frontend/js/views/widgets.js +++ b/frontend/js/views/widgets.js @@ -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) { `; break; + case 'directory-search': { + const boards = (loadedWidgets || []).filter(w => w.widget_type === 'directory-board'); + const sourceField = boards.length + ? ` +
${t('widget.dirsearch.source_hint')}
` + : `
${t('widget.dirsearch.source_empty')}
`; + html += ` +
${sourceField}
+
+
+
+
+
`; + 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 = `

${t('widget.empty_title')}

${t('widget.empty_desc')}

`; diff --git a/server/routes/widgets.js b/server/routes/widgets.js index 7212b15..6ce3014 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -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 '

Unknown widget

'; } } @@ -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) { `; } +// 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 `Directory Search + +

Directory source not found

Pick a directory board in the widget settings.

+`; +} + +// 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(/ + + +Directory Search + + + + + +
+
+ +`; +} + module.exports = router; diff --git a/server/server.js b/server/server.js index 7122de3..0bc21b3 100644 --- a/server/server.js +++ b/server/server.js @@ -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 diff --git a/server/test/directory-search.test.js b/server/test/directory-search.test.js new file mode 100644 index 0000000..e4d007b --- /dev/null +++ b/server/test/directory-search.test.js @@ -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 ', + entries: [{ identifier: '', name: '">bold', 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(' 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'); +});