Changeset fd8faf5 in Klonkt for src/routes/search.js


Ignore:
Timestamp:
09/30/2026 07:27:05 AM (2 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@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • 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;
Note: See TracChangeset for help on using the changeset viewer.