Changeset fd8faf5 in Klonkt for src/views/pages/search.ejs


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/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' })) %>
Note: See TracChangeset for help on using the changeset viewer.