source: Klonkt/src/assets/js/ward.js@ 39dab6b

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

Ward-PWA: de mini-speler, de themakleur en een breder kaartraster

Drie dingen in een ronde.

DE THEMAKLEUR (SkyPalette). De acht presets en de luminantie-regel komen
LETTERLIJK uit Shaer: dezelfde kleuren, en dezelfde beslissing welke
tekst erop leesbaar is (donker op zon, wit op nacht). Een handgehouden
lijstje zou uit de pas lopen; luminantie beslist. De keuze kleurt het
accent (knoppen, bubbels, de (+)), tint de achtergrond zacht, en laat de
hemel zijn placeholder-schijfjes bijkleuren. Opgeslagen per apparaat.

DE MINI-SPELER. Een feed-kaart rendert een gewone audio, maar de feed
wordt bij elke verversing opnieuw getekend en dan sterft dat element:
de muziek stopt midden in een nummer. Daarom is er EEN blijvende speler
buiten de feed; een audiotegel geeft alleen zijn bron door (gevangen in
de capture-fase) en pauzeert zichzelf. Zo overleeft het geluid een
herteken en een wissel naar de hemel, precies als in de app.

HET KAARTRASTER (Robins wens, 12-8). Op desktop net als op mobiel en
tablet MEER kolommen: auto-fill met een minimum van 280px vult zelf op,
een op een smalle telefoon, twee op een tablet, drie of meer breed.

New file:
src/assets/js/ward-theme.js

  • presets, onSky (luminantie), load/save/apply

Changed files:
src/assets/js/ward.js

  • wireTheme (swatches), wireMiniPlayer (gedeelde audio)
  • de hemel skint bij op een themawissel

src/assets/js/ward-sky.js

  • buildAtlas neemt het accent; reskin() bij een kleurwissel

src/views/pages/ward.ejs

  • themaknop, themadialoog, mini-speler-balk

src/assets/css/ward.css

  • accent/achtergrond als variabelen, mini-speler, swatches, responsief raster (auto-fill minmax 280px)

src/services/i18n.js

  • kleur + play/pause in nl, en, de

test/ward-theme.test.js

  • acht presets, en de tekstkleur volgt luminantie

remarks: 905 tests groen. Slepen en het echt doorspelen van audio zijn
in code getest, niet met oor en vinger; dat is aan Robin op dev.

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

  • Property mode set to 100644
