Changeset 1720482 in Klonkt
- Timestamp:
- 06/20/2026 02:33:25 AM (3 months ago)
- Branches:
- main
- Children:
- 5f47671
- Parents:
- 1ee967d
- Files:
-
- 3 edited
-
CHANGELOG.md (modified) (1 diff)
-
src/routes/search.js (modified) (4 diffs)
-
src/views/pages/search.ejs (modified) (5 diffs)
Legend:
- Unmodified
- Added
- Removed
-
CHANGELOG.md
r1ee967d r1720482 14 14 ## [Unreleased] 15 15 16 ### Toegevoegd 17 - Zoeken doorzoekt nu ook **nummers** (op titel, artiest én album), niet alleen posts. 18 Gevonden nummers zijn direct afspeelbaar in de resultatenlijst, met een 19 "in post →"-link naar de post/album/playlist waarin het nummer voorkomt. 20 - Posts-zoek gebruikt nu **prefix-matching**: typ "astr" en je vindt "astra" 21 (elk woord als prefix, AND tussen de woorden) — fijner zoeken-terwijl-je-typt. 22 16 23 ### Opgelost 24 - Mini-speler: spring + scroll naar de spelende track werkt nu ook vanuit een 25 **album of playlist** (de track-id zat niet in de afspeel-queue). 26 - De track die nu speelt krijgt een **blijvende highlight** in de post. 27 - Lege **album/playlist-covers** hersteld (een gemiste verwijzing in de 28 WebP-conversie) + ze vallen nu terug op de cover van de eerste track. 29 - Kapotte **avatar/profielfoto** in de kop na de WebP-conversie: de viewer-avatar 30 (en rol) wordt nu vers uit de database gelezen, dus een oude sessie herstelt 31 zichzelf zonder opnieuw inloggen. 32 33 ### Opgelost (eerder) 17 34 - Inline content-layout op touch versimpeld: geen opmaak-toolbar/rand meer inline 18 35 (je bewerkt toch fullscreen) — alleen de content-preview + de tik-om-te-bewerken-pill. -
src/routes/search.js
r1ee967d r1720482 2 2 * GET /search?q=... 3 3 * 4 * Queries the posts_fts virtual table (FTS5) for the current site. 5 * Search is restricted to published posts of the resolved site. 4 * Doorzoekt de huidige site op twee dingen: 5 * 1. Posts via de posts_fts virtuele tabel (FTS5) — published only. 6 * 2. Nummers (audio_tracks) op titel / artiest / album. 6 7 * 7 * FTS5 quirks handled: 8 * - Empty / whitespace-only query: render the form with no results. 9 * - User input is wrapped in double quotes so FTS5 treats it as a phrase 10 * (avoids syntax errors from special chars like "OR", parentheses, etc.). 11 * - Snippet() builds the highlighted excerpt; we keep markup minimal so 12 * the EJS view can wrap the matches in <mark>. 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. 13 18 */ 14 19 … … 16 21 import db from '../config/database.js'; 17 22 import { renderPage } from '../middleware/render.js'; 23 import { audioUrl } from '../services/AudioStreamService.js'; 18 24 19 25 const router = express.Router(); 20 26 21 // Wrap user input as a single FTS5 phrase. Strip embedded double-quotes so 22 // the wrapping stays balanced. FTS5 phrase queries are forgiving and avoid 23 // the operator-soup pitfalls of bare user input. 24 function asPhrase(q) { 25 return '"' + q.replace(/"/g, '') + '"'; 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. 30 function buildFtsQuery(q) { 31 const terms = q.split(/[^\p{L}\p{N}]+/u).filter(Boolean); 32 if (!terms.length) return null; 33 return terms.map((t) => '"' + t + '"*').join(' '); 34 } 35 36 // Escape LIKE-wildcards in user-input zodat % en _ letterlijk matchen. 37 function likeArg(q) { 38 return '%' + q.replace(/[%_\\]/g, '\\$&') + '%'; 26 39 } 27 40 … … 29 42 const site = res.locals.site; 30 43 const rawQ = (req.query.q || '').toString().trim(); 44 const isHub = res.locals.tenancy === 'hub'; 45 const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`); 31 46 32 47 if (!site) return res.status(404).send('No site'); 33 48 34 // Empty query — render the page with the form and no results.49 // Lege query — toon het formulier zonder resultaten. 35 50 if (!rawQ) { 36 51 return renderPage(req, res, 'pages/search', { 37 pageTitle: ' Search',52 pageTitle: 'Zoeken', 38 53 bodyClass: 'on-special', 39 54 query: '', 40 55 results: [], 56 tracks: [], 41 57 total: 0, 42 58 }); … … 44 60 45 61 let results = []; 46 let t otal = 0;62 let tracks = []; 47 63 let queryError = null; 48 64 49 try { 50 // FTS5 join → posts table, filter by site + published 51 results = db.prepare(` 52 SELECT 53 p.slug, 54 p.title, 55 p.excerpt, 56 p.published_at, 57 u.username AS author_username, 58 snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, 59 bm25(posts_fts) AS score 60 FROM posts_fts 61 JOIN posts p ON p.id = posts_fts.post_id 62 JOIN users u ON u.id = p.author_id 63 WHERE posts_fts MATCH ? 64 AND p.site_id = ? 65 AND p.status = 'published' 66 ORDER BY score ASC 67 LIMIT 50 68 `).all(asPhrase(rawQ), site.id); 69 70 total = results.length; 71 } catch (err) { 72 queryError = err.message; 65 // ── Posts (FTS5, prefix) ─────────────────────────────────────────── 66 const ftsQuery = buildFtsQuery(rawQ); 67 if (ftsQuery) { 68 try { 69 results = db.prepare(` 70 SELECT 71 p.slug, 72 p.title, 73 p.excerpt, 74 p.published_at, 75 u.username AS author_username, 76 snippet(posts_fts, 0, '<mark>', '</mark>', '…', 18) AS snippet, 77 bm25(posts_fts) AS score 78 FROM posts_fts 79 JOIN posts p ON p.id = posts_fts.post_id 80 JOIN users u ON u.id = p.author_id 81 WHERE posts_fts MATCH ? 82 AND p.site_id = ? 83 AND p.status = 'published' 84 ORDER BY score ASC 85 LIMIT 50 86 `).all(ftsQuery, site.id); 87 } catch (err) { 88 queryError = err.message; 89 } 73 90 } 74 91 92 // ── Nummers (audio_tracks: titel / artiest / album) ──────────────── 93 try { 94 const like = likeArg(rawQ); 95 const trackRows = db.prepare(` 96 SELECT t.id, t.title, t.artist, t.album, t.cover_url, t.play_count, m.filename 97 FROM audio_tracks t 98 LEFT JOIN media m ON m.id = t.media_id 99 WHERE t.site_id = @site 100 AND ( t.title LIKE @like ESCAPE '\\' 101 OR t.artist LIKE @like ESCAPE '\\' 102 OR t.album LIKE @like ESCAPE '\\' ) 103 ORDER BY t.play_count DESC, t.title ASC 104 LIMIT 25 105 `).all({ site: site.id, like }); 106 107 // Eén keer alle published posts van de site ophalen om per nummer de 108 // post/album/playlist-pagina te vinden waarin 'ie voorkomt (in-memory match). 109 const playable = trackRows.filter((t) => t.filename); 110 let posts = []; 111 if (playable.length) { 112 posts = db.prepare(` 113 SELECT slug, content FROM posts 114 WHERE site_id = ? AND status = 'published' 115 ORDER BY published_at DESC 116 `).all(site.id); 117 } 118 const postUrlForTrack = (t) => { 119 let hit = posts.find((p) => p.content && p.content.includes('[[track:' + t.id + ']]')); 120 if (!hit && t.album) { 121 hit = posts.find((p) => p.content && p.content.includes('[[album:' + t.album + ']]')); 122 } 123 if (!hit) { 124 const plids = db.prepare('SELECT playlist_id FROM playlist_tracks WHERE track_id = ?') 125 .all(t.id).map((r) => r.playlist_id); 126 if (plids.length) { 127 hit = posts.find((p) => p.content && plids.some((pl) => p.content.includes('[[playlist:' + pl + ']]'))); 128 } 129 } 130 return hit ? urlFor(hit.slug) : null; 131 }; 132 133 tracks = playable.map((t) => ({ 134 id: t.id, 135 title: t.title || 'Untitled', 136 artist: t.artist || '', 137 album: t.album || '', 138 cover: t.cover_url || '', 139 url: audioUrl(t.filename), 140 postUrl: postUrlForTrack(t), 141 })); 142 } catch (err) { 143 if (!queryError) queryError = err.message; 144 } 145 146 const total = results.length + tracks.length; 147 75 148 renderPage(req, res, 'pages/search', { 76 pageTitle: ` Search: ${rawQ}`,149 pageTitle: `Zoeken: ${rawQ}`, 77 150 bodyClass: 'on-special', 78 151 query: rawQ, 79 152 results, 153 tracks, 80 154 total, 81 155 queryError, -
src/views/pages/search.ejs
r1ee967d r1720482 1 1 <% const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; %> 2 <% const _tracks = (typeof tracks !== 'undefined' && tracks) ? tracks : []; %> 2 3 <div class="container search-page"> 3 <h1> Search</h1>4 <h1>Zoeken</h1> 4 5 5 6 <form method="get" action="<%= _base %>/search" class="search-page-form"> … … 8 9 name="q" 9 10 value="<%= query %>" 10 placeholder=" Search posts…"11 placeholder="Zoek posts en nummers…" 11 12 autocomplete="off" 12 13 autofocus 13 aria-label=" Search query">14 <button type="submit" class="btn btn-primary"> Search</button>14 aria-label="Zoekopdracht"> 15 <button type="submit" class="btn btn-primary">Zoek</button> 15 16 </form> 16 17 17 18 <% if (queryError) { %> 18 <p class="search-error"> Couldn't run that query. Try a simpler term.</p>19 <p class="search-error">Kon die zoekopdracht niet uitvoeren. Probeer een eenvoudiger term.</p> 19 20 <% } %> 20 21 21 22 <% if (query && !queryError) { %> 22 23 <p class="search-meta"> 23 <%= total %> result <%= total === 1 ? '' : 's' %> for “<%= query %>”24 <%= total %> resulta<%= total === 1 ? 'at' : 'ten' %> voor “<%= query %>” 24 25 </p> 25 26 <% } %> 26 27 28 <% if (_tracks.length) { %> 29 <h2 class="search-section-title">Nummers</h2> 30 <div class="search-tracks"> 31 <% _tracks.forEach(function(t) { 32 const tj = JSON.stringify({ id: t.id, url: t.url, title: t.title, artist: t.artist || '', cover: t.cover || '' }) 33 .replace(/&/g, '&').replace(/'/g, ''').replace(/</g, '<'); %> 34 <div class="post-audio-track search-track" id="track-<%= t.id %>" 35 data-pcms-track-id="<%= t.id %>" data-pcms-track-url="<%= t.url %>" 36 data-pcms-track='<%- tj %>'> 37 <button type="button" class="pat-play" aria-label="Speel <%= t.title %>"> 38 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg> 39 </button> 40 <% if (t.cover) { %> 41 <span class="search-track-cover" style="background-image:url('<%= t.cover %>')" aria-hidden="true"></span> 42 <% } %> 43 <div class="pat-info"> 44 <div class="pat-title"><%= t.title %></div> 45 <% if (t.artist || t.album) { %> 46 <div class="pat-artist"> 47 <%= t.artist %><% if (t.artist && t.album) { %> · <% } %><%= t.album %> 48 </div> 49 <% } %> 50 </div> 51 <% if (t.postUrl) { %> 52 <a class="search-track-link" 53 href="<%= t.postUrl %>" 54 hx-get="<%= t.postUrl %>?partial=1" 55 hx-target="#pcms-main" 56 hx-swap="innerHTML" 57 hx-push-url="<%= t.postUrl %>" 58 aria-label="Open in post">in post →</a> 59 <% } %> 60 </div> 61 <% }); %> 62 </div> 63 <% } %> 64 27 65 <% if (results && results.length) { %> 66 <% if (_tracks.length) { %><h2 class="search-section-title">Posts</h2><% } %> 28 67 <ol class="search-results"> 29 68 <% results.forEach(function(r) { %> 30 69 <li class="search-result"> 31 <h 2class="search-result-title">32 <a href=" /<%= r.slug %>"33 hx-get=" /<%= r.slug %>?partial=1"70 <h3 class="search-result-title"> 71 <a href="<%= _base %>/<%= r.slug %>" 72 hx-get="<%= _base %>/<%= r.slug %>?partial=1" 34 73 hx-target="#pcms-main" 35 74 hx-swap="innerHTML" 36 hx-push-url=" /<%= r.slug %>"75 hx-push-url="<%= _base %>/<%= r.slug %>" 37 76 hx-indicator="#pcms-loading"> 38 <%= r.title || '( untitled)' %>77 <%= r.title || '(zonder titel)' %> 39 78 </a> 40 </h 2>79 </h3> 41 80 <p class="search-result-meta"> 42 81 <%= r.author_username %> … … 47 86 <% }); %> 48 87 </ol> 49 <% } else if (query && !queryError) { %> 50 <p class="search-empty">No posts found.</p> 88 <% } %> 89 90 <% if (query && !queryError && !results.length && !_tracks.length) { %> 91 <p class="search-empty">Niets gevonden.</p> 51 92 <% } %> 52 93 </div> … … 80 121 margin: 0 0 1rem; 81 122 } 123 .search-section-title { 124 font-family: var(--font-display, serif); 125 font-size: 1.1rem; 126 margin: 1.75rem 0 0.75rem; 127 color: var(--ink-soft); 128 text-transform: uppercase; 129 letter-spacing: 0.05em; 130 } 82 131 .search-error { 83 132 background: rgba(200, 60, 60, 0.15); … … 93 142 } 94 143 144 /* Nummer-resultaten */ 145 .search-tracks { display: flex; flex-direction: column; gap: 0.25rem; } 146 .search-track.post-audio-track { 147 display: flex; 148 align-items: center; 149 gap: 0.75rem; 150 padding: 0.5rem 0.6rem; 151 border-radius: 8px; 152 } 153 .search-track .search-track-cover { 154 width: 38px; height: 38px; 155 flex: 0 0 38px; 156 border-radius: 5px; 157 background-size: cover; 158 background-position: center; 159 background-color: var(--paper-2); 160 } 161 .search-track .pat-info { flex: 1; min-width: 0; } 162 .search-track .pat-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 163 .search-track .pat-artist { font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 164 .search-track-link { 165 flex: 0 0 auto; 166 font-size: 0.82rem; 167 color: var(--accent); 168 text-decoration: none; 169 white-space: nowrap; 170 } 171 .search-track-link:hover { text-decoration: underline; } 172 95 173 .search-results { 96 174 list-style: none; 97 175 margin: 0; 98 176 padding: 0; 99 counter-reset: search-result;100 177 } 101 178 .search-result {
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)