source: Klonkt/src/assets/js/ward.js@ a5a6249

Last change on this file since a5a6249 was a5a6249, checked in by Robin <roboburr@…>, 3 months ago

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

  • Property mode set to 100644
File size: 8.7 KB
Line 
1/**
2 * De Ward PWA (FEP-633c), clientkant. Tegenhanger van guardian.js, maar dan
3 * vanuit het kind: wie waakt over mij, is dat vangnet heel, en wat ligt er bij
4 * hen te wachten.
5 *
6 * Geen framework, net als guardian.js: de toestand komt als JSON mee met de
7 * pagina en dit bestand tekent hem. Alles wat nieuw is komt van /ward/api/*.
8 */
9'use strict';
10
11const state = JSON.parse(document.getElementById('ward-state').textContent);
12const $ = (id) => document.getElementById(id);
13
14/**
15 * De §4-uitkomst in woorden.
16 *
17 * Drie gevallen, en het derde is het punt: "onbereikbaar" is GEEN goedkeuring.
18 * Een ward wiens guardian-server plat ligt heeft een ONGECONTROLEERD vangnet,
19 * en dat is iets anders dan een gecontroleerd vangnet. Niets tonen zou het
20 * eerste als het tweede laten lezen. Zelfde drieslag als in Shaer.
21 */
22const CHECK_NOTE = {
23 malformed: { text: 'Deze guardian heeft zelf guardians. Een noodoproep komt hier niet aan.', cls: 'alarm' },
24 unreachable: { text: 'Deze guardian was niet te bereiken, dus niet gecontroleerd.', cls: 'unknown' },
25 sound: null,
26};
27
28/** Beschikbaarheid in de taal die iedereen zonder handleiding leest. */
29const AVAILABILITY = {
30 active: { label: 'Beschikbaar', cls: 'ok' },
31 away: { label: 'Even weg', cls: 'away' },
32 dormant: { label: 'Offline', cls: 'off' },
33};
34
35function el(tag, cls, text) {
36 const n = document.createElement(tag);
37 if (cls) n.className = cls;
38 if (text != null) n.textContent = text;
39 return n;
40}
41
42function renderGuardians(checks) {
43 const list = $('guardian-list');
44 list.replaceChildren();
45 $('guardians-empty').hidden = state.guardians.length > 0;
46
47 state.guardians.forEach((g) => {
48 const li = el('li', 'card');
49 const head = el('div', 'row');
50 head.append(el('span', 'name', g.handle));
51
52 const av = AVAILABILITY[g.availability] || AVAILABILITY.active;
53 head.append(el('span', `chip ${av.cls}`, av.label));
54 li.append(head);
55
56 // De controle staat WAAR de guardian genoemd wordt, niet in een logboek:
57 // daar heeft het gat betekenis.
58 const note = CHECK_NOTE[checks?.[g.uri]];
59 if (note) li.append(el('p', `note ${note.cls}`, note.text));
60 list.append(li);
61 });
62}
63
64function renderOffers() {
65 const sec = $('offers');
66 const list = $('offer-list');
67 list.replaceChildren();
68 sec.hidden = !state.offers.length;
69
70 state.offers.forEach((o) => {
71 const li = el('li', 'card');
72 const who = o['shaer:candidate'] || o.actor || '';
73 li.append(el('p', 'name', `${who} wil jouw guardian worden`));
74 if (o['shaer:acceptedBy']) {
75 li.append(el('p', 'hint', `${o['shaer:acceptedBy'].length} van de partijen zei al ja`));
76 }
77 list.append(li);
78 });
79}
80
81function renderWaiting() {
82 const sec = $('waiting');
83 const list = $('waiting-list');
84 list.replaceChildren();
85 sec.hidden = !state.waitingFollows.length;
86
87 state.waitingFollows.forEach((w) => {
88 const li = el('li', 'card');
89 li.append(el('p', 'name', w.handle));
90 // Niet "pending": de bal ligt bij iemand die je kent, en geweigerd is
91 // helemaal geen wachten meer. Zelfde drie zinnen als in Shaer.
92 const denied = w.status === 'denied';
93 li.append(el('p', denied ? 'note alarm' : 'hint',
94 denied ? 'Je guardians hebben hier nee tegen gezegd.'
95 : 'Je guardians moeten hier nog ja tegen zeggen.'));
96 list.append(li);
97 });
98}
99
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
217/** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. */
218async function loadChecks() {
219 if (!state.guardians.length) return;
220 try {
221 const r = await fetch('/ward/api/guardian-checks', { headers: { Accept: 'application/json' } });
222 if (!r.ok) return;
223 const { checks } = await r.json();
224 renderGuardians(checks);
225 } catch { /* stilte is hier juist: dan blijft de lijst zonder oordeel staan */ }
226}
227
228function wireHelp() {
229 const btn = $('help-btn');
230 const hint = $('help-hint');
231 if (!state.guardians.length) {
232 btn.disabled = true;
233 hint.textContent = 'Je hebt nog geen guardians om te vragen.';
234 return;
235 }
236 hint.textContent = `Gaat naar ${state.guardians.length} guardian(s).`;
237 btn.addEventListener('click', () => document.getElementById('buoy').click());
238}
239
240renderGuardians(null);
241wireBuoy();
242loadFeed();
243renderOffers();
244renderWaiting();
245wireHelp();
246loadChecks();
Note: See TracBrowser for help on using the repository browser.