Changeset 097da22 in Klonkt


Ignore:
Timestamp:
06/21/2026 05:26:47 PM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
fefb80c
Parents:
e579a98
Message:

feat(zoeken): live results while typing + search in events & pages

  • /search/suggest JSON endpoint; overlay shows live (debounced) grouped results while typing, with "all results →" to the full page.
  • Sources expanded: in addition to posts + tracks now also calendar events (shows: city/location/country/note, if the calendar is enabled) and site pages (Agenda/Downloads/Links/Press Kit/Archive, only those available). Full search page shows those sections too. NL/EN/DE.
  • Overlay placeholder now translated (was hardcoded NL).

Co-Authored-By: Claude <noreply@…>

Location:
src
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • src/routes/search.js

    re579a98 r097da22  
    11/**
    2  * GET /search?q=...
     2 * GET /search?q=...          -> volledige resultatenpagina
     3 * GET /search/suggest?q=...  -> compacte JSON voor live resultaten in de overlay
    34 *
    4  * Doorzoekt de huidige site op twee dingen:
    5  *   1. Posts via de posts_fts virtuele tabel (FTS5) — published only.
     5 * Doorzoekt de huidige site op:
     6 *   1. Posts via posts_fts (FTS5, prefix-matching) — published only.
    67 *   2. Nummers (audio_tracks) op titel / artiest / album.
     8 *   3. Evenementen (shows) op plaats / locatie / land / notitie — als de agenda aan staat.
     9 *   4. Pagina's (Agenda / Downloads / Links / Perskit / Archief) op naam — alleen
     10 *      de beschikbare.
    711 *
    8  * Verbeteringen t.o.v. de oude versie:
    9  *   - Prefix-matching: elk woord wordt als prefix-term gezocht ("astr"* vindt
    10  *     "astra"), met AND tussen de woorden — typen-terwijl-je-zoekt werkt nu.
    11  *   - Nummers zijn doorzoekbaar en direct afspeelbaar in de resultatenlijst,
    12  *     met een link naar de post/album/playlist waarin het nummer voorkomt.
    13  *
    14  * FTS5-randgevallen:
    15  *   - Lege / whitespace-only query: form zonder resultaten.
    16  *   - User-input wordt getokeniseerd op niet-letter/cijfer en elk token tussen
    17  *     dubbele quotes + `*` gezet → geen operator-soup, geen syntax-errors.
     12 * FTS5: user-input wordt getokeniseerd op niet-letter/cijfer en elk token tussen
     13 * dubbele quotes + `*` gezet → prefix-match, geen operator-soup/syntax-errors.
    1814 */
    1915
     
    2218import { renderPage } from '../middleware/render.js';
    2319import { audioUrl } from '../services/AudioStreamService.js';
     20import { getSetting } from '../services/SettingsService.js';
     21import { premiumUnlocked } from '../services/PatreonService.js';
    2422
    2523const router = express.Router();
    2624
    27 // Bouw een veilige FTS5-prefix-query: tokeniseer op alles wat geen letter/cijfer
    28 // is, en maak van elk token een prefix-term. Spatie = impliciete AND.
    29 // Bv. 'rock astr' -> '"rock"* "astr"*'. Geeft null als er geen bruikbaar token is.
    3025function buildFtsQuery(q) {
    3126  const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
     
    3429}
    3530
    36 // Escape LIKE-wildcards in user-input zodat % en _ letterlijk matchen.
    3731function likeArg(q) {
    3832  return '%' + q.replace(/[%_\\]/g, '\\$&') + '%';
    3933}
    4034
    41 // Maak de FTS-snippet leesbaar: shortcodes ([[...]]) en markdown-tekens eruit.
    42 // De <mark>…</mark>-highlights van FTS blijven staan. Valt terug op de excerpt
    43 // (geëscaped) als er na het schoonmaken niets leesbaars overblijft.
    4435function cleanSnippet(html, excerpt) {
    4536  const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    4637  const s = (html || '')
    47     .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')   // markdown-afbeeldingen
    48     .replace(/\[\[[^\]]*?\]\]/g, ' ')         // shortcodes (incl. <mark> erin)
    49     .replace(/\[\[|\]\]/g, ' ')               // losse haakjes door truncatie
    50     .replace(/[#>*_`~]+/g, ' ')               // markdown-opmaaktekens
     38    .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
     39    .replace(/\[\[[^\]]*?\]\]/g, ' ')
     40    .replace(/\[\[|\]\]/g, ' ')
     41    .replace(/[#>*_`~]+/g, ' ')
    5142    .replace(/\s+/g, ' ')
    5243    .trim();
     
    5546}
    5647
     48// ── De kern: alle bronnen doorzoeken voor één site. `lim` begrenst per groep
     49//    (klein voor de live-suggesties, ruim voor de volle pagina). ──────────────
     50function searchSite(req, res, rawQ, lim) {
     51  const site = res.locals.site;
     52  const isHub = res.locals.tenancy === 'hub';
     53  const base = res.locals.siteUrlBase || '';
     54  const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
     55  const t = res.locals.t || ((k) => k);
     56
     57  const out = { results: [], tracks: [], events: [], pages: [], queryError: null };
     58  if (!site || !rawQ) return out;
     59  const like = likeArg(rawQ);
     60
     61  // 1. Posts (FTS5)
     62  const ftsQuery = buildFtsQuery(rawQ);
     63  if (ftsQuery) {
     64    try {
     65      out.results = db.prepare(`
     66        SELECT p.slug, p.title, p.excerpt, p.published_at, u.username AS author_username,
     67               snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, bm25(posts_fts) AS score
     68        FROM posts_fts
     69        JOIN posts p ON p.id = posts_fts.post_id
     70        JOIN users u ON u.id = p.author_id
     71        WHERE posts_fts MATCH ? AND p.site_id = ? AND p.status = 'published'
     72        ORDER BY score ASC LIMIT ?
     73      `).all(ftsQuery, site.id, lim.posts);
     74      out.results = out.results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) }));
     75    } catch (err) { out.queryError = err.message; }
     76  }
     77
     78  // 2. Nummers
     79  try {
     80    const trackRows = db.prepare(`
     81      SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
     82      FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
     83      WHERE t.site_id = @site
     84        AND ( t.title LIKE @like ESCAPE '\\' OR t.artist LIKE @like ESCAPE '\\' OR t.album LIKE @like ESCAPE '\\' )
     85      ORDER BY t.play_count DESC, t.title ASC LIMIT @lim
     86    `).all({ site: site.id, like, lim: lim.tracks });
     87    const playable = trackRows.filter((t) => t.filename);
     88    let posts = [];
     89    if (playable.length) {
     90      posts = db.prepare("SELECT slug, content FROM posts WHERE site_id = ? AND status = 'published' ORDER BY published_at DESC").all(site.id);
     91    }
     92    const postUrlForTrack = (tr) => {
     93      let hit = posts.find((p) => p.content && p.content.includes('[[track:' + tr.id + ']]'));
     94      if (!hit && tr.album) hit = posts.find((p) => p.content && p.content.includes('[[album:' + tr.album + ']]'));
     95      if (!hit) {
     96        const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?').all(tr.id).map((r) => r.playlist_id);
     97        if (plids.length) hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
     98      }
     99      return hit ? urlFor(hit.slug) : null;
     100    };
     101    out.tracks = playable.map((tr) => ({
     102      id: tr.id, title: tr.title || 'Untitled', artist: tr.artist || '', album: tr.album || '',
     103      cover: tr.cover_url || '', url: audioUrl(tr.filename), postUrl: postUrlForTrack(tr),
     104    }));
     105  } catch (err) { if (!out.queryError) out.queryError = err.message; }
     106
     107  // 3. Evenementen (agenda) — alleen als de agenda publiek aan staat.
     108  if (premiumUnlocked() && getSetting('agenda_enabled') === '1') {
     109    try {
     110      out.events = db.prepare(`
     111        SELECT date, time, city, venue, country FROM shows
     112        WHERE site_id = @site
     113          AND ( city LIKE @like ESCAPE '\\' OR venue LIKE @like ESCAPE '\\'
     114             OR country LIKE @like ESCAPE '\\' OR notes LIKE @like ESCAPE '\\' OR date LIKE @like ESCAPE '\\' )
     115        ORDER BY date ASC LIMIT @lim
     116      `).all({ site: site.id, like, lim: lim.events }).map((e) => ({
     117        date: e.date, time: e.time || '',
     118        where: [e.venue, e.city, e.country].filter(Boolean).join(', '),
     119        url: base + '/shows',
     120      }));
     121    } catch (err) { if (!out.queryError) out.queryError = err.message; }
     122  }
     123
     124  // 4. Pagina's — curated, alleen de beschikbare; match op de (vertaalde) naam.
     125  const ql = rawQ.toLowerCase();
     126  const candidates = [
     127    { key: 'search.page_agenda', url: base + '/shows', on: premiumUnlocked() && getSetting('agenda_enabled') === '1' },
     128    { key: 'search.page_downloads', url: base + '/downloads', on: premiumUnlocked() },
     129    { key: 'search.page_links', url: base + '/links', on: premiumUnlocked() },
     130    { key: 'search.page_perskit', url: base + '/pers', on: premiumUnlocked() },
     131    { key: 'search.page_archive', url: urlFor('archive'), on: !site || site.show_archive_link === undefined || site.show_archive_link },
     132  ];
     133  out.pages = candidates
     134    .filter((c) => c.on)
     135    .map((c) => ({ label: t(c.key), url: c.url }))
     136    .filter((c) => c.label.toLowerCase().includes(ql))
     137    .slice(0, lim.pages);
     138
     139  return out;
     140}
     141
     142// ── Volledige resultatenpagina ───────────────────────────────────────────────
    57143router.get('/', (req, res) => {
    58144  const site = res.locals.site;
     145  if (!site) return res.status(404).send('No site');
    59146  const rawQ = (req.query.q || '').toString().trim();
    60   const isHub = res.locals.tenancy === 'hub';
    61   const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
    62147
    63   if (!site) return res.status(404).send('No site');
    64 
    65   // Lege query — toon het formulier zonder resultaten.
    66148  if (!rawQ) {
    67149    return renderPage(req, res, 'pages/search', {
    68       pageTitle: 'Zoeken',
    69       bodyClass: 'on-special',
    70       query: '',
    71       results: [],
    72       tracks: [],
    73       total: 0,
     150      pageTitle: 'Zoeken', bodyClass: 'on-special', query: '',
     151      results: [], tracks: [], events: [], pages: [], total: 0,
    74152    });
    75153  }
    76154
    77   let results = [];
    78   let tracks = [];
    79   let queryError = null;
     155  const r = searchSite(req, res, rawQ, { posts: 50, tracks: 25, events: 25, pages: 8 });
     156  const total = r.results.length + r.tracks.length + r.events.length + r.pages.length;
     157  renderPage(req, res, 'pages/search', {
     158    pageTitle: `Zoeken: ${rawQ}`, bodyClass: 'on-special', query: rawQ,
     159    results: r.results, tracks: r.tracks, events: r.events, pages: r.pages,
     160    total, queryError: r.queryError,
     161  });
     162});
    80163
    81   // ── Posts (FTS5, prefix) ───────────────────────────────────────────
    82   const ftsQuery = buildFtsQuery(rawQ);
    83   if (ftsQuery) {
    84     try {
    85       results = db.prepare(`
    86         SELECT
    87           p.slug,
    88           p.title,
    89           p.excerpt,
    90           p.published_at,
    91           u.username AS author_username,
    92           snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet,
    93           bm25(posts_fts) AS score
    94         FROM posts_fts
    95         JOIN posts p ON p.id = posts_fts.post_id
    96         JOIN users u ON u.id = p.author_id
    97         WHERE posts_fts MATCH ?
    98           AND p.site_id = ?
    99           AND p.status = 'published'
    100         ORDER BY score ASC
    101         LIMIT 50
    102       `).all(ftsQuery, site.id);
    103       results = results.map((r) => ({ ...r, snippet: cleanSnippet(r.snippet, r.excerpt) }));
    104     } catch (err) {
    105       queryError = err.message;
    106     }
    107   }
     164// ── Live suggesties (JSON) ───────────────────────────────────────────────────
     165router.get('/suggest', (req, res) => {
     166  const site = res.locals.site;
     167  if (!site) return res.json({ posts: [], tracks: [], events: [], pages: [] });
     168  const rawQ = (req.query.q || '').toString().trim().slice(0, 100);
     169  if (rawQ.length < 2) return res.json({ posts: [], tracks: [], events: [], pages: [] });
    108170
    109   // ── Nummers (audio_tracks: titel / artiest / album) ────────────────
    110   try {
    111     const like = likeArg(rawQ);
    112     const trackRows = db.prepare(`
    113       SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename
    114       FROM audio_tracks t
    115       LEFT JOIN media m ON m.id = t.media_id
    116       WHERE t.site_id = @site
    117         AND ( t.title  LIKE @like ESCAPE '\\'
    118            OR t.artist LIKE @like ESCAPE '\\'
    119            OR t.album  LIKE @like ESCAPE '\\' )
    120       ORDER BY t.play_count DESC, t.title ASC
    121       LIMIT 25
    122     `).all({ site: site.id, like });
    123 
    124     // Eén keer alle published posts van de site ophalen om per nummer de
    125     // post/album/playlist-pagina te vinden waarin 'ie voorkomt (in-memory match).
    126     const playable = trackRows.filter((t) => t.filename);
    127     let posts = [];
    128     if (playable.length) {
    129       posts = db.prepare(`
    130         SELECT slug, content FROM posts
    131         WHERE site_id = ? AND status = 'published'
    132         ORDER BY published_at DESC
    133       `).all(site.id);
    134     }
    135     const postUrlForTrack = (t) => {
    136       let hit = posts.find((p) => p.content && p.content.includes('[[track:' + t.id + ']]'));
    137       if (!hit && t.album) {
    138         hit = posts.find((p) => p.content && p.content.includes('[[album:' + t.album + ']]'));
    139       }
    140       if (!hit) {
    141         const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?')
    142           .all(t.id).map((r) => r.playlist_id);
    143         if (plids.length) {
    144           hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]')));
    145         }
    146       }
    147       return hit ? urlFor(hit.slug) : null;
    148     };
    149 
    150     tracks = playable.map((t) => ({
    151       id: t.id,
    152       title: t.title || 'Untitled',
    153       artist: t.artist || '',
    154       album: t.album || '',
    155       cover: t.cover_url || '',
    156       url: audioUrl(t.filename),
    157       postUrl: postUrlForTrack(t),
    158     }));
    159   } catch (err) {
    160     if (!queryError) queryError = err.message;
    161   }
    162 
    163   const total = results.length + tracks.length;
    164 
    165   renderPage(req, res, 'pages/search', {
    166     pageTitle: `Zoeken: ${rawQ}`,
    167     bodyClass: 'on-special',
    168     query: rawQ,
    169     results,
    170     tracks,
    171     total,
    172     queryError,
     171  const isHub = res.locals.tenancy === 'hub';
     172  const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);
     173  const r = searchSite(req, res, rawQ, { posts: 5, tracks: 4, events: 3, pages: 4 });
     174  res.json({
     175    posts: r.results.map((p) => ({ title: p.title || '(zonder titel)', url: urlFor(p.slug) })),
     176    tracks: r.tracks.map((tr) => ({ title: tr.title, artist: tr.artist, url: tr.postUrl })),
     177    events: r.events.map((e) => ({ when: [e.date, e.time].filter(Boolean).join(' '), where: e.where, url: e.url })),
     178    pages: r.pages,
    173179  });
    174180});
  • src/services/i18n.js

    re579a98 r097da22  
    109109    'search.section_tracks': 'Nummers', 'search.section_posts': 'Posts',
    110110    'search.empty': 'Niets gevonden.', 'search.in_post': 'in post →',
     111    'search.section_events': 'Evenementen', 'search.section_pages': 'Pagina’s',
     112    'search.page_agenda': 'Agenda', 'search.page_downloads': 'Downloads', 'search.page_links': 'Links', 'search.page_perskit': 'Perskit', 'search.page_archive': 'Archief',
     113    'search.suggest_all': 'Alle resultaten →', 'search.suggest_empty': 'Geen resultaten', 'search.suggest_typing': 'Typ om te zoeken…',
    111114    'like.login_title': 'Log in om deze post te liken',
    112115    // === Beheer-sub-pagina's ===
     
    10721075    'search.section_tracks': 'Tracks', 'search.section_posts': 'Posts',
    10731076    'search.empty': 'Nothing found.', 'search.in_post': 'in post →',
     1077    'search.section_events': 'Events', 'search.section_pages': 'Pages',
     1078    'search.page_agenda': 'Agenda', 'search.page_downloads': 'Downloads', 'search.page_links': 'Links', 'search.page_perskit': 'Press kit', 'search.page_archive': 'Archive',
     1079    'search.suggest_all': 'All results →', 'search.suggest_empty': 'No results', 'search.suggest_typing': 'Type to search…',
    10741080    'like.login_title': 'Log in to like this post',
    10751081    // === Admin sub-pages ===
     
    20342040    'search.section_tracks': 'Titel', 'search.section_posts': 'Beiträge',
    20352041    'search.empty': 'Nichts gefunden.', 'search.in_post': 'im Beitrag →',
     2042    'search.section_events': 'Veranstaltungen', 'search.section_pages': 'Seiten',
     2043    'search.page_agenda': 'Termine', 'search.page_downloads': 'Downloads', 'search.page_links': 'Links', 'search.page_perskit': 'Pressekit', 'search.page_archive': 'Archiv',
     2044    'search.suggest_all': 'Alle Ergebnisse →', 'search.suggest_empty': 'Keine Ergebnisse', 'search.suggest_typing': 'Zum Suchen tippen…',
    20362045    'like.login_title': 'Zum Liken anmelden',
    20372046    // === Verwaltungs-Unterseiten ===
  • src/views/pages/search.ejs

    re579a98 r097da22  
    11<% const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; %>
    22<% const _tracks = (typeof tracks !== 'undefined' && tracks) ? tracks : []; %>
     3<% const _events = (typeof events !== 'undefined' && events) ? events : []; %>
     4<% const _pages = (typeof pages !== 'undefined' && pages) ? pages : []; %>
    35<div class="container search-page">
    46  <h1><%= t('nav.search') %></h1>
     
    8991  <% } %>
    9092
    91   <% if (query && !queryError && !results.length && !_tracks.length) { %>
     93  <% if (_events.length) { %>
     94    <h2 class="search-section-title"><%= t('search.section_events') %></h2>
     95    <ol class="search-results">
     96      <% _events.forEach(function(ev) { %>
     97        <li class="search-result">
     98          <h3 class="search-result-title"><a href="<%= ev.url %>"><%= ev.where || '—' %></a></h3>
     99          <p class="search-result-meta"><%= [ev.date, ev.time].filter(Boolean).join(' ') %></p>
     100        </li>
     101      <% }); %>
     102    </ol>
     103  <% } %>
     104
     105  <% if (_pages.length) { %>
     106    <h2 class="search-section-title"><%= t('search.section_pages') %></h2>
     107    <ul class="search-pages">
     108      <% _pages.forEach(function(pg) { %>
     109        <li><a class="search-page-link" href="<%= pg.url %>"><%= pg.label %> <span aria-hidden="true">→</span></a></li>
     110      <% }); %>
     111    </ul>
     112  <% } %>
     113
     114  <% if (query && !queryError && !results.length && !_tracks.length && !_events.length && !_pages.length) { %>
    92115    <p class="search-empty"><%= t('search.empty') %></p>
    93116  <% } %>
     
    211234  border-radius: 3px;
    212235}
     236.search-pages { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
     237.search-page-link {
     238  display: inline-flex; align-items: center; gap: 0.3rem;
     239  padding: 0.45rem 0.8rem; border: 1px solid var(--rule); border-radius: 999px;
     240  color: var(--ink); text-decoration: none; font-size: 0.9rem; background: var(--paper-2);
     241}
     242.search-page-link:hover { border-color: var(--accent); color: var(--accent); }
    213243</style>
  • src/views/partials/topnav.ejs

    re579a98 r097da22  
    183183
    184184<!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) -->
    185 <div id="search-overlay" class="search-overlay" hidden>
     185<div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search">
    186186  <div class="container">
    187187    <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search">
    188       <input type="search" name="q" placeholder="Zoeken in posts…" autocomplete="off">
    189       <button type="submit" class="nav-btn" aria-label="Zoeken">
     188      <input type="search" name="q" placeholder="<%= t('search.placeholder') %>" autocomplete="off">
     189      <button type="submit" class="nav-btn" aria-label="<%= t('search.button') %>">
    190190        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
    191191      </button>
    192       <button type="button" class="nav-btn" id="search-close" aria-label="Sluiten">
     192      <button type="button" class="nav-btn" id="search-close" aria-label="<%= t('imed.cancel') %>">
    193193        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
    194194      </button>
    195195    </form>
     196    <div id="search-suggest" class="search-suggest" data-lenis-prevent></div>
    196197  </div>
    197198</div>
     
    215216  function overlay() { return document.getElementById('search-overlay'); }
    216217  function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } }
    217   function closeSearch() { var o = overlay(); if (o) o.hidden = true; }
     218  function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } }
    218219
    219220  document.body.addEventListener('click', function(e) {
     
    228229
    229230  document.addEventListener('keydown', function(e) {
    230     if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) o.hidden = true; }
     231    if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); }
     232  });
     233
     234  // ── Live resultaten tijdens typen ───────────────────────────────
     235  var _ST = {
     236    posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>',
     237    events: '<%= t('search.section_events') %>', pages: '<%= t('search.section_pages') %>',
     238    all: '<%= t('search.suggest_all') %>', none: '<%= t('search.suggest_empty') %>'
     239  };
     240  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
     241  function renderSuggest(box, d, q, ov) {
     242    var html = '';
     243    function grp(title, items, fmt) {
     244      if (!items || !items.length) return;
     245      html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>';
     246    }
     247    grp(_ST.posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; });
     248    grp(_ST.tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; });
     249    grp(_ST.events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; });
     250    grp(_ST.pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; });
     251    var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length);
     252    if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_ST.none) + '</div>'; return; }
     253    var action = (ov && ov.getAttribute('data-action')) || '/search';
     254    html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_ST.all) + '</a>';
     255    box.innerHTML = html;
     256  }
     257  var _sTimer;
     258  document.addEventListener('input', function(e) {
     259    var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]');
     260    if (!inp) return;
     261    var box = document.getElementById('search-suggest');
     262    if (!box) return;
     263    var q = inp.value.trim();
     264    clearTimeout(_sTimer);
     265    if (q.length < 2) { box.innerHTML = ''; return; }
     266    _sTimer = setTimeout(function() {
     267      var ov = document.getElementById('search-overlay');
     268      var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest';
     269      fetch(url + '?q=' + encodeURIComponent(q))
     270        .then(function(r){ return r.ok ? r.json() : null; })
     271        .then(function(d){ if (d) renderSuggest(box, d, q, ov); })
     272        .catch(function(){});
     273    }, 200);
     274  });
     275  // Klik op een suggestie → overlay sluiten (de link/boost doet de navigatie).
     276  document.addEventListener('click', function(e) {
     277    if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; }
    231278  });
    232279})();
     
    499546  display: inline-flex; align-items: center; justify-content: center;
    500547}
     548/* Live-resultaten dropdown */
     549.search-suggest { margin-top: .6rem; max-height: 60vh; overflow-y: auto; display: flex; flex-direction: column; gap: .25rem; }
     550.search-suggest:empty { display: none; }
     551.ss-group { padding: .25rem 0; }
     552.ss-title { font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--ink-soft, var(--ink-muted)); padding: .35rem .25rem .2rem; }
     553.ss-item { display: block; padding: .55rem .6rem; border-radius: 8px; color: var(--ink); text-decoration: none; font-size: .95rem; }
     554.ss-item:hover { background: var(--paper-2); color: var(--accent); }
     555.ss-item.ss-noclick { opacity: .7; cursor: default; }
     556.ss-sub { color: var(--ink-soft, var(--ink-muted)); font-size: .85em; }
     557.ss-empty { padding: .8rem .6rem; color: var(--ink-soft, var(--ink-muted)); font-size: .9rem; }
     558.ss-all { display: block; margin-top: .35rem; padding: .55rem .6rem; border-top: 1px solid var(--rule); color: var(--accent); font-weight: 600; text-decoration: none; font-size: .9rem; }
     559.ss-all:hover { text-decoration: underline; }
    501560</style>
Note: See TracChangeset for help on using the changeset viewer.