Changeset a5a6249 in Klonkt for src/assets


Ignore:
Timestamp:
08/12/2026 07:52:13 PM (3 months ago)
Author:
Robin <roboburr@…>
Children:
084b046
Parents:
303bfc4
Message:

De Ward PWA: de feed en de reddingsboei

Plak 2. Nu valt er iets te doen op /ward in plaats van alleen te kijken.

DE FEED. Dezelfde bron als de Krant (getTimeline) en dezelfde partial
voor de body (renderNoteBody), zodat een post er overal hetzelfde uitziet:
opmaak, media, quote-kaart, embed. Alleen kale content doorgeven zou een
lege regel opleveren waar een foto hoort, precies de fout die het
guardian-dashboard ooit had.

DE BOEI, en die is hier BETER dan in de app. Shaer maakt een
schermafdruk van de kaart, met al het gedoe van buiten beeld renderen.
Op de server weten we wélke note het is, dus de hulpvraag draagt de note
zelf mee. Een guardian ziet daardoor de echte post en niet een plaatje
dat er ooit op leek.

Slepen met pointer-events, want dat is het enige dat op een telefoon en
met een muis hetzelfde doet. touch-action: none op de boei houdt de
pagina stil tijdens het slepen; zonder dat vecht het met scrollen en
verlies je hem halverwege. De kaart eronder licht op, want mikken met een
vinger zonder terugkoppeling is gokken. Een tik zonder slepen vraagt hulp
zonder dat er een post bij hoort, en de grote knop bovenaan doet
hetzelfde.

Changed files:
src/routes/ward.js

  • /api/feed met afgemaakte body per kaart
  • /api/help: direct bericht aan ALLE guardians, met de note erbij; 409 als er geen guardians zijn, want dan is er niets te vragen

src/views/pages/ward.ejs

  • feed-sectie, zwevende boei, hulpdialoog

src/assets/js/ward.js

  • kaarten tekenen, slepen, mikken, versturen

src/assets/css/ward.css

  • kaarten, boei, dialoog, sr-only

src/services/i18n.js

  • acht nieuwe sleutels in nl, en, de

test/ward-pwa.test.js

  • de feed levert een gerenderde body; de boei weigert zonder guardians; een vreemde kan hem niet afvuren

remarks: 889 tests groen. Slepen is alleen in code getest, niet met een
echte vinger op een echt scherm; dat is aan Robin op dev.

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