File size: 23.7 KB
RevLine 
[303bfc4]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 */
[084b046]9import { SkyRenderer } from '/ward/sky.js';
[39dab6b]10import { PRESETS, loadColor, saveColor, applyColor } from '/ward/theme.js';
[303bfc4]11
12const state = JSON.parse(document.getElementById('ward-state').textContent);
[39dab6b]13
14// De opgeslagen themakleur meteen toepassen, voor het eerste frame.
15applyColor(loadColor());
[303bfc4]16const $ = (id) => document.getElementById(id);
17
18/**
19 * De §4-uitkomst in woorden.
20 *
21 * Drie gevallen, en het derde is het punt: "onbereikbaar" is GEEN goedkeuring.
22 * Een ward wiens guardian-server plat ligt heeft een ONGECONTROLEERD vangnet,
23 * en dat is iets anders dan een gecontroleerd vangnet. Niets tonen zou het
24 * eerste als het tweede laten lezen. Zelfde drieslag als in Shaer.
25 */
26const CHECK_NOTE = {
27 malformed: { text: 'Deze guardian heeft zelf guardians. Een noodoproep komt hier niet aan.', cls: 'alarm' },
28 unreachable: { text: 'Deze guardian was niet te bereiken, dus niet gecontroleerd.', cls: 'unknown' },
29 sound: null,
30};
31
32/** Beschikbaarheid in de taal die iedereen zonder handleiding leest. */
33const AVAILABILITY = {
34 active: { label: 'Beschikbaar', cls: 'ok' },
35 away: { label: 'Even weg', cls: 'away' },
36 dormant: { label: 'Offline', cls: 'off' },
37};
38
39function el(tag, cls, text) {
40 const n = document.createElement(tag);
41 if (cls) n.className = cls;
42 if (text != null) n.textContent = text;
43 return n;
44}
45
46function renderGuardians(checks) {
47 const list = $('guardian-list');
48 list.replaceChildren();
49 $('guardians-empty').hidden = state.guardians.length > 0;
50
51 state.guardians.forEach((g) => {
52 const li = el('li', 'card');
53 const head = el('div', 'row');
54 head.append(el('span', 'name', g.handle));
55
56 const av = AVAILABILITY[g.availability] || AVAILABILITY.active;
57 head.append(el('span', `chip ${av.cls}`, av.label));
58 li.append(head);
59
60 // De controle staat WAAR de guardian genoemd wordt, niet in een logboek:
61 // daar heeft het gat betekenis.
62 const note = CHECK_NOTE[checks?.[g.uri]];
63 if (note) li.append(el('p', `note ${note.cls}`, note.text));
64 list.append(li);
65 });
66}
67
68function renderOffers() {
69 const sec = $('offers');
70 const list = $('offer-list');
71 list.replaceChildren();
72 sec.hidden = !state.offers.length;
73
74 state.offers.forEach((o) => {
75 const li = el('li', 'card');
76 const who = o['shaer:candidate'] || o.actor || '';
77 li.append(el('p', 'name', `${who} wil jouw guardian worden`));
78 if (o['shaer:acceptedBy']) {
79 li.append(el('p', 'hint', `${o['shaer:acceptedBy'].length} van de partijen zei al ja`));
80 }
81 list.append(li);
82 });
83}
84
85function renderWaiting() {
86 const sec = $('waiting');
87 const list = $('waiting-list');
88 list.replaceChildren();
89 sec.hidden = !state.waitingFollows.length;
90
91 state.waitingFollows.forEach((w) => {
92 const li = el('li', 'card');
93 li.append(el('p', 'name', w.handle));
94 // Niet "pending": de bal ligt bij iemand die je kent, en geweigerd is
95 // helemaal geen wachten meer. Zelfde drie zinnen als in Shaer.
96 const denied = w.status === 'denied';
97 li.append(el('p', denied ? 'note alarm' : 'hint',
98 denied ? 'Je guardians hebben hier nee tegen gezegd.'
99 : 'Je guardians moeten hier nog ja tegen zeggen.'));
100 list.append(li);
101 });
102}
103
[a5a6249]104/**
105 * De kaarten. De body komt AF van de server (dezelfde partial als de Krant),
106 * dus hier alleen invoegen; zelf HTML samenstellen zou opmaak, media en
107 * quote-kaarten opnieuw moeten uitvinden en er anders uit laten zien.
108 */
109function renderFeed(items) {
110 const box = document.getElementById('card-list');
111 box.replaceChildren();
112 document.getElementById('feed-empty').hidden = items.length > 0;
113
114 items.forEach((it) => {
115 const card = el('article', 'post');
116 card.dataset.note = it.id; // waar de boei op mikt
117 const head = el('div', 'row');
118 if (it.authorIcon) {
119 const img = el('img', 'avatar');
120 img.src = it.authorIcon; img.alt = ''; img.loading = 'lazy';
121 head.append(img);
122 }
123 head.append(el('span', 'name', it.authorName || it.author));
124 head.append(el('span', 'when', it.whenText || ''));
125 card.append(head);
126
127 if (it.cw) card.append(el('p', 'cw', it.cw));
128 const body = el('div', 'body');
129 body.innerHTML = it.bodyHtml || ''; // server-gerenderd en gesaniteerd
130 card.append(body);
131 box.append(card);
132 });
133}
134
135async function loadFeed() {
136 try {
137 const r = await fetch('/ward/api/feed', { headers: { Accept: 'application/json' } });
138 if (!r.ok) return;
139 renderFeed((await r.json()).items || []);
140 } catch { /* de pagina blijft staan met wat er al is */ }
141}
142
143/**
144 * De reddingsboei: sleep hem op een kaart en je guardians krijgen die post.
145 *
146 * Pointer-events in plaats van muis- of touch-events apart, want dat is het
147 * enige dat op een telefoon en met een muis hetzelfde doet. `setPointerCapture`
148 * zorgt dat de sleep van jou blijft, en `touch-action: none` op de boei houdt
149 * de pagina stil terwijl je hem verplaatst: zonder dat vecht het slepen met
150 * scrollen en verlies je hem halverwege.
151 */
152function wireBuoy() {
153 const buoy = document.getElementById('buoy');
154 const dlg = document.getElementById('help-dialog');
155 let dragging = false, dx = 0, dy = 0, target = null;
156
157 const cardUnder = (x, y) => document.elementFromPoint(x, y)?.closest('.post');
158
159 buoy.addEventListener('pointerdown', (e) => {
160 dragging = true;
161 buoy.setPointerCapture(e.pointerId);
162 const r = buoy.getBoundingClientRect();
163 dx = e.clientX - r.left; dy = e.clientY - r.top;
164 buoy.classList.add('dragging');
165 });
166
167 buoy.addEventListener('pointermove', (e) => {
168 if (!dragging) return;
169 buoy.style.left = `${e.clientX - dx}px`;
170 buoy.style.top = `${e.clientY - dy}px`;
171 buoy.style.right = 'auto'; buoy.style.bottom = 'auto';
172 // Onderweg oplichten waar hij zou landen, zodat je ziet wat je kiest.
173 document.querySelectorAll('.post.hover').forEach((n) => n.classList.remove('hover'));
174 cardUnder(e.clientX, e.clientY)?.classList.add('hover');
175 });
176
177 const drop = (e) => {
178 if (!dragging) return;
179 dragging = false;
180 buoy.classList.remove('dragging');
181 const card = cardUnder(e.clientX, e.clientY);
182 document.querySelectorAll('.post.hover').forEach((n) => n.classList.remove('hover'));
183 // Terug naar zijn hoek: de boei hoort altijd op dezelfde plek te liggen.
184 buoy.style.left = buoy.style.top = 'auto';
185 buoy.style.right = ''; buoy.style.bottom = '';
186 if (card) askHelp(card.dataset.note, card.querySelector('.name')?.textContent || '');
187 };
188 buoy.addEventListener('pointerup', drop);
189 buoy.addEventListener('pointercancel', drop);
190
191 // Een tik (zonder slepen) vraagt hulp zonder dat er een post bij hoort.
192 buoy.addEventListener('click', (e) => { if (!e.detail || !dragging) askHelp(null, null); });
193
194 function askHelp(noteId, who) {
195 document.getElementById('help-title').textContent = who
196 ? `Hulp vragen bij het bericht van ${who}?` : 'Hulp vragen aan je guardians?';
197 dlg.dataset.note = noteId || '';
198 document.getElementById('help-words').value = '';
199 dlg.showModal();
200 }
201
202 dlg.addEventListener('close', async () => {
203 if (dlg.returnValue !== 'send') return;
204 const body = { note: dlg.dataset.note || undefined, words: document.getElementById('help-words').value };
205 const hint = document.getElementById('help-hint');
206 try {
207 const r = await fetch('/ward/api/help', {
208 method: 'POST',
209 headers: { 'Content-Type': 'application/json' },
210 body: JSON.stringify(body),
211 });
212 const out = await r.json().catch(() => ({}));
213 hint.textContent = r.ok ? state.strings.helpSent
214 : (out.error === 'no_guardians' ? 'Je hebt nog geen guardians om te vragen.' : 'Dat lukte niet; probeer het nog eens.');
215 } catch {
216 hint.textContent = 'Dat lukte niet; probeer het nog eens.';
217 }
218 });
219}
220
[084b046]221/**
222 * De hemel en de kaarten zijn één scherm met twee standen.
223 *
224 * De hemel draait ALTIJD en ligt achter de kaarten; in kaartenstand is hij
225 * decor en vangt hij geen tikken, in berichtenstand vallen de kaarten weg en
226 * wordt hij de interface. Dat is precies de beweging uit Shaer: de kaarten
227 * vallen weg en wat eronder lag was er al.
228 */
229function wireSky() {
230 const canvas = document.getElementById('sky');
231 const btn = document.getElementById('mode');
232 const main = document.getElementById('app');
233 let convs = [];
234
235 const sky = new SkyRenderer(canvas, (i) => {
236 const c = convs[i];
[5218863]237 if (c) openThread(c);
[5412cbb]238 }, () => openFriendsPicker());
239
240 /* ── De (+): de deur naar iemand nieuw ─────────────────────────────────
241 De hemel toont alleen met wie je al sprak; de kiezer toont iedereen met
242 een volgrelatie. Kies iemand en het lege gesprek opent gewoon: het
243 eerste bericht maakt het echt. */
244 const friendsDlg = document.getElementById('friends-dialog');
245 async function openFriendsPicker() {
246 const list = document.getElementById('friends-list');
247 list.replaceChildren();
248 friendsDlg.showModal();
249 try {
250 const r = await fetch('/ward/api/friends', { headers: { Accept: 'application/json' } });
251 const items = r.ok ? (await r.json()).items || [] : [];
252 document.getElementById('friends-empty').hidden = items.length > 0;
253 items.forEach((f) => {
254 const li = el('li', 'card friend');
255 const row = el('button', 'row');
256 row.type = 'button';
257 if (f.icon) {
258 const img = el('img', 'avatar');
259 img.src = f.icon; img.alt = ''; img.loading = 'lazy';
260 row.append(img);
261 }
262 const kolom = el('span', 'wie');
263 kolom.append(el('span', 'name', f.name), el('span', 'hint', f.handle));
264 row.append(kolom);
265 row.addEventListener('click', () => {
266 friendsDlg.close();
267 openThread({ uri: f.uri, name: f.name, icon: f.icon });
268 });
269 li.append(row);
270 list.append(li);
271 });
272 } catch { /* de kiezer blijft leeg staan; annuleren kan altijd */ }
273 }
274 document.getElementById('friends-cancel').addEventListener('click', () => friendsDlg.close());
[5218863]275
276 /* ── Het gesprek: een overlay OVER de levende hemel ────────────────────
277 Geen aparte pagina: de hemel eronder blijft dezelfde draaiende
278 instantie, precies zoals de thread in Shaer over de hemel schuift. */
279 const thread = document.getElementById('thread');
280 const list = document.getElementById('thread-list');
281 let openWith = null;
282 let pollTimer = null;
283
284 async function loadThread() {
285 if (!openWith) return;
286 try {
287 const r = await fetch(`/ward/api/thread?with=${encodeURIComponent(openWith.uri)}`, { headers: { Accept: 'application/json' } });
288 if (!r.ok) return;
289 const { items } = await r.json();
290 list.replaceChildren();
291 // De server geeft nieuwste eerst; een gesprek lees je oudste eerst.
292 [...items].reverse().forEach((m) => {
293 const b = el('div', `bubble ${m.dir}${m.wave ? ' wave' : ''}`);
294 const body = el('div', 'body');
295 body.innerHTML = m.bodyHtml || '';
296 b.append(body, el('span', 'when', m.whenText || ''));
297 list.append(b);
298 });
299 list.scrollTop = list.scrollHeight;
300 } catch { /* volgende poll probeert opnieuw */ }
301 }
302
303 function openThread(c) {
304 openWith = c;
305 document.getElementById('thread-name').textContent = c.name;
306 thread.hidden = false;
307 loadThread();
308 // Pollen zolang het gesprek open staat: eenvoudig en zuinig genoeg.
309 // Een socket kan later; het ritme van een gesprek is geen milliseconde.
310 clearInterval(pollTimer);
311 pollTimer = setInterval(loadThread, 8000);
312 }
313
314 function closeThread() {
315 openWith = null;
316 thread.hidden = true;
317 clearInterval(pollTimer);
318 }
319 document.getElementById('thread-close').addEventListener('click', closeThread);
320 document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && openWith) closeThread(); });
321
322 document.getElementById('thread-form').addEventListener('submit', async (e) => {
323 e.preventDefault();
324 const input = document.getElementById('thread-text');
325 const text = input.value.trim();
326 if (!text || !openWith) return;
327 input.value = '';
328 await sendMessage(openWith.uri, text, false);
329 loadThread();
330 });
331
332 async function sendMessage(to, text, wave) {
333 try {
334 const r = await fetch('/ward/api/message', {
335 method: 'POST',
336 headers: { 'Content-Type': 'application/json' },
337 body: JSON.stringify({ to, text, wave }),
338 });
339 return r.ok;
340 } catch { return false; }
341 }
342
343 /* ── De zwaai-hand (§5) ────────────────────────────────────────────────
344 Dezelfde hoek als de boei, en ze wisselen elkaar af met de stand: met de
345 kaarten verdwijnt de boei, op de hemel hangt de hand. Slepen op een
346 gezicht opent het paneel; de tekst die je kiest IS het bericht. */
347 const hand = document.getElementById('hand');
348 const waveDlg = document.getElementById('wave-dialog');
349 let waveTarget = null;
350
351 (() => {
352 let dragging = false, dx = 0, dy = 0;
353 hand.addEventListener('pointerdown', (e) => {
354 dragging = true;
355 hand.setPointerCapture(e.pointerId);
356 const r = hand.getBoundingClientRect();
357 dx = e.clientX - r.left; dy = e.clientY - r.top;
358 hand.classList.add('dragging');
359 });
360 hand.addEventListener('pointermove', (e) => {
361 if (!dragging) return;
362 hand.style.left = `${e.clientX - dx}px`;
363 hand.style.top = `${e.clientY - dy}px`;
364 hand.style.right = 'auto'; hand.style.bottom = 'auto';
365 });
366 const drop = (e) => {
367 if (!dragging) return;
368 dragging = false;
369 hand.classList.remove('dragging');
370 hand.style.left = hand.style.top = 'auto';
371 hand.style.right = ''; hand.style.bottom = '';
372 // Zelfde rekensom als een tik op de hemel: het dichtstbijzijnde gezicht
373 // binnen bereik wint, en ernaast is bewust niets.
374 const r = canvas.getBoundingClientRect();
375 const hit = sky.pick(e.clientX - r.left, e.clientY - r.top);
376 if (hit != null && convs[hit]) {
377 waveTarget = convs[hit];
378 document.getElementById('wave-title').textContent =
379 state.strings.waveTo.replace('{who}', waveTarget.name);
380 waveDlg.showModal();
381 }
382 };
383 hand.addEventListener('pointerup', drop);
384 hand.addEventListener('pointercancel', drop);
385 })();
386
387 waveDlg.addEventListener('click', async (e) => {
388 const btn = e.target.closest('button');
389 if (!btn) return;
390 waveDlg.close();
391 if (btn.id === 'wave-cancel' || !waveTarget) return;
392 const isWave = btn.dataset.wave === '1';
393 const text = isWave ? '👋' : btn.dataset.text;
394 if (await sendMessage(waveTarget.uri, text, isWave)) openThread(waveTarget);
[084b046]395 });
396
397 let messagesMode = false;
398 const setMode = (on) => {
399 messagesMode = on;
400 sky.interactive = on;
401 canvas.setAttribute('aria-hidden', String(!on));
402 // touch-action pas uitzetten als de hemel echt bedienbaar is: anders kun
403 // je in kaartenstand niet meer scrollen.
404 canvas.style.touchAction = on ? 'none' : '';
405 canvas.style.pointerEvents = on ? 'auto' : 'none';
406 document.body.classList.toggle('messages-mode', on);
[5218863]407 document.getElementById('buoy').hidden = on;
408 hand.hidden = !on;
409 if (!on) closeThread();
[084b046]410 btn.textContent = on ? state.strings.toCards : state.strings.toSky;
411 btn.setAttribute('aria-pressed', String(on));
412 main.querySelectorAll('section').forEach((sec) => { sec.hidden = on; });
413 };
414
415 btn.addEventListener('click', () => setMode(!messagesMode));
416 setMode(false);
417
418 (async () => {
419 try {
420 const r = await fetch('/ward/api/conversations', { headers: { Accept: 'application/json' } });
421 if (!r.ok) return;
422 convs = (await r.json()).items || [];
423 await sky.setFaces(convs.map((c) => ({ name: c.name, icon: c.icon, ageMs: c.ageMs })));
424 sky.start();
425 } catch { /* zonder hemel blijft het scherm gewoon werken */ }
426 })();
[39dab6b]427
428 // De hemel skint zijn placeholder-schijfjes bij als de themakleur wisselt.
429 window.addEventListener('ward-accent', () => sky.reskin());
[084b046]430}
431
[2086ea2]432/**
433 * De composer: schrijven met foto, geluid of video.
434 *
435 * De media gaan eerst apart naar /ward/api/upload (dezelfde bak als de app),
436 * en de post draagt daarna alleen de url mee. Zo blijft de post-payload klein
437 * en kan de server elke upload los valideren en verrijken (poster, waveform).
438 */
439function wireComposer() {
440 const dlg = document.getElementById('compose-dialog');
441 const openBtn = document.getElementById('compose-open');
442 const text = document.getElementById('compose-text');
443 const mediaBox = document.getElementById('compose-media');
444 const status = document.getElementById('compose-status');
445 const sendBtn = document.getElementById('compose-send');
446 let media = []; // { url, mediaType, name }
447 let uploading = false;
448
449 function reset() {
450 text.value = '';
451 media = [];
452 mediaBox.replaceChildren();
453 status.textContent = '';
454 updateSend();
455 }
456 function updateSend() {
457 // Een lege post kan niet: tekst OF media, en nooit tijdens een upload.
458 sendBtn.disabled = (!text.value.trim() && !media.length) || uploading;
459 }
460 function renderMedia() {
461 mediaBox.replaceChildren();
462 media.forEach((m, i) => {
463 const chip = el('div', 'media-chip');
464 if (m.mediaType.startsWith('image/')) {
465 const img = el('img'); img.src = m.url; img.alt = m.name || ''; chip.append(img);
466 } else {
467 chip.append(el('span', 'kind', m.mediaType.startsWith('audio/') ? '🎤' : '🎬'));
468 chip.append(el('span', 'name', m.name || ''));
469 }
470 const x = el('button', 'drop', '✕'); x.type = 'button';
471 x.addEventListener('click', () => { media.splice(i, 1); renderMedia(); updateSend(); });
472 chip.append(x);
473 mediaBox.append(chip);
474 });
475 }
476
477 async function upload(file) {
478 if (media.length >= 4) return;
479 // 32 MB is de servergrens; hier alvast vangen scheelt een mislukte upload.
480 if (file.size > 32 * 1024 * 1024) { status.textContent = state.strings.tooBig; return; }
481 uploading = true; status.textContent = state.strings.uploading; updateSend();
482 try {
483 const fd = new FormData();
484 fd.append('file', file);
485 const r = await fetch('/ward/api/upload', { method: 'POST', body: fd });
486 if (r.ok) { media.push(await r.json()); renderMedia(); status.textContent = ''; }
487 else status.textContent = (await r.json().catch(() => ({}))).error === 'media_type'
488 ? 'Dat bestandstype kan niet.' : 'Uploaden lukte niet.';
489 } catch { status.textContent = 'Uploaden lukte niet.'; }
490 uploading = false; updateSend();
491 }
492
493 openBtn.addEventListener('click', () => { reset(); dlg.showModal(); });
494 document.getElementById('compose-cancel').addEventListener('click', () => dlg.close());
495 text.addEventListener('input', updateSend);
496
497 dlg.querySelectorAll('input[type=file]').forEach((inp) => {
498 inp.addEventListener('change', () => {
499 if (inp.files[0]) upload(inp.files[0]);
500 inp.value = ''; // zelfde bestand opnieuw kunnen kiezen
501 });
502 });
503
504 sendBtn.addEventListener('click', async () => {
505 if (sendBtn.disabled) return;
506 sendBtn.disabled = true;
507 try {
508 const r = await fetch('/ward/api/post', {
509 method: 'POST',
510 headers: { 'Content-Type': 'application/json' },
511 body: JSON.stringify({
512 text: text.value,
513 media,
514 visibility: document.getElementById('compose-vis').value,
515 }),
516 });
517 if (r.ok) { dlg.close(); loadFeed(); }
518 else status.textContent = 'Plaatsen lukte niet.';
519 } catch { status.textContent = 'Plaatsen lukte niet.'; }
520 updateSend();
521 });
522}
523
[39dab6b]524/**
525 * De themakiezer (SkyPalette): acht presets, gekozen kleur kleurt alles.
526 * Een rij swatches in een dialoog; de keuze staat meteen en blijft bewaard.
527 */
528function wireTheme() {
529 const dlg = document.getElementById('theme-dialog');
530 const row = document.getElementById('theme-swatches');
531 const current = loadColor();
532 row.replaceChildren();
533 PRESETS.forEach((rgb) => {
534 const b = el('button', 'swatch');
535 b.type = 'button';
536 b.style.background = '#' + rgb.toString(16).padStart(6, '0');
537 b.setAttribute('aria-label', 'kleur ' + rgb.toString(16));
538 if (rgb === current) b.classList.add('chosen');
539 b.addEventListener('click', () => {
540 saveColor(rgb);
541 row.querySelectorAll('.swatch').forEach((s) => s.classList.remove('chosen'));
542 b.classList.add('chosen');
543 // De hemel tekent de placeholder-schijfjes in het accent; even opnieuw.
544 window.dispatchEvent(new CustomEvent('ward-accent'));
545 });
546 row.append(b);
547 });
548 document.getElementById('theme-open').addEventListener('click', () => dlg.showModal());
549 document.getElementById('theme-close').addEventListener('click', () => dlg.close());
550}
551
552/**
553 * De mini-speler: audio blijft doorspelen terwijl je scrolt of van stand
554 * wisselt.
555 *
556 * Een feed-kaart rendert een gewone <audio>, maar de feed wordt bij elke
557 * verversing opnieuw getekend en dan sterft dat element -- de muziek stopt
558 * midden in een nummer. Daarom is er ÉÉN blijvende speler buiten de feed; een
559 * audiotegel geeft alleen zijn bron door en pauzeert zichzelf. Zo overleeft
560 * het geluid een herteken en een wissel naar de hemel, precies als in de app.
561 */
562function wireMiniPlayer() {
563 const bar = document.getElementById('mini-player');
564 const audio = document.getElementById('mini-audio');
565 const title = document.getElementById('mini-title');
566 const toggle = document.getElementById('mini-toggle');
567
568 // Vangen in de CAPTURE-fase: zodra een feed-audio wil spelen, neemt de
569 // gedeelde speler het over. Delegatie op document, want de kaarten komen en
570 // gaan.
571 document.addEventListener('play', (e) => {
572 const t = e.target;
573 if (!(t instanceof HTMLAudioElement) || t === audio) return;
574 e.preventDefault();
575 t.pause();
576 const card = t.closest('.post');
577 title.textContent = card?.querySelector('.name')?.textContent || 'Audio';
578 if (audio.src !== new URL(t.currentSrc || t.src, location.href).href) {
579 audio.src = t.currentSrc || t.src;
580 }
581 audio.play().catch(() => {});
582 bar.hidden = false;
583 }, true);
584
585 toggle.addEventListener('click', () => { audio.paused ? audio.play() : audio.pause(); });
586 audio.addEventListener('play', () => { toggle.textContent = '⏸'; });
587 audio.addEventListener('pause', () => { toggle.textContent = '▶'; });
588 audio.addEventListener('ended', () => { bar.hidden = true; });
589 document.getElementById('mini-close').addEventListener('click', () => { audio.pause(); bar.hidden = true; });
590}
591
592/** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. *//** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. */
[303bfc4]593async function loadChecks() {
594 if (!state.guardians.length) return;
595 try {
596 const r = await fetch('/ward/api/guardian-checks', { headers: { Accept: 'application/json' } });
597 if (!r.ok) return;
598 const { checks } = await r.json();
599 renderGuardians(checks);
600 } catch { /* stilte is hier juist: dan blijft de lijst zonder oordeel staan */ }
601}
602
603function wireHelp() {
604 const btn = $('help-btn');
605 const hint = $('help-hint');
606 if (!state.guardians.length) {
607 btn.disabled = true;
608 hint.textContent = 'Je hebt nog geen guardians om te vragen.';
609 return;
610 }
611 hint.textContent = `Gaat naar ${state.guardians.length} guardian(s).`;
[a5a6249]612 btn.addEventListener('click', () => document.getElementById('buoy').click());
[303bfc4]613}
614
615renderGuardians(null);
[a5a6249]616wireBuoy();
[084b046]617wireSky();
[2086ea2]618wireComposer();
[39dab6b]619wireTheme();
620wireMiniPlayer();
[a5a6249]621loadFeed();
[303bfc4]622renderOffers();
623renderWaiting();
624wireHelp();
625loadChecks();
Note: See TracBrowser for help on using the repository browser.