Changeset 097da22 in Klonkt
- Timestamp:
- 06/21/2026 05:26:47 PM (3 months ago)
- Branches:
- main
- Children:
- fefb80c
- Parents:
- e579a98
- Location:
- src
- Files:
-
- 4 edited
-
routes/search.js (modified) (4 diffs)
-
services/i18n.js (modified) (3 diffs)
-
views/pages/search.ejs (modified) (3 diffs)
-
views/partials/topnav.ejs (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/search.js
re579a98 r097da22 1 1 /** 2 * GET /search?q=... 2 * GET /search?q=... -> volledige resultatenpagina 3 * GET /search/suggest?q=... -> compacte JSON voor live resultaten in de overlay 3 4 * 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. 6 7 * 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. 7 11 * 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. 18 14 */ 19 15 … … 22 18 import { renderPage } from '../middleware/render.js'; 23 19 import { audioUrl } from '../services/AudioStreamService.js'; 20 import { getSetting } from '../services/SettingsService.js'; 21 import { premiumUnlocked } from '../services/PatreonService.js'; 24 22 25 23 const router = express.Router(); 26 24 27 // Bouw een veilige FTS5-prefix-query: tokeniseer op alles wat geen letter/cijfer28 // 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.30 25 function buildFtsQuery(q) { 31 26 const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean); … … 34 29 } 35 30 36 // Escape LIKE-wildcards in user-input zodat % en _ letterlijk matchen.37 31 function likeArg(q) { 38 32 return '%' + q.replace(/[%_\\]/g, '\\$&') + '%'; 39 33 } 40 34 41 // Maak de FTS-snippet leesbaar: shortcodes ([[...]]) en markdown-tekens eruit.42 // De <mark>…</mark>-highlights van FTS blijven staan. Valt terug op de excerpt43 // (geëscaped) als er na het schoonmaken niets leesbaars overblijft.44 35 function cleanSnippet(html, excerpt) { 45 36 const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 46 37 const s = (html || '') 47 .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // markdown-afbeeldingen48 .replace(/\[\[[^\]]*?\]\]/g, ' ') // shortcodes (incl. <mark> erin)49 .replace(/\[\[|\]\]/g, ' ') // losse haakjes door truncatie50 .replace(/[#>*_`~]+/g, ' ') // markdown-opmaaktekens38 .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') 39 .replace(/\[\[[^\]]*?\]\]/g, ' ') 40 .replace(/\[\[|\]\]/g, ' ') 41 .replace(/[#>*_`~]+/g, ' ') 51 42 .replace(/\s+/g, ' ') 52 43 .trim(); … … 55 46 } 56 47 48 // ── De kern: alle bronnen doorzoeken voor één site. `lim` begrenst per groep 49 // (klein voor de live-suggesties, ruim voor de volle pagina). ────────────── 50 function 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 ─────────────────────────────────────────────── 57 143 router.get('/', (req, res) => { 58 144 const site = res.locals.site; 145 if (!site) return res.status(404).send('No site'); 59 146 const rawQ = (req.query.q || '').toString().trim(); 60 const isHub = res.locals.tenancy === 'hub';61 const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);62 147 63 if (!site) return res.status(404).send('No site');64 65 // Lege query — toon het formulier zonder resultaten.66 148 if (!rawQ) { 67 149 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, 74 152 }); 75 153 } 76 154 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 }); 80 163 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) ─────────────────────────────────────────────────── 165 router.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: [] }); 108 170 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, 173 179 }); 174 180 }); -
src/services/i18n.js
re579a98 r097da22 109 109 'search.section_tracks': 'Nummers', 'search.section_posts': 'Posts', 110 110 '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…', 111 114 'like.login_title': 'Log in om deze post te liken', 112 115 // === Beheer-sub-pagina's === … … 1072 1075 'search.section_tracks': 'Tracks', 'search.section_posts': 'Posts', 1073 1076 '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…', 1074 1080 'like.login_title': 'Log in to like this post', 1075 1081 // === Admin sub-pages === … … 2034 2040 'search.section_tracks': 'Titel', 'search.section_posts': 'Beiträge', 2035 2041 '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…', 2036 2045 'like.login_title': 'Zum Liken anmelden', 2037 2046 // === Verwaltungs-Unterseiten === -
src/views/pages/search.ejs
re579a98 r097da22 1 1 <% const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; %> 2 2 <% const _tracks = (typeof tracks !== 'undefined' && tracks) ? tracks : []; %> 3 <% const _events = (typeof events !== 'undefined' && events) ? events : []; %> 4 <% const _pages = (typeof pages !== 'undefined' && pages) ? pages : []; %> 3 5 <div class="container search-page"> 4 6 <h1><%= t('nav.search') %></h1> … … 89 91 <% } %> 90 92 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) { %> 92 115 <p class="search-empty"><%= t('search.empty') %></p> 93 116 <% } %> … … 211 234 border-radius: 3px; 212 235 } 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); } 213 243 </style> -
src/views/partials/topnav.ejs
re579a98 r097da22 183 183 184 184 <!-- 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"> 186 186 <div class="container"> 187 187 <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') %>"> 190 190 <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> 191 191 </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') %>"> 193 193 <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> 194 194 </button> 195 195 </form> 196 <div id="search-suggest" class="search-suggest" data-lenis-prevent></div> 196 197 </div> 197 198 </div> … … 215 216 function overlay() { return document.getElementById('search-overlay'); } 216 217 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 = ''; } } 218 219 219 220 document.body.addEventListener('click', function(e) { … … 228 229 229 230 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 {'&':'&','<':'<','>':'>','"':'"'}[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; } 231 278 }); 232 279 })(); … … 499 546 display: inline-flex; align-items: center; justify-content: center; 500 547 } 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; } 501 560 </style>
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)