Changeset fd8faf5 in Klonkt
- Timestamp:
- 09/30/2026 07:27:05 AM (71 minutes ago)
- Branches:
- main
- Children:
- 19e28ce
- Parents:
- 86cfa1a
- Files:
-
- 3 added
- 1 deleted
- 8 edited
-
src/assets/js/mod/chrome.js (modified) (2 diffs)
-
src/middleware/render.js (modified) (1 diff)
-
src/routes/search.js (modified) (5 diffs)
-
src/views/pages/search.ejs (modified) (1 diff)
-
src/views/partials/fedi-interact-styles.ejs (modified) (1 diff)
-
src/views/partials/remote-preview.ejs (deleted)
-
src/views/partials/search-results.ejs (added)
-
src/views/partials/search-styles.ejs (added)
-
src/views/partials/search-surface.ejs (added)
-
src/views/partials/topnav.ejs (modified) (2 diffs)
-
src/views/shell.ejs (modified) (1 diff)
-
test/search-fediverse.test.js (modified) (9 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/js/mod/chrome.js
r86cfa1a rfd8faf5 119 119 try { localStorage.setItem('pcms-theme', next); } catch (e) {} 120 120 } 121 // ── Het zoeken: de balk bovenaan, het vak met resultaten eronder ───── 122 // Twee plekken, één vorm (Robins ontwerp, 30-9): de overlay die op elke 123 // pagina kan openen, en de pagina /search?q=... zelf. Alles hieronder werkt 124 // op elk .search-surface, zodat de twee zich ook hetzelfde gedragen. 121 125 function overlay() { return document.getElementById('search-overlay'); } 122 function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } } 123 function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } } 126 function openSearch() { 127 var o = overlay(); if (!o) return; 128 var inp = o.querySelector('input[name="q"]'); 129 var box = o.querySelector('.search-box-inner'); 130 // Een verse start: tekst en resultaten van de vorige keer horen niet te 131 // blijven staan als je hem opnieuw opent. 132 if (inp) inp.value = ''; 133 if (box) box.innerHTML = ''; 134 o.hidden = false; 135 document.documentElement.classList.add('search-open'); 136 if (inp) inp.focus(); 137 } 138 function closeSearch() { 139 var o = overlay(); if (!o) return; 140 o.hidden = true; 141 document.documentElement.classList.remove('search-open'); 142 } 124 143 125 144 document.body.addEventListener('click', function(e) { … … 137 156 }); 138 157 139 // ── Live results while typing ───────────────────────────────────140 // De teksten komen van de overlay zelf, elke keer opnieuw: bij een141 // htmx-navigatie wordt die vervangen en kan de taal gewisseld zijn.142 function _st() {143 var o = document.getElementById('search-overlay');144 try { return JSON.parse((o && o.getAttribute('data-i18n')) || '{}'); } catch (e) { return {}; }145 }146 158 function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); } 147 function renderSuggest(box, d, q, ov) { 148 var html = ''; 149 function grp(title, items, fmt) { 150 if (!items || !items.length) return; 151 html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>'; 152 } 153 grp(_st().posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; }); 154 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>'; }); 155 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>'; }); 156 grp(_st().pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; }); 157 var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length); 158 if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_st().none) + '</div>'; return; } 159 var action = (ov && ov.getAttribute('data-action')) || '/search'; 160 html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_st().all) + '</a>'; 161 box.innerHTML = html; 162 } 163 // Een ADRES is geen zoekterm (shaer-utpi). Dezelfde regel als lookupUri in 164 // routes/search.js: een schema is verplicht, anders wordt zoeken naar 165 // "soundfabrics.nl" een netwerkverzoek. De server beslist daarna nog eens, 166 // met de rechten erbij; dit scheelt alleen een vraag die toch nee oplevert. 167 function lookupUri(q) { 168 if (!/^https?:\/\//i.test(q) || /\s/.test(q)) return null; 169 try { var u = new URL(q); return u.hostname.indexOf('.') < 0 ? null : u.href; } catch (e) { return null; } 170 } 171 function allLink(ov, q) { 172 var action = (ov && ov.getAttribute('data-action')) || '/search'; 173 return '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_st().all) + '</a>'; 174 } 175 function fetchSuggest(box, q, ov, seq) { 176 var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest'; 177 fetch(url + '?q=' + encodeURIComponent(q)) 178 .then(function(r){ return r.ok ? r.json() : null; }) 179 .then(function(d){ if (d && seq === _sSeq) renderSuggest(box, d, q, ov); }) 180 .catch(function(){}); 181 } 182 // Elke toetsaanslag krijgt een volgnummer. Een ophaling van een andere 183 // server duurt soms seconden; zonder nummer overschrijft een traag antwoord 184 // op een oude vraag wat je inmiddels hebt getypt. 185 var _sTimer, _sSeq = 0; 159 function texts(surface) { try { return JSON.parse(surface.getAttribute('data-i18n') || '{}'); } catch (e) { return {}; } } 160 161 // Iets dat van een andere server moet komen: een adres of een handle. Dezelfde 162 // regels als lookupUri en lookupHandle in routes/search.js. Hier alleen om te 163 // weten of er meteen een wachtregel moet staan: de server beslist daarna 164 // zelf, met de rechten erbij. 165 function isRemoteLookup(q) { 166 if (/^https?:\/\//i.test(q) && !/\s/.test(q)) { 167 try { return new URL(q).hostname.indexOf('.') >= 0; } catch (e) { return false; } 168 } 169 return /^@?[a-z0-9_.-]+@[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/i.test(q); 170 } 171 172 // Elke zoekopdracht krijgt een volgnummer per vlak. Een ophaling van een 173 // andere server duurt soms seconden; zonder nummer overschrijft een traag 174 // antwoord op een oude vraag wat je inmiddels hebt getypt. 175 function runSearch(surface, now) { 176 if (!surface) return; 177 var inp = surface.querySelector('input[name="q"]'); 178 var box = surface.querySelector('.search-box-inner'); 179 if (!inp || !box) return; 180 var q = inp.value.trim(); 181 var seq = (surface.__seq = (surface.__seq || 0) + 1); 182 clearTimeout(surface.__timer); 183 if (q.length < 2) { box.innerHTML = ''; return; } 184 var remote = isRemoteLookup(q); 185 if (remote) box.innerHTML = '<div class="sr-loading">' + esc(texts(surface).looking) + '</div>'; 186 // Op de pagina beweegt het adres mee, zodat wat je ziet te delen blijft. 187 if (surface.classList.contains('is-page') && history.replaceState) { 188 history.replaceState(history.state, '', (surface.getAttribute('data-action') || '/search') + '?q=' + encodeURIComponent(q)); 189 } 190 surface.__timer = setTimeout(function() { 191 var url = surface.getAttribute('data-results') || '/search/results'; 192 fetch(url + '?q=' + encodeURIComponent(q), { credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } }) 193 .then(function(r) { return r.status === 200 ? r.text() : ''; }) 194 .then(function(html) { 195 if (seq !== surface.__seq) return; 196 box.innerHTML = html; 197 // De links naar een post zijn htmx-links; wat via innerHTML binnenkomt 198 // kent htmx nog niet. 199 if (window.htmx && window.htmx.process) window.htmx.process(box); 200 }) 201 .catch(function() {}); 202 }, now ? 0 : (remote ? 300 : 200)); 203 } 186 204 document.addEventListener('input', function(e) { 187 var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]'); 188 if (!inp) return; 189 var box = document.getElementById('search-suggest'); 190 if (!box) return; 191 var q = inp.value.trim(); 192 var seq = ++_sSeq; 193 clearTimeout(_sTimer); 194 if (q.length < 2) { box.innerHTML = ''; return; } 195 var ov = document.getElementById('search-overlay'); 196 var address = lookupUri(q); 197 if (!address) { _sTimer = setTimeout(function() { fetchSuggest(box, q, ov, seq); }, 200); return; } 198 // Een geplakt adres: de post zelf, niet de tekst van de link doorzocht. 199 box.innerHTML = '<div class="ss-remote-loading">' + esc(_st().looking) + '</div>'; 200 _sTimer = setTimeout(function() { 201 var url = (ov && ov.getAttribute('data-remote')) || '/search/remote'; 202 fetch(url + '?q=' + encodeURIComponent(address), { credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } }) 203 .then(function(r) { 204 if (seq !== _sSeq) return; 205 // 204: geen recht om op te halen (of toch geen adres). Dan is het 206 // gewoon een zoekterm en krijg je de gewone suggesties. 207 if (r.status === 204 || !r.ok) { fetchSuggest(box, q, ov, seq); return; } 208 return r.text().then(function(html) { if (seq === _sSeq) box.innerHTML = html + allLink(ov, q); }); 209 }) 210 .catch(function() { if (seq === _sSeq) fetchSuggest(box, q, ov, seq); }); 211 }, 300); 212 }); 213 // Een klik op een suggestie sluit de overlay, want de link gaat ergens heen. 214 // Behalve wat in een nieuw tabblad opent (het origineel, een profiel): dan 215 // blijf je hier, en hoort de kaart er nog te staan als je terugkomt. 205 var inp = e.target.closest && e.target.closest('.search-surface input[name="q"]'); 206 if (inp) runSearch(inp.closest('.search-surface'), false); 207 }); 208 // Enter blijft waar hij is: het vak IS de resultaten. Zonder JS gaat het 209 // formulier naar /search, dat er precies zo uitziet. 210 document.addEventListener('submit', function(e) { 211 var f = e.target.closest && e.target.closest('.search-bar-form'); 212 if (!f) return; 213 e.preventDefault(); 214 runSearch(f.closest('.search-surface'), true); 215 }); 216 // Sluiten op de pagina is teruggaan, als je van deze site kwam; anders volgt 217 // de link naar het begin van de site. 216 218 document.addEventListener('click', function(e) { 217 var a = e.target.closest && e.target.closest('#search-suggest a'); 218 if (a && a.getAttribute('target') !== '_blank') { var o = overlay(); if (o) o.hidden = true; } 219 var leave = e.target.closest && e.target.closest('[data-search-leave]'); 220 if (!leave) return; 221 if (document.referrer.indexOf(location.origin + '/') === 0 && history.length > 1) { e.preventDefault(); history.back(); } 222 }); 223 // Een klik op een resultaat in de overlay sluit hem, want de link gaat ergens 224 // heen. Behalve wat in een nieuw tabblad opent: dan blijf je hier. 225 document.addEventListener('click', function(e) { 226 var a = e.target.closest && e.target.closest('#search-overlay .search-box a'); 227 if (a && a.getAttribute('target') !== '_blank') closeSearch(); 219 228 }); 220 229 })(); -
src/middleware/render.js
r86cfa1a rfd8faf5 108 108 }; 109 109 110 const formatDate = (iso) => {110 export const formatDate = (iso) => { 111 111 const d = parseStamp(iso); 112 112 return d ? d.toLocaleDateString('nl-NL', { timeZone: siteTimezone(), day: 'numeric', month: 'long', year: 'numeric' }) : ''; -
src/routes/search.js
r86cfa1a rfd8faf5 21 21 import express from 'express'; 22 22 import db from '../config/database.js'; 23 import { renderPage } from '../middleware/render.js';23 import { renderPage, formatDate } from '../middleware/render.js'; 24 24 import { audioUrl } from '../services/AudioStreamService.js'; 25 25 import { getSetting } from '../services/SettingsService.js'; … … 86 86 87 87 /** 88 * Is dit een HANDLE (@naam@server) en geen zoekterm? Dan de genormaliseerde 89 * handle, anders null. 90 * 91 * Met of zonder @ vooraan, zoals Mastodon het ook aanneemt. De server moet een 92 * echte domeinnaam zijn (met een punt en een extensie): "@robin" is een naam om 93 * naar te zoeken, geen adres. Een spatie maakt er een zoekopdracht van. 94 * 95 * WebFinger zelf is openbaar en gaat niet ondertekend; het profiel erachter 96 * wel, via resolveRemoteActor, en alleen namens een ingelogde beheerder. 97 */ 98 export function lookupHandle(q) { 99 const m = /^@?([a-z0-9_.-]+)@([a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,})$/i.exec(String(q || '').trim()); 100 return m ? `@${m[1]}@${m[2].toLowerCase()}` : null; 101 } 102 103 /** 88 104 * Mag deze bezoeker een adres laten OPHALEN? 89 105 * … … 114 130 * een anonieme GET, en dan lijkt een bestaande post te ontbreken. 115 131 */ 116 async function lookUpRemote(remoteUri, site ) {132 async function lookUpRemote(remoteUri, site, { handle = false } = {}) { 117 133 let remote = null; 118 134 let remoteKind = null; 119 try { remote = await ActivityPubService.resolveRemoteNote(remoteUri, { asSlug: site.slug }); } catch { /* onbereikbaar */ } 135 // Een handle wijst altijd naar een persoon: geen post-poging, die zou alleen 136 // een verzoek kosten dat niets kan opleveren. 137 if (!handle) { 138 try { remote = await ActivityPubService.resolveRemoteNote(remoteUri, { asSlug: site.slug }); } catch { /* onbereikbaar */ } 139 } 120 140 if (remote) remoteKind = 'note'; 121 141 if (!remote) { … … 226 246 } 227 247 228 // ── Full results page ──────────────────────────────────────────────────────── 229 router.get('/', async (req, res) => { 230 const site = res.locals.site; 231 if (!site) return res.status(404).send('No site'); 232 const rawQ = (req.query.q || '').toString().trim(); 233 234 if (!rawQ) { 235 return renderPage(req, res, 'pages/search', { 236 pageTitle: 'Zoeken', bodyClass: 'on-special', query: '', 237 results: [], tracks: [], events: [], pages: [], total: 0, 238 }); 239 } 240 241 // Een adres wordt OPGEHAALD, niet doorzocht (shaer-utpi). Eerst als post, 242 // dan als profiel: een URL die geen van beide oplevert is gewoon een 243 // zoekterm die toevallig op een link lijkt, en dan blijft de rest staan. 244 const remoteUri = lookupUri(rawQ); 248 // ── Wat er bij een zoekopdracht hoort ──────────────────────────────────────── 249 // 250 // EEN verzameling voor beide ingangen: de pagina /search?q=... en het live vak 251 // onder de zoekbalk (/search/results). Ze tonen sinds Robins ontwerp van 30-9 252 // hetzelfde, dus ze horen ook hetzelfde op te halen. 253 // 254 // Een adres wordt OPGEHAALD, niet doorzocht (shaer-utpi). Eerst als post, dan 255 // als profiel; ondertekend, en alleen namens een ingelogde beheerder. 256 async function gather(req, res, rawQ) { 257 const site = res.locals.site; 258 // Een adres (https://...) of een handle (@naam@server): allebei iets dat van 259 // een andere server moet komen, dus allebei achter dezelfde rechtengrens. 260 const uri = lookupUri(rawQ); 261 const handle = uri ? null : lookupHandle(rawQ); 262 const remoteUri = uri || handle; 245 263 const mayLookup = remoteUri ? mayLookUp(req, res) : false; 246 264 const { remote, remoteKind, reacted: remoteReacted } = (remoteUri && mayLookup) 247 ? await lookUpRemote(remoteUri, site )265 ? await lookUpRemote(remoteUri, site, { handle: !!handle }) 248 266 : { remote: null, remoteKind: null, reacted: { liked: false, boosted: false } }; 249 250 267 const r = searchSite(req, res, rawQ, { posts: 50, tracks: 25, events: 25, pages: 8 }); 251 const total = r.results.length + r.tracks.length + r.events.length + r.pages.length; 268 return { 269 query: rawQ, 270 results: r.results, tracks: r.tracks, events: r.events, pages: r.pages, 271 total: r.results.length + r.tracks.length + r.events.length + r.pages.length, 272 queryError: r.queryError, 273 remoteUri, remote, remoteKind, mayLookup, remoteReacted, 274 siteTitle: site.title || '', 275 }; 276 } 277 278 // ── De pagina: het zoekvlak, open, met de resultaten erin ──────────────────── 279 router.get('/', async (req, res) => { 280 const site = res.locals.site; 281 if (!site) return res.status(404).send('No site'); 282 const rawQ = (req.query.q || '').toString().trim().slice(0, 2048); 283 const data = rawQ ? await gather(req, res, rawQ) : { query: '' }; 252 284 renderPage(req, res, 'pages/search', { 253 pageTitle: `Zoeken: ${rawQ}`, bodyClass: 'on-special', query: rawQ, 254 results: r.results, tracks: r.tracks, events: r.events, pages: r.pages, 255 total, queryError: r.queryError, 256 remoteUri, remote, remoteKind, mayLookup, 257 remoteReacted, 258 siteTitle: site.title || '', 285 pageTitle: rawQ ? `Zoeken: ${rawQ}` : 'Zoeken', bodyClass: 'on-special', 286 ...data, 259 287 }); 260 288 }); 261 289 262 // ── De live-preview van een adres (HTML-fragment) ─────────────────────────────290 // ── Het live vak onder de zoekbalk (HTML-fragment) ─────────────────────────── 263 291 // 264 // Plak je een link in de zoekbalk, dan haalt de uitklaplijst de post zelf op 265 // in plaats van te zoeken naar de tekst van de link (shaer-utpi). 292 // Dezelfde render als de pagina (partials/search-results.ejs), zodat het vak 293 // en de pagina er per constructie hetzelfde uitzien. Dit verving twee routes: 294 // /suggest (JSON, dat de browser zelf tot een lijstje bouwde, anders dan de 295 // pagina) en /remote (alleen het adres). 266 296 // 267 // Dezelfde grens als de volle pagina, en hier weegt hij zwaarder: deze route 268 // wordt bij elke toetsaanslag geraakt, dus zonder die grens is hij een 269 // haalservice die iedereen deze server op adressen naar keuze laat afsturen. 270 // Mag je niet ophalen, of is het geen adres, dan 204: de zoekbalk valt dan 271 // terug op de gewone suggesties. Geen 403, want voor een bezoeker is een 272 // geplakte link gewoon een zoekterm en geen geweigerde handeling. 297 // De rechtengrens voor het ophalen zit in gather en is dezelfde als voor de 298 // pagina; hier weegt hij zwaarder, want dit wordt bij elke toetsaanslag 299 // geraakt. Een bezoeker die een link plakt krijgt de gewone resultaten en de 300 // zin waarom er verder niets staat, en er gaat geen verzoek uit. 273 301 const VIEWS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'views'); 274 302 275 router.get('/re mote', async (req, res) => {276 const site = res.locals.site; 277 const r emoteUri = lookupUri(String(req.query.q || '').slice(0, 2048));278 if (!site || !remoteUri || !mayLookUp(req, res)) return res.status(204).end();279 const { remote, remoteKind, reacted } = await lookUpRemote(remoteUri, site);280 // Dezelfde taal als de pagina eromheen, anders staat er een Engelse kaart303 router.get('/results', async (req, res) => { 304 const site = res.locals.site; 305 const rawQ = String(req.query.q || '').trim().slice(0, 2048); 306 if (!site || rawQ.length < 2) return res.status(204).end(); 307 const data = await gather(req, res, rawQ); 308 // Dezelfde taal als de pagina eromheen, anders staat er een Engelse lijst 281 309 // in een Nederlandse zoekbalk. 282 310 const lang = resolveLang(req, { userLang: req.session?.user?.lang, defaultLang: getSetting('default_lang') }); 283 const html = await ejs.renderFile(path.join(VIEWS_DIR, 'partials', 'remote-preview.ejs'), { 311 const html = await ejs.renderFile(path.join(VIEWS_DIR, 'partials', 'search-results.ejs'), { 312 ...data, 284 313 t: (k, vars) => i18nT(lang, k, vars), 285 remote, remoteKind, remoteUri, reacted,286 site Title: site.title || '',314 formatDate, 315 siteUrlBase: res.locals.siteUrlBase || '', 287 316 }, { async: false }); 288 317 // Wat deze bezoeker ziet hangt van zijn rechten en zijn reacties af: niet … … 292 321 }); 293 322 294 // ── Live suggestions (JSON) ──────────────────────────────────────────────────295 router.get('/suggest', (req, res) => {296 const site = res.locals.site;297 if (!site) return res.json({ posts: [], tracks: [], events: [], pages: [] });298 const rawQ = (req.query.q || '').toString().trim().slice(0, 100);299 if (rawQ.length < 2) return res.json({ posts: [], tracks: [], events: [], pages: [] });300 301 const urlFor = (slug) => `/${slug}`;302 const r = searchSite(req, res, rawQ, { posts: 5, tracks: 4, events: 3, pages: 4 });303 res.json({304 posts: r.results.map((p) => ({ title: p.title || '(zonder titel)', url: urlFor(p.slug) })),305 tracks: r.tracks.map((tr) => ({ title: tr.title, artist: tr.artist, url: tr.postUrl })),306 events: r.events.map((e) => ({ when: [e.date, e.time].filter(Boolean).join(' '), where: e.where, url: e.url })),307 pages: r.pages,308 });309 });310 311 323 export default router; -
src/views/pages/search.ejs
r86cfa1a rfd8faf5 1 <% const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; %> 2 <% const _tracks = (typeof tracks !== 'undefined' && tracks) ? tracks : []; %> 3 <% const _events = (typeof events !== 'undefined' && events) ? events : []; %> 4 <% const _pages = (typeof pages !== 'undefined' && pages) ? pages : []; %> 5 <div class="container search-page"> 6 <h1><%= t('nav.search') %></h1> 1 <%# 2 /search?q=... is geen eigen pagina meer met een eigen uiterlijk (Robins 3 ontwerp, 30-9): het is het zoekvlak, open, met de resultaten al op de server 4 gerenderd. Een gedeelde link en Enter zonder JavaScript komen zo op precies 5 hetzelfde beeld uit als de zoekbalk op elke andere pagina. 7 6 8 <form method="get" action="<%= _base %>/search" class="search-page-form"> 9 <input 10 type="search" 11 name="q" 12 value="<%= query %>" 13 placeholder="<%= t('search.placeholder') %>" 14 autocomplete="off" 15 autofocus 16 aria-label="<%= t('nav.search') %>"> 17 <button type="submit" class="btn btn-primary"><%= t('search.button') %></button> 18 </form> 19 20 <% if (queryError) { %> 21 <p class="search-error"><%= t('search.error') %></p> 22 <% } %> 23 24 <% if (query && !queryError) { %> 25 <p class="search-meta"> 26 <%= t(total === 1 ? 'search.results_one' : 'search.results_other', { n: total, q: query }) %> 27 </p> 28 <% } %> 29 30 <%# Een geplakt adres is geen zoekterm maar een aanwijzing (shaer-utpi): het 31 object van de andere server staat bovenaan, met dezelfde knoppen als op 32 /authorize_interaction -- dezelfde partial, dus één render en één plek 33 waar een poort of een sanitizer kan wonen. %> 34 <% if (typeof remoteUri !== 'undefined' && remoteUri) { %> 35 <h2 class="search-section-title"><%= t('search.section_fediverse') %></h2> 36 <div class="auth-interact search-remote"> 37 <% if (!mayLookup) { %> 38 <%# Ophalen is een uitgaand verzoek namens deze server; dat doen we niet 39 op verzoek van een willekeurige bezoeker. %> 40 <p class="auth-interact-note"><%= t('search.fedi_signin') %></p> 41 <% } else if (remote && remoteKind === 'note') { %> 42 <%- include('../partials/remote-object', { target: remote, uri: remoteUri, reacted: remoteReacted, siteTitle: siteTitle, cancelUrl: _base + '/search?q=' + encodeURIComponent(query) }) %> 43 <% } else if (remote && remoteKind === 'actor') { %> 44 <%- include('../partials/remote-actor', { actor: remote, siteTitle: siteTitle }) %> 45 <% } else { %> 46 <p class="auth-interact-note"><%= t('fedi.remote_notfound') %></p> 47 <% } %> 48 </div> 49 <%- include('../partials/fedi-interact-styles') %> 50 <% } %> 51 52 <% if (_tracks.length) { %> 53 <h2 class="search-section-title"><%= t('search.section_tracks') %></h2> 54 <% var _inPost = t('search.in_post'); /* t() vóór de loop ophalen: function(t) overschaduwt 'm */ %> 55 <div class="search-tracks"> 56 <% _tracks.forEach(function(t) { 57 const tj = JSON.stringify({ id: t.id, url: t.url, title: t.title, artist: t.artist || '', cover: t.cover || '' }) 58 .replace(/&/g, '&').replace(/'/g, ''').replace(/</g, '<'); %> 59 <div class="post-audio-track search-track" id="track-<%= t.id %>" 60 data-pcms-track-id="<%= t.id %>" data-pcms-track-url="<%= t.url %>" 61 data-pcms-track='<%- tj %>'> 62 <button type="button" class="pat-play" aria-label="Speel <%= t.title %>"> 63 <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 4l12 8-12 8z"/></svg> 64 </button> 65 <% if (t.cover) { %> 66 <span class="search-track-cover" style="background-image:url('<%= t.cover %>')" aria-hidden="true"></span> 67 <% } %> 68 <div class="pat-info"> 69 <div class="pat-title"><%= t.title %></div> 70 <% if (t.artist || t.album) { %> 71 <div class="pat-artist"> 72 <%= t.artist %><% if (t.artist && t.album) { %> · <% } %><%= t.album %> 73 </div> 74 <% } %> 75 </div> 76 <% if (t.postUrl) { %> 77 <a class="search-track-link" 78 href="<%= t.postUrl %>" 79 hx-get="<%= t.postUrl %>?partial=1" 80 hx-target="#pcms-main" 81 hx-swap="innerHTML" 82 hx-push-url="<%= t.postUrl %>" 83 aria-label="<%= _inPost %>"><%= _inPost %></a> 84 <% } %> 85 </div> 86 <% }); %> 87 </div> 88 <% } %> 89 90 <% if (results && results.length) { %> 91 <% if (_tracks.length) { %><h2 class="search-section-title"><%= t('search.section_posts') %></h2><% } %> 92 <ol class="search-results"> 93 <% results.forEach(function(r) { %> 94 <li class="search-result"> 95 <h3 class="search-result-title"> 96 <a href="<%= _base %>/<%= r.slug %>" 97 hx-get="<%= _base %>/<%= r.slug %>?partial=1" 98 hx-target="#pcms-main" 99 hx-swap="innerHTML" 100 hx-push-url="<%= _base %>/<%= r.slug %>" 101 hx-indicator="#pcms-loading"> 102 <%= r.title || '(zonder titel)' %> 103 </a> 104 </h3> 105 <p class="search-result-meta"> 106 <%= r.author_username %> 107 <% if (r.published_at) { %> · <%= formatDate(r.published_at) %><% } %> 108 </p> 109 <p class="search-result-snippet"><%- r.snippet %></p> 110 </li> 111 <% }); %> 112 </ol> 113 <% } %> 114 115 <% if (_events.length) { %> 116 <h2 class="search-section-title"><%= t('search.section_events') %></h2> 117 <ol class="search-results"> 118 <% _events.forEach(function(ev) { %> 119 <li class="search-result"> 120 <h3 class="search-result-title"><a href="<%= ev.url %>"><%= ev.where || '—' %></a></h3> 121 <p class="search-result-meta"><%= [ev.date, ev.time].filter(Boolean).join(' ') %></p> 122 </li> 123 <% }); %> 124 </ol> 125 <% } %> 126 127 <% if (_pages.length) { %> 128 <h2 class="search-section-title"><%= t('search.section_pages') %></h2> 129 <ul class="search-pages"> 130 <% _pages.forEach(function(pg) { %> 131 <li><a class="search-page-link" href="<%= pg.url %>"><%= pg.label %> <span aria-hidden="true">→</span></a></li> 132 <% }); %> 133 </ul> 134 <% } %> 135 136 <% if (query && !queryError && !results.length && !_tracks.length && !_events.length && !_pages.length) { %> 137 <p class="search-empty"><%= t('search.empty') %></p> 138 <% } %> 139 </div> 140 141 <style> 142 .search-page { max-width: 720px; margin: 3rem auto; padding: 0 1rem; } 143 .search-page h1 { font-family: var(--font-display, serif); font-size: 2rem; margin: 0 0 1.5rem; } 144 145 .search-page-form { 146 display: flex; 147 gap: 0.5rem; 148 margin-bottom: 1.5rem; 149 } 150 .search-page-form input { 151 flex: 1; 152 padding: 0.6rem 0.9rem; 153 border: 1px solid var(--rule); 154 border-radius: 5px; 155 background: var(--paper-2); 156 color: var(--ink); 157 font-size: 1rem; 158 } 159 .search-page-form input:focus { 160 outline: none; 161 border-color: var(--accent); 162 } 163 164 .search-meta { 165 color: var(--ink-muted, var(--ink-soft)); 166 font-size: 0.9rem; 167 margin: 0 0 1rem; 168 } 169 .search-section-title { 170 font-family: var(--font-display, serif); 171 font-size: 1.1rem; 172 margin: 1.75rem 0 0.75rem; 173 color: var(--ink-soft); 174 text-transform: uppercase; 175 letter-spacing: 0.05em; 176 } 177 .search-error { 178 background: rgba(200, 60, 60, 0.15); 179 color: #c33; 180 padding: 0.75rem 1rem; 181 border-radius: 6px; 182 border: 1px solid rgba(200, 60, 60, 0.3); 183 } 184 .search-empty { 185 color: var(--ink-muted, var(--ink-soft)); 186 text-align: center; 187 margin: 3rem 0; 188 } 189 190 /* Nummer-resultaten */ 191 .search-tracks { display: flex; flex-direction: column; gap: 0.25rem; } 192 .search-track.post-audio-track { 193 display: flex; 194 align-items: center; 195 gap: 0.75rem; 196 padding: 0.5rem 0.6rem; 197 border-radius: 8px; 198 } 199 .search-track .search-track-cover { 200 width: 38px; height: 38px; 201 flex: 0 0 38px; 202 border-radius: 5px; 203 background-size: cover; 204 background-position: center; 205 background-color: var(--paper-2); 206 } 207 .search-track .pat-info { flex: 1; min-width: 0; } 208 .search-track .pat-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 209 .search-track .pat-artist { font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 210 .search-track-link { 211 flex: 0 0 auto; 212 font-size: 0.82rem; 213 color: var(--accent); 214 text-decoration: none; 215 white-space: nowrap; 216 } 217 .search-track-link:hover { text-decoration: underline; } 218 219 .search-results { 220 list-style: none; 221 margin: 0; 222 padding: 0; 223 } 224 .search-result { 225 padding: 1rem 0; 226 border-bottom: 1px solid var(--rule); 227 } 228 .search-result:last-child { border-bottom: 0; } 229 230 .search-result-title { 231 font-family: var(--font-display, serif); 232 font-size: 1.25rem; 233 margin: 0 0 0.25rem; 234 } 235 .search-result-title a { 236 color: var(--ink); 237 text-decoration: none; 238 } 239 .search-result-title a:hover { color: var(--accent); } 240 241 .search-result-meta { 242 margin: 0 0 0.5rem; 243 color: var(--ink-muted, var(--ink-soft)); 244 font-size: 0.85rem; 245 } 246 247 .search-result-snippet { 248 margin: 0; 249 color: var(--ink-soft); 250 line-height: 1.5; 251 } 252 .search-result-snippet mark { 253 background: var(--accent); 254 color: white; 255 padding: 0.05em 0.25em; 256 border-radius: 3px; 257 } 258 .search-pages { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; } 259 .search-page-link { 260 display: inline-flex; align-items: center; gap: 0.3rem; 261 padding: 0.45rem 0.8rem; border: 1px solid var(--rule); border-radius: 999px; 262 color: var(--ink); text-decoration: none; font-size: 0.9rem; background: var(--paper-2); 263 } 264 .search-page-link:hover { border-color: var(--accent); color: var(--accent); } 265 </style> 7 De opmaak staat in partials/search-styles.ejs, die de topnav al meebrengt. 8 %> 9 <%- include('../partials/search-surface', Object.assign({}, locals, { mode: 'page' })) %> -
src/views/partials/fedi-interact-styles.ejs
r86cfa1a rfd8faf5 159 159 border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); background: var(--paper, #fff); color: var(--ink, #000); } 160 160 161 /* De live-preview: open-link onder de kaart, en de kaart zonder rand-marge. */161 /* De compacte kaart in het zoekvak: de link naar de volle pagina eronder. */ 162 162 .rp-open { display: block; padding: 10px 4px 2px; text-align: right; font-weight: 600; font-size: .88rem; 163 163 color: var(--accent, #06c); text-decoration: none; } 164 164 .rp-open:hover { text-decoration: underline; } 165 .ss-remote { padding: 4px 0 6px; }166 .ss-remote-note { padding: .8rem .6rem; color: var(--ink-soft, var(--ink-muted)); font-size: .9rem; }167 .ss-remote-loading { display: flex; align-items: center; gap: 10px; padding: .9rem .6rem; color: var(--ink-soft, var(--ink-muted)); font-size: .9rem; }168 .ss-remote-loading::before { content: ''; width: 16px; height: 16px; border-radius: 50%; flex: 0 0 16px;169 border: 2px solid color-mix(in srgb, var(--accent, #06c) 30%, transparent); border-top-color: var(--accent, #06c);170 animation: ss-spin .8s linear infinite; }171 @keyframes ss-spin { to { transform: rotate(360deg); } }172 @media (prefers-reduced-motion: reduce) { .ss-remote-loading::before { animation: none; } }173 165 174 166 /* Smal scherm: alleen de tekens, gelijk verdeeld over de breedte. De woorden -
src/views/partials/topnav.ejs
r86cfa1a rfd8faf5 175 175 </header> 176 176 177 <!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) --> 178 <%# De teksten staan op het element dat ze gebruikt, niet in het script: een 179 module is een statisch bestand en kan geen vertaling interpoleren. %> 180 <div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-remote="<%= _siteUrlBase %>/search/remote" data-action="<%= _siteUrlBase %>/search" 181 data-i18n="<%= JSON.stringify({ posts: t('search.section_posts'), tracks: t('search.section_tracks'), events: t('search.section_events'), pages: t('search.section_pages'), all: t('search.suggest_all'), none: t('search.suggest_empty'), looking: t('search.fedi_looking') }) %>"> 182 <div class="container"> 183 <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search"> 184 <input type="search" name="q" placeholder="<%= t('search.placeholder') %>" autocomplete="off"> 185 <button type="submit" class="nav-btn" aria-label="<%= t('search.button') %>"> 186 <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> 187 </button> 188 <button type="button" class="nav-btn" id="search-close" aria-label="<%= t('imed.cancel') %>"> 189 <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> 190 </button> 191 </form> 192 <div id="search-suggest" class="search-suggest" data-lenis-prevent></div> 193 </div> 194 </div> 177 <!-- Het zoeken (gedeeld door de tabbalk op mobiel en de topnav op desktop) --> 178 <%# Balk + vak, dezelfde partial als de pagina /search: zie search-surface.ejs. %> 179 <%- include('./search-surface', { mode: 'overlay', t: t, siteUrlBase: _siteUrlBase }) %> 180 <%- include('./search-styles') %> 195 181 196 182 <%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %> … … 441 427 } 442 428 443 /* ── Search overlay ── */ 444 .search-overlay { 445 position: fixed; top: 0; left: 0; right: 0; 446 background: color-mix(in srgb, var(--paper) 96%, transparent); 447 backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); 448 border-bottom: 1px solid var(--rule); 449 padding: 1rem 0; z-index: 200; 450 padding-top: calc(1rem + env(safe-area-inset-top, 0)); 451 box-shadow: 0 8px 24px color-mix(in srgb, var(--ink) 8%, transparent); 452 } 453 .search-overlay-form { display: flex; gap: .5rem; align-items: center; } 454 .search-overlay-form input { 455 flex: 1; padding: .85rem 1rem; 456 min-height: 44px; 457 border: 1px solid var(--rule); border-radius: 8px; 458 font-family: var(--font-ui); font-size: 1rem; 459 background: var(--paper); color: var(--ink); 460 } 461 .search-overlay-form input:focus { 462 outline: 2px solid var(--accent); outline-offset: 0; 463 } 464 .search-overlay-form .nav-btn { 465 width: 44px; height: 44px; 466 display: inline-flex; align-items: center; justify-content: center; 467 } 468 /* Live-resultaten dropdown */ 469 .search-suggest { margin-top: .6rem; max-height: 60vh; max-height: 70dvh; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; display: flex; flex-direction: column; gap: .25rem; } 470 .search-suggest:empty { display: none; } 471 .ss-group { padding: .25rem 0; } 472 .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; } 473 .ss-item { display: block; padding: .55rem .6rem; border-radius: 8px; color: var(--ink); text-decoration: none; font-size: .95rem; } 474 .ss-item:hover { background: var(--paper-2); color: var(--accent); } 475 .ss-item.ss-noclick { opacity: .7; cursor: default; } 476 .ss-sub { color: var(--ink-soft, var(--ink-muted)); font-size: .85em; } 477 .ss-empty { padding: .8rem .6rem; color: var(--ink-soft, var(--ink-muted)); font-size: .9rem; } 478 .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; } 479 .ss-all:hover { text-decoration: underline; } 429 /* Het zoeken heeft zijn opmaak in partials/search-styles.ejs. */ 480 430 /* Notification bell + unread badge */ 481 431 .nav-notif { position: relative; } -
src/views/shell.ejs
r86cfa1a rfd8faf5 130 130 // ontwikkeling een jaar gecachet, dus zonder bump bereikt een reparatie 131 131 // iedereen behalve wie de fout al heeft. 132 const MOD_V = 6 5;132 const MOD_V = 66; 133 133 %><!DOCTYPE html> 134 134 <html lang="<%- _e(lang) %>" data-palette="<%- _e((typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt')) %>"> -
test/search-fediverse.test.js
r86cfa1a rfd8faf5 26 26 const searchMod = await import('../src/routes/search.js'); 27 27 const router = searchMod.default; 28 const { lookupUri } = searchMod;28 const { lookupUri, lookupHandle } = searchMod; 29 29 30 30 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)') … … 55 55 gevraagd.push(['actor', uri]); 56 56 actorOpties.push(opts || {}); 57 if (!uri.includes('/u/')) return null; 57 const actorUri = uri === '@oma@elders.test' ? 'https://elders.test/u/oma' : uri; 58 if (!actorUri.includes('/u/')) return null; 58 59 return { 59 actor_uri: uri, actor_name: 'Oma', actor_handle: '@oma@elders.test',60 actor_url: uri, actor_icon: 'https://elders.test/avatar.png', inbox: uri + '/inbox',60 actor_uri: actorUri, actor_name: 'Oma', actor_handle: '@oma@elders.test', 61 actor_url: actorUri, actor_icon: 'https://elders.test/avatar.png', inbox: actorUri + '/inbox', 61 62 }; 62 63 }; … … 105 106 assert.equal(status, 200); 106 107 assert.deepEqual(gevraagd, [], 'een zoekterm hoort geen netwerkverzoek te worden'); 107 assert.ok(!html.includes('s earch-remote'), 'en er hoort geen fediverse-blok te staan');108 assert.ok(!html.includes('sr-fediverse'), 'en er hoort geen fediverse-blok te staan'); 108 109 }); 109 110 … … 113 114 assert.ok(html.includes('Een bericht van ver weg'), 'de inhoud hoort er te staan'); 114 115 assert.ok(html.includes('@oma@elders.test'), 'en van wie het is'); 115 // De functies van authorize_interaction, op de zoekpagina. 116 for (const actie of ['/authorize_interaction/like', '/authorize_interaction/boost', '/authorize_interaction/report']) { 116 // Waarderen en boosten kan ter plekke; antwoorden en melden op de volle 117 // interactiepagina. De zoekpagina ziet er sinds 30-9 precies zo uit als het 118 // live vak, dus ook hier de compacte kaart. 119 for (const actie of ['/authorize_interaction/like', '/authorize_interaction/boost']) { 117 120 assert.ok(html.includes(`action="${actie}"`), `${actie} ontbreekt`); 118 121 } 119 assert.ok(html.includes('action="/authorize_interaction"'), 'het antwoordvenster ontbreekt'); 122 assert.ok(html.includes('/authorize_interaction?uri=https%3A%2F%2Felders.test%2Fnotes%2F1&reply=1'), 123 'antwoorden wijst naar de volle pagina, met het venster open'); 120 124 }); 121 125 … … 131 135 assert.equal(status, 200); 132 136 assert.equal(gevraagd.length, 2, 'beide vormen zijn geprobeerd'); 133 assert.ok( html.includes('search-remote'), 'het blok staat er, met de melding erin');134 assert.ok(html.includes('search- page-form'), 'en de zoekpaginazelf is niet weggevallen');137 assert.ok(/sr-fediverse[\s\S]*class="sr-note"/.test(html), 'het blok staat er, met de melding erin'); 138 assert.ok(html.includes('search-bar-form'), 'en de zoekbalk zelf is niet weggevallen'); 135 139 }); 136 140 … … 203 207 const preview = async (q) => { 204 208 gevraagd = []; opties = []; 205 const r = await fetch(`http://127.0.0.1:${poort}/search/re mote?q=${encodeURIComponent(q)}`, { signal: AbortSignal.timeout(10000) });209 const r = await fetch(`http://127.0.0.1:${poort}/search/results?q=${encodeURIComponent(q)}`, { signal: AbortSignal.timeout(10000) }); 206 210 return { status: r.status, html: await r.text(), cache: r.headers.get('cache-control') }; 207 211 }; … … 240 244 try { 241 245 const { status, html } = await preview('https://elders.test/notes/1'); 242 // 204 en geen 403: voor een bezoeker is een geplakte link gewoon een243 // zoekterm, en de zoekbalk valt dan terug op de gewone suggesties.244 assert.equal(status, 20 4);246 // Voor een bezoeker is een geplakte link gewoon een zoekterm: hij krijgt 247 // de gewone resultaten en de zin waarom er verder niets staat. 248 assert.equal(status, 200); 245 249 assert.deepEqual(gevraagd, [], 'zonder rechten hoort er geen enkel verzoek uit te gaan'); 246 assert. equal(html, '');250 assert.ok(!html.includes('/authorize_interaction/like'), 'en er staan geen knoppen'); 247 251 } finally { 248 252 ingelogd = true; … … 252 256 test('een zoekterm in de preview haalt niets op', async () => { 253 257 const { status } = await preview('soundfabrics.nl'); 254 assert.equal(status, 20 4);255 assert.deepEqual(gevraagd, [] );258 assert.equal(status, 200, 'gewoon zoeken'); 259 assert.deepEqual(gevraagd, [], 'maar niets ophalen'); 256 260 }); 257 261 … … 323 327 }); 324 328 329 // ── Eén zoekvlak: de pagina en het live vak zijn hetzelfde ───────────────── 330 // 331 // Robins ontwerp van 30-9: de zoekbalk en het vak eronder zijn het hele 332 // zoeken, en /search?q=... ziet er hetzelfde uit. Dat is alleen waar zolang 333 // beide dezelfde render gebruiken; deze toets vangt het moment dat ze uit 334 // elkaar gaan lopen. 335 336 test('de pagina en het live vak tonen dezelfde resultaten, teken voor teken', async () => { 337 const pagina = (await zoek('https://elders.test/notes/1')).html; 338 const vak = (await preview('https://elders.test/notes/1')).html.trim(); 339 assert.ok(vak.startsWith('<div class="sr">'), 'het vak levert de resultaten-render'); 340 assert.ok(pagina.includes(vak), 'de pagina bevat exact dezelfde render als het live vak'); 341 }); 342 343 test('een lege vraag in het live vak levert niets en haalt niets op', async () => { 344 const { status } = await preview('x'); 345 assert.equal(status, 204); 346 assert.deepEqual(gevraagd, []); 347 }); 348 349 // ── WebFinger: @naam@server in de zoekbalk ─────────────────────────────────── 350 351 test('een handle wordt herkend, een naam of zin niet', () => { 352 assert.equal(lookupHandle('@oma@elders.test'), '@oma@elders.test'); 353 assert.equal(lookupHandle('oma@Elders.Test'), '@oma@elders.test', 'zonder @ vooraan, zoals Mastodon'); 354 assert.equal(lookupHandle(' @oma.v_d-berg@sub.elders.test '), '@oma.v_d-berg@sub.elders.test'); 355 assert.equal(lookupHandle('@oma'), null, 'een naam om naar te zoeken, geen adres'); 356 assert.equal(lookupHandle('@oma@localhost'), null, 'een server zonder domeinnaam'); 357 assert.equal(lookupHandle('oma @elders.test'), null, 'een spatie maakt er een zoekopdracht van'); 358 assert.equal(lookupHandle('https://elders.test/@oma'), null, 'een adres is een adres, geen handle'); 359 }); 360 361 test('een handle haalt het profiel op, ondertekend, zonder eerst een post te proberen', async () => { 362 actorOpties = []; 363 const { html } = await preview('@oma@elders.test'); 364 assert.deepEqual(gevraagd, [['actor', '@oma@elders.test']], 'alleen het profiel: een handle is nooit een post'); 365 assert.equal(actorOpties[0] && actorOpties[0].asSlug, 'robo', 'het profiel achter de handle gaat ondertekend'); 366 assert.ok(html.includes('action="/authorize_interaction/follow"'), 'met de volgknop'); 367 assert.ok(html.includes('https://elders.test/u/oma'), 'voor het profiel dat WebFinger opleverde'); 368 }); 369 370 test('een handle op de zoekpagina geeft hetzelfde profiel', async () => { 371 const { html } = await zoek('oma@elders.test'); 372 assert.ok(html.includes('action="/authorize_interaction/follow"')); 373 }); 374 375 test('niet ingelogd levert een handle geen enkel verzoek op', async () => { 376 ingelogd = false; 377 try { 378 const { status, html } = await preview('@oma@elders.test'); 379 assert.equal(status, 200); 380 assert.deepEqual(gevraagd, [], 'geen WebFinger en geen profiel'); 381 assert.ok(!html.includes('/authorize_interaction/follow')); 382 } finally { 383 ingelogd = true; 384 } 385 }); 386
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)