Changeset fd8faf5 in Klonkt for src


Ignore:
Timestamp:
09/30/2026 07:27:05 AM (4 hours ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
19e28ce
Parents:
86cfa1a
Message:

Zoeken is één vlak: de balk bovenaan, de resultaten eronder, en @naam@server

Robins ontwerp van 30-9: de aparte resultatenpagina is niet meer nodig als het
vak onder de zoekbalk blijft staan. De balk en het vak eronder zijn samen het
hele zoeken, en het vak krijgt alle ruimte die er onder de balk is.

EEN VORM, TWEE INGANGEN. partials/search-surface.ejs is de balk plus het vak;
de overlay op elke pagina gebruikt hem, en /search?q=... ook. De resultaten
zelf staan in partials/search-results.ejs, en het live vak haalt precies die
render op (/search/results). De pagina bevat dus teken voor teken wat het vak
toont; een toets bewaakt dat, want twee renders lopen gegarandeerd uit de pas.
/search blijft bestaan zodat een gedeelde link en Enter zonder JavaScript op
hetzelfde beeld uitkomen.

/search/suggest (JSON, dat de browser tot een eigen lijstje bouwde, anders dan
de pagina) en /search/remote (alleen het adres) zijn weg; niets anders
gebruikte ze. remote-preview.ejs ging mee.

HET VLAK. Vaste positie over het hele scherm, de balk bovenaan, het vak vult
de rest en scrolt zelf. Op z-index 1060: de vorige overlay lag op 200, onder de
tabbalk (1050), zodat op een telefoon het onderste stuk resultaten wegviel. Enter
blijft staan en zoekt meteen, want het vak ís de resultaten. Op de pagina beweegt
het adres mee (/search?q=...), zodat wat je ziet te delen blijft; sluiten is daar
teruggaan. Balk en resultaten staan op één lijn: een scrollbalk alleen rechts
schoof de kolom anders zo'n 9 pixels naar links.

WEBFINGER. @naam@server (met of zonder @ vooraan, zoals Mastodon) haalt het
profiel op, met de volgknop. Zonder eerst een post te proberen: een handle is
nooit een post. De server moet een echte domeinnaam zijn; "@robin" blijft een
naam om naar te zoeken. WebFinger zelf is openbaar en loopt via safeFetch;
het profiel erachter gaat ondertekend, en alleen namens een ingelogde
beheerder, dezelfde grens als een geplakte link.

Onderweg gevonden: search-results.ejs verwees kaal naar queryError, en een
aanroeper die hem vergat liet de hele render omvallen. Alles gaat nu via
locals. In de app gaf gather hem altijd mee, dus live ging het goed; het was
één vergeten veld van een kapotte zoekpagina.

Nagelopen in de browser op 375 en 1280 pixels: de balk bovenaan, het vak tot
de onderrand, tabbalk bedekt, geen horizontale overloop, tikdoelen 44, balk en
resultaten op één lijn. Het gedrag met de echte chrome.js: openen vers en met
focus, typen, Enter zonder weg te gaan, de wachtregel bij een link en een
handle, een traag antwoord dat niets overschrijft, een resultaat aantikken en
Escape sluiten, en het adres dat op de pagina meebeweegt.

Getoetst: pagina en vak teken voor teken gelijk, de handle-herkenning, een
handle ondertekend en zonder post-poging, en niet ingelogd geen enkel verzoek.
Tegenproef: pagina laten afwijken, handle niet herkennen, bij een handle toch
een post proberen en de inlogcontrole eruit laten elk hun toetsen omvallen.
Volledige suite 1292 groen.

shaer-utpi

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

Location:
src
Files:
3 added
1 deleted
7 edited

Legend:

Unmodified
Added
Removed
  • src/assets/js/mod/chrome.js

    r86cfa1a rfd8faf5  
    119119    try { localStorage.setItem('pcms-theme', next); } catch (e) {}
    120120  }
     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.
    121125  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  }
    124143
    125144  document.body.addEventListener('click', function(e) {
    … …  
    137156  });
    138157
    139   // ── Live results while typing ───────────────────────────────────
    140   // De teksten komen van de overlay zelf, elke keer opnieuw: bij een
    141   // 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   }
    146158  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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  }
    186204  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.
    216218  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();
    219228  });
    220229})();
  • src/middleware/render.js

    r86cfa1a rfd8faf5  
    108108};
    109109
    110 const formatDate = (iso) => {
     110export const formatDate = (iso) => {
    111111  const d = parseStamp(iso);
    112112  return d ? d.toLocaleDateString('nl-NL', { timeZone: siteTimezone(), day: 'numeric', month: 'long', year: 'numeric' }) : '';
  • src/routes/search.js

    r86cfa1a rfd8faf5  
    2121import express from 'express';
    2222import db from '../config/database.js';
    23 import { renderPage } from '../middleware/render.js';
     23import { renderPage, formatDate } from '../middleware/render.js';
    2424import { audioUrl } from '../services/AudioStreamService.js';
    2525import { getSetting } from '../services/SettingsService.js';
    … …  
    8686
    8787/**
     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 */
     98export 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/**
    88104 * Mag deze bezoeker een adres laten OPHALEN?
    89105 *
    … …  
    114130 * een anonieme GET, en dan lijkt een bestaande post te ontbreken.
    115131 */
    116 async function lookUpRemote(remoteUri, site) {
     132async function lookUpRemote(remoteUri, site, { handle = false } = {}) {
    117133  let remote = null;
    118134  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  }
    120140  if (remote) remoteKind = 'note';
    121141  if (!remote) {
    … …  
    226246}
    227247
    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.
     256async 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;
    245263  const mayLookup = remoteUri ? mayLookUp(req, res) : false;
    246264  const { remote, remoteKind, reacted: remoteReacted } = (remoteUri && mayLookup)
    247     ? await lookUpRemote(remoteUri, site)
     265    ? await lookUpRemote(remoteUri, site, { handle: !!handle })
    248266    : { remote: null, remoteKind: null, reacted: { liked: false, boosted: false } };
    249 
    250267  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 ────────────────────
     279router.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: '' };
    252284  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,
    259287  });
    260288});
    261289
    262 // ── De live-preview van een adres (HTML-fragment) ─────────────────────────────
     290// ── Het live vak onder de zoekbalk (HTML-fragment) ───────────────────────────
    263291//
    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).
    266296//
    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.
    273301const VIEWS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'views');
    274302
    275 router.get('/remote', async (req, res) => {
    276   const site = res.locals.site;
    277   const remoteUri = 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 kaart
     303router.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
    281309  // in een Nederlandse zoekbalk.
    282310  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,
    284313    t: (k, vars) => i18nT(lang, k, vars),
    285     remote, remoteKind, remoteUri, reacted,
    286     siteTitle: site.title || '',
     314    formatDate,
     315    siteUrlBase: res.locals.siteUrlBase || '',
    287316  }, { async: false });
    288317  // Wat deze bezoeker ziet hangt van zijn rechten en zijn reacties af: niet
    … …  
    292321});
    293322
    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 
    311323export 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.
    76
    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, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;'); %>
    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) { %> &middot; <% } %><%= 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) { %> &middot; <%= 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  
    159159    border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); background: var(--paper, #fff); color: var(--ink, #000); }
    160160
    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. */
    162162  .rp-open { display: block; padding: 10px 4px 2px; text-align: right; font-weight: 600; font-size: .88rem;
    163163    color: var(--accent, #06c); text-decoration: none; }
    164164  .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; } }
    173165
    174166  /* Smal scherm: alleen de tekens, gelijk verdeeld over de breedte. De woorden
  • src/views/partials/topnav.ejs

    r86cfa1a rfd8faf5  
    175175</header>
    176176
    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') %>
    195181
    196182<%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %>
    … …  
    441427}
    442428
    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. */
    480430/* Notification bell + unread badge */
    481431.nav-notif { position: relative; }
  • src/views/shell.ejs

    r86cfa1a rfd8faf5  
    130130// ontwikkeling een jaar gecachet, dus zonder bump bereikt een reparatie
    131131// iedereen behalve wie de fout al heeft.
    132 const MOD_V = 65;
     132const MOD_V = 66;
    133133%><!DOCTYPE html>
    134134<html lang="<%- _e(lang) %>" data-palette="<%- _e((typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt')) %>">
Note: See TracChangeset for help on using the changeset viewer.