Location:
src/assets
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/ward.css

    r303bfc4 ra5a6249  
    4343.note.alarm { background: rgba(239,159,39,.16); color: var(--alarm); }
    4444.note.unknown { background: rgba(154,167,187,.14); color: var(--dim); }
     45
     46/* ── De feed ──────────────────────────────────────────────────────────── */
     47.cards { display: grid; gap: 12px; }
     48.post { background: var(--card); border-radius: 12px; padding: 12px 14px; border: 2px solid transparent; }
     49.post .avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; }
     50.post .when { margin-left: auto; color: var(--dim); font-size: .8rem; }
     51.post .cw { color: var(--alarm); font-size: .85rem; margin: 6px 0 0; }
     52.post .body { margin-top: 8px; overflow-wrap: anywhere; }
     53.post .body img, .post .body video { max-width: 100%; height: auto; border-radius: 8px; }
     54/* Waar de boei zou landen. Zichtbaar oplichten, want mikken met een vinger
     55   zonder terugkoppeling is gokken. */
     56.post.hover { border-color: var(--accent); }
     57
     58/* ── De reddingsboei ──────────────────────────────────────────────────── */
     59#buoy {
     60  position: fixed; right: 18px; bottom: calc(18px + env(safe-area-inset-bottom));
     61  width: 76px; height: 76px; border: 0; border-radius: 50%;
     62  background: transparent; font-size: 52px; line-height: 1;
     63  /* Zonder dit vecht slepen met scrollen en verlies je hem halverwege. */
     64  touch-action: none; cursor: grab; z-index: 50;
     65}
     66#buoy.dragging { cursor: grabbing; transform: scale(1.1); }
     67
     68dialog#help-dialog {
     69  border: 0; border-radius: 16px; padding: 18px; max-width: 420px; width: calc(100% - 32px);
     70  background: var(--card); color: var(--ink);
     71}
     72dialog#help-dialog::backdrop { background: rgba(0,0,0,.5); }
     73dialog#help-dialog h2 { margin: 0 0 10px; font-size: 1.05rem; color: var(--ink); }
     74dialog#help-dialog label { display: block; color: var(--dim); font-size: .85rem; margin-bottom: 4px; }
     75dialog#help-dialog textarea {
     76  width: 100%; border-radius: 10px; border: 1px solid #2a3342;
     77  background: #131a25; color: var(--ink); padding: 8px; font: inherit;
     78}
     79dialog#help-dialog menu { display: flex; gap: 8px; justify-content: flex-end; margin: 14px 0 0; padding: 0; }
     80dialog#help-dialog button { border: 0; border-radius: 10px; padding: 10px 14px; background: #2a3342; color: var(--ink); font: inherit; }
     81dialog#help-dialog button.primary { background: var(--accent); color: #fff; font-weight: 600; }
     82
     83/* Voor schermlezers, niet voor het oog. */
     84.sr-only {
     85  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
     86  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
     87}
  • src/assets/js/ward.js

    r303bfc4 ra5a6249  
    9898}
    9999
     100/**
     101 * De kaarten. De body komt AF van de server (dezelfde partial als de Krant),
     102 * dus hier alleen invoegen; zelf HTML samenstellen zou opmaak, media en
     103 * quote-kaarten opnieuw moeten uitvinden en er anders uit laten zien.
     104 */
     105function renderFeed(items) {
     106  const box = document.getElementById('card-list');
     107  box.replaceChildren();
     108  document.getElementById('feed-empty').hidden = items.length > 0;
     109
     110  items.forEach((it) => {
     111    const card = el('article', 'post');
     112    card.dataset.note = it.id;              // waar de boei op mikt
     113    const head = el('div', 'row');
     114    if (it.authorIcon) {
     115      const img = el('img', 'avatar');
     116      img.src = it.authorIcon; img.alt = ''; img.loading = 'lazy';
     117      head.append(img);
     118    }
     119    head.append(el('span', 'name', it.authorName || it.author));
     120    head.append(el('span', 'when', it.whenText || ''));
     121    card.append(head);
     122
     123    if (it.cw) card.append(el('p', 'cw', it.cw));
     124    const body = el('div', 'body');
     125    body.innerHTML = it.bodyHtml || '';     // server-gerenderd en gesaniteerd
     126    card.append(body);
     127    box.append(card);
     128  });
     129}
     130
     131async function loadFeed() {
     132  try {
     133    const r = await fetch('/ward/api/feed', { headers: { Accept: 'application/json' } });
     134    if (!r.ok) return;
     135    renderFeed((await r.json()).items || []);
     136  } catch { /* de pagina blijft staan met wat er al is */ }
     137}
     138
     139/**
     140 * De reddingsboei: sleep hem op een kaart en je guardians krijgen die post.
     141 *
     142 * Pointer-events in plaats van muis- of touch-events apart, want dat is het
     143 * enige dat op een telefoon en met een muis hetzelfde doet. `setPointerCapture`
     144 * zorgt dat de sleep van jou blijft, en `touch-action: none` op de boei houdt
     145 * de pagina stil terwijl je hem verplaatst: zonder dat vecht het slepen met
     146 * scrollen en verlies je hem halverwege.
     147 */
     148function wireBuoy() {
     149  const buoy = document.getElementById('buoy');
     150  const dlg = document.getElementById('help-dialog');
     151  let dragging = false, dx = 0, dy = 0, target = null;
     152
     153  const cardUnder = (x, y) => document.elementFromPoint(x, y)?.closest('.post');
     154
     155  buoy.addEventListener('pointerdown', (e) => {
     156    dragging = true;
     157    buoy.setPointerCapture(e.pointerId);
     158    const r = buoy.getBoundingClientRect();
     159    dx = e.clientX - r.left; dy = e.clientY - r.top;
     160    buoy.classList.add('dragging');
     161  });
     162
     163  buoy.addEventListener('pointermove', (e) => {
     164    if (!dragging) return;
     165    buoy.style.left = `${e.clientX - dx}px`;
     166    buoy.style.top = `${e.clientY - dy}px`;
     167    buoy.style.right = 'auto'; buoy.style.bottom = 'auto';
     168    // Onderweg oplichten waar hij zou landen, zodat je ziet wat je kiest.
     169    document.querySelectorAll('.post.hover').forEach((n) => n.classList.remove('hover'));
     170    cardUnder(e.clientX, e.clientY)?.classList.add('hover');
     171  });
     172
     173  const drop = (e) => {
     174    if (!dragging) return;
     175    dragging = false;
     176    buoy.classList.remove('dragging');
     177    const card = cardUnder(e.clientX, e.clientY);
     178    document.querySelectorAll('.post.hover').forEach((n) => n.classList.remove('hover'));
     179    // Terug naar zijn hoek: de boei hoort altijd op dezelfde plek te liggen.
     180    buoy.style.left = buoy.style.top = 'auto';
     181    buoy.style.right = ''; buoy.style.bottom = '';
     182    if (card) askHelp(card.dataset.note, card.querySelector('.name')?.textContent || '');
     183  };
     184  buoy.addEventListener('pointerup', drop);
     185  buoy.addEventListener('pointercancel', drop);
     186
     187  // Een tik (zonder slepen) vraagt hulp zonder dat er een post bij hoort.
     188  buoy.addEventListener('click', (e) => { if (!e.detail || !dragging) askHelp(null, null); });
     189
     190  function askHelp(noteId, who) {
     191    document.getElementById('help-title').textContent = who
     192      ? `Hulp vragen bij het bericht van ${who}?` : 'Hulp vragen aan je guardians?';
     193    dlg.dataset.note = noteId || '';
     194    document.getElementById('help-words').value = '';
     195    dlg.showModal();
     196  }
     197
     198  dlg.addEventListener('close', async () => {
     199    if (dlg.returnValue !== 'send') return;
     200    const body = { note: dlg.dataset.note || undefined, words: document.getElementById('help-words').value };
     201    const hint = document.getElementById('help-hint');
     202    try {
     203      const r = await fetch('/ward/api/help', {
     204        method: 'POST',
     205        headers: { 'Content-Type': 'application/json' },
     206        body: JSON.stringify(body),
     207      });
     208      const out = await r.json().catch(() => ({}));
     209      hint.textContent = r.ok ? state.strings.helpSent
     210        : (out.error === 'no_guardians' ? 'Je hebt nog geen guardians om te vragen.' : 'Dat lukte niet; probeer het nog eens.');
     211    } catch {
     212      hint.textContent = 'Dat lukte niet; probeer het nog eens.';
     213    }
     214  });
     215}
     216
    100217/** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. */
    101218async function loadChecks() {
    … …  
    118235  }
    119236  hint.textContent = `Gaat naar ${state.guardians.length} guardian(s).`;
     237  btn.addEventListener('click', () => document.getElementById('buoy').click());
    120238}
    121239
    122240renderGuardians(null);
     241wireBuoy();
     242loadFeed();
    123243renderOffers();
    124244renderWaiting();
Note: See TracChangeset for help on using the changeset viewer.