/** * De Ward PWA (FEP-633c), clientkant. Tegenhanger van guardian.js, maar dan * vanuit het kind: wie waakt over mij, is dat vangnet heel, en wat ligt er bij * hen te wachten. * * Geen framework, net als guardian.js: de toestand komt als JSON mee met de * pagina en dit bestand tekent hem. Alles wat nieuw is komt van /ward/api/*. */ 'use strict'; const state = JSON.parse(document.getElementById('ward-state').textContent); const $ = (id) => document.getElementById(id); /** * De §4-uitkomst in woorden. * * Drie gevallen, en het derde is het punt: "onbereikbaar" is GEEN goedkeuring. * Een ward wiens guardian-server plat ligt heeft een ONGECONTROLEERD vangnet, * en dat is iets anders dan een gecontroleerd vangnet. Niets tonen zou het * eerste als het tweede laten lezen. Zelfde drieslag als in Shaer. */ const CHECK_NOTE = { malformed: { text: 'Deze guardian heeft zelf guardians. Een noodoproep komt hier niet aan.', cls: 'alarm' }, unreachable: { text: 'Deze guardian was niet te bereiken, dus niet gecontroleerd.', cls: 'unknown' }, sound: null, }; /** Beschikbaarheid in de taal die iedereen zonder handleiding leest. */ const AVAILABILITY = { active: { label: 'Beschikbaar', cls: 'ok' }, away: { label: 'Even weg', cls: 'away' }, dormant: { label: 'Offline', cls: 'off' }, }; function el(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; } function renderGuardians(checks) { const list = $('guardian-list'); list.replaceChildren(); $('guardians-empty').hidden = state.guardians.length > 0; state.guardians.forEach((g) => { const li = el('li', 'card'); const head = el('div', 'row'); head.append(el('span', 'name', g.handle)); const av = AVAILABILITY[g.availability] || AVAILABILITY.active; head.append(el('span', `chip ${av.cls}`, av.label)); li.append(head); // De controle staat WAAR de guardian genoemd wordt, niet in een logboek: // daar heeft het gat betekenis. const note = CHECK_NOTE[checks?.[g.uri]]; if (note) li.append(el('p', `note ${note.cls}`, note.text)); list.append(li); }); } function renderOffers() { const sec = $('offers'); const list = $('offer-list'); list.replaceChildren(); sec.hidden = !state.offers.length; state.offers.forEach((o) => { const li = el('li', 'card'); const who = o['shaer:candidate'] || o.actor || ''; li.append(el('p', 'name', `${who} wil jouw guardian worden`)); if (o['shaer:acceptedBy']) { li.append(el('p', 'hint', `${o['shaer:acceptedBy'].length} van de partijen zei al ja`)); } list.append(li); }); } function renderWaiting() { const sec = $('waiting'); const list = $('waiting-list'); list.replaceChildren(); sec.hidden = !state.waitingFollows.length; state.waitingFollows.forEach((w) => { const li = el('li', 'card'); li.append(el('p', 'name', w.handle)); // Niet "pending": de bal ligt bij iemand die je kent, en geweigerd is // helemaal geen wachten meer. Zelfde drie zinnen als in Shaer. const denied = w.status === 'denied'; li.append(el('p', denied ? 'note alarm' : 'hint', denied ? 'Je guardians hebben hier nee tegen gezegd.' : 'Je guardians moeten hier nog ja tegen zeggen.')); list.append(li); }); } /** * De kaarten. De body komt AF van de server (dezelfde partial als de Krant), * dus hier alleen invoegen; zelf HTML samenstellen zou opmaak, media en * quote-kaarten opnieuw moeten uitvinden en er anders uit laten zien. */ function renderFeed(items) { const box = document.getElementById('card-list'); box.replaceChildren(); document.getElementById('feed-empty').hidden = items.length > 0; items.forEach((it) => { const card = el('article', 'post'); card.dataset.note = it.id; // waar de boei op mikt const head = el('div', 'row'); if (it.authorIcon) { const img = el('img', 'avatar'); img.src = it.authorIcon; img.alt = ''; img.loading = 'lazy'; head.append(img); } head.append(el('span', 'name', it.authorName || it.author)); head.append(el('span', 'when', it.whenText || '')); card.append(head); if (it.cw) card.append(el('p', 'cw', it.cw)); const body = el('div', 'body'); body.innerHTML = it.bodyHtml || ''; // server-gerenderd en gesaniteerd card.append(body); box.append(card); }); } async function loadFeed() { try { const r = await fetch('/ward/api/feed', { headers: { Accept: 'application/json' } }); if (!r.ok) return; renderFeed((await r.json()).items || []); } catch { /* de pagina blijft staan met wat er al is */ } } /** * De reddingsboei: sleep hem op een kaart en je guardians krijgen die post. * * Pointer-events in plaats van muis- of touch-events apart, want dat is het * enige dat op een telefoon en met een muis hetzelfde doet. `setPointerCapture` * zorgt dat de sleep van jou blijft, en `touch-action: none` op de boei houdt * de pagina stil terwijl je hem verplaatst: zonder dat vecht het slepen met * scrollen en verlies je hem halverwege. */ function wireBuoy() { const buoy = document.getElementById('buoy'); const dlg = document.getElementById('help-dialog'); let dragging = false, dx = 0, dy = 0, target = null; const cardUnder = (x, y) => document.elementFromPoint(x, y)?.closest('.post'); buoy.addEventListener('pointerdown', (e) => { dragging = true; buoy.setPointerCapture(e.pointerId); const r = buoy.getBoundingClientRect(); dx = e.clientX - r.left; dy = e.clientY - r.top; buoy.classList.add('dragging'); }); buoy.addEventListener('pointermove', (e) => { if (!dragging) return; buoy.style.left = `${e.clientX - dx}px`; buoy.style.top = `${e.clientY - dy}px`; buoy.style.right = 'auto'; buoy.style.bottom = 'auto'; // Onderweg oplichten waar hij zou landen, zodat je ziet wat je kiest. document.querySelectorAll('.post.hover').forEach((n) => n.classList.remove('hover')); cardUnder(e.clientX, e.clientY)?.classList.add('hover'); }); const drop = (e) => { if (!dragging) return; dragging = false; buoy.classList.remove('dragging'); const card = cardUnder(e.clientX, e.clientY); document.querySelectorAll('.post.hover').forEach((n) => n.classList.remove('hover')); // Terug naar zijn hoek: de boei hoort altijd op dezelfde plek te liggen. buoy.style.left = buoy.style.top = 'auto'; buoy.style.right = ''; buoy.style.bottom = ''; if (card) askHelp(card.dataset.note, card.querySelector('.name')?.textContent || ''); }; buoy.addEventListener('pointerup', drop); buoy.addEventListener('pointercancel', drop); // Een tik (zonder slepen) vraagt hulp zonder dat er een post bij hoort. buoy.addEventListener('click', (e) => { if (!e.detail || !dragging) askHelp(null, null); }); function askHelp(noteId, who) { document.getElementById('help-title').textContent = who ? `Hulp vragen bij het bericht van ${who}?` : 'Hulp vragen aan je guardians?'; dlg.dataset.note = noteId || ''; document.getElementById('help-words').value = ''; dlg.showModal(); } dlg.addEventListener('close', async () => { if (dlg.returnValue !== 'send') return; const body = { note: dlg.dataset.note || undefined, words: document.getElementById('help-words').value }; const hint = document.getElementById('help-hint'); try { const r = await fetch('/ward/api/help', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const out = await r.json().catch(() => ({})); hint.textContent = r.ok ? state.strings.helpSent : (out.error === 'no_guardians' ? 'Je hebt nog geen guardians om te vragen.' : 'Dat lukte niet; probeer het nog eens.'); } catch { hint.textContent = 'Dat lukte niet; probeer het nog eens.'; } }); } /** De controle kost per guardian een remote fetch, dus hij komt ná het tekenen. */ async function loadChecks() { if (!state.guardians.length) return; try { const r = await fetch('/ward/api/guardian-checks', { headers: { Accept: 'application/json' } }); if (!r.ok) return; const { checks } = await r.json(); renderGuardians(checks); } catch { /* stilte is hier juist: dan blijft de lijst zonder oordeel staan */ } } function wireHelp() { const btn = $('help-btn'); const hint = $('help-hint'); if (!state.guardians.length) { btn.disabled = true; hint.textContent = 'Je hebt nog geen guardians om te vragen.'; return; } hint.textContent = `Gaat naar ${state.guardians.length} guardian(s).`; btn.addEventListener('click', () => document.getElementById('buoy').click()); } renderGuardians(null); wireBuoy(); loadFeed(); renderOffers(); renderWaiting(); wireHelp(); loadChecks();