source: Klonkt/src/assets/js/mod/chrome.js@ 19e28ce

main
Last change on this file since 19e28ce was fd8faf5, checked in by Robin <roboburr@…>, 6 hours ago

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

  • Property mode set to 100644
File size: 18.3 KB
Line 
1// De chrome: alles wat om elke pagina heen staat (shaer-bqr).
2//
3// Dit stond als inline <script> in de partials. Dat kan niet blijven: de
4// CSP-nonce rouleert per verzoek, en de chrome wordt bij ELKE htmx-navigatie
5// out-of-band opnieuw ingevoegd. Zo'n script draagt dan een nonce die het
6// document niet kent en wordt geweigerd -- dus viel de chrome-JS bij de eerste
7// klik binnen de site al weg (shaer-0i6).
8//
9// Nu een module, geladen door de bootstrap in shell.ejs. Die zit in het
10// document zelf, heeft dus wel de goede nonce, en een dynamische import vanuit
11// een vertrouwd script is precies waar 'strict-dynamic' voor is.
12//
13// TWEE REGELS voor alles wat hier bij komt:
14//
15// GEDELEGEERD luister op document, nooit op een element dat er nu staat. De
16// chrome wordt vervangen, dus een vastgehouden verwijzing is na
17// een navigatie een verwijzing naar iets dat weg is.
18// IDEMPOTENT de module kan een tweede keer geladen worden. Een slot op
19// window voorkomt dat er een tweede stel luisteraars bij komt --
20// dat is hoe de themaknop ooit twee keer vuurde en dus niets deed.
21//
22// En: GEEN servergegevens in deze code. Interpolatie hoort niet in een statisch
23// bestand; wat de server wil meegeven komt via een data-attribuut op een element.
24
25(function () {
26 'use strict';
27 if (window.__chromeMod) return;
28 window.__chromeMod = true;
29
30 // De zoekknop in de bottom-tab opent de overlay. De overlay zelf wordt bij een
31 // htmx-navigatie vervangen, dus hem hier vasthouden zou na een klik binnen de
32 // site niets meer opleveren: elke keer opnieuw opzoeken.
33 document.addEventListener('click', function (e) {
34 if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return;
35 var o = document.getElementById('search-overlay');
36 if (o) {
37 o.hidden = false;
38 var i = o.querySelector('input');
39 if (i) i.focus();
40 }
41 });
42})();
43
44// ── de profielkop ──────────────────────────────────────────────
45// Zat inline in partials/profile-header.ejs, en die partial zit IN de
46// OOB-chrome -- dus hij kwam bij elke navigatie opnieuw binnen.
47(function () {
48 if (window.__pcmsFediFollowWired) return;
49 window.__pcmsFediFollowWired = true;
50 function modal() { return document.querySelector('.pf-follow'); }
51 function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
52 function go() {
53 var m = modal(); if (!m) return;
54 var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : '';
55 // Strip a full @user@host handle (and any scheme/path) down to the server host.
56 var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
57 if (!server) { if (inp) inp.focus(); return; }
58 try { localStorage.setItem('pcmsFediServer', server); } catch (e) {}
59 var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || '');
60 location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor);
61 }
62 document.addEventListener('click', function (e) {
63 var b = e.target.closest && e.target.closest('.profile-fedi-link');
64 if (b) { e.preventDefault();
65 var m = modal(); if (!m) return;
66 m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || '');
67 var inp = m.querySelector('.pf-follow-input');
68 try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {}
69 m.classList.add('is-open');
70 setTimeout(function () { if (inp) inp.focus(); }, 30);
71 return;
72 }
73 if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; }
74 if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; }
75 var open = document.querySelector('.pf-follow.is-open');
76 if (open && e.target === open) closeM(); /* click on backdrop */
77 });
78 document.addEventListener('keydown', function (e) {
79 var m = document.querySelector('.pf-follow.is-open'); if (!m) return;
80 if (e.key === 'Escape') { closeM(); }
81 else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); }
82 });
83})();
84
85/* Profile summary: click the avatar to open a modal with photo + details. */
86(function () {
87 if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true;
88 function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); }
89 document.addEventListener('click', function (e) {
90 if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; }
91 var open = document.querySelector('.pf-summary.is-open');
92 if (open && e.target === open) { close(); return; } /* click on backdrop */
93 var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]');
94 if (!t) return;
95 e.preventDefault();
96 var root = (t.closest && t.closest('#pcms-chrome')) || document;
97 var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary');
98 if (modal) modal.classList.add('is-open');
99 });
100 document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
101})();
102
103// ── de topnav ──────────────────────────────────────────────────
104// Zat inline in partials/topnav.ejs. De zoekteksten komen nu van het
105// overlay-element (data-i18n) in plaats van uit interpolatie.
106(function() {
107 // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation
108 // → without this guard the script would stack EXTRA listeners on every navigation,
109 // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working").
110 // Everything below uses event delegation on body/document, so it also works for
111 // buttons that appear after this run (OOB).
112 if (window.__pcmsChromeWired) return;
113 window.__pcmsChromeWired = true;
114
115 function toggleTheme() {
116 var cur = document.documentElement.getAttribute('data-theme') || 'dark';
117 var next = cur === 'dark' ? 'light' : 'dark';
118 document.documentElement.setAttribute('data-theme', next);
119 try { localStorage.setItem('pcms-theme', next); } catch (e) {}
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.
125 function overlay() { return document.getElementById('search-overlay'); }
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 }
143
144 document.body.addEventListener('click', function(e) {
145 if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; }
146 if (e.target.closest('#search-toggle')) { openSearch(); return; }
147 if (e.target.closest('#search-close')) { closeSearch(); return; }
148 // Close open dropdowns (user menu + language picker) on click outside.
149 document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) {
150 if (!d.contains(e.target)) d.removeAttribute('open');
151 });
152 });
153
154 document.addEventListener('keydown', function(e) {
155 if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); }
156 });
157
158 function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
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 }
204 document.addEventListener('input', function(e) {
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.
218 document.addEventListener('click', function(e) {
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();
228 });
229})();
230
231// ── De kaart van een post van een andere server ─────────────────────────
232// Staat hier en niet in authorize-interaction.js, want de kaart staat nu op
233// drie plekken: de live-preview (elke pagina, via de zoekbalk), de zoekpagina
234// en /authorize_interaction. Alles luistert op document, dus een kaart die
235// later binnenkomt (de preview, een htmx-wissel) doet vanzelf mee.
236(function() {
237 if (window.__remoteCardWired) return; window.__remoteCardWired = true;
238
239 // Waarderen en boosten ter plekke: POST via fetch, de knop omzetten, blijven
240 // waar je bent. Zonder JS doet het formulier hetzelfde met een omweg.
241 document.addEventListener('submit', function(e) {
242 var f = e.target.closest && e.target.closest('.fedi-react-form');
243 if (!f) return;
244 e.preventDefault();
245 var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
246 btn.disabled = true;
247 var body = new URLSearchParams();
248 new FormData(f).forEach(function(v, k) { body.append(k, v); });
249 fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
250 .then(function(r) { return r.ok ? r.json() : null; })
251 .then(function(j) {
252 if (!j) return;
253 var on = !!j.on;
254 btn.classList.toggle('is-on', on);
255 btn.setAttribute('aria-pressed', on ? 'true' : 'false');
256 var lbl = btn.querySelector('.rp-act-label');
257 if (lbl) lbl.textContent = on ? (btn.getAttribute('data-on') || lbl.textContent) : (btn.getAttribute('data-off') || lbl.textContent);
258 })
259 .catch(function() {})
260 .then(function() { btn.disabled = false; });
261 });
262
263 // Antwoorden klapt open waar de kaart staat. Staat er geen venster (de
264 // preview), dan volgt de knop gewoon zijn link naar de volle pagina, met het
265 // venster daar al open.
266 document.addEventListener('click', function(e) {
267 var open = e.target.closest && e.target.closest('[data-rp-reply]');
268 var close = !open && e.target.closest && e.target.closest('[data-rp-reply-close]');
269 if (!open && !close) return;
270 var panel = document.getElementById('rp-reply');
271 if (!panel) return;
272 e.preventDefault();
273 var btn = document.querySelector('[data-rp-reply][aria-controls="rp-reply"]');
274 var show = !!open && panel.hidden;
275 panel.hidden = !show;
276 if (btn) btn.setAttribute('aria-expanded', show ? 'true' : 'false');
277 if (show) {
278 var ed = panel.querySelector('.re-editor, textarea');
279 if (ed) ed.focus();
280 // Op een telefoon staat het venster vaak onder de vouw.
281 if (panel.scrollIntoView) panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
282 } else if (btn) {
283 btn.focus();
284 }
285 });
286})();
287
288// ── het profielblad ────────────────────────────────────────────
289// Zat inline in partials/profile-sheet.ejs, dat de shell opneemt.
290(function() {
291 const sheet = document.getElementById('profile-sheet');
292 if (!sheet) return;
293
294 const backdrop = document.getElementById('profile-sheet-backdrop');
295 const panel = sheet.querySelector('.profile-sheet-panel');
296 const closeBtn = document.getElementById('profile-sheet-close');
297 const dragZone = document.getElementById('profile-sheet-drag-zone');
298 const themeBtn = document.getElementById('profile-sheet-theme');
299 const themeLbl = document.getElementById('profile-sheet-theme-state');
300
301 function openSheet() {
302 sheet.classList.add('is-open');
303 sheet.setAttribute('aria-hidden', 'false');
304 document.body.classList.add('profile-sheet-locked');
305 syncTheme();
306 }
307 function closeSheet() {
308 sheet.classList.remove('is-open');
309 sheet.setAttribute('aria-hidden', 'true');
310 document.body.classList.remove('profile-sheet-locked');
311 panel.style.removeProperty('--pcms-drag-y');
312 }
313
314 // Open: any element with [data-profile-sheet-toggle]
315 document.addEventListener('click', function(e) {
316 const trigger = e.target.closest('[data-profile-sheet-toggle]');
317 if (trigger) {
318 e.preventDefault();
319 openSheet();
320 }
321 });
322
323 // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item
324 if (backdrop) backdrop.addEventListener('click', closeSheet);
325 if (closeBtn) closeBtn.addEventListener('click', closeSheet);
326 document.addEventListener('keydown', (e) => {
327 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
328 });
329
330 // Menu items that navigate: close synchronously before nav fires
331 sheet.querySelectorAll('[data-close-sheet]').forEach((el) => {
332 el.addEventListener('click', closeSheet);
333 });
334
335 // Drag-down-to-close on touch.
336 // Uses --pcms-drag-y custom property rather than overwriting the panel's
337 // transform string. This composes correctly with any horizontal centering
338 // (currently none here, but the pattern matches audio-sheet for safety).
339 let startY = 0, lastY = 0, dragging = false;
340 function onDown(e) {
341 if (e.pointerType !== 'touch') return;
342 if (panel.scrollTop > 0) return;
343 startY = lastY = e.clientY;
344 dragging = true;
345 panel.classList.add('is-dragging');
346 }
347 function onMove(e) {
348 if (!dragging) return;
349 lastY = e.clientY;
350 const dy = Math.max(0, lastY - startY);
351 panel.style.setProperty('--pcms-drag-y', dy + 'px');
352 }
353 function onUp() {
354 if (!dragging) return;
355 dragging = false;
356 panel.classList.remove('is-dragging');
357 const dy = lastY - startY;
358 if (dy > 80) closeSheet();
359 else panel.style.removeProperty('--pcms-drag-y');
360 }
361 if (window.PointerEvent && dragZone) {
362 dragZone.addEventListener('pointerdown', onDown);
363 document.addEventListener('pointermove', onMove);
364 document.addEventListener('pointerup', onUp);
365 document.addEventListener('pointercancel', onUp);
366 }
367
368 // Theme toggle inside sheet — syncs label
369 function syncTheme() {
370 if (!themeLbl) return;
371 const t = document.documentElement.getAttribute('data-theme') || 'dark';
372 // De twee labels staan op het element zelf: een module kan geen vertaling
373 // interpoleren, en zo hoort de tekst bij het ding dat hem toont.
374 themeLbl.textContent = themeLbl.getAttribute(t === 'dark' ? 'data-dark' : 'data-light') || themeLbl.textContent;
375 }
376 if (themeBtn) {
377 themeBtn.addEventListener('click', function() {
378 const cur = document.documentElement.getAttribute('data-theme') || 'dark';
379 const next = cur === 'dark' ? 'light' : 'dark';
380 document.documentElement.setAttribute('data-theme', next);
381 try { localStorage.setItem('pcms-theme', next); } catch (e) {}
382 syncTheme();
383 });
384 }
385})();
Note: See TracBrowser for help on using the repository browser.