Changeset fd8faf5 in Klonkt for src/assets/js


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@…>

File:
1 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})();
Note: See TracChangeset for help on using the changeset viewer.