Changeset 70677e96 in Klonkt for src/assets/js


Ignore:
Timestamp:
07/28/2026 07:43:49 PM (6 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
742ba7e
Parents:
a7bcf66
Message:

Guardian-PWA: een paneel per kind

/guardian was een platte lijst van zeven secties: de wards' berichten, de
volgverzoeken, de hulpvragen, adopteren, verzonden aanvragen, wards, meldingen.
Wie een kind wilde overzien moest tussen die secties heen en weer, en nergens
stond bij elkaar wat er over dat ene kind speelt. Een guardian denkt niet per
functie maar per kind.

Nu is de wards-lijst de ingang: elk kind is een regel die opent naar een paneel
met zijn instellingen, zijn volgverzoeken, zijn hulpvragen en zijn recente
berichten. De regel zelf draagt de tellers (hulpvragen, volgverzoeken), zodat
een dicht paneel nooit iets verbergt dat een antwoord nodig heeft; dat is
belangrijk voor de volgverzoeken, want daar zit de follow-gating in.

De hulpvragen blijven daarnaast bovenaan staan, over alle kinderen heen, want
dat is waar deze app voor bestaat en het moet opvallen zonder dat je eerst een
kind opent. Ze verschijnen dus bewust twee keer: bovenaan de recente, in het
paneel de volledige geschiedenis van dat kind.

De hulpvraag is geen alarm. Er wordt niemand automatisch gewaarschuwd; een
guardian kan hem stil oplossen. De kaart is daarom rustig gehouden.

Changed files:
src/views/pages/guardian.ejs

  • feed-section en follow-section weg als losse secties

src/assets/js/guardian.js

  • helpCard, feedCard en followCard losgetrokken zodat beide plekken ze delen
  • wardPanel: instellingen, volgverzoeken, hulpvragen, berichten, acties
  • openPanels onthoudt wat open staat, zodat verversen niet dichtklapt
  • feed en volgverzoeken vullen nu een cache en hertekenen de wards-lijst

src/assets/css/guardian.css

  • opmaak voor het paneel en de tellers op de regel

src/routes/guardian.js

  • authorUri en wardUri meegeven: de sleutels waarop gegroepeerd wordt
  • de nieuwe labels toegevoegd aan uiStrings

src/services/i18n.js

  • 39 strings voor het paneel in nl, en, de

New file:
test/guardian-panel.test.js

  • de client schrijft nergens naar een verdwenen sectie
  • elke paneelsectie heeft zijn groepeersleutel
  • elk label dat de client gebruikt wordt ook geserveerd

remarks: geverifieerd in de browser op een wegwerp-database met twee kinderen:
elk paneel toont alleen zijn eigen volgverzoek, hulpvraag en post, een geopend
paneel blijft open over een ververs-ronde heen, en op 375px loopt niets buiten
beeld. De afhandel-lifecycle (oppakken/afgehandeld, shaer-jxi) en luid publiek
als tweede gated feature (shaer-3kp) komen hierna in dit paneel.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/assets/js/guardian.js

    ra7bcf66 r70677e96  
    4343
    4444  // ── 1. Help requests ───────────────────────────────────────────────────
     45  // A call for help is not an alarm: it may well be settled quietly between a
     46  // guardian and the child. So the card carries no siren, it just has to be
     47  // impossible to miss. It shows up twice on purpose (Robins keuze): the recent
     48  // ones across all children at the top, the full history of one child in that
     49  // child's panel.
     50  var HELP_TOP = 5;
     51
     52  function helpCard(h) {
     53    var card = el('div', 'g-card help');
     54    var row = el('div', 'row');
     55    var who = el('span', 'who grow');
     56    // name_html carries the custom emojis (FEP-9098) of the display name, the
     57    // same way de Krant renders a byline. Falls back to the plain name.
     58    if (h.name_html) who.innerHTML = h.name_html;
     59    else who.textContent = h.actor_name || handleOf(h.actor_uri, h.actor_handle);
     60    row.appendChild(who);
     61    row.appendChild(el('span', 'when', when(h, h.published || h.created_at)));
     62    card.appendChild(row);
     63    var body = el('div', 'body g-note');
     64    // body_html is the shared note-body partial, rendered server-side: the
     65    // content with its emojis, the quote / link-preview card and the media.
     66    // Falls back to the bare content for rows stored before that existed.
     67    body.innerHTML = h.body_html || h.content || '';   // sanitized server-side on ingest
     68    card.appendChild(body);
     69    if (h.note_url) {
     70      var a = el('a', 'g-link', T.open || 'open');
     71      a.href = h.note_url; a.target = '_blank'; a.rel = 'noopener';
     72      card.appendChild(a);
     73    }
     74    return card;
     75  }
     76
    4577  function renderHelp() {
    4678    var list = document.getElementById('help-list');
    4779    list.textContent = '';
    4880    var help = S.help || [];
    49     help.forEach(function (h) {
    50       var card = el('div', 'g-card help');
    51       var row = el('div', 'row');
    52       var who = el('span', 'who grow');
    53       // name_html carries the custom emojis (FEP-9098) of the display name, the
    54       // same way de Krant renders a byline. Falls back to the plain name.
    55       if (h.name_html) who.innerHTML = h.name_html;
    56       else who.textContent = h.actor_name || handleOf(h.actor_uri, h.actor_handle);
    57       row.appendChild(who);
    58       row.appendChild(el('span', 'when', when(h, h.published || h.created_at)));
    59       card.appendChild(row);
    60       var body = el('div', 'body g-note');
    61       // body_html is the shared note-body partial, rendered server-side: the
    62       // content with its emojis, the quote / link-preview card and the media.
    63       // Falls back to the bare content for rows stored before that existed.
    64       body.innerHTML = h.body_html || h.content || '';   // sanitized server-side on ingest
    65       card.appendChild(body);
    66       if (h.note_url) {
    67         var a = el('a', 'g-link', T.open || 'open');
    68         a.href = h.note_url; a.target = '_blank'; a.rel = 'noopener';
    69         card.appendChild(a);
    70       }
    71       list.appendChild(card);
    72     });
     81    help.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); });
     82    if (help.length > HELP_TOP) {
     83      list.appendChild(el('p', 'g-sec-sub', '+ ' + (help.length - HELP_TOP) + ' — ' + (T.panel_help || '')));
     84    }
    7385    var badge = document.getElementById('help-count');
    7486    badge.textContent = help.length; badge.hidden = help.length === 0;
     
    119131  }
    120132
    121   // ── 4. Accepted wards ──────────────────────────────────────────────────
     133  // ── 4. Accepted wards: one panel per child ─────────────────────────────
     134  // A guardian thinks per child, not per function, so everything about one
     135  // child sits behind that child's row: the gated settings, the follow requests
     136  // waiting on them, their calls for help, their recent posts. The row itself
     137  // carries counts, so nothing that needs an answer hides inside a closed
     138  // panel.
     139  var openPanels = {};   // ward uri -> open, so a refresh does not close it
     140  // The follow requests and the wards' posts arrive from their own endpoints
     141  // and are grouped into the panels by ward, so they are cached here rather
     142  // than rendered into a section of their own.
     143  var FEED = [], FOLLOWS = [];
     144
     145  function sectionInto(panel, title, items, empty, build) {
     146    var h = el('div', 'g-panel-sec');
     147    h.appendChild(el('h3', null, title));
     148    if (!items.length) h.appendChild(el('p', 'g-empty small', empty));
     149    else items.forEach(function (it) { h.appendChild(build(it)); });
     150    panel.appendChild(h);
     151    return h;
     152  }
     153
     154  function embedsButton(w) {
     155    // Gated feature: external (non-fediverse) embeds. Off by default for a
     156    // ward; only a guardian can open it, and the gate is enforced server-side
     157    // when the feed is built, so this button is the only thing that moves it.
     158    // Shown for EVERY ward, including one on another server. There the value is
     159    // unknown (it lives on the ward's server), but proposing is exactly as
     160    // possible: the proposal travels, the ward's server tallies the guardians
     161    // and enforces. A guardian next door must not have more say than one far
     162    // away.
     163    var known = w.embeds === true || w.embeds === false;
     164    var emb = el('button', 'quiet small',
     165      (known ? (w.embeds ? T.embeds_on : T.embeds_off) : T.embeds_propose) || 'Link previews');
     166    emb.addEventListener('click', function () {
     167      emb.disabled = true;
     168      fetch('/guardian/wards/embeds', {
     169        method: 'POST', headers: { 'Content-Type': 'application/json' },
     170        body: JSON.stringify({ uri: w.other_uri, allow: known ? !w.embeds : true }),
     171      }).then(function (r) { return r.json(); })
     172        .then(function (j) {
     173          // Not settled yet: the other guardians still have to answer.
     174          if (j && j.state === 'open') {
     175            emb.textContent = (T.embeds_waiting || 'waiting for the other guardians');
     176            emb.disabled = true;
     177            return;
     178          }
     179          refresh();
     180        })
     181        .catch(function () { emb.disabled = false; });
     182    });
     183    return emb;
     184  }
     185
     186  function wardPanel(w) {
     187    var uri = w.other_uri;
     188    var panel = el('div', 'g-panel');
     189    panel.hidden = !openPanels[uri];
     190
     191    var set = el('div', 'g-panel-sec');
     192    set.appendChild(el('h3', null, T.settings_title || 'Settings'));
     193    var setRow = el('div', 'row');
     194    setRow.appendChild(embedsButton(w));
     195    set.appendChild(setRow);
     196    panel.appendChild(set);
     197
     198    sectionInto(panel, T.panel_follow || 'Follow requests',
     199      FOLLOWS.filter(function (f) { return f.wardUri === uri; }),
     200      T.panel_follow_empty || '', followCard);
     201
     202    sectionInto(panel, T.panel_help || 'Calls for help',
     203      (S.help || []).filter(function (h) { return h.actor_uri === uri; }),
     204      T.panel_help_empty || '', helpCard);
     205
     206    sectionInto(panel, T.panel_posts || 'Recent posts',
     207      FEED.filter(function (p) { return p.authorUri === uri; }),
     208      T.panel_posts_empty || '', feedCard);
     209
     210    var act = el('div', 'g-panel-sec');
     211    act.appendChild(el('h3', null, T.panel_actions || 'Actions'));
     212    var actRow = el('div', 'row');
     213    var wave = el('button', 'small', T.wave || '👋 Wave');
     214    wave.addEventListener('click', function () { sendWave(uri, wave); });
     215    actRow.appendChild(wave);
     216    var rel = el('button', 'quiet small', T.release);
     217    // Releasing a ward is heavy and hard to undo (coming back needs a fresh
     218    // offer the ward accepts), so it asks first and spells out what changes.
     219    rel.addEventListener('click', function () {
     220      var who = handleOf(uri, w.other_handle);
     221      var msg = (T.release_confirm || 'Release {who}?').replace('{who}', who);
     222      if (window.confirm(msg)) remove(uri, rel);
     223    });
     224    actRow.appendChild(rel);
     225    act.appendChild(actRow);
     226    panel.appendChild(act);
     227    return panel;
     228  }
     229
    122230  function renderWards() {
    123231    var list = document.getElementById('wards-list');
     
    125233    var wards = S.wards || [];
    126234    wards.forEach(function (w) {
    127       var card = el('div', 'g-card');
     235      var uri = w.other_uri;
     236      var card = el('div', 'g-card ward');
    128237      var row = el('div', 'row');
    129       row.appendChild(el('span', 'who grow', handleOf(w.other_uri, w.other_handle)));
     238      row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle)));
     239      // Counts on the row: whatever is waiting must be visible with the panel shut.
     240      var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri; }).length;
     241      var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length;
     242      if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp));
     243      if (nFollow) row.appendChild(el('span', 'tag co', nFollow + ' ' + (nFollow === 1 ? (T.badge_follow_one || '') : (T.badge_follow || ''))));
    130244      row.appendChild(el('span', 'tag ok', T.active));
    131       var wave = el('button', 'small', T.wave || '👋 Wave');
    132       wave.addEventListener('click', function () { sendWave(w.other_uri, wave); });
    133       row.appendChild(wave);
    134       // Gated feature: external (non-fediverse) embeds. Off by default for a
    135       // ward; only a guardian can open it, and the gate is enforced server-side
    136       // when the feed is built, so this button is the only thing that moves it.
    137       // Shown for EVERY ward, including one on another server. There the value
    138       // is unknown (it lives on the ward's server), but proposing is exactly as
    139       // possible: the proposal travels, the ward's server tallies the guardians
    140       // and enforces. A guardian next door must not have more say than one far
    141       // away.
    142       var known = w.embeds === true || w.embeds === false;
    143       var emb = el('button', 'quiet small',
    144         (known ? (w.embeds ? T.embeds_on : T.embeds_off) : T.embeds_propose) || 'Link previews');
    145       emb.addEventListener('click', function () {
    146         emb.disabled = true;
    147         fetch('/guardian/wards/embeds', {
    148           method: 'POST', headers: { 'Content-Type': 'application/json' },
    149           body: JSON.stringify({ uri: w.other_uri, allow: known ? !w.embeds : true }),
    150         }).then(function (r) { return r.json(); })
    151           .then(function (j) {
    152             // Not settled yet: the other guardians still have to answer.
    153             if (j && j.state === 'open') {
    154               emb.textContent = (T.embeds_waiting || 'waiting for the other guardians');
    155               emb.disabled = true;
    156               return;
    157             }
    158             refresh();
    159           })
    160           .catch(function () { emb.disabled = false; });
     245      var toggle = el('button', 'quiet small', openPanels[uri] ? T.panel_close : T.panel_open);
     246      row.appendChild(toggle);
     247      card.appendChild(row);
     248      var panel = wardPanel(w);
     249      card.appendChild(panel);
     250      toggle.addEventListener('click', function () {
     251        openPanels[uri] = !openPanels[uri];
     252        panel.hidden = !openPanels[uri];
     253        toggle.textContent = openPanels[uri] ? T.panel_close : T.panel_open;
    161254      });
    162       row.appendChild(emb);
    163       var btn = el('button', 'quiet small', T.release);
    164       // Releasing a ward is heavy and hard to undo (coming back needs a fresh
    165       // offer the ward accepts), so it asks first and spells out what changes.
    166       btn.addEventListener('click', function () {
    167         var who = handleOf(w.other_uri, w.other_handle);
    168         var msg = (T.release_confirm || 'Release {who}?').replace('{who}', who);
    169         if (window.confirm(msg)) remove(w.other_uri, btn);
    170       });
    171       row.appendChild(btn);
    172       card.appendChild(row);
    173255      list.appendChild(card);
    174256    });
     
    197279
    198280  // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
    199   function renderFeed(items) {
    200     var list = document.getElementById('feed-list');
    201     list.textContent = '';
    202     (items || []).forEach(function (p) {
    203       var card = el('div', 'g-card feed');
    204       var head = el('div', 'row');
    205       head.appendChild(el('span', 'who grow', p.author));
    206       if (p.published) head.appendChild(el('span', 'g-when', when(p, p.published)));
    207       card.appendChild(head);
    208       var body = el('div', 'feed-body');
    209       if (p.cw) {
    210         var d = document.createElement('details');
    211         var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
    212         var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner);
    213         body.appendChild(d);
    214       } else {
    215         body.innerHTML = p.content || '';   // server-sanitized HTML (same as Berichten)
    216       }
    217       card.appendChild(body);
    218       list.appendChild(card);
    219     });
    220     show('feed-section', (items || []).length > 0);
     281  // Lives inside each child's panel now, so the fetches only fill a cache and
     282  // ask the ward list to redraw. A guardian watches, it does not publish.
     283  function feedCard(p) {
     284    var card = el('div', 'g-card feed');
     285    var head = el('div', 'row');
     286    head.appendChild(el('span', 'who grow', p.author));
     287    if (p.published) head.appendChild(el('span', 'g-when', when(p, p.published)));
     288    card.appendChild(head);
     289    var body = el('div', 'feed-body');
     290    if (p.cw) {
     291      var d = document.createElement('details');
     292      var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
     293      var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner);
     294      body.appendChild(d);
     295    } else {
     296      body.innerHTML = p.content || '';   // server-sanitized HTML (same as Berichten)
     297    }
     298    card.appendChild(body);
     299    return card;
    221300  }
    222301
     
    224303    return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
    225304      .then(function (r) { return r.json(); })
    226       .then(function (f) { if (f && !f.error) renderFeed(f.items); })
    227       .catch(function () { /* corner just stays hidden */ });
     305      .then(function (f) { if (f && !f.error) { FEED = f.items || []; renderWards(); } })
     306      .catch(function () { /* panels just show "nothing yet" */ });
    228307  }
    229308
     
    236315    }).then(loadFollowReqs);
    237316  }
    238   function renderFollowReqs(items) {
    239     var list = document.getElementById('follow-list');
    240     list.textContent = '';
    241     (items || []).forEach(function (f) {
    242       var card = el('div', 'g-card');
    243       var row = el('div', 'row');
    244       row.appendChild(el('span', 'who grow', f.follower + '  →  ' + f.ward));
    245       var ok = el('button', 'small', T.accept || 'Accept');
    246       ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
    247       var no = el('button', 'quiet small', T.reject || 'Deny');
    248       no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
    249       row.appendChild(ok); row.appendChild(no);
    250       card.appendChild(row);
    251       list.appendChild(card);
    252     });
    253     show('follow-section', (items || []).length > 0);
     317  function followCard(f) {
     318    var card = el('div', 'g-card');
     319    var row = el('div', 'row');
     320    // Inside the child's own panel the ward name is a given, so only the
     321    // person asking is named here.
     322    row.appendChild(el('span', 'who grow', f.follower));
     323    var ok = el('button', 'small', T.accept || 'Accept');
     324    ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
     325    var no = el('button', 'quiet small', T.reject || 'Deny');
     326    no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
     327    row.appendChild(ok); row.appendChild(no);
     328    card.appendChild(row);
     329    return card;
    254330  }
    255331  function loadFollowReqs() {
    256332    return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
    257333      .then(function (r) { return r.json(); })
    258       .then(function (f) { if (f && !f.error) renderFollowReqs(f.items); })
    259       .catch(function () { /* stays hidden */ });
     334      .then(function (f) { if (f && !f.error) { FOLLOWS = f.items || []; renderWards(); } })
     335      .catch(function () { /* panels just show "none waiting" */ });
    260336  }
    261337
Note: See TracChangeset for help on using the changeset viewer.