Index: src/assets/css/guardian.css
===================================================================
--- src/assets/css/guardian.css	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/assets/css/guardian.css	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -168,2 +168,30 @@
 .g-card.gated { border-left: 3px solid var(--accent); }
 .g-card.gated .row { margin-top: 8px; gap: 8px; }
+
+/* Het gate-paneel per ward (shaer-ahy.1). Een rij per gate, met het soort en de
+   drempel erbij. Bewust rustig: dit is een overzicht om te LEZEN, en pas daarna
+   een plek om iets voor te stellen. */
+.g-gate { padding: .5rem 0; border-top: 1px solid var(--g-line, rgba(0,0,0,.08)); }
+.g-gate:first-of-type { border-top: 0; }
+.g-gate-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; }
+.g-gate-name { font-weight: 600; }
+/* Het soort is een fluistering, geen kop: het hoort erbij te staan zonder de
+   naam van de gate te overstemmen. */
+.g-gate-kind { font-size: .78em; opacity: .65; }
+.g-gate-meta { display: flex; gap: .75rem; flex-wrap: wrap; margin-top: .15rem; }
+.g-gate-meta .g-dim { opacity: .55; }
+/* Onomkeerbaar krijgt gewicht, want dat is het enige in dit paneel dat je niet
+   kunt terugdraaien. */
+.g-gate-warn { font-weight: 600; }
+.g-gate-wait { font-weight: 600; }
+
+/* De gedeelde staat van een hulpvraag (shaer-lgo). Rustig: dit is geen alarm,
+   het moet alleen onmogelijk te missen zijn wie er al op af is. */
+.g-help-state { margin-top: .5rem; }
+.g-help-pick { font-size: .9em; }
+.g-help-age { opacity: .6; }        /* oud, maar niet weg */
+.g-help-done { font-weight: 600; }
+.g-confirm { margin-top: .5rem; }
+
+/* Het archief van afgehandelde hulpvragen: aanwezig, niet in de weg. */
+.g-help-archive { margin-top: .5rem; opacity: .75; }
Index: src/assets/js/guardian.js
===================================================================
--- src/assets/js/guardian.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/assets/js/guardian.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -49,4 +49,18 @@
   // child's panel.
   var HELP_TOP = 5;
+
+  /**
+   * Wacht deze hulpvraag nog op iemand?
+   *
+   * EEN plek, met opzet. Dit werd eerst op twee plekken los beslist -- de lijst
+   * en de teller op de wardregel -- en toen de lijst gefilterd werd bleef die
+   * teller een afgehandelde vraag meetellen. Een derde plek komt er vast, en dan
+   * hoort hij hier langs te komen.
+   *
+   * Bij TWIJFEL open: alles wat geen expliciete afsluiting draagt telt als
+   * wachtend. De omgekeerde fout -- iets als afgehandeld tonen dat het niet is --
+   * is hier de gevaarlijke.
+   */
+  function helpOpen(h) { return !h || !h.state || h.state.open; }
 
   function helpCard(h) {
@@ -72,5 +86,80 @@
       card.appendChild(a);
     }
+    card.appendChild(helpState(h));
     return card;
+  }
+
+  /** Hoe lang geleden, grof. Een oppik vervalt niet maar hoort wel te verouderen. */
+  function ago(ms) {
+    var u = Math.floor(ms / 3600000);
+    if (u < 1) return T.help_just_now || 'just now';
+    if (u < 24) return (T.help_hours || '{n}h ago').replace('{n}', u);
+    return (T.help_days || '{n}d ago').replace('{n}', Math.floor(u / 24));
+  }
+
+  /**
+   * De gedeelde staat van een hulpvraag (shaer-lgo): wie er al op af is, of het
+   * is afgesloten, en de twee knoppen.
+   *
+   * De faalstand hier is NIET veilig: 'iedereen denkt dat het geregeld is' is
+   * gevaarlijker dan geen markering. Dus bij twijfel toont dit OPEN, en een oude
+   * oppik verkleurt in plaats van te verdwijnen -- anders ziet een hulpvraag er
+   * onaangeroerd uit terwijl er iemand mee bezig is.
+   */
+  function helpState(h) {
+    var st = h.state || { open: true, pickedUpBy: [], handled: null, ageMs: null };
+    var box = el('div', 'g-help-state');
+
+    if (st.handled) {
+      var wie = st.handled.handle || handleOf(st.handled.uri);
+      box.appendChild(el('div', 'g-help-done', (T.help_handled_by || 'Handled by {who}').replace('{who}', wie)));
+      // Geen terugdraaiknop: leeft de vraag nog, dan wordt hij opnieuw gesteld.
+      box.appendChild(el('p', 'small g-empty', T.help_handled_note || ''));
+      return box;
+    }
+
+    (st.pickedUpBy || []).forEach(function (p) {
+      var line = (T.help_picked_by || '{who} is looking into this').replace('{who}', p.handle || handleOf(p.uri));
+      box.appendChild(el('div', 'g-help-pick', line));
+    });
+    // Oud, maar niet weg. Dit is het verschil tussen 'er is iemand mee bezig' en
+    // 'er was ooit iemand mee bezig'.
+    if (st.ageMs != null && st.ageMs > 3600000) {
+      box.appendChild(el('div', 'g-help-age small', ago(st.ageMs)));
+    }
+
+    var row = el('div', 'row');
+    var mij = (st.pickedUpBy || []).some(function (p) { return p.uri === S.me; });
+    if (!mij) {
+      var pick = el('button', 'small', T.help_pick || 'I am on it');
+      pick.addEventListener('click', function () { markHelp(h, 'pickup', pick); });
+      row.appendChild(pick);
+    }
+    var done = el('button', 'quiet small', T.help_close || 'Mark handled');
+    // Een stevige bevestiging, en nooit een window.confirm: dat verstopt een
+    // uitleg achter een OK die mensen wegklikken. Zelfde lijn als het loslaten
+    // van een ward.
+    done.addEventListener('click', function () {
+      done.hidden = true;
+      var ask = el('div', 'g-confirm');
+      ask.appendChild(el('p', 'small', T.help_close_ask || ''));
+      var ja = el('button', 'small', T.help_close_yes || 'Yes, handled');
+      ja.addEventListener('click', function () { markHelp(h, 'handled', ja); });
+      var nee = el('button', 'quiet small', T.release_no || 'No');
+      nee.addEventListener('click', function () { ask.remove(); done.hidden = false; });
+      ask.appendChild(ja); ask.appendChild(nee);
+      box.appendChild(ask);
+    });
+    row.appendChild(done);
+    box.appendChild(row);
+    return box;
+  }
+
+  function markHelp(h, kind, btn) {
+    if (btn) btn.disabled = true;
+    fetch('/guardian/api/help/' + kind, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ note: h.object_uri, ward: h.actor_uri, site: S.site }),
+    }).then(refresh).catch(function () { if (btn) btn.disabled = false; });
   }
 
@@ -78,12 +167,40 @@
     var list = document.getElementById('help-list');
     list.textContent = '';
-    var help = S.help || [];
-    help.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); });
-    if (help.length > HELP_TOP) {
-      list.appendChild(el('p', 'g-sec-sub', '+ ' + (help.length - HELP_TOP) + ' — ' + (T.panel_help || '')));
-    }
+    var alle = S.help || [];
+    // Afgehandeld hoort niet meer in de lijst die om aandacht vraagt, en niet
+    // meer in de teller: anders blijft het cijfer alarm slaan voor iets dat af
+    // is, en neemt een gesloten vraag de ruimte in van een open vraag.
+    var open = alle.filter(helpOpen);
+    var klaar = alle.filter(function (h) { return !helpOpen(h); });
+
+    open.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); });
+    if (open.length > HELP_TOP) {
+      list.appendChild(el('p', 'g-sec-sub', '+ ' + (open.length - HELP_TOP) + ' — ' + (T.panel_help || '')));
+    }
+
+    // WEG is niet hetzelfde als AF. Een afgehandelde hulpvraag blijft bestaan --
+    // er wordt in dit systeem niets herschreven -- maar hij hoort achter een
+    // klik, niet voor je neus. De volledige geschiedenis per kind staat sowieso
+    // in het paneel van dat kind.
+    if (klaar.length) {
+      var doos = el('div', 'g-help-archive');
+      doos.hidden = true;
+      klaar.forEach(function (h) { doos.appendChild(helpCard(h)); });
+      var knop = el('button', 'quiet small', (T.help_archive || '{n} handled').replace('{n}', klaar.length));
+      knop.addEventListener('click', function () {
+        doos.hidden = !doos.hidden;
+        knop.textContent = doos.hidden
+          ? (T.help_archive || '{n} handled').replace('{n}', klaar.length)
+          : (T.help_archive_hide || 'hide');
+      });
+      list.appendChild(knop);
+      list.appendChild(doos);
+    }
+
     var badge = document.getElementById('help-count');
-    badge.textContent = help.length; badge.hidden = help.length === 0;
-    show('help-empty', help.length === 0);
+    badge.textContent = open.length; badge.hidden = open.length === 0;
+    // "Geen hulpverzoeken. Mooi zo." mag ook staan als er wel een archief is:
+    // er wacht dan immers niets.
+    show('help-empty', open.length === 0);
   }
 
@@ -351,4 +468,64 @@
   }
 
+  /** Het woord dat we voor een gate gebruiken; onbekend valt terug op de naam. */
+  function gateLabel(feature) {
+    return T['gate_' + feature.replace('shaer:', '')] || feature.replace('shaer:', '');
+  }
+
+  /**
+   * Een rij in het gate-paneel. Toont WAT er geldt, van welk SOORT het is, welke
+   * drempel er hoort en wat er loopt -- en pas daarna een knop, als er iets te
+   * verzetten valt.
+   *
+   * Het soort staat erbij omdat de gates niet hetzelfde werken: een stand is aan
+   * of uit, volgverzoeken zijn een stroom beslissingen, en een overdracht is
+   * onomkeerbaar zodra het kind hem gebruikt. Vier rijen met dezelfde schakelaar
+   * zouden dat verschil wegpoetsen.
+   */
+  function gateRow(w, g) {
+    var row = el('div', 'g-gate');
+    var head = el('div', 'g-gate-head');
+    head.appendChild(el('span', 'g-gate-name', gateLabel(g.feature)));
+    head.appendChild(el('span', 'g-gate-kind', T['gate_kind_' + g.kind] || g.kind));
+    row.appendChild(head);
+
+    // De stand. NULL is onbekend en dat is iets anders dan uit: bij een ward op
+    // een andere server wordt die kolom daar bijgehouden.
+    var stand = g.value === null || g.value === undefined
+      ? (T.gate_unknown || 'unknown')
+      : (g.value ? (T.prop_on || 'on') : (T.prop_off || 'off'));
+    var meta = el('div', 'g-gate-meta small');
+    meta.appendChild(el('span', null, stand));
+    if (g.threshold) {
+      meta.appendChild(el('span', null, (T.gate_threshold || '{need} of {of} guardians')
+        .replace('{need}', g.threshold.need).replace('{of}', g.threshold.of)));
+    } else {
+      // Geen drempel verzinnen die we niet kennen.
+      meta.appendChild(el('span', 'g-dim', T.gate_threshold_unknown || ''));
+    }
+    if (!g.reversible) meta.appendChild(el('span', 'g-gate-warn', T.gate_irreversible || ''));
+    if (g.waiting) {
+      meta.appendChild(el('span', 'g-gate-wait', (T.gate_waiting || '{n} waiting').replace('{n}', g.waiting)));
+    }
+    row.appendChild(meta);
+
+    if (g.proposal) {
+      row.appendChild(el('p', 'small g-prop g-prop-' + g.proposal.status,
+        (T.prop_line || 'Proposal {what} {value}: {status}')
+          .replace('{what}', gateLabel(g.feature))
+          .replace('{value}', g.proposal.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
+          .replace('{status}', T['prop_st_' + g.proposal.status] || g.proposal.status)));
+    }
+    if (g.blockedBy) {
+      row.appendChild(el('p', 'small g-empty', (T.gate_blocked || 'needs {what} first')
+        .replace('{what}', gateLabel(g.blockedBy))));
+    }
+    if (g.adjustable) {
+      row.appendChild(gateButton(w, g.feature, g.value, T.gate_propose || T.embeds_propose,
+        T.prop_on || 'on', T.prop_off || 'off'));
+    }
+    return row;
+  }
+
   function wardPanel(w) {
     var uri = w.other_uri;
@@ -358,28 +535,25 @@
     var set = el('div', 'g-panel-sec');
     set.appendChild(el('h3', null, T.settings_title || 'Settings'));
-    var setRow = el('div', 'row');
-    setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off));
-    // The heavier sibling (5.6): seeing that a video exists is one decision,
-    // letting a third party's player run inside the app is another. Hidden
-    // only when previews are known-OFF: for a ward on another server the
-    // value is unknown, and unknown is not off. Hiding it there meant a
-    // guardian elsewhere could never even propose playback, which is how a
-    // whole proposal round went into the wrong gate. The ward's server
-    // enforces play-needs-previews at serve time regardless.
-    if (w.embeds !== false) {
-      setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off));
-    }
-    set.appendChild(setRow);
-    // What this guardian proposed and how it stands (5.6). This used to be a
-    // button caption that vanished on refresh, so a running decision was
-    // invisible: you could not tell "waiting", "done" and "expired" apart.
-    (w.proposals || []).forEach(function (p) {
-      var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews');
-      var line = (T.prop_line || 'Proposal {what} {value}: {status}')
-        .replace('{what}', what)
-        .replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
-        .replace('{status}', T['prop_st_' + p.status] || p.status);
-      set.appendChild(el('p', 'small g-prop g-prop-' + p.status, line));
-    });
+    // Een rij per gate, uit de catalogus van de server (shaer-ahy.1). Losse
+    // knoppen lieten een guardian zelf uitzoeken wat er allemaal geldt, en wat
+    // niet verstelbaar is stond nergens -- terwijl dat de helft van het antwoord
+    // is op "wat mag dit kind".
+    (w.gates || []).forEach(function (g) { set.appendChild(gateRow(w, g)); });
+    // Terugval voor een server die de catalogus nog niet stuurt: dan de twee
+    // knoppen zoals ze waren, zodat een oudere Klonkt niet met een leeg vak zit.
+    if (!(w.gates || []).length) {
+      var setRow = el('div', 'row');
+      setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off));
+      if (w.embeds !== false) {
+        setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off));
+      }
+      set.appendChild(setRow);
+      (w.proposals || []).forEach(function (p) {
+        var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews');
+        set.appendChild(el('p', 'small g-prop g-prop-' + p.status, (T.prop_line || 'Proposal {what} {value}: {status}')
+          .replace('{what}', what).replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
+          .replace('{status}', T['prop_st_' + p.status] || p.status)));
+      });
+    }
     panel.appendChild(set);
 
@@ -420,6 +594,10 @@
       T.panel_follow_empty || '', followCard);
 
+    // Hier juist de VOLLEDIGE geschiedenis van dit kind -- dat is waar dit
+    // paneel voor is. Wel wat nog wacht bovenaan, want dat is waar je naar zoekt
+    // als je het opent.
     sectionInto(panel, T.panel_help || 'Calls for help',
-      (S.help || []).filter(function (h) { return h.actor_uri === uri; }),
+      (S.help || []).filter(function (h) { return h.actor_uri === uri; })
+        .sort(function (a, b) { return (helpOpen(b) ? 1 : 0) - (helpOpen(a) ? 1 : 0); }),
       T.panel_help_empty || '', helpCard);
 
@@ -453,5 +631,9 @@
     actRow.appendChild(rel);
     act.appendChild(actRow);
-    panel.appendChild(act);
+    // Bovenaan, niet onderaan: zwaaien en loslaten zijn de dingen die je DOET.
+    // De rest van het paneel is lezen -- instellingen, wie er nog meer op let,
+    // wat er binnenkwam. Wie het paneel opent om iets te doen hoorde eerst langs
+    // vijf secties te scrollen.
+    panel.insertBefore(act, panel.firstChild);
     return panel;
   }
@@ -467,5 +649,7 @@
       row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle)));
       // Counts on the row: whatever is waiting must be visible with the panel shut.
-      var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri; }).length;
+      // Alleen wat nog WACHT, zoals het commentaar hierboven al zei: een
+      // afgehandelde vraag hoorde de boei niet te laten staan.
+      var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }).length;
       var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length;
       if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp));
@@ -553,11 +737,17 @@
     card.appendChild(head);
     var body = el('div', 'feed-body');
+    // body_html is de gedeelde note-body-partial, serverside gerenderd: opmaak,
+    // media, quote-kaart en embed, precies als in de Krant en in Berichten.
+    // Valt terug op de kale content voor een client uit de cache.
+    var html = p.body_html || p.content || '';
     if (p.cw) {
+      // De content warning blijft van de PWA zelf: note-body versluiert alleen
+      // bij nsfw, en een ward-post met alleen een cw hoort hier dicht te staan.
       var d = document.createElement('details');
       var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
-      var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner);
+      var inner = el('div'); inner.innerHTML = html; d.appendChild(inner);
       body.appendChild(d);
     } else {
-      body.innerHTML = p.content || '';   // server-sanitized HTML (same as Berichten)
+      body.innerHTML = html;   // server-sanitized HTML (same as Berichten)
     }
     card.appendChild(body);
@@ -700,5 +890,19 @@
 
   renderAll(); pushState(); loadFeed(); loadFollowReqs();
-  setInterval(refresh, 45000);   // live-ish while open
+
+  // De push die de melding brengt is meteen het teken dat de staat veranderd is.
+  // Daarmee hoeft er geen tweede, open verbinding bij: hetzelfde kanaal doet het
+  // werk, en het werkt ook als de app dicht is.
+  if ('serviceWorker' in navigator) {
+    navigator.serviceWorker.addEventListener('message', function (e) {
+      if (e.data && e.data.klonkt === 'push') refresh();
+    });
+  }
+  // Het tikje blijft als vangnet -- niet elke verandering geeft een melding, en
+  // niet iedereen heeft meldingen aanstaan. Maar niet tikken terwijl niemand
+  // kijkt: dat waren verzoeken voor een tabblad op de achtergrond. Bij terugkomen
+  // meteen een keer, want dan is de kans op nieuws het grootst.
+  setInterval(function () { if (!document.hidden) refresh(); }, 45000);
+  document.addEventListener('visibilitychange', function () { if (!document.hidden) refresh(); });
   } catch (e) {
     fatal((e && e.message) || String(e));
Index: src/assets/js/mod/admin-audio.js
===================================================================
--- src/assets/js/mod/admin-audio.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-audio.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,442 @@
+// Audio in beheer -- verplaatst uit inline script, shaer-bqr.
+//
+// Servergegevens komen uit pageData(); interpolatie kan niet in een statisch
+// bestand. Inline script wordt bovendien geweigerd zodra deze pagina via een
+// link BINNEN de site binnenkomt (shaer-0i6).
+
+import { pageData } from './lib.js';
+
+  const T = pageData();
+
+(function() {
+
+  // ── Live filename display for custom file inputs ──────────────
+  document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
+    const nameEl = input.parentElement.querySelector('.ax-file-name');
+    if (!nameEl) return;
+    input.addEventListener('change', () => {
+      if (input.files && input.files[0]) {
+        nameEl.textContent = input.files[0].name;
+        nameEl.removeAttribute('data-empty');
+      } else {
+        nameEl.textContent = (T.no_file || '');
+        nameEl.setAttribute('data-empty', '');
+      }
+    });
+  });
+
+  // ── Inline track preview (routed through the global mini-player) ──
+  // Each .ax-track-play button is a thin wrapper around the global
+  // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
+  // .is-playing) is synced from the player's own audio element so it
+  // stays accurate even when the user uses the mini-player's controls.
+  (function setupTrackPreview() {
+    const buttons = document.querySelectorAll('.ax-track-play');
+    if (!buttons.length) return;
+
+    function setIcon(btn, playing) {
+      const icon = btn.querySelector('.ax-track-play-icon');
+      if (icon) icon.textContent = playing ? '⏸' : '▶';
+      btn.classList.toggle('is-playing', playing);
+      btn.setAttribute('aria-label', playing ? (T.pause || '') : (T.play || ''));
+    }
+
+    // Resync ALL preview buttons against the current audio element state.
+    // Called on every play/pause/ended event so admins always see the right
+    // icon — including the case where they hit pause on the mini-player
+    // bar instead of the row's button.
+    function resyncAll() {
+      const audio = document.getElementById('audio-element');
+      const player = window.pcmsAudioPlayer;
+      const playing = audio && !audio.paused && !audio.ended;
+      // audio.src is now a blob: URL (Spotify-style playback), so compare
+      // against the player's logical track URL, not the element src.
+      const cur = player && player.currentTrack();
+      const curUrl = cur ? cur.url : '';
+      buttons.forEach(b => {
+        const isThisOne = playing && curUrl === b.dataset.streamUrl;
+        setIcon(b, isThisOne);
+      });
+    }
+
+    buttons.forEach(btn => {
+      btn.addEventListener('click', e => {
+        e.preventDefault();
+        const player = window.pcmsAudioPlayer;
+        if (!player) {
+          console.warn('[admin-audio] miniplayer not available');
+          return;
+        }
+        const url = btn.dataset.streamUrl;
+        if (!url) return;
+
+        // Build track metadata from the row's DOM so the mini-player shows
+        // useful info (title/artist/album/cover) without an extra API call.
+        const row = btn.closest('.ax-track');
+        const titleEl  = row && row.querySelector('[data-cell="title"]');
+        const artistEl = row && row.querySelector('[data-cell="artist"]');
+        const albumEl  = row && row.querySelector('[data-cell="album"]');
+        const coverImg = row && row.querySelector('img[data-cover-thumb]');
+        const track = {
+          url,
+          title:  titleEl  ? titleEl.textContent.trim()  : 'Track',
+          artist: artistEl ? artistEl.textContent.trim() : '',
+          album:  albumEl  ? albumEl.textContent.trim()  : '',
+          cover:  coverImg ? coverImg.src                : '',
+        };
+
+        // If this exact track is already current, toggle pause/play instead
+        // of restarting from zero. Compare logical URLs (audio.src is a blob:).
+        const cur = player.currentTrack();
+        if (cur && cur.url === url) {
+          if (player.isPlaying()) player.pause();
+          else                    player.play();
+          return;
+        }
+
+        player.setQueue([track], 0);
+      });
+    });
+
+    // Hook the global audio element's events to keep the row buttons synced.
+    // We attach lazily after the player has built its DOM. The script in
+    // shell.ejs runs at page-load so #audio-element exists by the time
+    // this IIFE fires (script tag is below the body content).
+    const audio = document.getElementById('audio-element');
+    if (audio) {
+      ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
+        audio.addEventListener(ev, resyncAll);
+      });
+      // Initial state on page load (e.g. user navigated back while a track
+      // was already playing — buttons should reflect that).
+      resyncAll();
+    }
+  })();
+
+
+  // ── Bulk upload (drag-drop + sequential transcoding) ─────────
+  // Files dropped or picked are queued (not uploaded immediately) so the
+  // user can review the list, set shared metadata, then hit "Start upload".
+  // The loop POSTs one file at a time to /admin/audio/upload with
+  // Accept: application/json so the server returns structured per-file
+  // results instead of redirecting.
+  const dropzone   = document.getElementById('audio-dropzone');
+  const fileInput  = document.getElementById('audio-files');
+  const queueEl    = document.getElementById('upload-queue');
+  const actionsEl  = document.getElementById('upload-actions');
+  const startBtn   = document.getElementById('start-upload-btn');
+  const clearBtn   = document.getElementById('clear-queue-btn');
+  const artistInput= document.getElementById('batch-artist');
+  const albumInput = document.getElementById('batch-album');
+  const coverInput = document.getElementById('batch-cover');
+
+  /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
+  const queue = [];
+
+  function fmtBytes(n) {
+    if (n < 1024) return n + ' B';
+    if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
+    return (n / 1024 / 1024).toFixed(1) + ' MB';
+  }
+
+  function setItemStatus(item, status, label) {
+    const labels = {
+      queued:      (T.st_queued || ''),
+      uploading:   (T.st_uploading || ''),
+      transcoding: (T.st_transcoding || ''),
+      done:        '✓ ' + (T.st_done || ''),
+      error:       '✗ ' + (T.st_error || ''),
+    };
+    item.status = status;
+    const badge = item.el.querySelector('.ax-queue-status');
+    badge.className = 'ax-queue-status ax-queue-status--' + status;
+    badge.textContent = label || labels[status] || status;
+    // Restyle the row
+    item.el.classList.remove(
+      'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
+    );
+    if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
+    else if (status === 'done')  item.el.classList.add('ax-queue-item--done');
+    else if (status === 'error') item.el.classList.add('ax-queue-item--error');
+  }
+
+  function addFiles(files) {
+    let added = 0;
+    for (const file of files) {
+      if (!file.type.startsWith('audio/') &&
+          !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
+        // Silently skip non-audio drops; keeps the UX uncluttered.
+        continue;
+      }
+      const li = document.createElement('li');
+      li.className = 'ax-queue-item';
+      li.innerHTML =
+        '<div class="ax-queue-name"></div>' +
+        '<span class="ax-queue-status ax-queue-status--queued">' + (T.st_queued || '') + '</span>';
+      // Use textContent to avoid HTML-injection if a filename contains markup.
+      li.querySelector('.ax-queue-name').textContent = file.name;
+      // Append size hint inline
+      const size = document.createElement('span');
+      size.className = 'ax-queue-size';
+      size.textContent = fmtBytes(file.size);
+      li.querySelector('.ax-queue-name').appendChild(size);
+      queueEl.appendChild(li);
+      queue.push({ file, el: li, status: 'queued' });
+      added++;
+    }
+    if (added) {
+      queueEl.hidden = false;
+      actionsEl.hidden = false;
+    }
+  }
+
+  // ── Drop-zone events ─────────────────────────────────────────
+  // Page-level guard: a dropped file outside the zone would otherwise
+  // make the browser navigate to it (e.g. opening the audio inline), which
+  // discards typed metadata. We swallow drops anywhere unless the dropzone
+  // explicitly handles them.
+  ['dragover', 'drop'].forEach(ev => {
+    window.addEventListener(ev, e => {
+      // Allow drops INSIDE the dropzone — its own listener handles those.
+      if (dropzone.contains(e.target)) return;
+      e.preventDefault();
+    });
+  });
+
+  ['dragenter', 'dragover'].forEach(ev => {
+    dropzone.addEventListener(ev, e => {
+      e.preventDefault();
+      dropzone.classList.add('is-dragover');
+    });
+  });
+  ['dragleave', 'drop'].forEach(ev => {
+    dropzone.addEventListener(ev, e => {
+      e.preventDefault();
+      dropzone.classList.remove('is-dragover');
+    });
+  });
+  dropzone.addEventListener('drop', e => {
+    if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
+  });
+  fileInput.addEventListener('change', () => {
+    addFiles(fileInput.files);
+    // Reset so the same file can be picked again later if user wants
+    fileInput.value = '';
+  });
+
+  // ── Clear queue button ───────────────────────────────────────
+  clearBtn.addEventListener('click', () => {
+    // Only remove items that aren't currently uploading (anything queued
+    // or already finished). An in-progress upload finishes, then its row
+    // would also disappear once we re-render — but we keep it simple and
+    // just refuse to clear during an active run.
+    if (startBtn.disabled) return;
+    queue.length = 0;
+    queueEl.innerHTML = '';
+    queueEl.hidden = true;
+    actionsEl.hidden = true;
+  });
+
+  // ── Sequential upload loop ───────────────────────────────────
+  startBtn.addEventListener('click', async () => {
+    if (startBtn.disabled) return;
+    startBtn.disabled = true;
+    clearBtn.disabled = true;
+    dropzone.style.pointerEvents = 'none';
+    dropzone.style.opacity = '0.5';
+
+    const sharedArtist = artistInput.value.trim();
+    const sharedAlbum  = albumInput.value.trim();
+    const sharedCover  = coverInput.files && coverInput.files[0];
+
+    // Process queued items one at a time. We iterate via index so that
+    // if more files get dropped during the run they ALSO get processed
+    // (queue.push above mutates the same array we're iterating).
+    for (let i = 0; i < queue.length; i++) {
+      const item = queue[i];
+      if (item.status !== 'queued') continue;
+      try {
+        await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
+      } catch (err) {
+        console.error('upload failed for', item.file.name, err);
+        setItemStatus(item, 'error', '✗ ' + (err.message || (T.failed || '')));
+      }
+    }
+
+    startBtn.disabled = false;
+    clearBtn.disabled = false;
+    dropzone.style.pointerEvents = '';
+    dropzone.style.opacity = '';
+
+    // Reload the page so the new tracks appear in the list below.
+    // Could also fetch them and inject, but a full reload is simpler and
+    // ensures position indexes / album-grouping are correct.
+    const anyDone = queue.some(q => q.status === 'done');
+    if (anyDone) {
+      setTimeout(() => location.reload(), 700);
+    }
+  });
+
+  async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
+    setItemStatus(item, 'uploading');
+
+    const fd = new FormData();
+    fd.append('audio', item.file);
+    if (sharedArtist) fd.append('artist', sharedArtist);
+    if (sharedAlbum)  fd.append('album',  sharedAlbum);
+    if (sharedCover)  fd.append('cover',  sharedCover);
+    // Title is intentionally omitted — server uses filename fallback.
+
+    // We can't reliably distinguish "still uploading bytes" from
+    // "uploading done, ffmpeg running" without progress events, but the
+    // status flips to "Converteren…" once the request is past upload phase.
+    // We approximate this by waiting until the response arrives — by then
+    // both phases are complete on the server side. For a smoother feel we
+    // briefly show "transcoding" near the end of the request lifecycle.
+    const transcodeHint = setTimeout(() => {
+      if (item.status === 'uploading') setItemStatus(item, 'transcoding');
+    }, 1500);
+
+    try {
+      const res = await fetch('/admin/audio/upload', {
+        method: 'POST',
+        headers: { 'Accept': 'application/json' },
+        body: fd,
+        credentials: 'same-origin',
+      });
+      clearTimeout(transcodeHint);
+
+      // Server responds with JSON for our Accept header. If it didn't
+      // (e.g. session expired and got an HTML login page), surface that.
+      let data;
+      try { data = await res.json(); }
+      catch (_) { throw new Error((T.err_unexpected || '') + ' (' + res.status + ')'); }
+
+      if (!res.ok || !data.ok) {
+        throw new Error(data.error || ('HTTP ' + res.status));
+      }
+
+      setItemStatus(item, 'done', '✓ ' + (data.title || (T.st_done || '')));
+    } catch (err) {
+      clearTimeout(transcodeHint);
+      throw err;
+    }
+  }
+
+  // ── Click-to-copy embed codes ─────────────────────────────────
+  document.querySelectorAll('[data-copy]').forEach(el => {
+    el.addEventListener('click', async () => {
+      const text = el.dataset.copy;
+      try {
+        await navigator.clipboard.writeText(text);
+        el.classList.add('is-copied');
+        const original = el.textContent;
+        el.textContent = '✓ ' + (T.copied || '');
+        setTimeout(() => {
+          el.classList.remove('is-copied');
+          el.textContent = original;
+        }, 1200);
+      } catch (_) { /* fall through — selection still works */ }
+    });
+  });
+
+  // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
+  const addLinkBtn = document.getElementById('add-link-track-btn');
+  if (addLinkBtn) {
+    addLinkBtn.addEventListener('click', async () => {
+      addLinkBtn.disabled = true;
+      try {
+        const r = await fetch('/admin/audio/create-link', {
+          method: 'POST', credentials: 'same-origin',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ title: (T.new_track || '') }),
+        });
+        const j = await r.json();
+        if (!r.ok || !j.ok) throw new Error(j.error || (T.create_failed || ''));
+        if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
+        window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
+      } catch (err) {
+        alert((T.create_failed || '') + ': ' + err.message);
+      } finally {
+        addLinkBtn.disabled = false;
+      }
+    });
+  }
+
+  // ── Wire all "Edit" buttons to the track-editor modal ─────────
+  // After save we patch the row in-place rather than reloading,
+  // so the user keeps their scroll position on long lists.
+  document.querySelectorAll('[data-track-edit]').forEach(btn => {
+    btn.addEventListener('click', () => {
+      if (typeof window.openTrackEditor !== 'function') {
+        alert((T.editor_not_loaded || ''));
+        return;
+      }
+      const id = btn.dataset.id;
+      window.openTrackEditor({
+        id,
+        onSaved: (track) => {
+          const row = document.querySelector('li[data-track-id="' + id + '"]');
+          if (!row) return;
+          // Update visible cells
+          const titleEl  = row.querySelector('[data-cell="title"]');
+          const artistEl = row.querySelector('[data-cell="artist"]');
+          const albumEl  = row.querySelector('[data-cell="album"]');
+          if (titleEl)  titleEl.textContent  = track.title  || (T.untitled || '');
+          if (artistEl) artistEl.textContent = track.artist || '—';
+          if (albumEl)  {
+            albumEl.textContent = track.album || '';
+            if (track.album) albumEl.removeAttribute('hidden');
+            else albumEl.setAttribute('hidden', '');
+          }
+          // Update cover thumb (replace element if type changed)
+          const oldThumb = row.querySelector('[data-cover-thumb]');
+          if (oldThumb) {
+            const parent = oldThumb.parentElement;
+            if (track.cover_url) {
+              const img = document.createElement('img');
+              img.className = 'ax-track-cover';
+              img.src = track.cover_url;
+              img.alt = '';
+              img.dataset.coverThumb = '';
+              parent.replaceChild(img, oldThumb);
+            } else {
+              const sp = document.createElement('span');
+              sp.className = 'ax-track-cover ax-track-cover-empty';
+              sp.textContent = '♫';
+              sp.dataset.coverThumb = '';
+              parent.replaceChild(sp, oldThumb);
+            }
+          }
+        },
+      });
+    });
+  });
+
+  // Download-voor-email toggle — AJAX (geen pagina-reload meer).
+  document.querySelectorAll('[data-track-dl]').forEach(btn => {
+    btn.addEventListener('click', async () => {
+      if (btn.disabled) return;
+      const want = btn.dataset.on === '1' ? 0 : 1;
+      btn.disabled = true;
+      try {
+        const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ downloadable: !!want }),
+        });
+        if (!res.ok) throw new Error('HTTP ' + res.status);
+        btn.dataset.on = String(want);
+        btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
+        btn.style.opacity = want ? '1' : '.5';
+        btn.title = want
+          ? (T.dl_on || '')
+          : (T.dl_off || '');
+      } catch (e) {
+        alert((T.change_failed || '') + ': ' + (e.message || e));
+      } finally {
+        btn.disabled = false;
+      }
+    });
+  });
+})();
Index: src/assets/js/mod/admin-epk.js
===================================================================
--- src/assets/js/mod/admin-epk.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-epk.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,21 @@
+// Het EPK-scherm in beheer (pages/admin-epk.ejs) -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
+// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
+// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
+//
+// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
+// er nu staat) en tegen een tweede aanroep te kunnen.
+
+(function () {
+  var box = document.querySelector('.epk-track-pick');
+  if (!box) return;
+  var max = parseInt(box.dataset.max, 10) || 5;
+  function sync() {
+    var checks = box.querySelectorAll('input[type=checkbox]');
+    var n = box.querySelectorAll('input[type=checkbox]:checked').length;
+    checks.forEach(function (c) { c.disabled = (!c.checked && n >= max); });
+  }
+  box.addEventListener('change', sync);
+  sync();
+})();
Index: src/assets/js/mod/admin-help.js
===================================================================
--- src/assets/js/mod/admin-help.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-help.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,30 @@
+// Het hulpscherm in beheer (pages/admin-help.ejs) -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
+// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
+// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
+//
+// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
+// er nu staat) en tegen een tweede aanroep te kunnen.
+
+(function () {
+  var q = document.getElementById('hb-q');
+  var items = [].slice.call(document.querySelectorAll('.hb-item'));
+  var empty = document.getElementById('hb-empty');
+  var count = document.getElementById('hb-count');
+  if (!q) return;
+  // Bewaar de originele tekst per item (voor highlight-reset).
+  items.forEach(function (it) { it._txt = it.textContent.toLowerCase(); });
+  function run() {
+    var term = q.value.trim().toLowerCase();
+    var shown = 0;
+    items.forEach(function (it) {
+      var hit = !term || it._txt.indexOf(term) >= 0;
+      it.classList.toggle('hb-hidden', !hit);
+      if (hit) shown++;
+    });
+    empty.hidden = shown !== 0;
+    count.textContent = term ? (shown + ' onderwerp' + (shown === 1 ? '' : 'en') + ' gevonden') : '';
+  }
+  q.addEventListener('input', run);
+})();
Index: src/assets/js/mod/admin-media.js
===================================================================
--- src/assets/js/mod/admin-media.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-media.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,38 @@
+// Media in beheer -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
+// BINNEN de site binnenkomt (shaer-0i6). Servergegevens komen uit pageData();
+// interpolatie kan niet in een statisch bestand.
+
+import { pageData } from './lib.js';
+
+(function () {
+  if (window.__mediaWired) return; window.__mediaWired = true;
+  var T = pageData();
+  document.addEventListener('click', function (e) {
+    var c = e.target.closest('[data-copy]');
+    if (c) {
+      var u = location.origin + c.getAttribute('data-copy');
+      var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); };
+      if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); });
+      else window.prompt('URL', u);
+      return;
+    }
+    var d = e.target.closest('[data-del]');
+    if (d) {
+      if (!window.confirm(T.delC)) return;
+      fetch('/admin/media/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) })
+        .then(function (r) { return r.json(); })
+        .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } else window.alert((j && j.error) || 'Error'); })
+        .catch(function () { window.alert('Error'); });
+      return;
+    }
+    if (e.target.closest('#media-cleanup')) {
+      if (!window.confirm(T.cleanC)) return;
+      fetch('/admin/media/cleanup', { method: 'POST', credentials: 'same-origin' })
+        .then(function (r) { return r.json(); })
+        .then(function (j) { location.href = '/admin/media?success=' + encodeURIComponent(((j && j.removed) || 0) + ' file(s) removed'); })
+        .catch(function () { window.alert('Error'); });
+    }
+  });
+})();
Index: src/assets/js/mod/admin-paid.js
===================================================================
--- src/assets/js/mod/admin-paid.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-paid.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,21 @@
+// Betaalde posts in beheer (pages/admin-paid.ejs) -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
+// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
+// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
+//
+// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
+// er nu staat) en tegen een tweede aanroep te kunnen.
+
+(function () {
+  var field = document.getElementById('pd-redirect');
+  var btn = document.getElementById('pd-copy');
+  if (!field || !btn) return;
+  field.addEventListener('focus', function () { field.select(); });
+  btn.addEventListener('click', function () {
+    field.select();
+    var done = function () { var t = btn.textContent; btn.textContent = document.getElementById('pd-copy').getAttribute('data-copied'); setTimeout(function () { btn.textContent = t; }, 1400); };
+    if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(field.value).then(done, function () { try { document.execCommand('copy'); done(); } catch (e) {} }); }
+    else { try { document.execCommand('copy'); done(); } catch (e) {} }
+  });
+})();
Index: src/assets/js/mod/admin-playlists.js
===================================================================
--- src/assets/js/mod/admin-playlists.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-playlists.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,75 @@
+// Afspeellijsten in beheer -- verplaatst uit inline script, shaer-bqr.
+//
+// Servergegevens (het csrf-token en drie teksten) komen uit pageData();
+// interpolatie kan niet in een statisch bestand.
+
+import { pageData } from './lib.js';
+
+(function() {
+  const _d = pageData();
+  const csrf = _d.csrf || '';
+
+  document.getElementById('pl-new-btn')?.addEventListener('click', () => {
+    if (typeof window.openPlaylistEditor === 'function') {
+      window.openPlaylistEditor({ mode: 'create', onSaved: () => location.reload() });
+    }
+  });
+
+  // Click-to-copy on shortcodes
+  document.querySelectorAll('[data-copy]').forEach(el => {
+    el.addEventListener('click', async () => {
+      try {
+        await navigator.clipboard.writeText(el.dataset.copy);
+        el.classList.add('is-copied');
+        const original = el.textContent;
+        el.textContent = '✓ ' + (_d.copied || '');
+        setTimeout(() => { el.classList.remove('is-copied'); el.textContent = original; }, 1200);
+      } catch (_) { /* fall back to selection */ }
+    });
+  });
+
+  document.querySelectorAll('[data-pl-edit]').forEach(btn => {
+    btn.addEventListener('click', () => {
+      if (typeof window.openPlaylistEditor === 'function') {
+        window.openPlaylistEditor({ mode: 'edit', id: btn.dataset.id, onSaved: () => location.reload() });
+      }
+    });
+  });
+
+  document.querySelectorAll('[data-pl-delete]').forEach(btn => {
+    btn.addEventListener('click', async () => {
+      const id = btn.dataset.id;
+      const title = btn.dataset.title || id;
+      if (!confirm((_d.delConfirm || '').replace('{title}', title))) return;
+      try {
+        const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
+          method: 'POST',
+          headers: { 'X-CSRF-Token': csrf },
+          credentials: 'same-origin',
+        });
+        const j = await r.json();
+        if (j.ok) location.reload();
+        else alert((_d.delFailed || '') + ': ' + (j.error || ''));
+      } catch (err) {
+        alert((_d.delFailed || '') + ': ' + err.message);
+      }
+    });
+  });
+
+  // P52 — deep-link from playlist embed (?edit=<id>) auto-opens the editor.
+  // openPlaylistEditor is defined synchronously by the included partial, so
+  // it's available by the time this IIFE runs.
+  (function deepLinkEdit() {
+    const params = new URLSearchParams(location.search);
+    const editId = params.get('edit');
+    if (!editId) return;
+    if (typeof window.openPlaylistEditor !== 'function') return;
+    // Strip the query param immediately so reload after save doesn't re-open.
+    history.replaceState({}, '', location.pathname);
+    window.openPlaylistEditor({
+      mode: 'edit',
+      id: editId,
+      onSaved: () => location.reload(),
+    });
+  })();
+})();
Index: src/assets/js/mod/admin-push.js
===================================================================
--- src/assets/js/mod/admin-push.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-push.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,110 @@
+// Pushmeldingen in beheer (pages/admin-push.ejs) -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
+// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
+// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
+//
+// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
+// er nu staat) en tegen een tweede aanroep te kunnen.
+
+(function () {
+  var cfg = JSON.parse(document.getElementById('np-data').textContent);
+  var elState = document.getElementById('np-state');
+  var btnOn = document.getElementById('np-on'), btnOff = document.getElementById('np-off'), btnTest = document.getElementById('np-test');
+  var alertsBox = document.getElementById('np-alerts'), savedMsg = document.getElementById('np-saved');
+  var currentEndpoint = null;
+
+  var isIos = /iPad|iPhone|iPod/.test(navigator.userAgent);
+  var standalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
+  if (isIos && !standalone) document.getElementById('np-ios-hint').hidden = false;
+
+  if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
+    document.getElementById('np-unsupported').hidden = false;
+    elState.textContent = cfg.i18n.unsupported;
+    return;
+  }
+
+  function b64ToU8(s) {
+    var pad = '='.repeat((4 - (s.length % 4)) % 4);
+    var raw = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/'));
+    var out = new Uint8Array(raw.length);
+    for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
+    return out;
+  }
+  function post(url, body) {
+    return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
+  }
+  function deviceLabel() {
+    var ua = navigator.userAgent;
+    var browser = /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
+    var os = /Android/.test(ua) ? 'Android' : /iPad|iPhone|iPod/.test(ua) ? 'iOS' : /Mac/.test(ua) ? 'macOS' : /Win/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : '';
+    return (browser + (os ? ' op ' + os : ''));
+  }
+  function readAlertBoxes() {
+    var out = {};
+    alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { out[cb.getAttribute('data-alert')] = cb.checked ? 1 : 0; });
+    return out;
+  }
+  function setAlertBoxes(alerts) {
+    alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) {
+      cb.checked = !!alerts[cb.getAttribute('data-alert')];
+    });
+  }
+
+  function render(sub) {
+    currentEndpoint = sub ? sub.endpoint : null;
+    elState.textContent = sub ? cfg.i18n.on : (Notification.permission === 'denied' ? cfg.i18n.denied : cfg.i18n.off);
+    btnOn.hidden = !!sub || Notification.permission === 'denied';
+    btnOff.hidden = !sub;
+    btnTest.hidden = !sub;
+    alertsBox.hidden = !sub;
+  }
+
+  navigator.serviceWorker.ready.then(function (reg) {
+    return reg.pushManager.getSubscription();
+  }).then(function (sub) {
+    // Show this device's SAVED prefs when we know them, defaults otherwise.
+    setAlertBoxes((sub && cfg.saved[sub.endpoint]) ? Object.assign({}, cfg.alerts, cfg.saved[sub.endpoint]) : cfg.alerts);
+    render(sub);
+  }).catch(function () { elState.textContent = cfg.i18n.unknown; });
+
+  btnOn.addEventListener('click', function () {
+    btnOn.disabled = true;
+    Notification.requestPermission().then(function (perm) {
+      if (perm !== 'granted') { btnOn.disabled = false; render(null); return; }
+      navigator.serviceWorker.ready.then(function (reg) {
+        return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToU8(cfg.vapid) });
+      }).then(function (sub) {
+        return post('/push/subscribe', { subscription: sub.toJSON(), alerts: readAlertBoxes(), uaLabel: deviceLabel() })
+          .then(function (r) { if (!r.ok) throw new Error('subscribe failed'); render(sub); location.reload(); });
+      }).catch(function () { btnOn.disabled = false; elState.textContent = cfg.i18n.failed; });
+    });
+  });
+
+  btnOff.addEventListener('click', function () {
+    navigator.serviceWorker.ready.then(function (reg) { return reg.pushManager.getSubscription(); }).then(function (sub) {
+      if (!sub) { render(null); return; }
+      var ep = sub.endpoint;
+      sub.unsubscribe().then(function () { return post('/push/unsubscribe', { endpoint: ep }); })
+        .then(function () { location.reload(); });
+    });
+  });
+
+  btnTest.addEventListener('click', function () {
+    btnTest.disabled = true;
+    post('/push/test').then(function () { setTimeout(function () { btnTest.disabled = false; }, 2000); });
+  });
+
+  alertsBox.addEventListener('change', function () {
+    if (!currentEndpoint) return;
+    post('/push/alerts', { endpoint: currentEndpoint, alerts: readAlertBoxes() }).then(function (r) {
+      if (r.ok) { savedMsg.hidden = false; setTimeout(function () { savedMsg.hidden = true; }, 1500); }
+    });
+  });
+
+  document.querySelectorAll('.np-remove').forEach(function (btn) {
+    btn.addEventListener('click', function () {
+      post('/push/unsubscribe', { endpoint: btn.getAttribute('data-endpoint') }).then(function () { location.reload(); });
+    });
+  });
+})();
Index: src/assets/js/mod/admin-settings.js
===================================================================
--- src/assets/js/mod/admin-settings.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-settings.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,20 @@
+// Instellingen in beheer (pages/admin-settings.ejs) -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
+// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
+// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
+//
+// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
+// er nu staat) en tegen een tweede aanroep te kunnen.
+
+    (function () {
+      var f = document.getElementById('mode-form');
+      if (!f || f.__age18wired) return; f.__age18wired = true;
+      f.addEventListener('submit', function (e) {
+        var sel = f.querySelector('input[name=mode]:checked');
+        if (sel && sel.value === 'cirkels' && f.getAttribute('data-was-cirkels') === '0') {
+          if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault();
+        }
+      });
+    })();
+    
Index: src/assets/js/mod/admin-site-edit.js
===================================================================
--- src/assets/js/mod/admin-site-edit.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-site-edit.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,130 @@
+// Site bewerken in beheer (pages/admin-site-edit.ejs) -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
+// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
+// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
+//
+// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
+// er nu staat) en tegen een tweede aanroep te kunnen.
+
+    (function(){
+      if (window.__themePreviewWired) return; window.__themePreviewWired = true;
+      var html = document.documentElement;
+      function applyAccent(hex){
+        if(!/^#[0-9a-fA-F]{6}$/.test(hex)) return;
+        var sa = document.getElementById('pcms-site-accent');
+        if(!sa){ sa = document.createElement('style'); sa.id = 'pcms-site-accent'; document.head.appendChild(sa); }
+        sa.textContent = ':root,[data-palette]{--accent:'+hex+';--accent-soft:color-mix(in srgb,'+hex+' 80%,white);--accent-tint:color-mix(in srgb,'+hex+' 12%,transparent);}';
+      }
+      document.addEventListener('change', function(e){
+        var t = e.target; if(!t || !t.name) return;
+        if(t.name === 'palette'){ html.setAttribute('data-palette', t.value); }
+        else if(t.name === 'accent'){ applyAccent(t.value); }
+        else if(t.name === 'theme_override'){
+          if(t.value === 'light' || t.value === 'dark'){ html.setAttribute('data-theme', t.value); }
+          else { var dk = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; html.setAttribute('data-theme', dk ? 'dark' : 'light'); }
+        }
+      });
+    })();
+    
+
+// ── volgend blok ──
+
+(function() {
+  // Slug URL field: show the real host as a dimmed prefix (the slug itself is
+  // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>.
+  var sh = document.getElementById('slug-host');
+  if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/');
+})();
+(function() {
+  // Profile-links repeater: add row from <template>, remove on click.
+  var rows = document.getElementById('profile-links-rows');
+  var tpl  = document.getElementById('profile-link-template');
+  var add  = document.getElementById('profile-link-add');
+  if (!rows || !tpl || !add) return;
+
+  add.addEventListener('click', function() {
+    var clone = tpl.content.cloneNode(true);
+    rows.appendChild(clone);
+  });
+  rows.addEventListener('click', function(e) {
+    if (e.target && e.target.classList.contains('pl-remove')) {
+      var row = e.target.closest('.profile-link-row');
+      if (row) row.remove();
+    }
+  });
+})();
+
+// P63 — Profile photo picker: upload via /admin/sites/upload-photo,
+// then write the returned URL into the visible input. Live thumb preview.
+(function() {
+  var picker  = document.getElementById('photo-picker');
+  if (!picker) return;
+  var thumb   = document.getElementById('photo-thumb');
+  var preview = document.getElementById('photo-preview');
+  var urlEl   = document.getElementById('photo-url');
+  var trigger = document.getElementById('photo-upload-trigger');
+  var clear   = document.getElementById('photo-clear');
+  var field   = document.getElementById('photo-upload-field');
+  var status  = document.getElementById('photo-status');
+
+  function showPreview(url) {
+    if (url) {
+      preview.src = url;
+      preview.hidden = false;
+      thumb.removeAttribute('data-empty');
+      clear.hidden = false;
+    } else {
+      preview.src = '';
+      preview.hidden = true;
+      thumb.setAttribute('data-empty', '');
+      clear.hidden = true;
+    }
+  }
+
+  if (urlEl) {
+    urlEl.addEventListener('input', function() {
+      showPreview(urlEl.value.trim());
+    });
+  }
+
+  if (trigger && field) {
+    trigger.addEventListener('click', function() { field.click(); });
+    field.addEventListener('change', async function() {
+      var file = field.files && field.files[0];
+      if (!file) return;
+      status.classList.remove('is-error');
+      status.textContent = 'Uploaden…';
+      try {
+        var fd = new FormData();
+        fd.append('photo', file);
+        var r = await fetch('/admin/sites/upload-photo', {
+          method: 'POST',
+          body: fd,
+          credentials: 'same-origin',
+        });
+        var j = await r.json();
+        if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')'));
+        urlEl.value = j.url;
+        showPreview(j.url);
+        status.textContent = 'Geüpload ✓';
+        setTimeout(function() { status.textContent = ''; }, 2000);
+      } catch (e) {
+        status.classList.add('is-error');
+        status.textContent = 'Mislukt: ' + e.message;
+      } finally {
+        field.value = '';
+      }
+    });
+  }
+
+  if (clear) {
+    clear.addEventListener('click', function() {
+      urlEl.value = '';
+      showPreview('');
+      status.textContent = '';
+      // Note: doesn't delete the file from disk — saving the form with empty
+      // URL leaves the file orphaned on the server. Acceptable for now.
+    });
+  }
+})();
Index: src/assets/js/mod/admin-videos.js
===================================================================
--- src/assets/js/mod/admin-videos.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-videos.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,30 @@
+// Video's in beheer -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
+// BINNEN de site binnenkomt (shaer-0i6). Servergegevens komen uit pageData();
+// interpolatie kan niet in een statisch bestand.
+
+import { pageData } from './lib.js';
+
+(function () {
+  if (window.__videosWired) return; window.__videosWired = true;
+  var T = pageData();
+  document.addEventListener('click', function (e) {
+    var c = e.target.closest('[data-copy]');
+    if (c) {
+      var u = location.origin + c.getAttribute('data-copy');
+      var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); };
+      if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); });
+      else window.prompt('URL', u);
+      return;
+    }
+    var d = e.target.closest('[data-del]');
+    if (d) {
+      if (!window.confirm(T.delC)) return;
+      fetch('/admin/media/videos/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) })
+        .then(function (r) { return r.json(); })
+        .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } })
+        .catch(function () {});
+    }
+  });
+})();
Index: src/assets/js/mod/auth-register.js
===================================================================
--- src/assets/js/mod/auth-register.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/auth-register.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,19 @@
+// Het registratieformulier (pages/auth-register.ejs) -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
+// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
+// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
+//
+// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
+// er nu staat) en tegen een tweede aanroep te kunnen.
+
+(function () {
+  var u = document.getElementById('setup-username'), out = document.getElementById('setup-handle');
+  if (!u || !out) return;
+  function upd() {
+    var v = (u.value || '').toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'username';
+    out.textContent = '@' + v + '@' + location.host;
+  }
+  u.addEventListener('input', upd);
+  upd();
+})();
Index: src/assets/js/mod/authorize-interaction.js
===================================================================
--- src/assets/js/mod/authorize-interaction.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/authorize-interaction.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,61 @@
+// Interactie autoriseren (pages/authorize-interaction.ejs) -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
+// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
+// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
+//
+// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
+// er nu staat) en tegen een tweede aanroep te kunnen.
+
+    (function () {
+      if (window.__fediBmWired) return; window.__fediBmWired = true;
+      var a = document.getElementById('fedi-bm-btn'); if (!a) return;
+      a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
+      a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); });
+    })();
+    
+
+// ── volgend blok ──
+
+      (function () {
+        if (window.__fediEditWired) return; window.__fediEditWired = true;
+        document.addEventListener('click', function (e) {
+          var b = e.target.closest && e.target.closest('.fedi-edit-btn');
+          if (!b) return;
+          var li = b.closest('.fedi-manage-item'); if (!li) return;
+          var form = li.querySelector('.fedi-edit-form'); if (!form) return;
+          var open = form.classList.toggle('is-open');
+          b.classList.toggle('is-open', open);
+          b.setAttribute('aria-expanded', open ? 'true' : 'false');
+          if (open) { var ed = form.querySelector('.re-editor') || form.querySelector('textarea'); if (ed) ed.focus(); }
+        });
+      })();
+      
+
+// ── volgend blok ──
+
+/* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page. */
+(function(){
+  if (window.__fediReactWired) return; window.__fediReactWired = true;
+  document.addEventListener('submit', function(e){
+    var f = e.target.closest && e.target.closest('.fedi-react-form');
+    if (!f) return;
+    e.preventDefault();
+    var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
+    btn.disabled = true;
+    var body = new URLSearchParams();
+    new FormData(f).forEach(function(v, k){ body.append(k, v); });
+    fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
+      .then(function(r){ return r.ok ? r.json() : null; })
+      .then(function(j){
+        if (j) {
+          var on = !!j.on;
+          btn.classList.toggle('is-on', on);
+          var lbl = btn.querySelector('.fedi-bigact-label');
+          if (lbl) lbl.textContent = on ? (btn.getAttribute('data-on') || lbl.textContent) : (btn.getAttribute('data-off') || lbl.textContent);
+        }
+      })
+      .catch(function(){})
+      .then(function(){ btn.disabled = false; });
+  });
+})();
Index: src/assets/js/mod/chrome.js
===================================================================
--- src/assets/js/mod/chrome.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/chrome.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,284 @@
+// De chrome: alles wat om elke pagina heen staat (shaer-bqr).
+//
+// Dit stond als inline <script> in de partials. Dat kan niet blijven: de
+// CSP-nonce rouleert per verzoek, en de chrome wordt bij ELKE htmx-navigatie
+// out-of-band opnieuw ingevoegd. Zo'n script draagt dan een nonce die het
+// document niet kent en wordt geweigerd -- dus viel de chrome-JS bij de eerste
+// klik binnen de site al weg (shaer-0i6).
+//
+// Nu een module, geladen door de bootstrap in shell.ejs. Die zit in het
+// document zelf, heeft dus wel de goede nonce, en een dynamische import vanuit
+// een vertrouwd script is precies waar 'strict-dynamic' voor is.
+//
+// TWEE REGELS voor alles wat hier bij komt:
+//
+//   GEDELEGEERD  luister op document, nooit op een element dat er nu staat. De
+//                chrome wordt vervangen, dus een vastgehouden verwijzing is na
+//                een navigatie een verwijzing naar iets dat weg is.
+//   IDEMPOTENT   de module kan een tweede keer geladen worden. Een slot op
+//                window voorkomt dat er een tweede stel luisteraars bij komt --
+//                dat is hoe de themaknop ooit twee keer vuurde en dus niets deed.
+//
+// En: GEEN servergegevens in deze code. Interpolatie hoort niet in een statisch
+// bestand; wat de server wil meegeven komt via een data-attribuut op een element.
+
+(function () {
+  'use strict';
+  if (window.__chromeMod) return;
+  window.__chromeMod = true;
+
+  // De zoekknop in de bottom-tab opent de overlay. De overlay zelf wordt bij een
+  // htmx-navigatie vervangen, dus hem hier vasthouden zou na een klik binnen de
+  // site niets meer opleveren: elke keer opnieuw opzoeken.
+  document.addEventListener('click', function (e) {
+    if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return;
+    var o = document.getElementById('search-overlay');
+    if (o) {
+      o.hidden = false;
+      var i = o.querySelector('input');
+      if (i) i.focus();
+    }
+  });
+})();
+
+// ── de profielkop ──────────────────────────────────────────────
+// Zat inline in partials/profile-header.ejs, en die partial zit IN de
+// OOB-chrome -- dus hij kwam bij elke navigatie opnieuw binnen.
+(function () {
+  if (window.__pcmsFediFollowWired) return;
+  window.__pcmsFediFollowWired = true;
+  function modal() { return document.querySelector('.pf-follow'); }
+  function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
+  function go() {
+    var m = modal(); if (!m) return;
+    var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : '';
+    // Strip a full @user@host handle (and any scheme/path) down to the server host.
+    var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
+    if (!server) { if (inp) inp.focus(); return; }
+    try { localStorage.setItem('pcmsFediServer', server); } catch (e) {}
+    var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || '');
+    location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor);
+  }
+  document.addEventListener('click', function (e) {
+    var b = e.target.closest && e.target.closest('.profile-fedi-link');
+    if (b) { e.preventDefault();
+      var m = modal(); if (!m) return;
+      m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || '');
+      var inp = m.querySelector('.pf-follow-input');
+      try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {}
+      m.classList.add('is-open');
+      setTimeout(function () { if (inp) inp.focus(); }, 30);
+      return;
+    }
+    if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; }
+    if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; }
+    var open = document.querySelector('.pf-follow.is-open');
+    if (open && e.target === open) closeM(); /* click on backdrop */
+  });
+  document.addEventListener('keydown', function (e) {
+    var m = document.querySelector('.pf-follow.is-open'); if (!m) return;
+    if (e.key === 'Escape') { closeM(); }
+    else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); }
+  });
+})();
+
+/* Profile summary: click the avatar to open a modal with photo + details. */
+(function () {
+  if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true;
+  function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); }
+  document.addEventListener('click', function (e) {
+    if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; }
+    var open = document.querySelector('.pf-summary.is-open');
+    if (open && e.target === open) { close(); return; } /* click on backdrop */
+    var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]');
+    if (!t) return;
+    e.preventDefault();
+    var root = (t.closest && t.closest('#pcms-chrome')) || document;
+    var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary');
+    if (modal) modal.classList.add('is-open');
+  });
+  document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
+})();
+
+// ── de topnav ──────────────────────────────────────────────────
+// Zat inline in partials/topnav.ejs. De zoekteksten komen nu van het
+// overlay-element (data-i18n) in plaats van uit interpolatie.
+(function() {
+  // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation
+  // → without this guard the script would stack EXTRA listeners on every navigation,
+  // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working").
+  // Everything below uses event delegation on body/document, so it also works for
+  // buttons that appear after this run (OOB).
+  if (window.__pcmsChromeWired) return;
+  window.__pcmsChromeWired = true;
+
+  function toggleTheme() {
+    var cur = document.documentElement.getAttribute('data-theme') || 'dark';
+    var next = cur === 'dark' ? 'light' : 'dark';
+    document.documentElement.setAttribute('data-theme', next);
+    try { localStorage.setItem('pcms-theme', next); } catch (e) {}
+  }
+  function overlay() { return document.getElementById('search-overlay'); }
+  function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } }
+  function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } }
+
+  document.body.addEventListener('click', function(e) {
+    if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; }
+    if (e.target.closest('#search-toggle')) { openSearch(); return; }
+    if (e.target.closest('#search-close')) { closeSearch(); return; }
+    // Close open dropdowns (user menu + language picker) on click outside.
+    document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) {
+      if (!d.contains(e.target)) d.removeAttribute('open');
+    });
+  });
+
+  document.addEventListener('keydown', function(e) {
+    if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); }
+  });
+
+  // ── Live results while typing ───────────────────────────────────
+  // De teksten komen van de overlay zelf, elke keer opnieuw: bij een
+  // htmx-navigatie wordt die vervangen en kan de taal gewisseld zijn.
+  function _st() {
+    var o = document.getElementById('search-overlay');
+    try { return JSON.parse((o && o.getAttribute('data-i18n')) || '{}'); } catch (e) { return {}; }
+  }
+  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
+  function renderSuggest(box, d, q, ov) {
+    var html = '';
+    function grp(title, items, fmt) {
+      if (!items || !items.length) return;
+      html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>';
+    }
+    grp(_st().posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; });
+    grp(_st().tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; });
+    grp(_st().events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; });
+    grp(_st().pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; });
+    var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length);
+    if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_st().none) + '</div>'; return; }
+    var action = (ov && ov.getAttribute('data-action')) || '/search';
+    html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_st().all) + '</a>';
+    box.innerHTML = html;
+  }
+  var _sTimer;
+  document.addEventListener('input', function(e) {
+    var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]');
+    if (!inp) return;
+    var box = document.getElementById('search-suggest');
+    if (!box) return;
+    var q = inp.value.trim();
+    clearTimeout(_sTimer);
+    if (q.length < 2) { box.innerHTML = ''; return; }
+    _sTimer = setTimeout(function() {
+      var ov = document.getElementById('search-overlay');
+      var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest';
+      fetch(url + '?q=' + encodeURIComponent(q))
+        .then(function(r){ return r.ok ? r.json() : null; })
+        .then(function(d){ if (d) renderSuggest(box, d, q, ov); })
+        .catch(function(){});
+    }, 200);
+  });
+  // Click on a suggestion → close the overlay (the link/boost handles navigation).
+  document.addEventListener('click', function(e) {
+    if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; }
+  });
+})();
+
+// ── het profielblad ────────────────────────────────────────────
+// Zat inline in partials/profile-sheet.ejs, dat de shell opneemt.
+(function() {
+  const sheet = document.getElementById('profile-sheet');
+  if (!sheet) return;
+
+  const backdrop  = document.getElementById('profile-sheet-backdrop');
+  const panel     = sheet.querySelector('.profile-sheet-panel');
+  const closeBtn  = document.getElementById('profile-sheet-close');
+  const dragZone  = document.getElementById('profile-sheet-drag-zone');
+  const themeBtn  = document.getElementById('profile-sheet-theme');
+  const themeLbl  = document.getElementById('profile-sheet-theme-state');
+
+  function openSheet() {
+    sheet.classList.add('is-open');
+    sheet.setAttribute('aria-hidden', 'false');
+    document.body.classList.add('profile-sheet-locked');
+    syncTheme();
+  }
+  function closeSheet() {
+    sheet.classList.remove('is-open');
+    sheet.setAttribute('aria-hidden', 'true');
+    document.body.classList.remove('profile-sheet-locked');
+    panel.style.removeProperty('--pcms-drag-y');
+  }
+
+  // Open: any element with [data-profile-sheet-toggle]
+  document.addEventListener('click', function(e) {
+    const trigger = e.target.closest('[data-profile-sheet-toggle]');
+    if (trigger) {
+      e.preventDefault();
+      openSheet();
+    }
+  });
+
+  // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item
+  if (backdrop) backdrop.addEventListener('click', closeSheet);
+  if (closeBtn) closeBtn.addEventListener('click', closeSheet);
+  document.addEventListener('keydown', (e) => {
+    if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
+  });
+
+  // Menu items that navigate: close synchronously before nav fires
+  sheet.querySelectorAll('[data-close-sheet]').forEach((el) => {
+    el.addEventListener('click', closeSheet);
+  });
+
+  // Drag-down-to-close on touch.
+  // Uses --pcms-drag-y custom property rather than overwriting the panel's
+  // transform string. This composes correctly with any horizontal centering
+  // (currently none here, but the pattern matches audio-sheet for safety).
+  let startY = 0, lastY = 0, dragging = false;
+  function onDown(e) {
+    if (e.pointerType !== 'touch') return;
+    if (panel.scrollTop > 0) return;
+    startY = lastY = e.clientY;
+    dragging = true;
+    panel.classList.add('is-dragging');
+  }
+  function onMove(e) {
+    if (!dragging) return;
+    lastY = e.clientY;
+    const dy = Math.max(0, lastY - startY);
+    panel.style.setProperty('--pcms-drag-y', dy + 'px');
+  }
+  function onUp() {
+    if (!dragging) return;
+    dragging = false;
+    panel.classList.remove('is-dragging');
+    const dy = lastY - startY;
+    if (dy > 80) closeSheet();
+    else panel.style.removeProperty('--pcms-drag-y');
+  }
+  if (window.PointerEvent && dragZone) {
+    dragZone.addEventListener('pointerdown', onDown);
+    document.addEventListener('pointermove', onMove);
+    document.addEventListener('pointerup', onUp);
+    document.addEventListener('pointercancel', onUp);
+  }
+
+  // Theme toggle inside sheet — syncs label
+  function syncTheme() {
+    if (!themeLbl) return;
+    const t = document.documentElement.getAttribute('data-theme') || 'dark';
+    // De twee labels staan op het element zelf: een module kan geen vertaling
+    // interpoleren, en zo hoort de tekst bij het ding dat hem toont.
+    themeLbl.textContent = themeLbl.getAttribute(t === 'dark' ? 'data-dark' : 'data-light') || themeLbl.textContent;
+  }
+  if (themeBtn) {
+    themeBtn.addEventListener('click', function() {
+      const cur = document.documentElement.getAttribute('data-theme') || 'dark';
+      const next = cur === 'dark' ? 'light' : 'dark';
+      document.documentElement.setAttribute('data-theme', next);
+      try { localStorage.setItem('pcms-theme', next); } catch (e) {}
+      syncTheme();
+    });
+  }
+})();
Index: src/assets/js/mod/download.js
===================================================================
--- src/assets/js/mod/download.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/download.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,11 @@
+// De downloadpagina -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
+// BINNEN de site binnenkomt (shaer-0i6). Wat de server meegeeft komt uit
+// pageData(); interpolatie kan niet in een statisch bestand.
+
+import { pageData } from './lib.js';
+
+        // Auto-start de download (zelfde-origin attachment-link).
+        setTimeout(function(){ try { window.location.href = (pageData().fileUrl || ''); } catch(e){} }, 600);
+      
Index: src/assets/js/mod/lib.js
===================================================================
--- src/assets/js/mod/lib.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/lib.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,48 @@
+// Gedeeld gereedschap voor de pagina-modules (shaer-bqr).
+
+/**
+ * De servergegevens van deze pagina.
+ *
+ * Een module is een statisch bestand, dus er kan geen EJS in. Wat het script van
+ * de server nodig heeft -- vertalingen, ids, instellingen -- zet de pagina in
+ * een <script type="application/json"> via partials/page-data.ejs, en dit leest
+ * het terug.
+ *
+ * ELKE KEER OPNIEUW LEZEN en niet eenmalig onthouden: bij een htmx-navigatie
+ * wisselt de inhoud terwijl de module blijft leven, dus een gegevensblok dat je
+ * bij het laden vasthoudt is een pagina later verouderd.
+ *
+ * Nooit een uitzondering: een module die valt over ontbrekende gegevens neemt de
+ * hele pagina mee. Wat er niet is, is een leeg object.
+ */
+export function pageData() {
+  try {
+    var el = document.querySelector('script[type="application/json"][data-page-data]');
+    return el ? (JSON.parse(el.textContent) || {}) : {};
+  } catch (e) {
+    return {};
+  }
+}
+
+/**
+ * Een enkele waarde, met een terugval. Scheelt in elke module hetzelfde
+ * gedoe met ontbrekende sleutels.
+ */
+export function pageValue(key, fallback) {
+  var d = pageData();
+  return (d && Object.prototype.hasOwnProperty.call(d, key)) ? d[key] : fallback;
+}
+
+/**
+ * Een tekst veilig in HTML zetten.
+ *
+ * De modules bouwen op sommige plekken HTML met stringplakwerk. Vroeger ging een
+ * vertaling daar door de escapende EJS-tag heen; nu niet meer. Zonder deze functie
+ * breekt een apostrof in een vertaling het attribuut af waar hij in staat -- het
+ * soort fout dat pas in een andere taal opvalt.
+ */
+export function esc(s) {
+  return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
+    return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
+  });
+}
Index: src/assets/js/mod/messages.js
===================================================================
--- src/assets/js/mod/messages.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/messages.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,132 @@
+// Berichten: filteren, zoeken, en gesprekken in- en uitklappen (shaer-bqr).
+//
+// Dit stond inline in pages/messages.ejs. Dat werkte alleen na een volledige
+// laadbeurt: kwam je hier via een link BINNEN de site, dan arriveerde het script
+// via htmx met een nonce die het document niet kent, en weigerde de CSP het
+// (shaer-0i6). Chips, zoeken en het inklappen deden dan niets, en de reply-editor
+// laadde niet -- dat was Barts melding.
+//
+// TWEE DINGEN VERANDERD bij de verhuizing, en ze zijn allebei nodig omdat een
+// module ANDERS leeft dan een inline script:
+//
+//   NIETS VASTHOUDEN   het inline script pakte .msg-list, #msg-q en .msg-nomatch
+//                      een keer bij het inladen. Een module wordt per document
+//                      maar EEN keer geimporteerd, dus als je Berichten verlaat
+//                      en terugkomt wijzen die naar elementen die er niet meer
+//                      zijn. Nu wordt er per keer opgezocht.
+//   OPNIEUW INDEXEREN  bij binnenkomst op deze pagina, niet alleen bij "meer
+//                      laden". Anders is de lijst na een navigatie niet
+//                      geindexeerd en filtert het zoeken op niets.
+//
+// Alles gedelegeerd op document, met een slot: de module kan een tweede keer
+// geladen worden en mag dan geen tweede stel luisteraars neerzetten.
+
+(function () {
+  'use strict';
+  if (window.__msgWired) return;
+  window.__msgWired = true;
+
+  var kind = 'all';
+  var items = [];
+
+  // Per keer opzoeken. Zie de kop: vasthouden overleeft een navigatie niet.
+  function list() { return document.querySelector('.msg-list'); }
+  function noMatch() { return document.querySelector('.msg-nomatch'); }
+  function query() { return document.getElementById('msg-q'); }
+
+  function indexItem(li) {
+    // Een draad indexeren op zijn eerste bubbel zou de rest onvindbaar maken,
+    // dus daar nemen we de hele tekst; losse regels blijven gericht geindexeerd.
+    if (li.classList.contains('msg-thread')) {
+      li._search = ((li.getAttribute('data-who') || '') + ' ' + li.textContent).toLowerCase();
+      return;
+    }
+    var body = li.querySelector('.msg-content');
+    var post = li.querySelector('.msg-post');
+    var poll = li.querySelector('.msg-poll');
+    li._search = ((li.getAttribute('data-who') || '') + ' ' +
+      (body ? body.textContent : '') + ' ' + (post ? post.textContent : '') + ' ' +
+      (poll ? poll.textContent : '')).toLowerCase();
+  }
+
+  function reindex() {
+    var l = list();
+    items = l ? Array.prototype.slice.call(l.querySelectorAll(':scope > .msg-item')) : [];
+    items.forEach(function (li) { if (!li._search) indexItem(li); });
+  }
+
+  function apply() {
+    if (!list()) return;
+    var qEl = query();
+    var term = (qEl && qEl.value || '').trim().toLowerCase();
+    var shown = 0;
+    items.forEach(function (li) {
+      var ok = (kind === 'all' || li.getAttribute('data-kind') === kind) &&
+        (!term || li._search.indexOf(term) !== -1);
+      li.style.display = ok ? '' : 'none';
+      // Een treffer die in een dichtgeklapt gesprek zit, laat anders alleen de
+      // naam zien: je zoekt iets, het staat er, en je ziet het niet. Zolang er
+      // gezocht wordt gaat zo'n gesprek open; daarna keert hij terug naar de
+      // stand die de lezer zelf koos.
+      if (li.classList.contains('msg-thread')) {
+        li.classList.toggle('is-search-open', !!term && ok);
+      }
+      if (ok) shown++;
+    });
+    var nm = noMatch();
+    if (nm) nm.hidden = shown !== 0;
+  }
+
+  // In- en uitklappen door op de tegenpartij te tikken. De stand leeft alleen in
+  // deze pagina: standaard uitgeklapt, en na een herlading weer. Dat is bewust --
+  // een gesprek dat je gisteren dichtklapte stilhouden is niet hetzelfde als het
+  // opruimen van je scherm van nu.
+  document.addEventListener('click', function (e) {
+    var who = e.target.closest && e.target.closest('.msg-thread-who'); if (!who) return;
+    var li = who.closest('.msg-thread'); if (!li) return;
+    var collapsed = li.classList.toggle('is-collapsed');
+    who.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
+  });
+
+  document.addEventListener('click', function (e) {
+    var chip = e.target.closest && e.target.closest('.msg-chip'); if (!chip) return;
+    kind = chip.getAttribute('data-show');
+    document.querySelectorAll('.msg-chip').forEach(function (c) { c.classList.toggle('is-on', c === chip); });
+    apply();
+  });
+
+  // Gedelegeerd in plaats van op het invoerveld zelf: dat veld wordt bij een
+  // navigatie vervangen.
+  document.addEventListener('input', function (e) {
+    if (e.target && e.target.id === 'msg-q') apply();
+  });
+
+  document.body.addEventListener('htmx:afterSettle', function (e) {
+    // "Meer laden" voegt rijen toe aan de bestaande lijst; een navigatie brengt
+    // een hele nieuwe lijst. In beide gevallen opnieuw indexeren en de actieve
+    // chip toepassen.
+    if (e.target && (e.target.id === 'msg-list' || e.target.querySelector && e.target.querySelector('.msg-list'))) {
+      reindex();
+      apply();
+    }
+  });
+
+  // De bookmarklet-knop: bouwt zijn href pas in de browser, want hij heeft de
+  // origin nodig.
+  function wireBookmarklet() {
+    var a = document.getElementById('fedi-bm-btn');
+    if (!a || a.__wired) return;
+    a.__wired = true;
+    a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
+    a.addEventListener('click', function (e) {
+      e.preventDefault();
+      a.classList.add('nudge');
+      setTimeout(function () { a.classList.remove('nudge'); }, 600);
+    });
+  }
+
+  function start() { reindex(); apply(); wireBookmarklet(); }
+  start();
+  // Kom je hier via een link binnen de site, dan is de lijst er pas na de swap.
+  document.body.addEventListener('pcmsNav', start);
+})();
Index: src/assets/js/mod/news.js
===================================================================
--- src/assets/js/mod/news.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/news.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,129 @@
+// De Krant -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
+// BINNEN de site binnenkomt (shaer-0i6). Wat de server meegeeft komt uit
+// pageData(); interpolatie kan niet in een statisch bestand.
+
+import { pageData } from './lib.js';
+
+  (function () {
+    if (window.__tlPasteWired) return; window.__tlPasteWired = true;
+    function modal() { return document.getElementById('tl-paste'); }
+    function openM() {
+      var m = modal(); if (!m) return;
+      var inp = m.querySelector('.tl-paste-input');
+      m.classList.add('is-open');
+      if (inp) {
+        inp.value = ''; inp.focus();
+        if (navigator.clipboard && navigator.clipboard.readText) {
+          navigator.clipboard.readText().then(function (t) {
+            t = (t || '').trim();
+            if (/^https?:\/\//i.test(t) && !inp.value) { inp.value = t; inp.select(); }
+          }).catch(function () {});
+        }
+      }
+    }
+    function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
+    function go() {
+      var m = modal(); if (!m) return;
+      var inp = m.querySelector('.tl-paste-input'), v = (inp ? inp.value : '').trim();
+      if (!/^https?:\/\//i.test(v)) { if (inp) inp.focus(); return; }
+      location.href = '/authorize_interaction?uri=' + encodeURIComponent(v);
+    }
+    document.addEventListener('click', function (e) {
+      if (e.target.closest && e.target.closest('#tl-paste-btn')) { e.preventDefault(); openM(); return; }
+      if (e.target.closest && e.target.closest('.tl-paste-go')) { e.preventDefault(); go(); return; }
+      if (e.target.closest && (e.target.closest('.tl-paste-cancel') || e.target.closest('.tl-paste-x'))) { e.preventDefault(); closeM(); return; }
+      var op = document.querySelector('.tl-paste.is-open'); if (op && e.target === op) closeM();
+    });
+    document.addEventListener('keydown', function (e) {
+      var m = document.querySelector('.tl-paste.is-open'); if (!m) return;
+      if (e.key === 'Escape') closeM();
+      else if (e.key === 'Enter' && e.target.closest && e.target.closest('.tl-paste')) { e.preventDefault(); go(); }
+    });
+  })();
+  
+
+// ── volgend blok ──
+
+(function(){
+  if (window.__fediRemoteWired) return; window.__fediRemoteWired = true;
+  var current = null, currentBtn = null;
+  function close(){ if (current) { current.remove(); current = null; currentBtn = null; } }
+  function go(raw, uri){ var d=(raw||'').trim().replace(/^@?[^@\s]*@/,'').replace(/^https?:\/\//i,'').replace(/\/.*$/,'').trim(); if(d){ try{ localStorage.setItem('pcmsFediServer', d); }catch(e){} location.href='https://'+d+'/authorize_interaction?uri='+encodeURIComponent(uri||''); } }
+  function place(f, b){ var r=b.getBoundingClientRect(); f.style.top=(r.bottom+window.scrollY+6)+'px'; f.style.left=Math.max(8, Math.min(r.left+window.scrollX, window.scrollX+window.innerWidth-340))+'px'; }
+  document.addEventListener('click', function(e){
+    if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; }
+    if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return;
+    var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn');
+    if (b) {
+      e.preventDefault();
+      if (currentBtn === b) { close(); return; }
+      close();
+      var f = document.createElement('form'); f.className='fedi-remote-form'; f.dataset.uri = b.getAttribute('data-fedi-uri')||'';
+      f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false"><button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">&rarr;</button><button type="button" class="fedi-remote-cancel" aria-label="x">&times;</button>';
+      var _inp = f.querySelector('input'); _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social';
+      try{ var _sv = localStorage.getItem('pcmsFediServer'); if(_sv) _inp.value = _sv; }catch(e){}
+      document.body.appendChild(f); place(f, b); current=f; currentBtn=b; _inp.focus(); _inp.select();
+      return;
+    }
+    if (current) close();
+  });
+  document.addEventListener('submit', function(e){ var f=e.target.closest && e.target.closest('.fedi-remote-form'); if(!f) return; e.preventDefault(); go(f.querySelector('input').value, f.dataset.uri); });
+  document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
+  window.addEventListener('scroll', close, true);
+})();
+
+/* Short feed videos (gif/cover loops like an animated cover) autoplay + loop muted like a GIF;
+   longer real videos keep their controls. Decided on the actual duration once metadata loads. */
+(function(){
+  document.querySelectorAll('.tl-media-video').forEach(function(v){
+    if (v.dataset.gifWired) return; v.dataset.gifWired = '1';
+    var decide = function(){
+      if (v.duration && v.duration <= 30) {
+        v.removeAttribute('controls'); v.loop = true; v.muted = true; v.play().catch(function(){});
+      }
+    };
+    if (v.readyState >= 1) decide(); else v.addEventListener('loadedmetadata', decide, { once: true });
+  });
+})();
+
+/* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page (no reload, no banner). */
+(function(){
+  if (window.__newsReactWired) return; window.__newsReactWired = true;
+  document.addEventListener('submit', function(e){
+    var f = e.target.closest && e.target.closest('.tl-react-form');
+    if (!f) return;
+    e.preventDefault();
+    var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
+    btn.disabled = true;
+    var body = new URLSearchParams();
+    new FormData(f).forEach(function(v, k){ body.append(k, v); });
+    fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
+      .then(function(r){ return r.ok ? r.json() : null; })
+      .then(function(j){ if (j) btn.classList.toggle('is-on', !!j.on); })
+      .catch(function(){})
+      .then(function(){ btn.disabled = false; });
+  });
+})();
+
+// ── volgend blok ──
+
+// Collapse long post bodies to a max height with a "read more" toggle — only when the content
+// actually overflows. Runs on every /news render (full load + htmx swap); a per-element flag
+// prevents double-wiring.
+(function(){
+  var _d = pageData(); var RM = _d.readMore || 'Lees meer', SL = _d.showLess || 'Toon minder';
+  document.querySelectorAll('.tl-content:not(.nsfw-media):not([data-rm])').forEach(function(c){
+    c.setAttribute('data-rm', '1');
+    if (c.scrollHeight > 360) {
+      c.classList.add('tl-clamp');
+      var b = document.createElement('button');
+      b.type = 'button'; b.className = 'tl-readmore'; b.textContent = RM;
+      b.addEventListener('click', function(){
+        b.textContent = c.classList.toggle('tl-clamp') ? RM : SL;
+      });
+      c.insertAdjacentElement('afterend', b);
+    }
+  });
+})();
Index: src/assets/js/mod/paid-gate.js
===================================================================
--- src/assets/js/mod/paid-gate.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/paid-gate.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,61 @@
+// De betaalmuur -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
+// BINNEN de site binnenkomt (shaer-0i6). Servergegevens komen uit pageData();
+// interpolatie kan niet in een statisch bestand.
+
+import { pageData } from './lib.js';
+
+(function () {
+  var _d = pageData();
+  var base = _d.base || "";
+  var slug = _d.slug || "";
+  var hasPatron = !!_d.hasPatron;
+  var I = _d.i18n || {};
+  var btn = document.getElementById('pg-unlock');
+  var status = document.getElementById('pg-status');
+  function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
+  function toLink() { location.href = base + '/paid/link?post=' + encodeURIComponent(slug); }
+
+  // No WebAuthn here: an assertion is impossible. With a Patreon page there is
+  // already a "Word supporter" button, so hide the (dead) unlock button rather
+  // than turn it into a second "Word supporter". Without one, this IS the button.
+  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
+    if (hasPatron) { btn.style.display = 'none'; }
+    else { btn.textContent = I.join; btn.addEventListener('click', toLink); }
+    return;
+  }
+
+  btn.addEventListener('click', function () {
+    btn.disabled = true;
+    say(I.confirm);
+    fetch(base + '/paid/challenge?post=' + encodeURIComponent(slug))
+      .then(function (r) { if (!r.ok) throw { link: true }; return r.json(); })
+      .then(function (data) {
+        return window.SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: data.options })
+          .then(function (response) {
+            return fetch(base + '/paid/unlock', {
+              method: 'POST', headers: { 'Content-Type': 'application/json' },
+              body: JSON.stringify({ response: response, blob: data.blob }),
+            });
+          });
+      })
+      .then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })
+      .then(function (res) {
+        if (res.j && res.j.ok && res.j.redirect) {
+          // Reload the real post page via the one-shot unlock capability, so it
+          // renders through its normal template (layout, styles, audio).
+          location.href = res.j.redirect;
+        } else if (res.status === 403) {
+          toLink();   // no valid passkey yet (or lapsed tier): link via Patreon
+        } else {
+          btn.disabled = false; say(I.failed, true);
+        }
+      })
+      .catch(function (e) {
+        if (e && e.link) { toLink(); return; }
+        if (e && e.name === 'NotAllowedError') { toLink(); return; }   // cancelled / no passkey -> link
+        btn.disabled = false; say(I.error, true);
+      });
+  });
+})();
Index: src/assets/js/mod/paid-passkey.js
===================================================================
--- src/assets/js/mod/paid-passkey.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/paid-passkey.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,41 @@
+// De passkey-ontgrendeling -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
+// BINNEN de site binnenkomt (shaer-0i6). Servergegevens komen uit pageData();
+// interpolatie kan niet in een statisch bestand.
+
+import { pageData } from './lib.js';
+
+(function () {
+  var _d = pageData();
+  var options = _d.options || {};
+  var blob = _d.blob || "";
+  var I = _d.i18n || {};
+  var postUrl = _d.postUrl || "";
+  var btn = document.getElementById('pk-go');
+  var status = document.getElementById('pk-status');
+  function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
+
+  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
+    btn.disabled = true;
+    say(I.unsupported, true);
+    return;
+  }
+  btn.addEventListener('click', function () {
+    btn.disabled = true;
+    say(I.follow);
+    window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: options })
+      .then(function (response) {
+        return fetch('/paid/register', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ response: response, blob: blob }),
+        });
+      })
+      .then(function (r) { return r.json(); })
+      .then(function (j) {
+        if (j && j.ok) { say(I.done); setTimeout(function () { location.href = postUrl; }, 900); }
+        else { btn.disabled = false; say(I.failed.replace('{err}', (j && j.error) || '?'), true); }
+      })
+      .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? I.cancelled : I.error, true); });
+  });
+})();
Index: src/assets/js/mod/playlist-editor.js
===================================================================
--- src/assets/js/mod/playlist-editor.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/playlist-editor.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,412 @@
+// De afspeellijst-editor -- verplaatst uit inline script, shaer-bqr.
+//
+// Servergegevens komen uit pageData(); interpolatie kan niet in een statisch
+// bestand. Inline script wordt bovendien geweigerd zodra deze pagina via een
+// link BINNEN de site binnenkomt (shaer-0i6).
+
+import { pageData } from './lib.js';
+
+(function() {
+  // Idempotency: if window.openPlaylistEditor already defined (multiple
+  // partial includes on a single page), skip re-binding.
+  if (typeof window.openPlaylistEditor === 'function') return;
+
+  const CSRF = pageData().csrf || '';
+
+  function esc(s) {
+    return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
+      '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
+    }[c]));
+  }
+  function fmtDur(sec) {
+    if (!sec) return '';
+    const m = Math.floor(sec / 60), s = sec % 60;
+    return `${m}:${String(s).padStart(2, '0')}`;
+  }
+
+  async function api(method, url, body) {
+    const opts = {
+      method, credentials: 'same-origin',
+      headers: { 'X-CSRF-Token': CSRF },
+    };
+    if (body !== undefined) {
+      opts.headers['Content-Type'] = 'application/json';
+      opts.body = JSON.stringify(body);
+    }
+    const r = await fetch(url, opts);
+    return r.json();
+  }
+
+  /**
+   * Public entry point: open the editor.
+   *  opts: { mode: 'create'|'edit', id?, onSaved? }
+   *  onSaved is called with { id, playlist } after a successful save.
+   */
+  window.openPlaylistEditor = async function(opts) {
+    opts = opts || {};
+    const mode = opts.mode === 'edit' ? 'edit' : 'create';
+    const isEdit = mode === 'edit';
+
+    // Load all audio tracks for the picker
+    let tracks = [];
+    try {
+      const j = await api('GET', '/admin/playlists/api/tracks');
+      if (Array.isArray(j.tracks)) tracks = j.tracks;
+    } catch (e) {
+      alert('Tracks ophalen mislukt');
+      return;
+    }
+    if (!tracks.length) {
+      alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');
+      return;
+    }
+
+    // Existing playlist data when editing
+    let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] };
+    if (isEdit && opts.id) {
+      try {
+        const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
+        if (j.ok) initial = { ...initial, ...j.playlist };
+      } catch (e) {}
+    }
+
+    // ── DOM ────────────────────────────────────────────────────────
+    const backdrop = document.createElement('div');
+    backdrop.className = 'pl-modal-backdrop';
+    backdrop.innerHTML = `
+      <div class="pl-modal" role="dialog" aria-label="Playlist editor">
+        <div class="pl-modal-header">
+          <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3>
+          <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button>
+        </div>
+        <div class="pl-modal-body">
+          <div class="pl-editor-cols">
+            <div class="pl-editor-left">
+              <div class="pl-meta-grid">
+                <label class="pl-field pl-field-full">
+                  <span>Titel *</span>
+                  <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
+                </label>
+                <label class="pl-field">
+                  <span>Artiest</span>
+                  <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
+                </label>
+                <label class="pl-field">
+                  <span>Jaar</span>
+                  <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
+                </label>
+                <label class="pl-field">
+                  <span>Type</span>
+                  <select id="pli-kind">
+                    <option value="album"    ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option>
+                    <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option>
+                  </select>
+                </label>
+                <div class="pl-field pl-field-full">
+                  <span>Cover</span>
+                  <div class="pl-cover-row">
+                    <span class="pl-cover-thumb" id="pli-cover-thumb">
+                      ${initial.cover
+                        ? `<img src="${esc(initial.cover)}" alt="">`
+                        : `<span class="pl-cover-empty">🎨</span>`}
+                    </span>
+                    <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1">
+                  </div>
+                  <div class="pl-cover-upload-row">
+                    <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
+                    <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
+                    <span class="pl-cover-status" id="pli-cover-status"></span>
+                  </div>
+                </div>
+              </div>
+              <div class="pl-section-title">
+                Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
+                <small>(sleep ⠿ om te ordenen)</small>
+              </div>
+              <div id="pli-selected" class="pl-selected-list"></div>
+            </div>
+            <div class="pl-editor-right">
+              <div class="pl-section-title">Beschikbare tracks</div>
+              <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
+              <div id="pli-available" class="pl-available-list"></div>
+            </div>
+          </div>
+        </div>
+        <div class="pl-modal-footer">
+          <button type="button" class="btn" id="pli-cancel">Annuleren</button>
+          <button type="button" class="btn btn-primary" id="pli-save" disabled>
+            ${isEdit ? 'Opslaan' : 'Aanmaken'}
+          </button>
+        </div>
+      </div>
+    `;
+    document.body.appendChild(backdrop);
+
+    const $ = sel => backdrop.querySelector(sel);
+    const close = () => backdrop.remove();
+    backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
+    $('.pl-modal-close').addEventListener('click', close);
+    $('#pli-cancel').addEventListener('click', close);
+
+    // Index tracks by id for fast lookup
+    const trackById = new Map(tracks.map(t => [t.id, t]));
+    let selected = (initial.track_ids || []).filter(id => trackById.has(id));
+
+    const titleEl = $('#pli-title');
+    const artistEl = $('#pli-artist');
+    const yearEl = $('#pli-year');
+    const kindEl = $('#pli-kind');
+    const coverEl = $('#pli-cover');
+    const coverThumb = $('#pli-cover-thumb');
+    const saveBtn = $('#pli-save');
+    const selectedEl = $('#pli-selected');
+    const availEl = $('#pli-available');
+    const searchEl = $('#pli-search');
+    const countEl = $('#pli-count');
+
+    coverEl.addEventListener('input', () => {
+      const u = coverEl.value.trim();
+      coverThumb.innerHTML = u
+        ? `<img src="${esc(u)}" alt="" data-fallback>`
+        : `<span class="pl-cover-empty">🎨</span>`;
+    });
+
+    // ── Cover file upload (werkt in create ÉN edit) ───────────
+    // We uploaden naar het generieke image-endpoint (/posts/upload-image,
+    // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
+    // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het
+    // cover-veld en wordt bij 'Aanmaken'/'Opslaan' met de playlist meegestuurd.
+    const coverFileInput = $('#pli-cover-file');
+    const coverPickBtn   = $('#pli-cover-pick');
+    const coverStatus    = $('#pli-cover-status');
+    if (coverFileInput && coverPickBtn) {
+      coverPickBtn.addEventListener('click', () => coverFileInput.click());
+      coverFileInput.addEventListener('change', async (e) => {
+        const file = e.target.files && e.target.files[0];
+        coverFileInput.value = '';
+        if (!file) return;
+        if (!/^image\//.test(file.type)) {
+          coverStatus.textContent = 'Alleen afbeeldingen';
+          coverStatus.className = 'pl-cover-status is-error';
+          return;
+        }
+        coverStatus.textContent = 'Uploaden…';
+        coverStatus.className = 'pl-cover-status';
+        const fd = new FormData();
+        fd.append('image', file);
+        try {
+          const r = await fetch('/posts/upload-image',
+            { method: 'POST', body: fd, credentials: 'same-origin' }
+          );
+          const j = await r.json();
+          if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt');
+          const url = j.url || '';
+          coverEl.value = url;
+          coverThumb.innerHTML = url
+            ? `<img src="${esc(url)}" alt="">`
+            : `<span class="pl-cover-empty">🎨</span>`;
+          coverStatus.textContent = '✓ Geüpload';
+          coverStatus.className = 'pl-cover-status is-ok';
+        } catch (err) {
+          coverStatus.textContent = 'Mislukt: ' + err.message;
+          coverStatus.className = 'pl-cover-status is-error';
+        }
+      });
+    }
+
+    function updateSaveBtn() {
+      saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
+    }
+
+    function renderSelected() {
+      countEl.textContent = selected.length;
+      if (selected.length === 0) {
+        selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
+        updateSaveBtn();
+        return;
+      }
+      selectedEl.innerHTML = selected.map((id, i) => {
+        const t = trackById.get(id);
+        if (!t) return '';
+        const cover = t.cover
+          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
+          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
+        return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
+          <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
+          <span class="pl-row-num">${i + 1}</span>
+          ${cover}
+          <span class="pl-row-info">
+            <span class="pl-row-title">${esc(t.title)}</span>
+            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
+          </span>
+          <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
+        </div>`;
+      }).join('');
+
+      selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
+        b.addEventListener('click', () => {
+          selected = selected.filter(x => x !== b.dataset.id);
+          renderSelected();
+          renderAvailable();
+        });
+      });
+      bindDrag();
+      updateSaveBtn();
+    }
+
+    function renderAvailable() {
+      const q = searchEl.value.trim().toLowerCase();
+      const matches = tracks.filter(t => {
+        if (!q) return true;
+        return (t.title || '').toLowerCase().includes(q)
+            || (t.artist || '').toLowerCase().includes(q);
+      });
+      if (matches.length === 0) {
+        availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
+        return;
+      }
+      availEl.innerHTML = matches.map(t => {
+        const isAdded = selected.includes(t.id);
+        const cls = ['pl-avail-row'];
+        if (isAdded) cls.push('is-added');
+        if (!t.playable) cls.push('is-unplayable');
+        const cover = t.cover
+          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
+          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
+        return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
+          ${cover}
+          <span class="pl-row-info">
+            <span class="pl-row-title">${esc(t.title)}</span>
+            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
+          </span>
+          <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
+        </div>`;
+      }).join('');
+
+      availEl.querySelectorAll('.pl-avail-row').forEach(row => {
+        if (row.classList.contains('is-unplayable')) return;
+        row.addEventListener('click', () => {
+          const id = row.dataset.id;
+          if (selected.includes(id)) selected = selected.filter(x => x !== id);
+          else selected.push(id);
+          renderSelected();
+          renderAvailable();
+        });
+      });
+    }
+
+    // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
+    function bindDrag() {
+      selectedEl.querySelectorAll('.pl-row').forEach(row => {
+        const handle = row.querySelector('.pl-row-handle');
+        if (!handle) return;
+        let dragging = false, originalIdx = -1;
+
+        handle.addEventListener('pointerdown', e => {
+          e.preventDefault();
+          handle.setPointerCapture(e.pointerId);
+          dragging = true;
+          originalIdx = parseInt(row.dataset.pos, 10);
+          row.classList.add('is-dragging');
+        });
+        handle.addEventListener('pointermove', e => {
+          if (!dragging) return;
+          e.preventDefault();
+          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
+          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
+          let targetIdx = -1, above = false;
+          for (let i = 0; i < rows.length; i++) {
+            const r = rows[i]; if (r === row) continue;
+            const rect = r.getBoundingClientRect();
+            const mid = rect.top + rect.height / 2;
+            if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
+            if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
+              targetIdx = i; above = e.clientY < mid; break;
+            }
+          }
+          if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
+        });
+        const finish = e => {
+          if (!dragging) return;
+          dragging = false;
+          try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
+          row.classList.remove('is-dragging');
+          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
+          let targetIdx = -1, above = false;
+          for (let i = 0; i < rows.length; i++) {
+            if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
+            if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
+          }
+          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
+          if (targetIdx === -1 || targetIdx === originalIdx) return;
+          const moved = selected[originalIdx];
+          selected.splice(originalIdx, 1);
+          let newIdx = targetIdx;
+          if (originalIdx < targetIdx) newIdx--;
+          if (!above) newIdx++;
+          newIdx = Math.max(0, Math.min(selected.length, newIdx));
+          selected.splice(newIdx, 0, moved);
+          renderSelected();
+        };
+        handle.addEventListener('pointerup', finish);
+        handle.addEventListener('pointercancel', () => {
+          dragging = false;
+          row.classList.remove('is-dragging');
+          selectedEl.querySelectorAll('.drop-above, .drop-below')
+            .forEach(r => r.classList.remove('drop-above', 'drop-below'));
+        });
+      });
+    }
+
+    titleEl.addEventListener('input', updateSaveBtn);
+    searchEl.addEventListener('input', renderAvailable);
+    renderSelected();
+    renderAvailable();
+
+    saveBtn.addEventListener('click', async () => {
+      if (saveBtn.disabled) return;
+      const orig = saveBtn.textContent;
+      saveBtn.disabled = true;
+      saveBtn.textContent = 'Opslaan...';
+
+      const payload = {
+        title:  titleEl.value.trim(),
+        artist: artistEl.value.trim(),
+        year:   parseInt(yearEl.value, 10) || 0,
+        cover:  coverEl.value.trim(),
+        kind:   kindEl.value === 'playlist' ? 'playlist' : 'album',
+        tracks: selected.slice(),
+      };
+
+      try {
+        const url = isEdit
+          ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
+          : '/admin/playlists/api';
+        const j = await api('POST', url, payload);
+        if (!j.ok) {
+          alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
+          saveBtn.disabled = false;
+          saveBtn.textContent = orig;
+          return;
+        }
+        const savedId = isEdit ? initial.id : j.id;
+        close();
+        if (typeof opts.onSaved === 'function') {
+          opts.onSaved({ id: savedId, playlist: payload });
+        }
+      } catch (err) {
+        alert('Opslaan mislukt: ' + err.message);
+        saveBtn.disabled = false;
+        saveBtn.textContent = orig;
+      }
+    });
+
+    // ESC to close
+    document.addEventListener('keydown', function onEsc(e) {
+      if (e.key === 'Escape' && document.body.contains(backdrop)) {
+        close();
+        document.removeEventListener('keydown', onEsc);
+      }
+    });
+  };
+})();
Index: src/assets/js/mod/post-edit.js
===================================================================
--- src/assets/js/mod/post-edit.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/post-edit.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,1098 @@
+// De posteditor -- verplaatst uit inline script, shaer-bqr.
+//
+// Zeven blokken, ruim 1000 regels. Alle servertekst komt uit pageData() en gaat
+// door esc(): de EJS-tag ontsnapte die vroeger ook, dus dit is gedragsgelijk en
+// het houdt een apostrof in een vertaling uit de HTML die hier geplakt wordt.
+
+import { pageData, esc } from './lib.js';
+
+const T = pageData();
+
+          (function () {
+            var cb = document.getElementById('pe-fedi-audio'), w = document.getElementById('pe-fedi-audio-warn');
+            if (cb && w && !cb.__wired) { cb.__wired = true; cb.addEventListener('change', function () { w.hidden = !cb.checked; }); }
+          })();
+          
+
+// ── volgend blok ──
+
+        (function () {
+          var cw = document.getElementById('pe-cw'), nsfw = document.getElementById('pe-nsfw');
+          // Typing a warning text implies the post is sensitive → auto-tick NSFW.
+          if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true;
+            cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; });
+          }
+        })();
+        
+
+// ── volgend blok ──
+
+        (function () {
+          var box = document.getElementById('pe-poll-fields');
+          var tog = document.getElementById('pe-poll-toggle');
+          var opts = document.getElementById('pe-poll-opts');
+          var add = document.getElementById('pe-poll-add');
+          if (!box || !opts) return;
+          if (tog && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); }
+          var PH = opts.getAttribute('data-ph') || '', DEL = opts.getAttribute('data-del') || '';
+          function rows() { return opts.querySelectorAll('.pe-poll-row'); }
+          // A poll needs at least 2 options: hide the ✕ at the minimum, and cap adding at 8.
+          function refresh() {
+            var n = rows().length;
+            opts.querySelectorAll('.pe-poll-del').forEach(function (b) { b.hidden = n <= 2; });
+            if (add) add.disabled = n >= 8;
+          }
+          function makeRow() {
+            var row = document.createElement('div'); row.className = 'pe-poll-row';
+            var i = document.createElement('input'); i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100; i.placeholder = PH;
+            var d = document.createElement('button'); d.type = 'button'; d.className = 'pe-poll-del'; d.setAttribute('aria-label', DEL); d.title = DEL; d.innerHTML = '&times;';
+            row.appendChild(i); row.appendChild(d); return row;
+          }
+          if (add && !add.__wired) { add.__wired = true; add.addEventListener('click', function () { if (rows().length >= 8) return; opts.appendChild(makeRow()); refresh(); }); }
+          if (!opts.__wired) { opts.__wired = true; opts.addEventListener('click', function (e) { var d = e.target.closest('.pe-poll-del'); if (!d || rows().length <= 2) return; d.closest('.pe-poll-row').remove(); refresh(); }); }
+          refresh();
+        })();
+        
+
+// ── volgend blok ──
+
+            (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price');
+              if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })();
+          
+
+// ── volgend blok ──
+
+            (function () {
+              var cb = document.getElementById('pe-sched-toggle');
+              var box = document.getElementById('pe-sched-fields');
+              if (!cb || !box) return;
+              var inp = document.getElementById('pe-publish-at');
+              var SITE_TZ = '' + (T._timezone || '') + ''; // configured site timezone; empty = browser local
+              var pad = function (n) { return String(n).padStart(2, '0'); };
+              // Offset (ms) between a timezone and UTC at a given moment.
+              function tzOffset(date, tz) {
+                var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
+                var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; });
+                return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime();
+              }
+              // datetime-local "wall time" (in the site zone) → UTC Date.
+              function wallToUtc(wall) {
+                if (!SITE_TZ) return new Date(wall);
+                var guess = new Date(wall + ':00Z').getTime();
+                return new Date(guess - tzOffset(new Date(guess), SITE_TZ));
+              }
+              // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone.
+              function utcToWall(iso) {
+                var d = new Date(iso); if (isNaN(d)) return '';
+                if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
+                var f = new Intl.DateTimeFormat('en-CA', { timeZone: SITE_TZ, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
+                var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; });
+                return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
+              }
+              // Prefill: stored UTC → wall time in the site zone.
+              if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso);
+              // "Scheduled for" in human-readable time in the site zone.
+              var when = document.getElementById('pe-sched-when');
+              if (when && when.dataset.iso) {
+                var dw = new Date(when.dataset.iso);
+                if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined);
+              }
+              function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; }
+              cb.addEventListener('change', sync); sync();
+              // On save: wall time in the site zone → UTC-ISO via a hidden field.
+              var form = cb.closest('form');
+              if (form) {
+                form.addEventListener('submit', function () {
+                  if (inp) inp.removeAttribute('name');
+                  var old = form.querySelector('input[data-pa-utc]');
+                  if (old) old.remove();
+                  if (cb.checked && inp && inp.value) {
+                    var d2 = wallToUtc(inp.value);
+                    if (!isNaN(d2)) {
+                      var h = document.createElement('input');
+                      h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', '');
+                      h.value = d2.toISOString();
+                      form.appendChild(h);
+                    }
+                  }
+                });
+              }
+            })();
+          
+
+// ── volgend blok ──
+
+(function() {
+
+  // ── Cover upload ────────────────────────────────────────────────
+  const coverField   = document.getElementById('cover-upload-field');
+  const coverTrigger = document.getElementById('cover-upload-trigger');
+  const coverUrl     = document.getElementById('cover-url-field');
+  const coverVideo   = document.getElementById('cover-video-field');
+  const coverStatus  = document.getElementById('cover-upload-status');
+  const coverWrap    = document.getElementById('cover-preview-wrap');
+  const coverImg     = document.getElementById('cover-preview-img');
+
+  async function uploadImage(file) {
+    const fd = new FormData();
+    fd.append('image', file);
+    const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
+    if (!res.ok) {
+      const j = await res.json().catch(() => ({}));
+      throw new Error(j.error || ('Upload failed (' + res.status + ')'));
+    }
+    return await res.json();   // {url, size, mime}
+  }
+
+  // ── Image editor (rotate / crop / mirror) ──────────
+  // Lazy-load Cropper.js (locally vendored) on first use.
+  let _cropperReady = null;
+  function ensureCropper() {
+    if (window.Cropper) return Promise.resolve();
+    if (_cropperReady) return _cropperReady;
+    _cropperReady = new Promise((resolve, reject) => {
+      if (!document.querySelector('link[data-cropper-css]')) {
+        const l = document.createElement('link');
+        l.rel = 'stylesheet'; l.href = '/assets/vendor/cropper.min.css'; l.setAttribute('data-cropper-css', '');
+        document.head.appendChild(l);
+      }
+      const s = document.createElement('script');
+      s.src = '/assets/vendor/cropper.min.js';
+      s.onload = () => resolve();
+      s.onerror = () => reject(new Error('cropper load failed'));
+      document.head.appendChild(s);
+    });
+    return _cropperReady;
+  }
+
+  // True for an animated WebP (VP8X chunk with the animation flag set) — like a GIF it must skip
+  // the canvas editor, otherwise it'd be flattened to a single static frame.
+  async function isAnimatedWebpFile(file) {
+    if (!file || file.type !== 'image/webp') return false;
+    try {
+      const b = new Uint8Array(await file.slice(0, 40).arrayBuffer());
+      return b.length >= 21 && String.fromCharCode(b[12], b[13], b[14], b[15]) === 'VP8X' && (b[20] & 0x02) !== 0;
+    } catch (_) { return false; }
+  }
+
+  // Opens the editor for a chosen file; resolves with an edited File,
+  // or null if the user cancels. Animated images (GIF / animated WebP) are NOT sent through the
+  // canvas editor (they would become static) — those upload directly.
+  async function openImageEditor(file) {
+    if (!file || !file.type || !file.type.startsWith('image/')) return file;
+    if (file.type === 'image/gif') return file;               // preserve animation
+    if (await isAnimatedWebpFile(file)) return file;          // animated WebP → preserve animation
+    try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly
+
+    return new Promise((resolve) => {
+      const back = document.createElement('div');
+      back.className = 'imed-backdrop';
+      back.innerHTML =
+        '<div class="imed-modal" role="dialog" aria-modal="true" aria-label="' + esc(T.title) + '">' +
+          '<div class="imed-stage"><img alt=""></div>' +
+          '<div class="imed-tools">' +
+            '<button type="button" data-act="rl" title="' + esc(T.rotate_left) + '">⟲</button>' +
+            '<button type="button" data-act="rr" title="' + esc(T.rotate_right) + '">⟳</button>' +
+            '<button type="button" data-act="fh" title="' + esc(T.flip_h) + '">⇆</button>' +
+            '<button type="button" data-act="fv" title="' + esc(T.flip_v) + '">⇅</button>' +
+            '<button type="button" data-act="zi" title="' + esc(T.zoom_in) + '">＋</button>' +
+            '<button type="button" data-act="zo" title="' + esc(T.zoom_out) + '">－</button>' +
+            '<button type="button" data-act="reset" title="' + esc(T.reset) + '">↺</button>' +
+          '</div>' +
+          '<div class="imed-actions">' +
+            '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary">' + esc(T.cancel) + '</button>' +
+            '<button type="button" data-act="apply" class="pe-btn pe-btn-primary">' + esc(T.apply) + '</button>' +
+          '</div>' +
+        '</div>';
+      document.body.appendChild(back);
+      const img = back.querySelector('img');
+      const url = URL.createObjectURL(file);
+      let cropper = null, sx = 1, sy = 1;
+
+      function cleanup() {
+        try { if (cropper) cropper.destroy(); } catch (_) {}
+        URL.revokeObjectURL(url);
+        back.remove();
+        document.removeEventListener('keydown', onKey);
+      }
+      function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } }
+      document.addEventListener('keydown', onKey);
+
+      img.onload = () => {
+        cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true });
+      };
+      img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original
+      img.src = url;
+
+      back.addEventListener('click', (e) => {
+        const btn = e.target.closest('[data-act]');
+        if (e.target === back) { cleanup(); resolve(null); return; }
+        if (!btn || !cropper) return;
+        const a = btn.getAttribute('data-act');
+        if (a === 'rl') cropper.rotate(-90);
+        else if (a === 'rr') cropper.rotate(90);
+        else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); }
+        else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); }
+        else if (a === 'zi') cropper.zoom(0.1);
+        else if (a === 'zo') cropper.zoom(-0.1);
+        else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); }
+        else if (a === 'cancel') { cleanup(); resolve(null); }
+        else if (a === 'apply') {
+          const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' });
+          const png = (file.type === 'image/png' || file.type === 'image/webp');
+          const mime = png ? 'image/png' : 'image/jpeg';
+          const ext = png ? '.png' : '.jpg';
+          canvas.toBlob((blob) => {
+            cleanup();
+            if (!blob) { resolve(file); return; }
+            const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, '');
+            resolve(new File([blob], base + ext, { type: mime }));
+          }, mime, 0.92);
+        }
+      });
+    });
+  }
+
+  function showCoverPreview(url) {
+    if (!coverWrap || !coverImg) return;
+    if (url) {
+      coverImg.src = url;
+      coverImg.hidden = false;
+      coverWrap.removeAttribute('data-empty');
+      const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
+      if (emptyIcon) emptyIcon.remove();
+    } else {
+      coverImg.hidden = true;
+      coverImg.src = '';
+      coverWrap.setAttribute('data-empty', '');
+      if (!coverWrap.querySelector('.pe-cover-empty')) {
+        const span = document.createElement('span');
+        span.className = 'pe-cover-empty';
+        span.textContent = '🖼';
+        coverWrap.appendChild(span);
+      }
+    }
+  }
+
+  if (coverTrigger && coverField) {
+    coverTrigger.addEventListener('click', () => coverField.click());
+  }
+  if (coverField) {
+    coverField.addEventListener('change', async () => {
+      if (!coverField.files[0]) return;
+      const edited = await openImageEditor(coverField.files[0]);
+      coverField.value = '';
+      if (!edited) return; // cancelled
+      coverStatus.classList.remove('is-error');
+      coverStatus.textContent = '' + esc(T.js_uploading) + '';
+      try {
+        const j = await uploadImage(edited);
+        coverUrl.value = j.url;
+        if (coverVideo) coverVideo.value = j.video || ''; // muted loop MP4 for an animated cover
+        showCoverPreview(j.url);
+        coverStatus.textContent = (j.video ? '🎬 ' : '') + '' + esc(T.js_uploaded) + ' ✓';
+        setTimeout(() => { coverStatus.textContent = ''; }, 2000);
+      } catch (e) {
+        coverStatus.classList.add('is-error');
+        coverStatus.textContent = '' + esc(T.js_failed) + ': ' + e.message;
+      }
+    });
+  }
+  // Live-update preview when user pastes a URL manually
+  if (coverUrl) {
+    coverUrl.addEventListener('input', () => {
+      const v = coverUrl.value.trim();
+      if (v) showCoverPreview(v); else showCoverPreview('');
+    });
+  }
+
+  // ── WYSIWYG editor (P58) ────────────────────────────────────────
+  // Architecture:
+  //   - Visible <div contenteditable> (`#content-editor`) is what the user
+  //     types in; it shows real HTML (formatted, not raw markup).
+  //   - Hidden <input name="content"> (`#content-hidden`) is what submits.
+  //     On submit we serialize the editor's HTML into it, with shortcode
+  //     chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
+  //   - Initial content comes from a <script type="application/json"> tag
+  //     to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
+  //     and walk text nodes to render shortcode tokens as chips.
+  const contentField  = document.getElementById('content-upload-field');
+  const contentBtn    = document.getElementById('insert-image-btn');
+  const contentStatus = document.getElementById('content-upload-status');
+  const editor        = document.getElementById('content-editor');
+  const hiddenField   = document.getElementById('content-hidden');
+  const charCountEl   = document.getElementById('char-count');
+  const initialEl     = document.getElementById('initial-content');
+  const toolbar       = document.getElementById('pe-toolbar');
+  const form          = editor && editor.closest('form');
+
+  if (!editor) return;
+
+  // Auto-focus the title only on desktop (mouse/trackpad). On touch this would
+  // immediately open the keyboard when the editor opens — not desired.
+  try {
+    const titleInput = form && form.querySelector('input[name="title"]');
+    if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
+      titleInput.focus({ preventScroll: true });
+    }
+  } catch (_) {}
+
+  // ── Shortcode chip rendering / serialization ────────────────────
+  // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
+  // — but we DON'T want to chipify text the user is mid-typing inside an
+  // HTML attribute; since chipify only walks text nodes (never attribute
+  // values) that's already safe.
+  const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g;
+
+  const SC_ICONS = {
+    track:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>',
+    album:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>',
+    playlist: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>',
+    embed:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 9 15.5 12 10 15"/></svg>',
+  };
+
+  function chipLabel(kind, value) {
+    if (kind === 'track') {
+      // UUIDs are noisy — show a 6-char prefix for visual hint
+      const v = String(value || '');
+      return '' + esc(T.chip_track) + ' ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
+    }
+    if (kind === 'album')    return '' + esc(T.chip_album) + ' ' + value;
+    if (kind === 'playlist') return '' + esc(T.chip_playlist) + ' ' + value;
+    if (kind === 'embed') {
+      const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, '');
+      return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean);
+    }
+    return value;
+  }
+
+  function makeChip(kind, value) {
+    const span = document.createElement('span');
+    span.className = 'sc-chip';
+    span.contentEditable = 'false';
+    span.setAttribute('data-sc', kind + ':' + value);
+    span.innerHTML =
+      '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
+      '<span class="sc-chip-label"></span>';
+    span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
+    return span;
+  }
+
+  // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
+  function chipifyShortcodes(root) {
+    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
+    const targets = [];
+    while (walker.nextNode()) {
+      const n = walker.currentNode;
+      // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
+      if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
+      if (SC_RE.test(n.nodeValue)) targets.push(n);
+      SC_RE.lastIndex = 0;
+    }
+    for (const node of targets) {
+      const txt = node.nodeValue;
+      const frag = document.createDocumentFragment();
+      let last = 0;
+      let m;
+      SC_RE.lastIndex = 0;
+      while ((m = SC_RE.exec(txt)) !== null) {
+        if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
+        frag.appendChild(makeChip(m[1], m[2].trim()));
+        last = m.index + m[0].length;
+      }
+      if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
+      node.parentNode.replaceChild(frag, node);
+    }
+  }
+
+  // Inverse of chipify: clone the editor, replace every chip with its text.
+  function serializeChips(rootClone) {
+    const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
+    for (const c of chips) {
+      const txt = '[[' + c.getAttribute('data-sc') + ']]';
+      c.replaceWith(document.createTextNode(txt));
+    }
+  }
+
+  // ── Boot: load initial content as HTML, then render shortcodes as chips
+  try {
+    const initial = JSON.parse(initialEl.textContent || '""');
+    editor.innerHTML = initial || '';
+    chipifyShortcodes(editor);
+  } catch (e) {
+    console.error('[editor] could not parse initial content', e);
+    editor.innerHTML = '';
+  }
+
+  // ── Char counter
+  function updateCharCount() {
+    const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
+    if (charCountEl) charCountEl.textContent = String(text.length);
+  }
+  updateCharCount();
+  editor.addEventListener('input', updateCharCount);
+
+  // ── Toolbar wiring
+  // Lock the scroll position around an edit command. execCommand/insert scrolls
+  // the caret into view by default → the view "jumps" when clicking a formatting
+  // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …)
+  // + the page and restore them — sync and over a few frames, because Chrome
+  // sometimes scrolls a frame later. The user scrolls themselves.
+  function scrollableAncestors(el) {
+    const list = [];
+    let node = el;
+    while (node && node !== document.body && node !== document.documentElement) {
+      const oy = getComputedStyle(node).overflowY;
+      if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node);
+      node = node.parentElement;
+    }
+    return list;
+  }
+  function keepScroll(fn) {
+    // In fullscreen the page is locked (body overflow:hidden) and the field may
+    // scroll to the caret freely — no page jump possible, so nothing to fix.
+    const frame = document.querySelector('.pe-editor-frame');
+    if (frame && frame.classList.contains('pe-fs')) { fn(); return; }
+    const wx = window.scrollX, wy = window.scrollY;
+    const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; });
+    const restore = function () {
+      window.scrollTo(wx, wy);
+      anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; });
+    };
+    fn();
+    restore();
+    requestAnimationFrame(restore);
+  }
+  function execCmd(cmd, arg) {
+    keepScroll(function () {
+      editor.focus({ preventScroll: true });
+      document.execCommand(cmd, false, arg);
+    });
+    updateToolbarState();
+    updateCharCount();
+  }
+  function wrapCode() {
+    const sel = window.getSelection();
+    if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
+    keepScroll(function () {
+      const range = sel.getRangeAt(0);
+      const code = document.createElement('code');
+      code.textContent = sel.toString();
+      range.deleteContents();
+      range.insertNode(code);
+      // Move caret after the new node
+      range.setStartAfter(code);
+      range.collapse(true);
+      sel.removeAllRanges();
+      sel.addRange(range);
+      editor.focus({ preventScroll: true });
+    });
+  }
+  function linkPrompt() {
+    const url = window.prompt('' + esc(T.js_link_prompt) + '');
+    if (!url) return;
+    execCmd('createLink', url);
+  }
+  // Is the current selection inside a <blockquote> within the editor? Return it.
+  function blockquoteAncestor() {
+    const sel = window.getSelection();
+    if (!sel || sel.rangeCount === 0) return null;
+    let node = sel.anchorNode;
+    while (node && node !== editor) {
+      if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node;
+      node = node.parentNode;
+    }
+    return null;
+  }
+  // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but
+  // can never turn it OFF (browser quirk). If the caret is already in a quote →
+  // unwrap it; otherwise apply blockquote.
+  function toggleBlockquote() {
+    keepScroll(function () {
+      editor.focus({ preventScroll: true });
+      const bq = blockquoteAncestor();
+      if (bq) {
+        const parent = bq.parentNode;
+        // Extract content from the quote in place, then remove the empty wrapper.
+        const ref = bq;
+        let firstMoved = null;
+        while (bq.firstChild) {
+          const child = bq.firstChild;
+          if (!firstMoved) firstMoved = child;
+          parent.insertBefore(child, ref);
+        }
+        parent.removeChild(bq);
+        // Restore the caret inside the unwrapped content.
+        if (firstMoved) {
+          const sel = window.getSelection();
+          const range = document.createRange();
+          range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent);
+          range.collapse(false);
+          sel.removeAllRanges();
+          sel.addRange(range);
+        }
+      } else {
+        document.execCommand('formatBlock', false, 'blockquote');
+      }
+    });
+    updateToolbarState();
+    updateCharCount();
+  }
+
+  if (toolbar) {
+    // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection
+    // from the editor field. Without this the selection is lost on tap
+    // → execCommand operates on an empty selection (bold can no longer be toggled OFF)
+    // and the browser scrolls the caret back into view (the "jump down"). preventDefault
+    // on mousedown keeps focus in the editor; the click still fires normally.
+    toolbar.addEventListener('mousedown', (e) => {
+      if (e.target.closest('button')) e.preventDefault();
+    });
+    toolbar.addEventListener('click', (e) => {
+      const btn = e.target.closest('button[data-cmd]');
+      if (!btn) return;
+      e.preventDefault();
+      const cmd = btn.dataset.cmd;
+      const arg = btn.dataset.arg || null;
+      if (cmd === 'link-prompt') linkPrompt();
+      else if (cmd === 'code-wrap') wrapCode();
+      else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote();
+      else execCmd(cmd, arg);
+    });
+  }
+
+  // ── Full-screen writing mode: the writing field fills the whole page.
+  const fsBtn = document.getElementById('pe-fullscreen-btn');
+  const editorFrame = document.querySelector('.pe-editor-frame');
+  const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
+
+  // On mobile the keyboard pushes the visible (visual) viewport up while
+  // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar
+  // slides out of view. Keep the fullscreen frame aligned to the visual
+  // viewport (top + height) so the toolbar stays visible at the top.
+  function syncFsViewport() {
+    if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
+    const vv = window.visualViewport;
+    if (!vv) return;
+    editorFrame.style.top = vv.offsetTop + 'px';
+    editorFrame.style.height = vv.height + 'px';
+  }
+  function clearFsViewport() {
+    if (!editorFrame) return;
+    editorFrame.style.top = '';
+    editorFrame.style.height = '';
+  }
+  function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); }
+  function applyFs(on) {
+    if (!editorFrame) return;
+    editorFrame.classList.toggle('pe-fs', on);
+    document.body.classList.toggle('pe-fs-open', on);
+    document.documentElement.classList.toggle('pe-fs-open', on);
+    if (fsBtn) {
+      fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
+      fsBtn.title = on ? '' + esc(T.tb_done) + '' : '' + esc(T.tb_fullscreen) + '';
+    }
+    if (window.visualViewport) {
+      if (on) {
+        window.visualViewport.addEventListener('resize', syncFsViewport);
+        window.visualViewport.addEventListener('scroll', syncFsViewport);
+        syncFsViewport();
+      } else {
+        window.visualViewport.removeEventListener('resize', syncFsViewport);
+        window.visualViewport.removeEventListener('scroll', syncFsViewport);
+        clearFsViewport();
+      }
+    }
+    // On touch the field is NOT editable inline; only in fullscreen.
+    if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false');
+    if (on) {
+      editor.focus({ preventScroll: true });
+    } else {
+      if (isTouch) editor.blur();
+      // On close: scroll to the TOP of the content instead of staying
+      // somewhere at the bottom (footer).
+      requestAnimationFrame(function () {
+        try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {}
+      });
+    }
+  }
+  // The fullscreen writing "page": opening pushes a history state so the browser
+  // back button (and the Done button) closes it and returns you to the form — feels
+  // like a separate page, but all form fields remain intact (same DOM).
+  function openFs() {
+    if (isFs()) return;
+    try { history.pushState({ peFs: true }, ''); } catch (_) {}
+    applyFs(true);
+  }
+  function closeFs() {
+    if (!isFs()) return;
+    if (history.state && history.state.peFs) history.back(); // → popstate closes it
+    else applyFs(false);
+  }
+  function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); }
+  window.addEventListener('popstate', function () { if (isFs()) applyFs(false); });
+  if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
+  var fsDoneBtn = document.getElementById('pe-fs-done');
+  if (fsDoneBtn) fsDoneBtn.addEventListener('click', closeFs);
+  document.addEventListener('keydown', (e) => {
+    if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); }
+  });
+
+  // On mobile/tablet (touch): the content field is NOT editable inline — it is
+  // not a text field there. One tap → fullscreen, where it becomes editable
+  // (toggleFullscreen toggles contenteditable). This prevents inline typing.
+  if (isTouch) {
+    editor.setAttribute('contenteditable', 'false');
+    editor.classList.add('pe-tap-to-edit');
+    editor.addEventListener('click', function () {
+      if (!isFs()) openFs();
+    });
+  }
+
+  // Reflect bold/italic/list state on the toolbar buttons
+  function updateToolbarState() {
+    if (!toolbar) return;
+    const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
+    for (const cmd of cmds) {
+      const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
+      if (!btn) continue;
+      try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
+    }
+    // Quote button: active when the caret is inside a <blockquote> (toggle feedback).
+    const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
+    if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
+  }
+  document.addEventListener('selectionchange', () => {
+    if (document.activeElement === editor) updateToolbarState();
+  });
+
+  // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
+  editor.addEventListener('keydown', (e) => {
+    const mod = e.ctrlKey || e.metaKey;
+    if (!mod) return;
+    const k = e.key.toLowerCase();
+    if (k === 'b') { e.preventDefault(); execCmd('bold'); }
+    else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
+    else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
+    else if (k === 'k') { e.preventDefault(); linkPrompt(); }
+  });
+
+  // Paste: keep it simple — strip formatting unless user wants it. Default
+  // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
+  // styles from clipboard only when shift is held — otherwise plain text.
+  editor.addEventListener('paste', (e) => {
+    if (e.shiftKey) return; // user wants formatted paste
+    const text = (e.clipboardData || window.clipboardData).getData('text/plain');
+    if (text == null) return;
+    e.preventDefault();
+    document.execCommand('insertText', false, text);
+  });
+
+  // ── Image upload (button + drag-drop into the editor)
+  async function uploadAndInsertImage(file) {
+    const edited = await openImageEditor(file);
+    if (!edited) return; // cancelled
+    contentStatus.classList.remove('is-error');
+    contentStatus.textContent = '' + esc(T.js_uploading) + '';
+    try {
+      const j = await uploadImage(edited);
+      const img = '<img src="' + j.url + '" alt="">';
+      editor.focus({ preventScroll: true });
+      document.execCommand('insertHTML', false, img);
+      contentStatus.textContent = '' + esc(T.js_inserted) + ' ✓';
+      setTimeout(() => { contentStatus.textContent = ''; }, 2000);
+      updateCharCount();
+    } catch (e) {
+      contentStatus.classList.add('is-error');
+      contentStatus.textContent = '' + esc(T.js_failed) + ': ' + e.message;
+    }
+  }
+
+  if (contentBtn && contentField) {
+    contentBtn.addEventListener('click', () => contentField.click());
+    contentField.addEventListener('change', () => {
+      if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
+      contentField.value = '';
+    });
+
+    editor.addEventListener('dragover', (e) => {
+      if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
+        e.preventDefault();
+        editor.classList.add('is-dragover');
+      }
+    });
+    editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
+    editor.addEventListener('drop', async (e) => {
+      editor.classList.remove('is-dragover');
+      const files = e.dataTransfer && e.dataTransfer.files;
+      if (!files || !files.length) return;
+      e.preventDefault();
+      for (const f of files) {
+        if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
+      }
+    });
+  }
+
+  // ── Insert chip helpers (track / playlist)
+  function insertChip(kind, value) {
+    editor.focus({ preventScroll: true });
+    const chip = makeChip(kind, value);
+    // Insert at caret using the Selection API (execCommand insertNode)
+    const sel = window.getSelection();
+    if (sel && sel.rangeCount > 0) {
+      const range = sel.getRangeAt(0);
+      range.deleteContents();
+      range.insertNode(chip);
+      // Insert a trailing space so the user can keep typing after the chip
+      const space = document.createTextNode('\u00A0');
+      chip.after(space);
+      range.setStartAfter(space);
+      range.collapse(true);
+      sel.removeAllRanges();
+      sel.addRange(range);
+    } else {
+      editor.appendChild(chip);
+      editor.appendChild(document.createTextNode('\u00A0'));
+    }
+    updateCharCount();
+  }
+
+  // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes
+  //    an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
+  const embedBtn = document.getElementById('insert-embed-btn');
+  if (embedBtn) {
+    embedBtn.addEventListener('click', () => {
+      const raw = window.prompt('' + esc(T.js_embed_prompt) + '');
+      if (!raw) return;
+      const url = raw.trim();
+      if (!/^https?:\/\//i.test(url)) { alert('' + esc(T.js_embed_invalid) + ''); return; }
+      insertChip('embed', url);
+    });
+  }
+
+  // ── Track insert: opens the track-picker modal (P59)
+  const trackBtn = document.getElementById('insert-track-btn');
+  const trackPicker = document.getElementById('track-picker');
+  if (trackBtn && trackPicker) {
+    const tpList   = document.getElementById('tp-list');
+    const tpEmpty  = document.getElementById('tp-empty');
+    const tpSearch = document.getElementById('tp-search');
+    let tpCache = null;       // cached track list (fetched once per page load)
+    let tpLastFocus = null;   // element to restore focus to on close
+
+    const SVG_NOTE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>';
+
+    function fmtDur(sec) {
+      sec = Math.max(0, Math.floor(sec || 0));
+      const m = Math.floor(sec / 60), s = sec % 60;
+      return m + ':' + String(s).padStart(2, '0');
+    }
+    function escAttr(s) {
+      return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
+        '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
+      }[c]));
+    }
+
+    function renderList(filter) {
+      if (!Array.isArray(tpCache)) return;
+      const q = (filter || '').trim().toLowerCase();
+      const filtered = q
+        ? tpCache.filter(t =>
+            (t.title  || '').toLowerCase().includes(q) ||
+            (t.artist || '').toLowerCase().includes(q))
+        : tpCache;
+
+      if (!filtered.length) {
+        tpList.innerHTML = '';
+        tpEmpty.textContent = q ? '' + esc(T.js_no_tracks_found) + ' ' + q : '' + esc(T.js_no_tracks_yet) + '';
+        tpList.appendChild(tpEmpty);
+        return;
+      }
+
+      tpList.innerHTML = filtered.map(t => {
+        const cov = t.cover
+          ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
+          : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
+        const dis = t.playable ? '' : ' aria-disabled="true"';
+        const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
+        return (
+          '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
+            cov +
+            '<span class="tp-meta">' +
+              '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
+              sub +
+            '</span>' +
+            '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
+          '</button>'
+        );
+      }).join('');
+    }
+
+    async function loadTracks() {
+      if (Array.isArray(tpCache)) return tpCache;
+      tpEmpty.textContent = '' + esc(T.js_tracks_loading) + '';
+      try {
+        const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
+        const j = await r.json();
+        tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
+      } catch (e) {
+        tpCache = [];
+        tpEmpty.textContent = '' + esc(T.js_tracks_load_fail) + ': ' + e.message;
+      }
+      return tpCache;
+    }
+
+    function openPicker() {
+      tpLastFocus = document.activeElement;
+      trackPicker.hidden = false;
+      trackPicker.setAttribute('aria-hidden', 'false');
+      document.body.classList.add('tp-locked');
+      tpSearch.value = '';
+      renderList('');
+      // Defer focus so the open animation doesn't get jumped
+      setTimeout(() => tpSearch.focus(), 30);
+    }
+    function closePicker() {
+      trackPicker.hidden = true;
+      trackPicker.setAttribute('aria-hidden', 'true');
+      document.body.classList.remove('tp-locked');
+      if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
+        try { tpLastFocus.focus(); } catch(_) {}
+      }
+    }
+
+    trackBtn.addEventListener('click', async () => {
+      openPicker();
+      await loadTracks();
+      renderList(tpSearch.value);
+    });
+
+    // Close: backdrop click, [data-tp-close], or Escape
+    trackPicker.addEventListener('click', (e) => {
+      if (e.target.closest('[data-tp-close]')) {
+        closePicker();
+        return;
+      }
+      const row = e.target.closest('.tp-row[data-track-id]');
+      if (row) {
+        if (row.getAttribute('aria-disabled') === 'true') return;
+        const id = row.dataset.trackId;
+        if (id) {
+          insertChip('track', id);
+          closePicker();
+        }
+      }
+    });
+    document.addEventListener('keydown', (e) => {
+      if (!trackPicker.hidden && e.key === 'Escape') {
+        e.preventDefault();
+        closePicker();
+      }
+    });
+
+    // Live filter
+    tpSearch.addEventListener('input', () => renderList(tpSearch.value));
+  }
+
+  // ── Playlist insert (open existing or create new via modal)
+  const playlistBtn = document.getElementById('insert-playlist-btn');
+  if (playlistBtn) {
+    playlistBtn.addEventListener('click', async () => {
+      if (typeof window.openPlaylistEditor !== 'function') {
+        alert('' + esc(T.js_playlist_editor_missing) + '');
+        return;
+      }
+      try {
+        const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
+        const j = await r.json();
+        if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
+          const choice = prompt(
+            '' + esc(T.js_playlist_existing) + '\n\n' +
+            j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
+            '\n\n' + esc(T.js_playlist_choose) + ''
+          );
+          if (choice && /^\d+$/.test(choice.trim())) {
+            const idx = parseInt(choice.trim(), 10) - 1;
+            if (idx >= 0 && idx < j.playlists.length) {
+              insertChip('playlist', j.playlists[idx].id);
+              return;
+            }
+          }
+          if (choice === null) return;
+        }
+      } catch (_) { /* fall through to create */ }
+
+      window.openPlaylistEditor({
+        mode: 'create',
+        onSaved: ({ id }) => insertChip('playlist', id),
+      });
+    });
+  }
+
+  // ── Post type: segmented control + type-aware panels ──────────
+  (function () {
+    const typeInput = document.getElementById('pe-type-input');
+    const card = document.querySelector('.pe-type-card');
+    if (!typeInput || !card) return;
+    const seg = card.querySelector('.pe-typeseg');
+    const panels = card.querySelectorAll('.pe-type-panel');
+
+    function applyType(tt) {
+      typeInput.value = tt;
+      seg.querySelectorAll('.pe-typeseg-btn').forEach(b => {
+        const on = b.dataset.type === tt;
+        b.classList.toggle('is-active', on);
+        b.setAttribute('aria-checked', on ? 'true' : 'false');
+      });
+      panels.forEach(p => { p.hidden = (p.dataset.panel !== tt); });
+    }
+    seg.addEventListener('click', (e) => {
+      const btn = e.target.closest('.pe-typeseg-btn');
+      if (btn) applyType(btn.dataset.type);
+    });
+    applyType(typeInput.value || 'post');
+
+    // Video URL → [[embed:url]] chip
+    const vBtn = document.getElementById('pe-video-insert');
+    const vUrl = document.getElementById('pe-video-url');
+    if (vBtn && vUrl) {
+      const doInsert = () => {
+        const url = (vUrl.value || '').trim();
+        if (!/^https?:\/\//i.test(url)) { alert('' + esc(T.js_embed_invalid) + ''); return; }
+        insertChip('embed', url);
+        vUrl.value = '';
+      };
+      vBtn.addEventListener('click', doInsert);
+      vUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doInsert(); } });
+    }
+
+    // Audio: inline upload → transcodes server-side → [[track:id]] chip
+    const drop = document.getElementById('pe-audio-drop');
+    const fileInput = document.getElementById('pe-audio-file');
+    const list = document.getElementById('pe-audio-list');
+    if (drop && fileInput && list) {
+      const pick = () => fileInput.click();
+      drop.addEventListener('click', pick);
+      drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
+      ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('is-drag'); }));
+      ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('is-drag'); }));
+      drop.addEventListener('drop', (e) => { if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); });
+      fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; });
+
+      function clientDuration(f) {
+        return new Promise((resolve) => {
+          try {
+            const u = URL.createObjectURL(f);
+            const a = document.createElement('audio');
+            a.preload = 'metadata';
+            a.onloadedmetadata = () => { URL.revokeObjectURL(u); resolve(Number.isFinite(a.duration) ? Math.round(a.duration) : null); };
+            a.onerror = () => { URL.revokeObjectURL(u); resolve(null); };
+            a.src = u;
+          } catch (_) { resolve(null); }
+        });
+      }
+      async function handleFiles(files) {
+        for (const f of Array.from(files || [])) await uploadOne(f);
+      }
+      async function uploadOne(f) {
+        const li = document.createElement('li');
+        li.className = 'pe-audio-item';
+        const nameEl = document.createElement('span');
+        nameEl.className = 'pe-audio-item-name';
+        nameEl.textContent = f.name;
+        const stateEl = document.createElement('span');
+        stateEl.className = 'pe-audio-item-state';
+        stateEl.textContent = '⏳ ' + esc(T.audio_up_busy) + '';
+        li.appendChild(nameEl); li.appendChild(stateEl);
+        list.appendChild(li);
+        try {
+          const dur = await clientDuration(f);
+          const fd = new FormData();
+          fd.append('audio', f);
+          if (dur) fd.append('duration', String(dur));
+          const res = await fetch('/admin/audio/upload', {
+            method: 'POST', body: fd,
+            headers: { 'Accept': 'application/json' },
+            credentials: 'same-origin',
+          });
+          const j = await res.json().catch(() => ({}));
+          if (!res.ok || !j.ok || !j.id) throw new Error(j.error || ('HTTP ' + res.status));
+          insertChip('track', j.id);
+          stateEl.textContent = '✓ ' + esc(T.audio_up_done) + '';
+          li.classList.add('is-done');
+        } catch (err) {
+          stateEl.textContent = '✕ ' + esc(T.audio_up_fail) + ': ' + err.message;
+          li.classList.add('is-fail');
+        }
+      }
+    }
+  })();
+
+  // ── Submit: serialize editor contents into the hidden field
+  if (form && hiddenField) {
+    form.addEventListener('submit', () => {
+      const clone = editor.cloneNode(true);
+      serializeChips(clone);
+      hiddenField.value = clone.innerHTML;
+    });
+  }
+})();
+
+// ── volgend blok ──
+
+(function () {
+  // Pin: checkbox toggles the hidden rank field (0 = not pinned),
+  // ▲▼ shifts the position, with a readable description instead of a raw number.
+  var toggle = document.getElementById('pin-toggle');
+  var rank   = document.getElementById('pin-rank');
+  var pos    = document.getElementById('pin-pos');
+  var label  = document.getElementById('pin-label');
+  var up     = document.getElementById('pin-up');    // higher = lower number (towards 1/top)
+  var down   = document.getElementById('pin-down');
+  if (!toggle || !rank || !pos) return;
+
+  function descr(n) {
+    n = Number(n) || 0;
+    if (n <= 1) return '' + esc(T.pin_top) + '';
+    return n + '' + esc(T.pin_nth_suffix) + '';
+  }
+  function render() {
+    var on = toggle.checked;
+    pos.hidden = !on;
+    if (on && Number(rank.value) < 1) rank.value = 1;
+    if (!on) rank.value = 0;
+    if (label) label.textContent = on ? descr(rank.value) : '';
+    if (up) up.disabled = Number(rank.value) <= 1;
+  }
+  toggle.addEventListener('change', render);
+  if (up)   up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
+  if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
+  render();
+})();
+
+(function () {
+  // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible
+  // obstacles by setting a dynamic bottom offset = the greater of:
+  //  1) the height of the keyboard area NOT covered by the layout viewport
+  //     (on iOS the visual viewport shifts; on Android the layout viewport shrinks
+  //     due to interactive-widget=resizes-content → offset ≈ 0);
+  //  2) the height of the playing audio player (fixed, z-index 1000).
+  // We stick with sticky (no fixed/top tricks → no bar floating in the middle).
+  var bar = document.querySelector('.pe-actions');
+  if (!bar) return;
+  var vv = window.visualViewport;
+  function position() {
+    var ap = document.querySelector('.audio-player');
+    var playing = document.body.classList.contains('has-audio-player') &&
+                  ap && getComputedStyle(ap).display !== 'none';
+    var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0;
+    var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
+    var offset = Math.max(audioOffset, kbCovered);
+    bar.style.bottom = offset ? offset + 'px' : '';
+  }
+  position();
+  window.addEventListener('resize', position);
+  if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); }
+  // has-audio-player is toggled via a body class → observe it.
+  try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
+})();
Index: src/assets/js/mod/post.js
===================================================================
--- src/assets/js/mod/post.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/post.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,57 @@
+// De post-pagina: het "beantwoorden vanaf je eigen server"-veld (shaer-bqr).
+//
+// Stond inline in pages/post.ejs. Kwam je op een post via een link BINNEN de
+// site, dan arriveerde dat script via htmx met een nonce die het document niet
+// kent en weigerde de CSP het (shaer-0i6) -- dus deed de knop niets, precies op
+// de plek waar de meeste bezoekers binnenkomen.
+//
+// Kon ongewijzigd mee: alles is al gedelegeerd op document, er wordt geen enkel
+// pagina-element vastgehouden, en er staat een slot op. Dat is niet toevallig --
+// het is geschreven voor een popover die na een htmx-swap moet blijven werken.
+
+  (function(){
+    if (window.__fediRemoteWired) return; window.__fediRemoteWired = true;
+    var current = null, currentBtn = null;
+    function close(){ if (current) { current.remove(); current = null; currentBtn = null; } }
+    function go(raw, uri){
+      var d = (raw||'').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
+      if (d) { try { localStorage.setItem('pcmsFediServer', d); } catch(e){} location.href = 'https://' + d + '/authorize_interaction?uri=' + encodeURIComponent(uri||''); }
+    }
+    function place(f, b){
+      var r = b.getBoundingClientRect();
+      f.style.top = (r.bottom + window.scrollY + 6) + 'px';
+      f.style.left = Math.max(8, Math.min(r.left + window.scrollX, window.scrollX + window.innerWidth - 340)) + 'px';
+    }
+    document.addEventListener('click', function(e){
+      if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; }
+      if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return; // click inside → keep
+      var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn');
+      if (b) {
+        e.preventDefault();
+        if (currentBtn === b) { close(); return; }   // toggle off
+        close();
+        var f = document.createElement('form');
+        f.className = 'fedi-remote-form';
+        f.dataset.uri = b.getAttribute('data-fedi-uri') || '';
+        f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false">'
+          + '<button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">&rarr;</button>'
+          + '<button type="button" class="fedi-remote-cancel" aria-label="x">&times;</button>';
+        var _inp = f.querySelector('input');
+        _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social';
+        try { var _sv = localStorage.getItem('pcmsFediServer'); if (_sv) _inp.value = _sv; } catch(e){}
+        document.body.appendChild(f);            // floating popover → no layout reflow
+        place(f, b); current = f; currentBtn = b;
+        _inp.focus(); _inp.select();
+        return;
+      }
+      if (current) close(); // click anywhere else closes it
+    });
+    document.addEventListener('submit', function(e){
+      var f = e.target.closest && e.target.closest('.fedi-remote-form');
+      if (!f) return; e.preventDefault();
+      go(f.querySelector('input').value, f.dataset.uri);
+    });
+    document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
+    window.addEventListener('scroll', close, true);
+  })();
+  
Index: src/assets/js/mod/track-editor.js
===================================================================
--- src/assets/js/mod/track-editor.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/track-editor.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,482 @@
+// De track-editor (beheer > audio) (partials/track-editor.ejs) -- verplaatst uit inline script, shaer-bqr.
+//
+// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
+// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
+// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
+//
+// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
+// er nu staat) en tegen een tweede aanroep te kunnen.
+
+(function() {
+
+  function esc(s) {
+    return String(s == null ? '' : s)
+      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+      .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+  }
+
+  async function api(method, url, body) {
+    const opts = { method, credentials: 'same-origin', headers: {} };
+    if (body !== undefined) {
+      if (body instanceof FormData) {
+        opts.body = body;
+      } else {
+        opts.headers['Content-Type'] = 'application/json';
+        opts.body = JSON.stringify(body);
+      }
+    }
+    const res = await fetch(url, opts);
+    let data;
+    try { data = await res.json(); }
+    catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); }
+    if (!res.ok) data.ok = false;
+    return data;
+  }
+
+  /**
+   * Open the track editor.
+   * @param {object} opts
+   * @param {string} opts.id        — track id to edit
+   * @param {function?} opts.onSaved — called with updated track on success
+   */
+  window.openTrackEditor = async function openTrackEditor({ id, onSaved }) {
+    if (!id) return;
+    let track, albumSuggestions = [];
+    try {
+      const [trackJson, listJson] = await Promise.all([
+        api('GET', '/admin/audio/api/' + encodeURIComponent(id)),
+        api('GET', '/admin/audio/api/albums'),
+      ]);
+      if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden');
+      track = trackJson.track;
+      if (listJson.ok && Array.isArray(listJson.albums)) {
+        albumSuggestions = listJson.albums.filter(Boolean);
+      }
+    } catch (err) {
+      alert('Track ophalen mislukt: ' + err.message);
+      return;
+    }
+
+    const backdrop = document.createElement('div');
+    backdrop.className = 'te-backdrop';
+    backdrop.innerHTML = `
+      <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken">
+        <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div>
+
+        <div class="te-header">
+          <h3>✎ Track bewerken</h3>
+          <button type="button" class="te-close" aria-label="Sluiten">×</button>
+        </div>
+
+        <div class="te-body">
+
+          ${track.stream_url ? `
+          <div class="te-preview">
+            <div class="te-preview-cover">
+              ${track.cover_url
+                ? `<img src="${esc(track.cover_url)}" alt="">`
+                : `🎵`}
+            </div>
+            <div class="te-preview-meta">
+              <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div>
+              <div class="te-preview-sub">
+                ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''}
+              </div>
+            </div>
+            <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button>
+          </div>
+          ` : ''}
+
+          <div class="te-form">
+
+            <label class="te-field">
+              <span>Titel <span class="te-required" aria-hidden="true">*</span></span>
+              <input type="text" id="te-title" maxlength="200" required
+                     autocomplete="off" autocapitalize="words" spellcheck="false"
+                     value="${esc(track.title || '')}">
+            </label>
+
+            <div class="te-row te-row-2">
+              <label class="te-field">
+                <span>Artiest</span>
+                <input type="text" id="te-artist" maxlength="200"
+                       autocomplete="off" autocapitalize="words" spellcheck="false"
+                       value="${esc(track.artist || '')}">
+              </label>
+              <label class="te-field">
+                <span>Album</span>
+                <input type="text" id="te-album" maxlength="200"
+                       autocomplete="off" autocapitalize="words" spellcheck="false"
+                       list="te-album-list" value="${esc(track.album || '')}">
+                <datalist id="te-album-list">
+                  ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')}
+                </datalist>
+              </label>
+            </div>
+
+            <label class="te-field">
+              <span>Duur <small>(seconden — automatisch bepaald, hier te overschrijven)</small></span>
+              <input type="number" id="te-duration" min="0" step="1"
+                     inputmode="numeric" pattern="[0-9]*"
+                     value="${track.duration || ''}" placeholder="auto">
+            </label>
+
+            <div class="te-row te-row-2">
+              <label class="te-field">
+                <span>Eigenaar / credit <small>(copyright-houder)</small></span>
+                <div class="te-credit-row">
+                  <input type="text" id="te-credit" maxlength="200"
+                         autocomplete="off" spellcheck="false"
+                         placeholder="bv. © 2025 Mara Vos"
+                         value="${esc(track.credit || '')}">
+                  <button type="button" class="te-sym-btn" id="te-credit-copyr"
+                          title="© invoegen" aria-label="Copyright-teken invoegen">©</button>
+                </div>
+              </label>
+              <label class="te-field">
+                <span>Licentie</span>
+                <input type="text" id="te-license" maxlength="120"
+                       autocomplete="off" spellcheck="false" list="te-license-list"
+                       placeholder="Alle rechten voorbehouden"
+                       value="${esc(track.license || '')}">
+                <datalist id="te-license-list">
+                  <option value="Alle rechten voorbehouden"></option>
+                  <option value="CC BY 4.0"></option>
+                  <option value="CC BY-SA 4.0"></option>
+                  <option value="CC BY-NC 4.0"></option>
+                  <option value="CC BY-NC-SA 4.0"></option>
+                  <option value="CC BY-ND 4.0"></option>
+                  <option value="CC0 1.0 (publiek domein)"></option>
+                </datalist>
+              </label>
+            </div>
+
+            <div class="te-field">
+              <span>Open in <small>(links naar dezelfde track elders)</small></span>
+              <input type="url" id="te-link-spotify" inputmode="url" autocomplete="off" spellcheck="false"
+                     placeholder="Spotify-URL (https://open.spotify.com/…)" value="${esc(track.link_spotify || '')}">
+              <input type="url" id="te-link-youtube" inputmode="url" autocomplete="off" spellcheck="false"
+                     placeholder="YouTube-URL (https://youtu.be/…)" value="${esc(track.link_youtube || '')}">
+              <input type="url" id="te-link-soundcloud" inputmode="url" autocomplete="off" spellcheck="false"
+                     placeholder="SoundCloud-URL (https://soundcloud.com/…)" value="${esc(track.link_soundcloud || '')}">
+            </div>
+
+            <div class="te-field">
+              <span>Audiobestand</span>
+              <div class="te-cover-btn-row">
+                <input type="file" id="te-audio-file" accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg,.aac" hidden>
+                <button type="button" class="te-cover-btn" id="te-audio-pick">${track.stream_url ? '🔁 Vervang audiobestand' : '🎵 Audiobestand kiezen'}</button>
+                <span class="te-cover-status" id="te-audio-status"></span>
+              </div>
+            </div>
+
+            <div class="te-field">
+              <span>Cover</span>
+              <div class="te-cover-row">
+                <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button"
+                     aria-label="Klik om cover te kiezen">
+                  ${track.cover_url
+                    ? `<img src="${esc(track.cover_url)}" alt="">`
+                    : `<span class="te-cover-empty">🎨</span>`}
+                </div>
+                <input type="file" id="te-cover-file"
+                       accept="image/jpeg,image/png,image/webp,image/gif" hidden>
+                <div class="te-cover-actions">
+                  <div class="te-cover-btn-row">
+                    <button type="button" class="te-cover-btn" id="te-cover-pick">
+                      📷 Foto kiezen
+                    </button>
+                    ${track.cover_url ? `
+                    <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove">
+                      × Verwijder
+                    </button>` : ''}
+                  </div>
+                  <input type="text" id="te-cover-url" inputmode="url"
+                         placeholder="/media/… of https://…"
+                         autocomplete="off" autocapitalize="none" spellcheck="false"
+                         value="${esc(track.cover_url || '')}">
+                  <div class="te-cover-status" id="te-cover-status"></div>
+                </div>
+              </div>
+            </div>
+
+          </div>
+        </div>
+
+        <div class="te-footer">
+          <button type="button" class="te-btn" id="te-cancel">Annuleren</button>
+          <div class="te-footer-spacer"></div>
+          <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button>
+        </div>
+      </div>
+    `;
+
+    document.body.appendChild(backdrop);
+    document.body.classList.add('te-modal-open');
+
+    const $ = sel => backdrop.querySelector(sel);
+
+    // ── Inline preview player (routed through global mini-player) ──
+    // We don't build our own <audio>; instead we tell the global
+    // window.pcmsAudioPlayer to load this single track. Visual state
+    // syncs against the global audio element so toggle works correctly
+    // even if the user pauses from the mini-bar.
+    const previewBtn = $('#te-preview-play');
+    if (previewBtn) {
+      const setPreviewState = (playing) => {
+        previewBtn.textContent = playing ? '⏸' : '▶';
+        previewBtn.classList.toggle('is-playing', playing);
+        previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
+      };
+      const isOurTrack = () => {
+        // audio.src is a blob: URL (Spotify-style playback) — compare against
+        // the player's logical current-track URL instead.
+        const player = window.pcmsAudioPlayer;
+        const cur = player && player.currentTrack();
+        return !!(cur && track.stream_url && cur.url === track.stream_url);
+      };
+      const resync = () => {
+        const audio = document.getElementById('audio-element');
+        const playing = audio && !audio.paused && !audio.ended && isOurTrack();
+        setPreviewState(!!playing);
+      };
+
+      previewBtn.addEventListener('click', () => {
+        const player = window.pcmsAudioPlayer;
+        if (!player || !track.stream_url) {
+          console.warn('preview: miniplayer or url missing');
+          return;
+        }
+        const audio = document.getElementById('audio-element');
+        if (audio && isOurTrack()) {
+          // Same track loaded — toggle
+          if (audio.paused) player.play(); else player.pause();
+        } else {
+          player.setQueue([{
+            url:    track.stream_url,
+            title:  track.title  || '(zonder titel)',
+            artist: track.artist || '',
+            album:  track.album  || '',
+            cover:  track.cover_url || '',
+          }], 0);
+        }
+      });
+
+      const audio = document.getElementById('audio-element');
+      if (audio) {
+        const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied'];
+        evs.forEach(ev => audio.addEventListener(ev, resync));
+        // Detach listeners on close so we don't leak them
+        backdrop._previewCleanup = () => {
+          evs.forEach(ev => audio.removeEventListener(ev, resync));
+        };
+        resync();   // initial state
+      }
+    }
+
+    function close() {
+      // Detach our resync listeners (mini-player stays running)
+      if (backdrop._previewCleanup) backdrop._previewCleanup();
+      document.body.classList.remove('te-modal-open');
+      document.removeEventListener('keydown', onEsc);
+      backdrop.remove();
+    }
+    function onEsc(e) { if (e.key === 'Escape') close(); }
+    document.addEventListener('keydown', onEsc);
+
+    // A click on the backdrop does NOT close the editor — too easy to lose edits by
+    // mis-clicking outside. Close deliberately via ×, Cancel or Esc.
+    $('.te-close').addEventListener('click', close);
+    $('#te-cancel').addEventListener('click', close);
+
+    // ── Cover picking + URL paste + drag-drop ────────────────
+    const thumb     = $('#te-cover-thumb');
+    const fileInput = $('#te-cover-file');
+    const urlInput  = $('#te-cover-url');
+    const status    = $('#te-cover-status');
+    const pickBtn   = $('#te-cover-pick');
+    const removeBtn = $('#te-cover-remove');
+
+    function setStatus(text, kind) {
+      status.textContent = text || '';
+      status.className = 'te-cover-status' + (kind ? ' is-' + kind : '');
+    }
+    function setThumb(url) {
+      if (url) {
+        thumb.innerHTML = `<img src="${esc(url)}" alt="">`;
+      } else {
+        thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`;
+      }
+    }
+
+    pickBtn.addEventListener('click', () => fileInput.click());
+    thumb.addEventListener('click', () => fileInput.click());
+    thumb.addEventListener('keydown', e => {
+      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
+    });
+
+    async function uploadCoverFile(file) {
+      if (!file) return;
+      if (!/^image\//.test(file.type)) {
+        setStatus('Alleen afbeeldingen toegestaan', 'error'); return;
+      }
+      setStatus('Uploaden…', null);
+      const fd = new FormData();
+      fd.append('cover', file);
+      try {
+        const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', {
+          method: 'POST', body: fd, credentials: 'same-origin',
+        });
+        const j = await r.json();
+        if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
+        urlInput.value = j.cover_url || '';
+        setThumb(j.cover_url);
+        setStatus('✓ Geüpload', 'ok');
+      } catch (err) {
+        setStatus('Mislukt: ' + err.message, 'error');
+      }
+    }
+
+    fileInput.addEventListener('change', e => {
+      const f = e.target.files && e.target.files[0];
+      if (f) uploadCoverFile(f);
+      fileInput.value = '';
+    });
+
+    // Drag-drop on thumb (desktop nicety)
+    ['dragenter', 'dragover'].forEach(ev =>
+      thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); }));
+    ['dragleave', 'drop'].forEach(ev =>
+      thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); }));
+    thumb.addEventListener('drop', e => {
+      const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
+      if (f) uploadCoverFile(f);
+    });
+
+    // URL paste auto-preview
+    urlInput.addEventListener('input', () => {
+      const v = urlInput.value.trim();
+      setThumb(v);
+    });
+
+    if (removeBtn) {
+      removeBtn.addEventListener('click', () => {
+        urlInput.value = '';
+        setThumb('');
+        removeBtn.remove();
+      });
+    }
+
+    // ── Replace the audio file of this track ──────────────────
+    const aPick = $('#te-audio-pick');
+    const aFile = $('#te-audio-file');
+    const aStatus = $('#te-audio-status');
+    if (aPick && aFile) {
+      aPick.addEventListener('click', () => aFile.click());
+      aFile.addEventListener('change', async (e) => {
+        const f = e.target.files && e.target.files[0];
+        aFile.value = '';
+        if (!f) return;
+        aStatus.textContent = '⏳ Converteren… (kan even duren)'; aStatus.className = 'te-cover-status';
+        aPick.disabled = true;
+        try {
+          const fd = new FormData();
+          fd.append('audio', f);
+          const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id) + '/replace-audio', fd);
+          if (!j.ok) throw new Error(j.error || 'mislukt');
+          aStatus.textContent = '✓ Vervangen'; aStatus.className = 'te-cover-status is-ok';
+          track.stream_url = j.stream_url || track.stream_url;
+          if (j.duration) { const d = $('#te-duration'); if (d) d.value = j.duration; }
+        } catch (err) {
+          aStatus.textContent = 'Mislukt: ' + err.message; aStatus.className = 'te-cover-status is-error';
+        } finally { aPick.disabled = false; }
+      });
+    }
+
+    // ── Insert © symbol into the credit field ─────────────────
+    const copyrBtn = $('#te-credit-copyr');
+    if (copyrBtn) {
+      copyrBtn.addEventListener('click', () => {
+        const inp = $('#te-credit');
+        if (!inp) return;
+        const sym = '© ';
+        const start = inp.selectionStart != null ? inp.selectionStart : inp.value.length;
+        const end = inp.selectionEnd != null ? inp.selectionEnd : inp.value.length;
+        inp.value = inp.value.slice(0, start) + sym + inp.value.slice(end);
+        inp.focus();
+        const pos = start + sym.length;
+        try { inp.setSelectionRange(pos, pos); } catch (e) {}
+      });
+    }
+
+    // ── Save ────────────────────────────────────────────────
+    $('#te-save').addEventListener('click', async () => {
+      const titleEl = $('#te-title');
+      const title = titleEl.value.trim();
+      if (!title) {
+        titleEl.focus();
+        alert('Titel is verplicht');
+        return;
+      }
+      const saveBtn = $('#te-save');
+      saveBtn.disabled = true;
+      saveBtn.textContent = '⏳ Opslaan…';
+
+      try {
+        const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), {
+          title,
+          artist: $('#te-artist').value.trim() || null,
+          album:  $('#te-album').value.trim() || null,
+          credit:  $('#te-credit').value.trim() || null,
+          license: $('#te-license').value.trim() || null,
+          link_spotify:    $('#te-link-spotify').value.trim() || null,
+          link_youtube:    $('#te-link-youtube').value.trim() || null,
+          link_soundcloud: $('#te-link-soundcloud').value.trim() || null,
+          duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
+          cover_url: urlInput.value.trim() || null,
+        });
+        if (!j.ok) throw new Error(j.error || 'Opslaan mislukt');
+        if (typeof onSaved === 'function') onSaved(j.track || { id, title,
+          artist: $('#te-artist').value.trim() || null,
+          album:  $('#te-album').value.trim() || null,
+          cover_url: urlInput.value.trim() || null });
+        close();
+      } catch (err) {
+        alert('Opslaan mislukt: ' + err.message);
+        saveBtn.disabled = false;
+        saveBtn.textContent = '💾 Opslaan';
+      }
+    });
+
+    // ── Auto-duration ───────────────────────────────────────────
+    // The server already determines duration automatically on upload. This is the
+    // fallback/UX layer: if an admin opens an existing track without a duration,
+    // we read it from the audio metadata and fill the field — so you never need
+    // to type seconds manually. An existing value is never overwritten. We fetch
+    // the bytes via the same header gate as the player (X-Audio-Player).
+    (async function autoDuration() {
+      const durEl = $('#te-duration');
+      if (!durEl || !track.stream_url) return;
+      if (durEl.value && Number(durEl.value) > 0) return;  // already filled → leave it alone
+      let objUrl = null;
+      try {
+        const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } });
+        if (!r.ok) return;
+        objUrl = URL.createObjectURL(await r.blob());
+        const probe = new Audio();
+        probe.preload = 'metadata';
+        probe.addEventListener('loadedmetadata', () => {
+          if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) {
+            durEl.value = Math.round(probe.duration);
+          }
+          if (objUrl) URL.revokeObjectURL(objUrl);
+        });
+        probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); });
+        probe.src = objUrl;
+      } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); }
+    })();
+
+    // Focus title for fast typing
+    setTimeout(() => $('#te-title').focus(), 60);
+  };
+})();
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/config/database.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -106,4 +106,13 @@
     PRIMARY KEY (guardian_slug, id)
   )`);
+  // Guardianship Fase 2 (shaer-jdb): een doorgestuurde follow-goedkeuring draagt
+  // een RICHTING. Bij een inkomende is de follower iemand anders en de ward het
+  // doel; bij een uitgaande is de ward zelf de follower en staat het doel in het
+  // Follow-object. Zonder deze twee kolommen werd een uitgaande opgeslagen als
+  // "deze ward wil deze ward volgen" en viel het doel weg -- dan valt er niets
+  // zinnigs te tonen, hoe je de wachtrij ook vult.
+  ensureColumn('ap_follow_reviews', 'direction', "TEXT DEFAULT 'incoming'");
+  ensureColumn('ap_follow_reviews', 'target_uri', 'TEXT');
+  ensureColumn('ap_follow_reviews', 'target_handle', 'TEXT');
   ensureColumn('sites', 'profile_photo', 'TEXT');
   ensureColumn('audio_tracks', 'cover_url', 'TEXT');
@@ -469,4 +478,16 @@
       UNIQUE(slug, actor_uri)
     );
+    -- Antwoorden van accounts die we volgen komen gewoon binnen, ondertekend
+    -- door de schrijver, maar horen niet in de Krant (belongsInTimeline) en
+    -- werden daarna nergens bewaard. Kwam er later een doorgestuurd antwoord OP
+    -- zo'n bericht, dan kenden we de ouder niet en wezen we het af (shaer-e9g).
+    -- Alleen de URI, geen inhoud: dit voedt uitsluitend de vraag "kennen wij dit
+    -- bericht?". Wordt na 30 dagen gesnoeid; doorsturen gebeurt kort na het
+    -- antwoord, dus langer bewaren levert niets op.
+    CREATE TABLE IF NOT EXISTS ap_seen_notes (
+      uri TEXT PRIMARY KEY,
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+    );
+    CREATE INDEX IF NOT EXISTS idx_ap_seen_notes_age ON ap_seen_notes(created_at);
     CREATE TABLE IF NOT EXISTS ap_timeline (
       id TEXT NOT NULL,              -- the remote note's AP id
@@ -714,4 +735,26 @@
   ensureColumn('ap_outbox', 'to_actors', 'TEXT');   // JSON array of recipient actor URIs for direct notes
   ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
+  // Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
+  // Los van ap_mentions, want dit is GEDEELDE staat: elke guardian van dit kind
+  // heeft er een kopie van, en die komt binnen als bericht van een ander. Een
+  // kolom op de mention zou alleen over onszelf gaan.
+  //
+  // OPGEPIKT mag stapelen: twee mensen die tegelijk reageren op een kind dat om
+  // hulp vraagt is geen probleem. Twee mensen die allebei niets doen omdat de
+  // ander het "geclaimd" had, wel.
+  //
+  // AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
+  // bevestiging, en leeft de vraag daarna nog, dan wordt hij opnieuw gesteld --
+  // een nieuwe hulpvraag. Zo blijft het verslag eerlijk: er wordt niets
+  // herschreven, er wordt toegevoegd.
+  db.exec(`CREATE TABLE IF NOT EXISTS ap_help_state (
+    note_uri TEXT NOT NULL,
+    guardian_uri TEXT NOT NULL,
+    kind TEXT NOT NULL,                 -- pickup | handled
+    guardian_handle TEXT,
+    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (note_uri, guardian_uri, kind)
+  )`);
+  db.exec('CREATE INDEX IF NOT EXISTS idx_ap_help_state_note ON ap_help_state(note_uri)');
   ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
   ensureColumn('ap_outbox', 'wave', 'INTEGER');    // FEP-633c shaer:wave (guardian -> ward nudge)
@@ -759,4 +802,85 @@
   ensureColumn('ap_followers', 'handle', 'TEXT');  // @user@host
   ensureColumn('ap_followers', 'icon', 'TEXT');    // avatar URL
+  feedStateTriggers();
+}
+
+/**
+ * Wat er met een tijdlijn gebeurd is, op één plek (shaer-n05).
+ *
+ * De inbox-lezing voegt vier bronnen samen. De vraag "is er iets veranderd" werd
+ * eerst beantwoord met MAX(rowid) over die vier -- een TOEVALLIGE eigenschap van
+ * de tabellen, geen feit dat ergens is opgeschreven. Dat gaf precies de gebreken
+ * die je van zo'n afleiding verwacht: bewerkingen en verwijderingen bewogen hem
+ * niet, en hij kon achteruit lopen. Dezelfde fout als reacties uitlezen uit
+ * ap_timeline.liked (shaer-9e9).
+ *
+ * Nu één rij per bericht per tijdlijn, met een oplopende `rev` en `kind`. Dat
+ * beantwoordt drie vragen die anders drie eigen oplossingen zouden krijgen:
+ * is er iets veranderd sinds N, wát is er veranderd, en is dit bericht bewerkt.
+ *
+ * Bijgehouden door TRIGGERS en niet door de aanroepende code, om dezelfde reden
+ * dat er geen gebeurtenis-emitter is: een trigger zit in de database, dus geen
+ * enkel codepad kan hem vergeten. De prijs is onzichtbare logica -- wie alleen de
+ * JavaScript leest ziet niet waarom deze tabel vult. Vandaar dat ze hier staan,
+ * bij de tabel, en niet verspreid.
+ *
+ * Let op de `UPDATE OF`-kolomlijsten: die zijn niet decoratief. Een like schrijft
+ * ap_timeline.liked en een 🔁 schrijft .boosted; zonder die afbakening zou je
+ * eigen like het bericht als BEWERKT merken en elke wachtende client wekken.
+ */
+function feedStateTriggers() {
+  try {
+    db.exec(`
+      CREATE TABLE IF NOT EXISTS ap_feed_state (
+        slug TEXT NOT NULL,
+        object_uri TEXT NOT NULL,
+        rev INTEGER NOT NULL,
+        kind TEXT NOT NULL,             -- new | updated | deleted
+        at DATETIME DEFAULT CURRENT_TIMESTAMP,
+        PRIMARY KEY (slug, object_uri)
+      );
+      CREATE INDEX IF NOT EXISTS idx_ap_feed_state_rev ON ap_feed_state(slug, rev);
+      -- Eén doorlopende teller voor de hele instance. Bewust niet MAX(rev) uit de
+      -- tabel zelf: verdwijnt de hoogste rij, dan zou die teruglopen en denkt een
+      -- client dat er niets gebeurd is.
+      CREATE TABLE IF NOT EXISTS ap_feed_rev (n INTEGER NOT NULL);
+    `);
+    if (!db.prepare('SELECT COUNT(*) AS n FROM ap_feed_rev').get().n) {
+      db.prepare('INSERT INTO ap_feed_rev (n) VALUES (0)').run();
+    }
+    // slug + object_uri verschillen per bron; de rest is voor alle vier gelijk.
+    const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '') => `
+      DROP TRIGGER IF EXISTS ${naam};
+      CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel} BEGIN
+        UPDATE ap_feed_rev SET n = n + 1;
+        INSERT INTO ap_feed_state (slug, object_uri, rev, kind)
+          ${extra || `VALUES (${slug}, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}')`}
+          ON CONFLICT(slug, object_uri) DO UPDATE
+            SET rev = excluded.rev, kind = excluded.kind, at = CURRENT_TIMESTAMP;
+      END;`;
+    const joinPosts = (uri, kind) => `
+          SELECT s.slug, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}'
+            FROM posts p JOIN sites s ON s.id = p.site_id`;
+    db.exec([
+      zet('trg_feed_tl_ins', 'INSERT', 'ap_timeline', 'NEW.slug', 'NEW.id', 'new'),
+      zet('trg_feed_tl_upd', 'UPDATE OF content, media_json, nsfw, cw, url, poll_json, quote_json, embed_json', 'ap_timeline', 'NEW.slug', 'NEW.id', 'updated'),
+      zet('trg_feed_tl_del', 'DELETE', 'ap_timeline', 'OLD.slug', 'OLD.id', 'deleted'),
+      zet('trg_feed_mn_ins', 'INSERT', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'new'),
+      zet('trg_feed_mn_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'updated'),
+      zet('trg_feed_mn_del', 'DELETE', 'ap_mentions', 'OLD.slug', 'OLD.object_uri', 'deleted'),
+      zet('trg_feed_ob_ins', 'INSERT', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'new'),
+      zet('trg_feed_ob_upd', 'UPDATE OF content, attachments', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'updated'),
+      zet('trg_feed_ob_del', 'DELETE', 'ap_outbox', 'OLD.site_slug', 'OLD.id', 'deleted'),
+      // ap_interactions draagt geen slug: die hangt aan de POST. Vandaar de join,
+      // en vandaar dat deze drie niet in de gewone vorm passen.
+      zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`),
+      zet('trg_feed_ia_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'updated')} WHERE p.id = NEW.post_id`),
+      zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`),
+    ].join('\n'));
+  } catch (e) {
+    // Niet fataal: zonder deze tabel valt het wachten terug op "altijd de tijd
+    // volmaken", en dat is traag maar niet stuk.
+    console.error('❌ feed-state triggers:', e.message);
+  }
 }
 
Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/middleware/render.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -249,4 +249,9 @@
     appVersion: APP_VERSION,
     bodyClass: data.bodyClass || 'on-home',
+    // Welke module(s) deze pagina nodig heeft (shaer-bqr). De shell zet ze op
+    // body[data-js]; de bootstrap daar importeert ze uit /assets/js/mod/.
+    // Alleen kleine letters, cijfers, streepjes en spaties -- de naam wordt een
+    // pad.
+    pageJs: /^[a-z0-9 -]*$/.test(String(data.pageJs || '')) ? (data.pageJs || '') : '',
     socialDescr: data.socialDescr || '',
     socialImage: data.socialImage || '',
@@ -286,6 +291,10 @@
         ? _site.accent : '#e8b04b';
       const _navPalette = (_site && _site.palette) ? _site.palette : 'klonkt';
+      // Welke modules de nieuwe pagina wil (shaer-bqr). De bootstrap in de shell
+      // zet dit op de body en haalt op wat er nieuw bij staat; 'chrome' hoort er
+      // altijd bij, want die komt bij elke navigatie opnieuw binnen.
+      const _navJs = ('chrome ' + (locals.pageJs || '')).trim();
       const triggerJson = JSON.stringify({
-        pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette },
+        pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs },
         pcmsPostSwap: data.post ? {
           title: data.post.title,
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/activitypub.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -253,5 +253,5 @@
 }
 queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
-queueRoute('follows', (id) => Guardianship.followsCollection(id));
+queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me));
 // §5.3 turned around (shaer-p729): what this ward has asked to follow, still
 // waiting on its guardians. Owner-only like the rest — who a child wants to
@@ -268,8 +268,40 @@
 // they follow) as Create(Note) items, so an app (Shaer) can build a unified
 // feed. Anyone else gets 403; the inbox stays write-only for the public.
-router.get('/ap/users/:slug/inbox', (req, res) => {
+router.get('/ap/users/:slug/inbox', async (req, res) => {
   const auth = OAuth.verifyBearer(req.headers.authorization);
   if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
   const base = baseUrl(req);
+  // Wachten is een UITBREIDING van deze lezing, geen tweede endpoint (shaer-n05).
+  // Geef `since` (de shaer:cursor van je vorige antwoord) en `wait` mee, en het
+  // antwoord blijft hangen tot er iets is of de tijd om is. Zonder die twee
+  // gedraagt de route zich exact zoals altijd.
+  //
+  // Bewust hetzelfde antwoord in plaats van een "er is nieuws"-seintje: dan
+  // hoeft er niets nieuws geparsed te worden, is er geen tweede beschrijving van
+  // de kaartvorm die uit de pas kan lopen, en scheelt het de client een tweede
+  // ronde.
+  const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
+  if (req.query.since && wachtS > 0) {
+    const afbreken = new AbortController();
+    res.on('close', () => afbreken.abort());   // client hing op: niet doorgaan met wachten
+    const uit = await AP.waitForFeedChange(auth.site.slug, {
+      since: String(req.query.since), waitMs: wachtS * 1000, signal: afbreken.signal,
+    });
+    if (res.writableEnded || afbreken.signal.aborted) return undefined;
+    // Niets veranderd? Dan een LEEG antwoord (Barts punt): de hele collectie
+    // terugsturen terwijl er niets gebeurd is, is elke 25 seconden een tijdlijn
+    // over de mobiele verbinding voor niets. Met 304 kost stilte niets en kost
+    // nieuws nog steeds maar één rondje -- beter dan een apart seintje-endpoint,
+    // dat voor nieuws twee rondjes nodig heeft.
+    //
+    // De '0'-uitzondering is geen franje. Ontbreekt ap_feed_state (een instance
+    // die de migratie nog niet draaide), dan geeft feedCursor altijd '0' terug,
+    // en zou een client hier eeuwig 304 krijgen en nooit meer inhoud zien. Bij
+    // een lege merksteen sturen we dus gewoon de collectie.
+    if (!uit.changed && uit.cursor !== '0') {
+      res.set('Vary', 'Authorization');
+      return res.status(304).end();
+    }
+  }
   // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
   // world outside the fediverse is the guardians' call. The gate is applied
@@ -285,5 +317,10 @@
   const playbackAllowed = embedsAllowed
     && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
-  const posts = AP.getTimeline(auth.site.slug, 60).map((t) => ({
+  const rows = AP.getTimeline(auth.site.slug, 60);
+  // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
+  // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op
+  // ap_timeline. Per rij vragen zou hier een N+1 opleveren.
+  const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id));
+  const posts = rows.map((t) => ({
     id: `${t.id}#create`,
     type: 'Create',
@@ -333,6 +370,6 @@
       // Whether THIS account already liked/boosted the note, so the app's
       // detail-view buttons show the current state (and can toggle/undo).
-      'shaer:liked': !!t.liked,
-      'shaer:boosted': !!t.boosted,
+      'shaer:liked': !!(reacties.get(t.id) || {}).liked,
+      'shaer:boosted': !!(reacties.get(t.id) || {}).boosted,
       // An external (non-fediverse) embed, thumbnail-only and never an iframe.
       // Omitted entirely when the gate is closed (see above).
@@ -439,7 +476,12 @@
       'shaer:externalLinks': playbackAllowed,
     },
+    // Het merk van wat hierin zit. Geef hem terug als `since` om op het
+    // volgende te wachten. NA het samenstellen bepaald, zodat hij precies dekt
+    // wat je in handen hebt en niet iets dat er ondertussen bij kwam.
+    'shaer:cursor': AP.feedCursor(auth.site.slug),
     totalItems: items.length,
     orderedItems: items,
   });
+  return undefined;
 });
 
Index: src/routes/admin-audio.js
===================================================================
--- src/routes/admin-audio.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-audio.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -111,4 +111,6 @@
   const embedUrl = base + (res.locals.siteUrlBase || '') + '/embed';
   renderPage(req, res, 'pages/admin-audio', {
+    // admin-audio neemt de track-editor op, dus die module hoort erbij.
+    pageJs: 'admin-audio track-editor',
     pageTitleKey: 'admin.t_audio',
     bodyClass: 'on-admin',
Index: src/routes/admin-epk.js
===================================================================
--- src/routes/admin-epk.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-epk.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -33,4 +33,5 @@
   try { const r = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]'); if (Array.isArray(r)) chosen = r; } catch (e) {}
   renderPage(req, res, 'pages/admin-epk', {
+    pageJs: 'admin-epk',
     pageTitleKey: 'admin.t_epk',
     bodyClass: 'on-admin',
Index: src/routes/admin-media.js
===================================================================
--- src/routes/admin-media.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-media.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -81,4 +81,5 @@
     .sort((a, b) => b._mtime - a._mtime); // newest first
   renderPage(req, res, 'pages/admin-media', {
+    pageJs: 'admin-media',
     pageTitleKey: 'admin.t_media',
     bodyClass: 'on-admin',
@@ -122,4 +123,5 @@
   if (!site) return res.status(404).send('Site required');
   renderPage(req, res, 'pages/admin-videos', {
+    pageJs: 'admin-videos',
     pageTitleKey: 'admin.t_media',
     bodyClass: 'on-admin',
Index: src/routes/admin-paid.js
===================================================================
--- src/routes/admin-paid.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-paid.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -36,4 +36,5 @@
   if (!gate(req, res)) return;
   renderPage(req, res, 'pages/admin-paid', {
+    pageJs: 'admin-paid',
     pageTitleKey: 'apaid.t',
     bodyClass: 'on-admin',
Index: src/routes/admin-playlists.js
===================================================================
--- src/routes/admin-playlists.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-playlists.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -62,4 +62,6 @@
   const playlists = PlaylistService.list(site.id);
   renderPage(req, res, 'pages/admin-playlists', {
+    // admin-playlists neemt de playlist-editor op, dus die module hoort erbij.
+    pageJs: 'admin-playlists playlist-editor',
     pageTitleKey: 'admin.t_playlists',
     playlists,
Index: src/routes/admin-push.js
===================================================================
--- src/routes/admin-push.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-push.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -12,4 +12,5 @@
 router.get('/', requireSiteManager, async (req, res) => {
   renderPage(req, res, 'pages/admin-push', {
+    pageJs: 'admin-push',
     pageTitleKey: 'push.t',
     bodyClass: 'on-admin',
Index: src/routes/admin-settings.js
===================================================================
--- src/routes/admin-settings.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-settings.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -66,4 +66,5 @@
 router.get('/', requireGod, (req, res) => {
   renderPage(req, res, 'pages/admin-settings', {
+    pageJs: 'admin-settings',
     pageTitleKey: 'admin.t_settings',
     bodyClass: 'on-admin',
Index: src/routes/admin-sites.js
===================================================================
--- src/routes/admin-sites.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-sites.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -200,4 +200,5 @@
 router.get('/new', requireGod, (req, res) => {
   renderPage(req, res, 'pages/admin-site-edit', {
+    pageJs: 'admin-site-edit',
     pageTitleKey: 'admin.t_newsite',
     bodyClass: 'on-admin',
@@ -283,4 +284,5 @@
 
   renderPage(req, res, 'pages/admin-site-edit', {
+    pageJs: 'admin-site-edit',
     pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
     bodyClass: 'on-admin',
Index: src/routes/admin.js
===================================================================
--- src/routes/admin.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -87,4 +87,5 @@
 router.get('/handleiding', requireAuth, (req, res) => {
   renderPage(req, res, 'pages/admin-help', {
+    pageJs: 'admin-help',
     pageTitleKey: 'admin.t_manual',
     bodyClass: 'on-admin',
Index: src/routes/auth.js
===================================================================
--- src/routes/auth.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/auth.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -115,4 +115,5 @@
   if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
   renderPage(req, res, 'pages/auth-register', {
+    pageJs: 'auth-register',
     pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
     error: null, username: '', email: '', siteName: '', next,
@@ -124,4 +125,5 @@
   const next = safeNext(req.body.next) || '';
   const renderErr = (error) => renderPage(req, res, 'pages/auth-register', {
+    pageJs: 'auth-register',
     pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
     error, username: username || '', email: email || '', siteName: siteName || '', next,
@@ -205,5 +207,5 @@
       } else {
         // Production without SMTP: NEVER log the token. Refer to the CLI break-glass.
-        console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`);
+        console.log(`[password-reset] requested for ${user.email} (no SMTP — use 'npm run reset-admin')`);
       }
     }
Index: src/routes/download.js
===================================================================
--- src/routes/download.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/download.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -85,4 +85,5 @@
   const fan = req.session && req.session.user;
   renderPage(req, res, 'pages/download', {
+    pageJs: 'download',
     pageTitle: track.title + ' — download',
     bodyClass: 'on-download',
Index: src/routes/guardian.js
===================================================================
--- src/routes/guardian.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/guardian.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -57,5 +57,15 @@
     'prop_line', 'prop_embeds', 'prop_play', 'prop_on', 'prop_off',
     'prop_st_open', 'prop_st_accepted', 'prop_st_rejected', 'prop_st_expired',
-    'panel_guards_far'];
+    'panel_guards_far',
+    // Het gate-paneel per ward (shaer-ahy.1): een rij per gate, met het soort en
+    // de drempel erbij. De namen volgen de catalogus in gated.js.
+    'gate_externalEmbeds', 'gate_externalPlayback', 'gate_follows',
+    'gate_kind_setting', 'gate_kind_perRequest', 'gate_kind_handover',
+    'gate_unknown', 'gate_threshold', 'gate_threshold_unknown',
+    'gate_irreversible', 'gate_waiting', 'gate_blocked', 'gate_propose',
+    // Oppikken en afhandelen van een hulpvraag (shaer-lgo).
+    'help_pick', 'help_close', 'help_picked_by', 'help_handled_by', 'help_handled_note',
+    'help_close_ask', 'help_close_yes', 'help_just_now', 'help_hours', 'help_days',
+    'help_archive', 'help_archive_hide'];
   const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
   s.wave = i18nT(L, 'guardian.wave');
@@ -71,6 +81,14 @@
             emoji_json, actor_emoji_json, media_json, quote_json, embed_json
      FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
-  ).all(site.slug).map((h) => ({
+  ).all(site.slug);
+  // De gedeelde staat in EEN query (shaer-lgo): wie er al op af is en of het is
+  // afgesloten. Per kaart vragen zou hier een N+1 opleveren, en dit is precies
+  // het scherm dat een guardian in een haast openslaat.
+  const helpStaat = Guardianship.help.statusFor(help.map((h) => h.object_uri));
+  const helpItems = help.map((h) => ({
     ...h,
+    // Bij twijfel OPEN. Een hulpvraag die er afgehandeld uitziet terwijl hij dat
+    // niet is, is de gevaarlijke fout -- niet andersom.
+    state: helpStaat.get(h.object_uri) || { open: true, pickedUpBy: [], handled: null, ageMs: null },
     // The dashboard is built in the browser, so it gets the body finished: the
     // same partial de Krant and Berichten use. A 🛟 often carries a screenshot
@@ -103,4 +121,8 @@
         status: Guardianship.gated.sentStatus(p, Date.now()),
       })),
+      // Alles wat voor dit kind gated is op EEN plek, met per gate het soort en
+      // de drempel (shaer-ahy.1). Losse knoppen lieten een guardian zelf
+      // uitzoeken wat er allemaal geldt; wat niet verstelbaar is stond nergens.
+      gates: wardGates(site.slug, w.other_uri),
     })),
     offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
@@ -113,5 +135,5 @@
       id: r.id, ward: r.ward_uri, proposer: r.proposer, feature: r.feature, value: !!r.value,
     })),
-    help,
+    help: helpItems,
     strings: uiStrings(L),
   };
@@ -192,4 +214,5 @@
   const site = siteForUser(req);
   if (!site) return res.status(404).json({ error: 'no_site' });
+  const L = resolveLang(req);
   ensureWardConnections(site);
   const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
@@ -209,4 +232,10 @@
       cw: p.cw || null,
       media: p.media_json ? JSON.parse(p.media_json) : [],
+      // Een post van je ward hoort er hetzelfde uit te zien als in de Krant en
+      // in Berichten: dezelfde partial, dus opmaak, media, quote-kaart en
+      // embed. Tot nu toe kreeg de PWA alleen kale content -- een guardian zag
+      // een lege regel waar een foto stond. `content` blijft ernaast staan voor
+      // een client die nog uit de cache draait.
+      body_html: renderNoteBody(p, L),
     }));
   res.json({ items, following: wardUris.size });
@@ -319,4 +348,42 @@
   if (!r) return res.status(502).json({ error: 'delivery' });
   res.json({ ok: true, delivered: r.delivered });
+});
+
+// ── Een hulpvraag oppikken of afsluiten (shaer-lgo) ───────────────
+// Gaat naar de WARD en naar de MEDE-GUARDIANS. De ward hoort te weten dat er
+// iemand komt -- dat is de helft van de gerustheid -- en de anderen dat het
+// loopt, zodat niemand denkt dat de ander het al doet.
+//
+// OPPIKKEN mag stapelen: twee mensen die tegelijk reageren is geen probleem.
+// AFSLUITEN kent geen terugdraai; leeft de vraag nog, dan wordt hij opnieuw
+// gesteld. De stevige bevestiging zit in de client, net als bij het loslaten van
+// een ward: nooit een window.confirm.
+router.post('/api/help/:kind', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
+  const site = siteForUser(req);
+  if (!site) return res.status(404).json({ error: 'no_site' });
+  const kind = req.params.kind === 'handled' ? 'handled' : 'pickup';
+  const noteUri = String(req.body?.note || '').trim();
+  const wardUri = String(req.body?.ward || '').trim();
+  if (!noteUri || !/^https?:\/\//i.test(noteUri)) return res.status(400).json({ error: 'no_note' });
+  // Alleen over een hulpvraag van een kind dat je echt bewaakt.
+  const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
+  if (!isWard) return res.status(403).json({ error: 'not_your_ward' });
+
+  const me = AP.actorId((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site.slug);
+  // Onze eigen kopie meteen, zonder op bezorging te wachten: het scherm van
+  // degene die klikt hoort niet te liegen omdat een andere server traag is.
+  Guardianship.help.record(noteUri, me, kind, null);
+
+  const anderen = Guardianship.listGuardians(wardUri.replace(/.*\/ap\/users\//, '')) || [];
+  const ontvangers = [wardUri, ...anderen.map((g) => g.other_uri)].filter((u) => u && u !== me);
+  const r = await AP.deliverDirectNote(site, {
+    recipients: ontvangers,
+    text: kind === 'handled' ? 'Deze hulpvraag is afgehandeld.' : 'Ik kijk hiernaar.',
+    helpMark: { kind, noteUri },
+  }).catch(() => null);
+  // Bezorging kan mislukken; de eigen staat staat er dan toch. Dat melden we,
+  // want "verstuurd" zeggen terwijl het niet aankwam is hier het ergste soort
+  // stilte.
+  res.json({ ok: true, delivered: r ? r.delivered : 0, recipients: ontvangers.length });
 });
 
@@ -531,4 +598,29 @@
 /** The playback gate of a ward we host (5.6): the heavier sibling. */
 function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
+/**
+ * De gate-rijen van een ward voor het paneel.
+ *
+ * De standen komen uit onze eigen kolommen als we het kind hosten; bij een ward
+ * elders weten we ze niet en blijft het NULL -- onbekend, niet uit. Het aantal
+ * guardians idem: dat wordt op de server van die ward bijgehouden, en zonder dat
+ * getal wordt er geen drempel verzonnen.
+ */
+function wardGates(mySlug, wardUri) {
+  const statuses = wardGuardianStatuses(wardUri);
+  const wachtend = Guardianship.follows.listReviewsByDirection(mySlug, 'incoming')
+    .filter((r) => r.ward_uri === wardUri).length;
+  return Guardianship.gated.gateRows({
+    settings: {
+      'shaer:externalEmbeds': wardEmbedSetting(wardUri),
+      'shaer:externalPlayback': wardPlaybackSetting(wardUri),
+    },
+    guardianCount: statuses ? statuses.length : null,
+    proposals: Guardianship.gated.listSent(mySlug, wardUri).map((p) => ({
+      feature: p.feature, value: !!p.value, status: Guardianship.gated.sentStatus(p, Date.now()),
+    })),
+    waiting: { 'shaer:follows': wachtend || undefined },
+  });
+}
+
 function wardGateSetting(uri, column) {
   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
Index: src/routes/paid.js
===================================================================
--- src/routes/paid.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/paid.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -76,4 +76,5 @@
   const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900);
   renderPage(req, res, 'pages/paid-passkey', {
+    pageJs: 'paid-passkey',
     pageTitleKey: 'ppk.t', bodyClass: 'on-post',
     optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/posts.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -256,4 +256,6 @@
 
   renderPage(req, res, 'pages/post-edit', {
+    // post-edit neemt de playlist-editor op.
+    pageJs: 'post-edit playlist-editor',
     post: {
       id: uuid(),
@@ -795,4 +797,5 @@
   }
   renderPage(req, res, 'pages/authorize-interaction', {
+    pageJs: 'authorize-interaction',
     pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
     bodyClass: 'on-special',
@@ -806,5 +809,5 @@
     liked: !!req.query.liked,
     boosted: !!req.query.boosted,
-    reacted: (site && uri) ? ActivityPubService.getMyReactions(site.slug, uri) : { liked: false, boosted: false },
+    reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false },
     siteTitle: site ? site.title : '',
   });
@@ -839,9 +842,10 @@
   let on = false;
   if (site && uri) {
-    on = !ActivityPubService.getMyReactions(site.slug, uri).liked;
+    on = !ActivityPubService.getReaction(site.slug, uri).liked;
     ActivityPubService.resolveRemoteNote(uri)
       .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
       .catch((e) => console.warn('[AP] remote like failed:', e.message));
-    ActivityPubService.setMyReaction(site.slug, uri, 'like', on);
+    // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
+    ActivityPubService.setReaction(site.slug, uri, 'like', on);
   }
   if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
@@ -856,5 +860,5 @@
   let on = false;
   if (site && uri) {
-    on = !ActivityPubService.getMyReactions(site.slug, uri).boosted;
+    on = !ActivityPubService.getReaction(site.slug, uri).boosted;
     ActivityPubService.resolveRemoteNote(uri)
       .then((note) => {
@@ -862,10 +866,15 @@
         const id = note.object_uri || uri;
         return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
-          // Boost → store the post in the timeline (even if you don't follow the author) so it
-          // surfaces in the Cirkel; unboost → just clear the flag.
-          .then(() => on ? ActivityPubService.upsertBoostedNote(site.slug, note) : ActivityPubService.unmarkBoosted(site.slug, id));
+          // De note gaat mee: een boost zet niet alleen een vlag maar trekt de
+          // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in
+          // de Cirkel verschijnt.
+          .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null }));
       })
       .catch((e) => console.warn('[AP] remote boost failed:', e.message));
-    ActivityPubService.setMyReaction(site.slug, uri, 'boost', on);
+    // Meteen zetten, zodat de knop klopt voordat de resolve terug is. Via
+    // setReaction en niet via setMyReaction: ook dit korte moment mag geen
+    // halve schrijfactie zijn. De resolve hierboven werkt hem daarna bij met de
+    // note, zodat de post ook in je tijdlijn belandt.
+    ActivityPubService.setReaction(site.slug, uri, 'boost', on);
   }
   if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
@@ -930,5 +939,5 @@
     : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
   renderPage(req, res, 'pages/messages', {
-    pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
+    pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages', items, seenAt,
     hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
     success: req.query.success || null, error: req.query.error || null,
@@ -965,4 +974,52 @@
   } catch { /* fall through */ }
   res.redirect(back + '?error=quickreply');
+});
+
+// Antwoorden vanuit een gesprek in Berichten. Twee paden, en welke het wordt
+// bepaalt de draad zelf (zie groupConversations → replyTo):
+//   - hangt de draad aan een post van jou, dan is dit een gewone reply op het
+//     nieuwste ontvangen bericht erin: deliverReply, publiek zoals de thread;
+//   - hangt hij aan een persoon, dan is het een direct bericht terug.
+// Rijk in beide gevallen: `content` is de HTML uit de reply-editor, `text` de
+// platte versie die de editor er altijd bij levert (en die het no-JS-formulier
+// als enige stuurt).
+router.post('/messages/reply', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  const back = `${res.locals.siteUrlBase || ''}/messages`;
+  if (!site) return res.status(404).send('Site required');
+  const text = String(req.body.text || '');
+  const html = String(req.body.content || '');
+  let attachments = [];
+  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
+  let mentions;
+  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
+  const language = String(req.body.language || '');
+  // Leeg is leeg: een bericht zonder tekst EN zonder media is geen bericht.
+  if (!text.trim() && !html.trim() && !attachments.length) return res.redirect(back + '?error=reply_empty');
+
+  const interactionId = parseInt(req.body.interaction_id, 10) || 0;
+  const postSlug = String(req.body.post_slug || '');
+  const toActor = String(req.body.to || '');
+  try {
+    if (interactionId && postSlug) {
+      const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, postSlug);
+      const parent = ActivityPubService.getInteractionById(interactionId);
+      // De parent MOET bij deze post horen: anders zou een gemanipuleerd
+      // formulier een antwoord onder andermans draad kunnen hangen.
+      if (!post || !parent || parent.post_id !== post.id) return res.redirect(back + '?error=reply_target');
+      await ActivityPubService.deliverReply(site, {
+        postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions, language,
+      });
+    } else if (/^https?:\/\//i.test(toActor)) {
+      const r = await Guardianship.deliverDirectNote(site, { recipients: [toActor], text, html, language, attachments });
+      if (!r) return res.redirect(back + '?error=reply_failed');
+    } else {
+      return res.redirect(back + '?error=reply_target');
+    }
+  } catch (e) {
+    console.warn('[AP] reply from Berichten failed:', e.message);
+    return res.redirect(back + '?error=reply_failed');
+  }
+  res.redirect(back + '?success=reply_sent');
 });
 
@@ -1139,4 +1196,5 @@
   }
   renderPage(req, res, 'pages/news', {
+    pageJs: 'news',
     pageTitle: 'News', bodyClass: 'on-special',
     timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
@@ -1229,7 +1287,7 @@
   let on = false;
   if (site && note) {
-    on = !ActivityPubService.getTimelineReaction(site.slug, note).liked;
+    on = !ActivityPubService.getReaction(site.slug, note).liked;
     try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
-    if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note);
+    ActivityPubService.setReaction(site.slug, note, 'like', on);
   }
   if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
@@ -1243,16 +1301,14 @@
   let on = false;
   if (site && note) {
-    on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
+    on = !ActivityPubService.getReaction(site.slug, note).boosted;
     try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
+    ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
     if (on) {
-      ActivityPubService.markBoosted(site.slug, note); // instant UI state
       // Fire-and-forget: re-resolve the note so the cached row is refreshed
       // (cover/content) — boosting again heals a stale copy from EVERY boost
       // path, not just the interact page.
       ActivityPubService.resolveRemoteNote(note)
-        .then((n) => { if (n) ActivityPubService.upsertBoostedNote(site.slug, n); })
+        .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
         .catch(() => { /* best-effort */ });
-    } else {
-      ActivityPubService.unmarkBoosted(site.slug, note);
     }
   }
@@ -1338,4 +1394,5 @@
     const { newerPost, olderPost } = postNeighbors(site, post);
     return renderPage(req, res, 'pages/paid-gate', {
+    pageJs: 'paid-gate',
       pageTitle: post.title || 'Voor supporters',
       bodyClass: 'on-special',
@@ -1454,4 +1511,5 @@
 
   renderPage(req, res, 'pages/post', {
+    pageJs: 'post',
     post,
     poll: ActivityPubService.ownPollView(post),
@@ -1505,17 +1563,19 @@
   const kind = req.body.kind === 'boost' ? 'boost' : 'like';
   if (parent && parent.post_id === post.id && parent.object_uri) {
-    if (kind === 'boost') {
-      // Toggle: boost an unboosted comment, or retract it (Undo Announce) if already boosted.
-      const on = !parent.acted_boost;
-      ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', parent.object_uri, parent.actor_uri)
-        .catch((e) => console.warn('[AP] reaction failed:', e.message));
-      ActivityPubService.setInteractionBoosted(parent.id, on);
-    } else {
-      // Toggle: like an unliked comment, or un-favourite (Undo Like) if already liked.
-      const on = !parent.acted_like;
-      ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', parent.object_uri, parent.actor_uri)
-        .catch((e) => console.warn('[AP] reaction failed:', e.message));
-      ActivityPubService.setInteractionLiked(parent.id, on);
-    }
+    // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
+    // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
+    // de kolom en de knop uit de tussentabel, dan draait een divergentie de
+    // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
+    const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
+    const on = kind === 'boost' ? !ik.boosted : !ik.liked;
+    ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
+      .catch((e) => console.warn('[AP] reaction failed:', e.message));
+    // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
+    // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
+    // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
+    // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
+    ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
+    if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
+    else ActivityPubService.setInteractionLiked(parent.id, on);
   }
   res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
Index: src/server.js
===================================================================
--- src/server.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/server.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -65,5 +65,5 @@
 import apRoutes from './routes/activitypub.js';
 import oauthRoutes from './routes/oauth.js';
-import { apWants, startDeliveryWorker, selfHealTimeline } from './services/ActivityPubService.js';
+import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js';
 
 // SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one
@@ -197,4 +197,8 @@
 startScheduler(); // release planning: publish scheduled posts when publish_at is reached
 startDeliveryWorker(); // retry failed fediverse deliveries with backoff
+// Once per REACTIONS_MIGRATION_VERSION bump: reacties naar de tussentabel, onder
+// de canonieke object-URI. Moet VOOR het serveren, want vanaf nu leest de code
+// die tabel -- draait hij niet, dan tonen oude likes als niet-gegeven.
+migrateReactions();
 selfHealTimeline(); // once per SELFHEAL_VERSION bump: re-sync the fediverse cache (covers/edits) after a drastic update
 
@@ -542,11 +546,20 @@
   try { d = e.data ? e.data.json() : {}; } catch (err) { /* non-JSON push */ }
   const title = d.title || 'Klonkt';
-  e.waitUntil(self.registration.showNotification(title, {
-    body: d.body || '',
-    icon: '/favicon.svg',
-    badge: '/favicon.svg',
-    tag: d.type ? ('klonkt-' + d.type) : undefined,   // collapse same-type bursts
-    data: { url: d.url || '/' },
-  }));
+  e.waitUntil(Promise.all([
+    self.registration.showNotification(title, {
+      body: d.body || '',
+      icon: '/favicon.svg',
+      badge: '/favicon.svg',
+      tag: d.type ? ('klonkt-' + d.type) : undefined,   // collapse same-type bursts
+      data: { url: d.url || '/' },
+    }),
+    // Wek ook een pagina die al openstaat. De push IS het teken dat er iets
+    // veranderd is, dus een aparte live-verbinding ernaast zou hetzelfde nog
+    // eens doen -- en die tweede zou alleen werken zolang de app open is,
+    // terwijl dit kanaal er ook is als hij dicht is. Een kanaal, twee doelen.
+    self.clients.matchAll({ type: 'window', includeUncontrolled: true })
+      .then(list => { for (const c of list) c.postMessage({ klonkt: 'push', type: d.type || null }); })
+      .catch(() => { /* geen open venster: niets te wekken */ }),
+  ]));
 });
 self.addEventListener('notificationclick', e => {
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/ActivityPubService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -627,4 +627,19 @@
 // outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into
 // one grouped item (actors list + count) so activity doesn't drown out conversations.
+/** ap_outbox.attachments ([{url, mediaType, name}]) naar de vorm die note-body
+ *  leest (media_json: [{url, type, name}]). Geeft null bij niets of rommel,
+ *  zodat een kapotte kolom hooguit media kost en niet de hele regel. */
+function outboxMediaJson(attachments) {
+  if (!attachments) return null;
+  try {
+    const list = JSON.parse(attachments);
+    if (!Array.isArray(list) || !list.length) return null;
+    const media = list
+      .filter((a) => a && a.url)
+      .map((a) => ({ url: a.url, type: a.mediaType || a.type || '', name: a.name || undefined }));
+    return media.length ? JSON.stringify(media) : null;
+  } catch { return null; }
+}
+
 export function getMessages(slug, limit, offset) {
   const off = Math.max(0, offset || 0);
@@ -638,9 +653,30 @@
     for (const m of listOutbox(slug).slice(0, need)) {
       items.push({
-        type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to,
-        content: m.content, editable: m.editable, language: m.language, created_at: m.created_at,
+        type: 'sent', outboxId: m.id, to_handle: m.to_handle, to_actor: m.to_actor, to_actors: m.to_actors,
+        in_reply_to: m.in_reply_to, post_slug: m.post_slug, content: m.content,
+        editable: m.editable, language: m.language, created_at: m.created_at,
+        // Je eigen bericht hoort er hetzelfde uit te zien als dat van een ander:
+        // note-body rendert Berichten, de Krant en de Guardian-PWA, maar leest
+        // media uit media_json met een `type`, terwijl ap_outbox ze als
+        // `attachments` met een `mediaType` bewaart. Zonder deze vertaling kwam
+        // een foto die JIJ meestuurde als kale tekst binnen.
+        media_json: outboxMediaJson(m.attachments),
       });
     }
   } catch { /* ignore */ }
+  // Een verzonden antwoord kent zijn post_slug maar niet de titel (ap_outbox
+  // bewaart die niet). Zonder titel toont een draad waarin JIJ als enige iets
+  // zei alleen een slug, dus vullen we ze in één query aan.
+  try {
+    const missing = [...new Set(items.filter((i) => i.post_slug && !i.post_title).map((i) => i.post_slug))];
+    if (missing.length) {
+      const rows = db.prepare(
+        `SELECT slug, title FROM posts WHERE slug IN (${missing.map(() => '?').join(',')})
+           AND site_id = (SELECT id FROM sites WHERE slug = ?)`,
+      ).all(...missing, slug);
+      const byslug = new Map(rows.map((r) => [r.slug, r.title]));
+      for (const i of items) if (i.post_slug && !i.post_title) i.post_title = byslug.get(i.post_slug) || null;
+    }
+  } catch { /* zonder titel valt de draad terug op de slug */ }
   items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
   const out = [];
@@ -656,5 +692,112 @@
     out.push(it);
   }
-  return out.slice(off, off + lim);
+  // Antwoorden, mentions en je eigen verzonden berichten vouwen samen tot
+  // draden; likes/boosts/follows/reports blijven losse regels. Na deze stap
+  // telt een draad als één item voor de paginering, wat klopt: je scrolt door
+  // gesprekken, niet door losse zinnen.
+  return groupConversations(out).slice(off, off + lim);
+}
+
+// De drie soorten die samen een gesprek vormen. Vroeger zaten ze in drie
+// aparte chips: 'reply' en 'mention' onder Berichten/Gesprekken (afhankelijk van
+// de zichtbaarheid) en 'sent' onder Verzonden. Wie een uitwisseling wilde volgen
+// moest dus tussen chips heen en weer, terwijl het één draad is.
+const CONV_TYPES = new Set(['reply', 'mention', 'sent']);
+
+/** Waar hangt dit bericht aan? Twee soorten draden, en de volgorde telt:
+ *
+ *  1. Aan een post van jou. Een ontvangen antwoord kent zijn post via de join
+ *     op `posts`, een verzonden antwoord via ap_outbox.post_slug. Dat is
+ *     dezelfde sleutel, en daarom staan ze nu in dezelfde draad.
+ *  2. Aan een persoon. Een mention hangt aan niets van jou (het is iemands
+ *     eigen post waarin je genoemd wordt) en heeft geen post_slug; die draad
+ *     loopt per tegenpartij.
+ *
+ *  De post wint van de persoon: twee mensen die onder dezelfde post reageren
+ *  voeren één gesprek, geen twee. Geeft null terug voor alles wat geen gesprek
+ *  is (likes, boosts, follows, reports, poll-uitslagen); die stromen ongemoeid
+ *  door.
+ */
+export function threadKey(it) {
+  if (!it || !CONV_TYPES.has(it.type)) return null;
+  if (it.post_slug) return `post:${it.post_slug}`;
+  let who = it.handle || it.to_handle || '';
+  // Een direct bericht kan zonder to_handle in de tabel staan (de handle van de
+  // ontvanger was niet af te leiden). De eerste uit to_actors is dan alsnog de
+  // tegenpartij, en zonder deze terugval kreeg een gesprek dat JIJ begon geen
+  // draad -- precies het geval waarin het meest onlogisch is dat het los blijft.
+  if (!who && it.to_actors) {
+    try {
+      const first = JSON.parse(it.to_actors)[0];
+      if (first) who = deriveHandle(first);
+    } catch { /* geen bruikbare lijst → geen sleutel, het blijft een losse regel */ }
+  }
+  const norm = String(who || '').trim().toLowerCase().replace(/^@/, '');
+  return norm ? `actor:${norm}` : null;
+}
+
+/** Vouw losse berichten samen tot draden, met alles wat geen gesprek is
+ *  ongemoeid ertussen. Verwacht [items] al gesorteerd op created_at aflopend
+ *  (zoals getMessages ze aanlevert); de draad komt daardoor op de plek van zijn
+ *  nieuwste bericht te staan en `created_at` van de draad IS dat bericht. Binnen
+ *  de draad draait het om: een gesprek leest naar beneden, oud naar nieuw.
+ */
+export function groupConversations(items) {
+  const threads = new Map();
+  const out = [];
+  for (const it of items || []) {
+    const key = threadKey(it);
+    if (!key) { out.push(it); continue; }
+    let t = threads.get(key);
+    if (!t) {
+      // Eerste keer dat we deze draad zien = het nieuwste bericht erin, want de
+      // invoer is aflopend gesorteerd. Vandaar created_at hier en niet later.
+      t = { type: 'thread', key, post: null, people: [], messages: [], created_at: it.created_at };
+      threads.set(key, t);
+      out.push(t);
+    }
+    t.messages.push(it);
+    // De context bij de draad: gaat het over een post, dan hoort de link
+    // erbij, anders is een los antwoord in een lijst niet te plaatsen.
+    // De titel blijft LEEG zolang hij onbekend is, in plaats van terug te
+    // vallen op de slug: het nieuwste bericht in een draad is vaak je eigen
+    // verzonden antwoord, en dat kent alleen de slug. Zou die de titel worden,
+    // dan kan het ontvangen antwoord eronder de echte titel niet meer
+    // invullen. De terugval op de slug hoort in de weergave, niet in de data.
+    if (it.post_slug) {
+      if (!t.post) t.post = { slug: it.post_slug, title: it.post_title || null };
+      else if (!t.post.title && it.post_title) t.post.title = it.post_title;
+    }
+  }
+  for (const t of threads.values()) {
+    t.messages.sort((a, b) => _msgTs(a) - _msgTs(b));
+    t.count = t.messages.length;
+    // Wie zit er in dit gesprek, jij niet meegerekend: 'sent' ben jij.
+    const seen = new Set();
+    for (const m of t.messages) {
+      if (m.type === 'sent') continue;
+      const h = m.handle || m.name;
+      if (!h || seen.has(h)) continue;
+      seen.add(h);
+      t.people.push({ name: m.name, handle: m.handle, icon: m.icon, url: m.url });
+    }
+    // Heb JIJ in deze draad iets gezegd? Bepaalt of hij als uitwisseling of als
+    // onbeantwoord bericht leest.
+    t.mine = t.messages.some((m) => m.type === 'sent');
+    // Waar gaat een antwoord uit deze draad heen? Twee paden, en ze sluiten
+    // elkaar uit: hangt de draad aan een post, dan antwoord je op het NIEUWSTE
+    // ontvangen bericht erin (dat is de parent van de thread) -- anders is het
+    // een direct bericht aan de tegenpartij.
+    const inkomend = t.messages.filter((m) => m.type !== 'sent');
+    const laatste = inkomend[inkomend.length - 1];
+    t.replyTo = {
+      interactionId: (laatste && laatste.interactionId) || null,
+      postSlug: (t.post && t.post.slug) || null,
+      actorUri: (laatste && (laatste.actorUri || laatste.url))
+        || (t.messages.find((m) => m.to_actor) || {}).to_actor
+        || (() => { try { return JSON.parse((t.messages.find((m) => m.to_actors) || {}).to_actors || '[]')[0] || null; } catch { return null; } })(),
+    };
+  }
+  return out;
 }
 
@@ -1043,7 +1186,20 @@
   const siteIcon = (site && site.profile_photo) || null;
 
+  // Wat JIJ met deze reacties deed komt uit de tussentabel, niet meer uit
+  // acted_* (shaer-ipb). Eén batch-lookup, want een drukke thread zou anders een
+  // N+1 worden. De sleutel loopt door canonicalReactionUri, precies zoals aan de
+  // schrijfkant -- staat dezelfde note toevallig ook in je tijdlijn, dan is het
+  // één feit en niet twee knoppen die los van elkaar aan kunnen staan.
+  const mijnSleutel = new Map();
+  for (const r of rows) {
+    if (r.kind === 'reply' && r.object_uri) mijnSleutel.set(r.object_uri, canonicalReactionUri(site && site.slug, r.object_uri));
+  }
+  const mijn = getReactionsFor(site && site.slug, [...mijnSleutel.values()]);
+  const mijnReactie = (uri) => mijn.get(mijnSleutel.get(uri)) || { liked: false, boosted: false };
+
   const nodes = [];
   for (const r of rows) {
     if (r.kind !== 'reply') continue;
+    const ik = mijnReactie(r.object_uri);
     nodes.push({
       noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
@@ -1052,5 +1208,5 @@
       actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
       emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (thread render)
-      acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
+      acted_boost: ik.boosted, acted_like: ik.liked,
       children: [],
     });
@@ -1141,12 +1297,39 @@
 }
 
-export async function fetchActor(url) {
+export async function fetchActor(url, opts = {}) {
+  // Authorized fetch (Mastodons secure mode): zo'n instance serveert zijn
+  // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
+  // verzoek en antwoordt anders met 401. Zonder sleutel kunnen we een correct
+  // ondertekende Follow van die instance niet verifiëren en wijzen we hem af,
+  // waarna Mastodon het dagenlang blijft proberen. Gemeten op boiert.eu: vier
+  // accounts eindeloos geweigerd, en precies die vier geven 401 op een
+  // onbetekende GET (shaer-afq).
+  //
+  // Geen kip-ei: om ONZE handtekening te controleren haalt de andere kant ons
+  // actor-document op, en dat serveert Klonkt publiek.
+  //
+  // ONBETEKEND EERST, en dat is een veiligheidskeuze en geen optimalisatie.
+  // verifyRequest haalt de keyId-URL op VOORDAT er iets geverifieerd is, en die
+  // URL komt uit een header die iedereen mag sturen. Tekenden we dat verzoek
+  // standaard, dan kan een volslagen onbekende ons een ONDERTEKEND verzoek laten
+  // sturen naar een adres van zijn keuze -- met onze identiteit eronder. Dat is
+  // precies hoe een instance op een blocklist belandt. Ondertekenen doen we dus
+  // pas als het onbetekend niet lukt, en dan alleen voor deze ene URL.
+  let doc = null;
   try {
     const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
-    if (!r.ok) return null;
-    const len = Number(r.headers.get('content-length') || 0);
-    if (len > 2_000_000) return null; // refuse oversized actor docs
-    return await r.json();
-  } catch { return null; }
+    if (r.ok) {
+      const len = Number(r.headers.get('content-length') || 0);
+      if (len > 2_000_000) return null; // refuse oversized actor docs
+      doc = await r.json();
+    }
+  } catch { /* val door naar de ondertekende poging */ }
+  // Genoeg? Dan klaar. Sommige instances serveren onbetekend wel een document
+  // maar zonder sleutel; voor een verificatie hebben we daar niets aan, dus die
+  // telt als mislukt.
+  if (doc && (!opts.asSlug || (doc.publicKey && doc.publicKey.publicKeyPem))) return doc;
+  if (!opts.asSlug) return doc;
+  const signed = await signedGetJson(opts.asSlug, url).catch(() => null);
+  return (signed && signed.id) ? signed : doc;
 }
 
@@ -1225,4 +1408,14 @@
 }
 
+/** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
+ *  (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
+let _signSlug;
+function anySigningSlug() {
+  if (_signSlug !== undefined) return _signSlug;
+  try { const r = db.prepare('SELECT slug FROM sites ORDER BY rowid LIMIT 1').get(); _signSlug = (r && r.slug) || null; }
+  catch { _signSlug = null; }
+  return _signSlug;
+}
+
 // Best-effort verification of an incoming signed request. Returns the sender's
 // actor doc if the signature checks out, else null. (Not gating yet — MVP.)
@@ -1230,10 +1423,14 @@
 // federating servers with drifting clocks; an operator can widen it via env.
 const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
-export async function verifyRequest(req) {
+export async function verifyRequest(req, asSlug = null) {
   const sigH = req.headers['signature'];
   if (!sigH) return null;
   const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
   if (!p.keyId || !p.signature) return null;
-  const actor = await fetchActor(p.keyId.split('#')[0]);
+  // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized
+  // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we
+  // als een willekeurige lokale actor: elke Klonkt-actor is een geldige
+  // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is.
+  const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() });
   const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
   if (!pem) return null;
@@ -1495,4 +1692,157 @@
 }
 
+/**
+ * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).
+ *
+ * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke
+ * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN
+ * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,
+ * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet
+ * binnen, zonder dat iemand een fout zag.
+ *
+ * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
+ * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de
+ * bezorgde inhoud NIET en halen het object op bij de bron.
+ *
+ * Vier voorwaarden, en geen ervan is optioneel:
+ *
+ *  1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te
+ *     dereferencen -- het object is weg -- dus die blijft geweigerd.
+ *  2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit
+ *     anker wijst een doorsturer je naar een host die hij zelf beheert, waar
+ *     attributedTo alles kan beweren.
+ *  3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders
+ *     levert een doorsturer een echt id met verdraaide inhoud.
+ *  4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de
+ *     geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
+ */
+/** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een
+ *  gecachete post in de tijdlijn, of een reactie die al in een thread van ons
+ *  staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop
+ *  doorstuurt; iets anders is dat niet. */
+function knownNoteUri(uri) {
+  if (!uri || typeof uri !== 'string') return false;
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  try {
+    if (base && uri.startsWith(`${base}/ap/notes/`)) {
+      const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);
+      if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;
+      if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;
+    }
+    if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
+    if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
+    // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).
+    if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;
+  } catch { /* bij twijfel niet ophalen */ }
+  return false;
+}
+
+/**
+ * Onthoud dat we dit bericht al eens bezorgd kregen.
+ *
+ * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit
+ * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri
+ * stelt voordat er iets bij de bron wordt opgehaald.
+ *
+ * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:
+ * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een
+ * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een
+ * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor
+ * schrijvers die je zelf volgt.
+ */
+const SEEN_NOTES_DAYS = 30;
+let _seenSinceSnoei = 0;
+function rememberNoteUri(uri) {
+  if (!uri || typeof uri !== 'string') return;
+  try {
+    db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);
+    // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt
+    // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat
+    // ouder is dan een maand beantwoordt geen enkele vraag meer.
+    if (++_seenSinceSnoei >= 500) {
+      _seenSinceSnoei = 0;
+      const r = db.prepare(`DELETE FROM ap_seen_notes WHERE created_at < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();
+      if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);
+    }
+  } catch { /* niet fataal */ }
+}
+const isFollowedActor = (uri) => {
+  try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }
+};
+
+// Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
+// dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
+// vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
+const _derefMiss = new Map();
+const DEREF_MISS_MS = 30 * 60 * 1000;
+function derefRecentlyFailed(uri) {
+  const t = _derefMiss.get(uri);
+  if (t === undefined) return false;
+  if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(uri); return false; }
+  return true;
+}
+function noteDerefFailure(uri) {
+  if (_derefMiss.size > 500) {   // simpele begrenzing: oudste helft eruit
+    const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
+    for (const [k] of oud) _derefMiss.delete(k);
+  }
+  _derefMiss.set(uri, Date.now());
+}
+
+async function dereferenceForwarded(act, claimedActor, type, slugParam) {
+  // Every exit states its reason. Five of the six used to return silently, so a
+  // rejection count could not be told apart from a narrowing that closed too far
+  // — and that is exactly the measurement shaer-drf is waiting for. Bounded by
+  // the signer-mismatch rate (tens per hour), so this is not a noisy log.
+  const skipped = (reason, detail) => {
+    console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');
+    return null;
+  };
+  if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);
+  const o = act && act.object;
+  const objId = typeof o === 'string' ? o : (o && o.id);
+  if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');
+  try {
+    if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId);   // ankereis
+  } catch { return skipped('unparsable id', objId); }
+  // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
+  // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
+  // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
+  // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
+  // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
+  // niets aan legitiem verkeer waarvan we de ouder kennen.
+  const parent = typeof o === 'object' && o
+    ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
+    : null;
+  if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
+  if (derefRecentlyFailed(objId)) return skipped('recent failure', objId);
+  // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
+  // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
+  // reden als bij fetchActor sinds efe5633.
+  let fetched = await apGetJson(objId).catch(() => null);
+  if (!fetched || fetched.id !== objId) {
+    // The signer used to be slugParam, which is null on the shared inbox — and
+    // that is where forwarded traffic lands, because we advertise a sharedInbox.
+    // signedGetJson falls back to an unsigned GET for a null slug, so a source in
+    // secure mode could never be dereferenced at all. Same fix verifyRequest got
+    // in shaer-afq: any local actor is a valid signer.
+    const asSlug = slugParam || anySigningSlug();
+    if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);
+  }
+  const attributed = fetched && (typeof fetched.attributedTo === 'string'
+    ? fetched.attributedTo
+    : (fetched.attributedTo && fetched.attributedTo.id));
+  if (!fetched || fetched.id !== objId) {
+    noteDerefFailure(objId);
+    return skipped('fetch failed', objId);
+  }
+  if (attributed !== claimedActor) {
+    // Not a transport hiccup: the source itself says someone else wrote this.
+    noteDerefFailure(objId);
+    return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
+  }
+  return fetched;
+}
+
 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
 export async function handleInbox(req, slugParam, preVerified = null) {
@@ -1508,5 +1858,5 @@
   // keeps everything below identical, including the actor-versus-signer check,
   // which is exactly the check that must not be skipped for being local.
-  const verified = preVerified || await verifyRequest(req).catch(() => null);
+  const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
 
   // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
@@ -1518,6 +1868,24 @@
   const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
   if (GATED.includes(type)) {
-    if (!verified || !claimedActor || verified.id !== claimedActor) {
-      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
+    // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
+    // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
+    // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
+    let forwarded = null;
+    if (verified && claimedActor && verified.id !== claimedActor) {
+      forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
+      if (forwarded) {
+        act.object = forwarded;   // de OPGEHAALDE inhoud, niet de bezorgde
+        console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);
+      }
+    }
+    if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
+      // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
+      // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
+      // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
+      // of geldig ondertekend door iemand anders.
+      const reden = verified ? '(signer mismatch)'
+        : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
+        : '(no signature)';
+      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
       return 401;
     }
@@ -1809,8 +2177,31 @@
       }
     }
+    // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
+    // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
+    // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
+    // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
+    // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
+    // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
+    // moet iemand zijn die je zelf bent gaan volgen.
+    if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
+      rememberNoteUri(o.id);
+    }
     // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
     // above): store a mention notification for each of our actors named in the Mention tags.
     // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
     // someone else's actor, not ours.
+    // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten
+    // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met
+    // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de
+    // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward
+    // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.
+    if (actorUri && !isLocalActor) {
+      const mark = Guardianship.help.parseMarker(o);
+      if (mark) {
+        const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);
+        Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);
+        console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);
+      }
+    }
     if (actorUri && !isLocalActor && o.id) {
       const slugs = localMentionSlugs(o.tag, base);
@@ -2497,6 +2888,17 @@
         const kind = type === 'Announce' ? 'boost' : 'like';
         await sendInteraction(site, kind, objUri, authorUri);
-        setMyReaction(site.slug, targetUri, kind, true);
-        if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
+        // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
+        // De note gaat mee zodat een boost de post je tijdlijn in trekt.
+        try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
+        catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
+        // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
+        // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
+        // de reactie wel opgeslagen (setMyReaction, de webroute leest die),
+        // maar kreeg de app altijd liked:false terug: het hartje sprong bij de
+        // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood
+        // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like.
+        // Anders dan bij een boost geen upsert: een like hoort een post niet
+        // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
+        // terecht een no-op.
         return { status: 202, url: objUri };
       }
@@ -2547,6 +2949,6 @@
           const objUri = (note && note.object_uri) || innerTarget;
           await sendInteraction(site, kind, objUri, note && note.actor_uri);
-          setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
-          if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
+          try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
+          catch { /* non-fatal */ }
           return { status: 202, url: objUri };
         }
@@ -2913,5 +3315,9 @@
 }
 export function listOutbox(siteSlug) {
-  return db.prepare('SELECT id, content, to_handle, in_reply_to, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
+  // post_slug reist mee sinds Berichten gesprekken toont: het is de sleutel
+  // waarop een verzonden antwoord bij de ontvangen antwoorden op dezelfde post
+  // gaat staan (zie threadKey). Zonder die kolom viel een uitwisseling uit
+  // elkaar in "Verzonden" en "Gesprekken".
+  return db.prepare('SELECT id, content, to_handle, to_actor, to_actors, post_slug, in_reply_to, attachments, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
     .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
 }
@@ -3051,5 +3457,27 @@
   return { ins: _insTl, list: _listTl, del: _delTl };
 }
-export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
+/**
+ * De tijdlijn, met liked/boosted uit de TUSSENTABEL (shaer-9e9).
+ *
+ * De rijen komen met SELECT *, dus ap_timeline.liked en .boosted liften mee --
+ * en die zijn sinds fase 1 nog maar een afgeleide. De Krant tekende zijn
+ * knoppen daar wel op, terwijl de toggle al uit getReaction besliste: tekenen en
+ * beslissen leunden dus op verschillende bronnen. Ze waren het eens zolang de
+ * migratie ze gelijk hield, maar dat was synchronisatie en geen ontwerp.
+ *
+ * Bewust in JS en niet als join: met SELECT * zouden twee kolommen `liked`
+ * heten en hangt het van de driver af welke wint. Eén extra query per pagina
+ * (dezelfde batch die de C2S-tijdlijn gebruikt) is dat niet waard.
+ */
+export function getTimeline(slug, limit, offset) {
+  const rows = tlStmts().list.all(slug, limit || 50, offset || 0);
+  const reacties = getReactionsFor(slug, rows.map((r) => r.id));
+  for (const r of rows) {
+    const x = reacties.get(r.id);
+    r.liked = !!(x && x.liked);
+    r.boosted = !!(x && x.boosted);
+  }
+  return rows;
+}
 
 /**
@@ -3087,4 +3515,86 @@
 }
 
+/**
+ * Een merk voor "is er iets veranderd aan wat de inbox-lezing zou opleveren?"
+ * (shaer-n05).
+ *
+ * Alle VIER de poten die de inbox samenvoegt tellen mee -- tijdlijn, berichten,
+ * antwoorden op je eigen posts, en wat je zelf verstuurde. Zou er een ontbreken,
+ * dan blijft een wachtende client slapen terwijl er wel degelijk iets is
+ * bijgekomen, en dat is erger dan niet wachten: het lijkt te werken.
+ *
+ * rowid en niet een tijdstempel: rowid loopt strikt op per invoeging, terwijl
+ * twee dingen in dezelfde seconde kunnen aankomen en een `published` van een
+ * andere server niet te vertrouwen is.
+ *
+ * Ondoorzichtig voor de client. Hij krijgt hem terug en geeft hem ongewijzigd
+ * mee; de vorm mag veranderen zonder dat dat iets breekt.
+ */
+export function feedCursor(slug) {
+  try {
+    const r = db.prepare('SELECT MAX(rev) AS n FROM ap_feed_state WHERE slug = ?').get(slug);
+    return String((r && r.n) || 0);
+  } catch { return '0'; }
+}
+
+/**
+ * Wat er sinds `rev` met deze tijdlijn gebeurd is: welke berichten er nieuw zijn,
+ * bewerkt, of weg.
+ *
+ * Nog niet gebruikt door een leespad -- de vorm van de aankomst is shaer-of7 en
+ * de "bewerkt"-markering is daar nog een open beslissing. Maar de gegevens
+ * ontstaan hoe dan ook bij het bijhouden van de merksteen, en dit is de enige
+ * plek waar ze samen te lezen zijn.
+ */
+export function feedChangesSince(slug, rev, limit = 200) {
+  try {
+    return db.prepare(`SELECT object_uri, kind, rev FROM ap_feed_state
+                        WHERE slug = ? AND rev > ? ORDER BY rev ASC LIMIT ?`)
+      .all(slug, parseInt(rev, 10) || 0, limit);
+  } catch { return []; }
+}
+
+// Zoveel clients mogen er tegelijk op EEN account staan wachten. Een client met
+// een kapotte herverbind-lus mag de instance niet vastzetten; de overtolligen
+// krijgen gewoon meteen antwoord in plaats van een fout.
+const FEED_WAIT_MAX = 4;
+const _wachters = new Map();
+
+/**
+ * Wacht tot de inbox-lezing iets anders zou opleveren dan bij `since`.
+ *
+ * Bewust met een interne tik en niet met een gebeurtenis-emitter. Een emitter
+ * moet op ELKE plek worden aangeroepen waar er iets bijkomt, en de plek die je
+ * vergeet is precies de melding die nooit aankomt. Twee tot vier MAX(rowid)-
+ * queries per seconde is niets, en dit kan niets missen. Prijs: hooguit een tik
+ * vertraging.
+ */
+export async function waitForFeedChange(slug, opts = {}) {
+  const tickMs = Math.max(50, opts.tickMs || 1000);
+  const waitMs = Math.max(0, opts.waitMs || 0);
+  const since = String(opts.since || '');
+  let cursor = feedCursor(slug);
+  // Geen sinds, al iets veranderd, of niet willen wachten: meteen antwoorden.
+  if (!since || since !== cursor || !waitMs) return { cursor, changed: !!since && since !== cursor, waited: false };
+
+  const bezet = _wachters.get(slug) || 0;
+  if (bezet >= FEED_WAIT_MAX) return { cursor, changed: false, waited: false, busy: true };
+  _wachters.set(slug, bezet + 1);
+  try {
+    const einde = Date.now() + waitMs;
+    while (Date.now() < einde) {
+      if (opts.signal && opts.signal.aborted) break;   // client hing op
+      const rest = Math.min(tickMs, einde - Date.now());
+      await new Promise((r) => setTimeout(r, rest));
+      cursor = feedCursor(slug);
+      if (cursor !== since) return { cursor, changed: true, waited: true };
+    }
+    return { cursor, changed: false, waited: true };
+  } finally {
+    const n = (_wachters.get(slug) || 1) - 1;
+    if (n > 0) _wachters.set(slug, n); else _wachters.delete(slug);
+  }
+}
+
 export function getDirectMessages(slug, limit) {
   try {
@@ -3243,8 +3753,13 @@
     if (!_cirkelPosts) _cirkelPosts = db.prepare(`
       SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
-             t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
+             t.content, t.url, t.published, t.media_json, t.nsfw, t.cw,
+             (rb.target_uri IS NOT NULL) AS boosted
       FROM ap_timeline t
       LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
-      WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
+      -- Uit de tussentabel, niet uit t.boosted: die kolom is een afgeleide. De
+      -- UNIQUE(site_slug, target_uri, kind) garandeert hoogstens één match, dus
+      -- deze join kan geen rijen verdubbelen.
+      LEFT JOIN ap_my_reactions rb ON rb.site_slug = t.slug AND rb.target_uri = t.id AND rb.kind = 'boost'
+      WHERE t.slug = ? AND (f.auto_boost = 1 OR rb.target_uri IS NOT NULL)
       ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
       LIMIT ? OFFSET ?`);
@@ -3255,5 +3770,14 @@
   try { if (!_cirkelMembers) _cirkelMembers = db.prepare('SELECT name, url, icon FROM ap_following WHERE slug = ? AND auto_boost = 1 ORDER BY name'); return _cirkelMembers.all(slug); } catch { return []; }
 }
-// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
+// AFGELEIDE, GEEN BRON (shaer-9e9). De waarheid over "heb ik hierop gereageerd"
+// staat in ap_my_reactions; deze vlaggen worden daaruit bijgehouden door
+// setReaction en door niets anders. Roep ze niet los aan -- dan schrijf je de
+// helft, en dat is precies hoe shaer:liked maandenlang false bleef (04aca12).
+//
+// ap_timeline.boosted verdient zijn bestaan wel: hij staat in de WHERE van de
+// Cirkel-feed (getCirkelPosts) en in boostedCount, dus hij is een index en geen
+// kopie. ap_timeline.liked wordt nergens als verzameling bevraagd en kan weg
+// zodra fase 2 lang genoeg goed staat; hij is nu nog het vangnet waarmee
+// terugdraaien een code-revert blijft in plaats van dataherstel.
 let _markBoost, _unmarkBoost, _boostedCount;
 export function markBoosted(slug, noteId) {
@@ -3270,4 +3794,207 @@
   try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
 }
+/**
+ * Zet een reactie van JOU op een object. Dit hoort het enige schrijfpad te zijn
+ * (shaer-9e9): de tussentabel ap_my_reactions is de waarheid, de vlaggen op
+ * ap_timeline zijn de afgeleide. Zolang markLiked en broers los aanroepbaar
+ * blijven kan een aanroeper ze vergeten, en dat is niet hypothetisch -- precies
+ * dat leverde de shaer:liked-bug op (04aca12).
+ *
+ * `opts.note` is de opgeloste remote note bij een boost. Die is niet optioneel
+ * uit netheid: een boost moet de post je tijdlijn IN trekken als je de auteur
+ * niet volgt, anders heeft de vlag geen rij om op te landen en verschijnt de
+ * boost nergens -- ook niet in de Cirkel.
+ *
+ * `opts.flagUri` bestaat omdat de twee bronnen vandaag verschillend gesleuteld
+ * worden: de tussentabel op de URI die de client stuurde, de vlag op de
+ * opgeloste object-URI. Meestal zijn die gelijk, maar niet gegarandeerd. Deze
+ * naad houdt fase 1 gedragsbehoudend; het samentrekken van die twee sleutels is
+ * werk voor fase 2, mét datamigratie.
+ */
+// Reactie-migratie (shaer-9e9). Draait bij boot, EEN keer per bump, net als
+// selfHealTimeline. Bewust automatisch: klonkt-update tilt een hele vloot in een
+// stap naar nieuwe code, en een handmatig script per instance wordt vergeten --
+// terwijl het falen stil is (een reactie die niemand meer ziet geeft geen fout).
+// v2 haalt de derde bron erbij: ap_interactions.acted_* (shaer-ipb). Een bump
+// laat alle stappen opnieuw lopen, en dat mag -- ze zijn alle drie idempotent.
+const REACTIONS_MIGRATION_VERSION = 2;
+
+/**
+ * Brengt alle reacties naar de tussentabel, onder de canonieke object-URI.
+ *
+ * Twee stappen, en ze zijn allebei nodig:
+ *
+ *  1. HERSLEUTELEN. De oude interact-route bewaarde de URI waarmee je binnenkwam
+ *     en de bookmarklet geeft window.location.href door, dus de permalink. Sinds
+ *     canonicalReactionUri wordt er op de object-URI gezocht, waardoor die rijen
+ *     wees zouden zijn. De created_at reist mee: bij hersleutelen weten we
+ *     wanneer je reageerde, bij aanvullen niet.
+ *  2. AANVULLEN vanuit de afgeleide kolommen. Alles wat op oude code via de
+ *     Krant is gegeven staat alleen daar; zonder deze stap toont het als
+ *     niet-gereageerd en klikt een gebruiker opnieuw -- met een tweede Like de
+ *     fediverse in als gevolg.
+ *
+ * Idempotent. Geeft terug wat er gebeurd is, zodat het script het kan tonen.
+ */
+export function migrateReactions(opts = {}) {
+  const uit = { hersleuteld: 0, aangevuld: 0, reacties: 0, overgeslagen: false };
+  try {
+    if (!opts.force) {
+      const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('reactions_migration_version');
+      const cur = r ? (parseInt(r.value, 10) || 0) : 0;
+      if (cur >= REACTIONS_MIGRATION_VERSION) { uit.overgeslagen = true; return uit; }
+    }
+  } catch { return uit; }   // geen app_settings → deze database is te oud om aan te raken
+
+  // Een rij die NIET op een tijdlijn-id staat maar wel op een tijdlijn-url.
+  const wees = `
+    FROM ap_my_reactions r JOIN ap_timeline t ON t.slug = r.site_slug AND t.url = r.target_uri
+     WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t2 WHERE t2.slug = r.site_slug AND t2.id = r.target_uri)`;
+  const scheef = (kind, kolom) => `
+    FROM ap_timeline t
+     WHERE t.${kolom} = 1
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = t.slug AND r.target_uri = t.id AND r.kind = '${kind}')`;
+  // 3. De derde bron: wat JIJ deed met een reactie onder je eigen post. De slug
+  //    hangt hier niet aan de rij maar aan de post; vandaar de twee joins. Een
+  //    rij zonder object_uri kan nooit een reactie dragen (fedi-react eist hem),
+  //    dus die uitsluiting verliest per constructie niets.
+  const acted = (kind, kolom) => `
+    FROM ap_interactions i
+     JOIN posts p ON p.id = i.post_id
+     JOIN sites s ON s.id = p.site_id
+     WHERE i.${kolom} = 1 AND IFNULL(i.object_uri, '') <> ''
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri AND r.kind = '${kind}')`;
+
+  if (opts.dryRun) {
+    const tel = (sql) => { try { return db.prepare(`SELECT COUNT(*) AS n ${sql}`).get().n; } catch { return 0; } };
+    uit.hersleuteld = tel(wees);
+    uit.aangevuld = tel(scheef('like', 'liked')) + tel(scheef('boost', 'boosted'));
+    uit.reacties = tel(acted('like', 'acted_like')) + tel(acted('boost', 'acted_boost'));
+    return uit;
+  }
+
+  try {
+    db.transaction(() => {
+      // 1. Hersleutelen: eerst de canonieke variant erbij, dan de permalink weg.
+      //    In die volgorde, zodat een onderbreking hooguit een dubbele rij
+      //    oplevert en nooit een verdwenen reactie.
+      uit.hersleuteld = db.prepare(`
+        INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at)
+        SELECT r.site_slug, t.id, r.kind, r.created_at ${wees}`).run().changes;
+      db.prepare(`DELETE FROM ap_my_reactions WHERE rowid IN (SELECT r.rowid ${wees})`).run();
+
+      // 2. Aanvullen vanuit de kolommen.
+      for (const [kind, kolom] of [['like', 'liked'], ['boost', 'boosted']]) {
+        uit.aangevuld += db.prepare(`
+          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
+          SELECT t.slug, t.id, '${kind}' ${scheef(kind, kolom)}`).run().changes;
+      }
+
+      // 3. En vanuit acted_* op de reacties onder je eigen posts.
+      for (const [kind, kolom] of [['like', 'acted_like'], ['boost', 'acted_boost']]) {
+        uit.reacties += db.prepare(`
+          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
+          SELECT s.slug, i.object_uri, '${kind}' ${acted(kind, kolom)}`).run().changes;
+      }
+    })();
+    if (uit.hersleuteld || uit.aangevuld || uit.reacties) {
+      console.log(`[AP] reaction migration v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} re-keyed, ${uit.aangevuld} backfilled, ${uit.reacties} from comments`);
+    }
+    if (!opts.force) {
+      db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
+        .run('reactions_migration_version', String(REACTIONS_MIGRATION_VERSION));
+    }
+  } catch (e) {
+    // Niet fataal: de kolommen staan er nog, dus de oude waarheid is niet weg.
+    // Een volgende boot probeert het opnieuw, want de versie is niet gezet.
+    console.warn('[AP] reaction migration failed:', e.message);
+  }
+  return uit;
+}
+
+/**
+ * Van wat de client stuurde naar de canonieke sleutel voor een reactie.
+ *
+ * Een post heeft twee URI's: zijn AP-object-id (.../ap/notes/<uuid>) en zijn
+ * leesbare permalink (.../effortlesseffect). De Krant en het C2S-pad spreken de
+ * eerste, de interact-pagina de tweede. Werden reacties onder allebei opgeslagen,
+ * dan bestond dezelfde like twee keer -- en erger: een like uit de Krant was op
+ * de interact-pagina onzichtbaar, want daar werd op de permalink gezocht.
+ *
+ * Dit was de naad die fase 1 bewust open liet ("samentrekken is werk voor fase
+ * 2"). Robin liep er meteen tegenaan: een geboost en geliket bericht toonde geen
+ * highlight. Vandaar hier, en niet later.
+ *
+ * De object-URI wint, want dat is waar ap_timeline op sleutelt en waar de
+ * backfill op is gebaseerd. Kennen we de post niet, dan blijft de invoer staan:
+ * een reactie op iets buiten je tijdlijn moet gewoon werken.
+ */
+export function canonicalReactionUri(slug, uri) {
+  if (!slug || !uri) return uri;
+  try {
+    if (db.prepare('SELECT 1 FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, uri)) return uri;
+    const row = db.prepare('SELECT id FROM ap_timeline WHERE slug = ? AND url = ? LIMIT 1').get(slug, uri);
+    return (row && row.id) || uri;
+  } catch { return uri; }
+}
+
+/**
+ * Wat heb IK met dit object gedaan? Leest de tussentabel, de bron van waarheid
+ * sinds shaer-9e9 fase 2. Vervangt getMyReactions en getTimelineReaction, die
+ * dezelfde vraag beantwoordden uit twee verschillende bronnen.
+ */
+export function getReaction(slug, uri) {
+  try {
+    const key = canonicalReactionUri(slug, uri);
+    const rows = (slug && key)
+      ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, key)
+      : [];
+    return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
+  } catch { return { liked: false, boosted: false }; }
+}
+
+/**
+ * Dezelfde vraag voor een hele pagina in EEN query. De C2S-tijdlijn zet
+ * shaer:liked op elke post; per rij vragen zou dat een N+1 maken, en dan had je
+ * een consistentiebug geruild voor een traagheidsbug.
+ */
+export function getReactionsFor(slug, uris) {
+  const out = new Map();
+  const list = [...new Set((uris || []).filter(Boolean))].slice(0, 500);
+  if (!slug || !list.length) return out;
+  try {
+    const rows = db.prepare(
+      `SELECT target_uri, kind FROM ap_my_reactions
+        WHERE site_slug = ? AND target_uri IN (${list.map(() => '?').join(',')})`,
+    ).all(slug, ...list);
+    for (const r of rows) {
+      const cur = out.get(r.target_uri) || { liked: false, boosted: false };
+      if (r.kind === 'like') cur.liked = true;
+      if (r.kind === 'boost') cur.boosted = true;
+      out.set(r.target_uri, cur);
+    }
+  } catch { /* leeg = niets gereageerd, en dat is een veilige uitkomst */ }
+  return out;
+}
+
+export function setReaction(slug, uri, kind, on, opts = {}) {
+  if (!slug || !uri || (kind !== 'like' && kind !== 'boost')) return;
+  // EEN sleutel voor beide bronnen. opts.flagUri is de opgeloste object-URI van
+  // de aanroeper (het C2S-pad kent die uit resolveRemoteNote en dat is
+  // betrouwbaarder dan onze cache); anders leiden we hem af. Vroeger kreeg de
+  // tussentabel de URI die de client stuurde en de vlag de opgeloste -- dat
+  // maakte dezelfde like onvindbaar vanaf de andere pagina.
+  const flagUri = opts.flagUri || canonicalReactionUri(slug, uri);
+  setMyReaction(slug, flagUri, kind, !!on);
+  if (kind === 'boost') {
+    if (!on) unmarkBoosted(slug, flagUri);
+    else if (opts.note) upsertBoostedNote(slug, opts.note);
+    else markBoosted(slug, flagUri);
+  } else if (on) markLiked(slug, flagUri);
+  else unmarkLiked(slug, flagUri);
+}
+
 export function getTimelineReaction(slug, noteId) {
   try { const r = db.prepare('SELECT liked, boosted FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, noteId); return { liked: !!(r && r.liked), boosted: !!(r && r.boosted) }; } catch { return { liked: false, boosted: false }; }
@@ -3303,5 +4030,12 @@
 }
 export function boostedCount(slug) {
-  try { if (!_boostedCount) _boostedCount = db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE slug = ? AND boosted = 1'); return _boostedCount.get(slug).n; } catch { return 0; }
+  // Geboost EN in je tijdlijn, zoals voorheen: de tussentabel kan ook een boost
+  // bevatten van iets dat er (nog) niet in staat.
+  try {
+    if (!_boostedCount) _boostedCount = db.prepare(`SELECT COUNT(*) AS n FROM ap_my_reactions r
+      JOIN ap_timeline t ON t.slug = r.site_slug AND t.id = r.target_uri
+      WHERE r.site_slug = ? AND r.kind = 'boost'`);
+    return _boostedCount.get(slug).n;
+  } catch { return 0; }
 }
 
@@ -4018,5 +4752,5 @@
     await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
   }
-  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'naar', inboxes.length, 'inbox(en)');
+  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'to', inboxes.length, 'inbox(es)');
   return { ok: true, target: target.id, inboxes: inboxes.length };
 }
@@ -4198,5 +4932,18 @@
       const wardDoc = await fetchActor(wardUri).catch(() => null);
       const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
-      Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
+      // De RICHTING bewaren (shaer-jdb). shaer:direction wordt sinds de uitgaande
+      // gate meegestuurd maar werd nergens gelezen, dus een uitgaande belandde
+      // hier als "deze ward wil deze ward volgen" met het doel weggegooid.
+      // Terugval voor oudere afzenders: is de volger de ward zelf, dan is het
+      // uitgaand -- dat volgt uit de vorm en hoeft niet geloofd te worden.
+      const uitgaand = act['shaer:direction'] === 'outgoing' || follower === wardUri;
+      const doel = uitgaand ? (typeof fo.object === 'string' ? fo.object : (fo.object && fo.object.id)) : null;
+      const dai = uitgaand ? actorInfo(await fetchActor(doel).catch(() => null), doel) : null;
+      Guardianship.follows.recordReview(gslug, {
+        id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox,
+        follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo),
+        direction: uitgaand ? 'outgoing' : 'incoming',
+        target: doel || null, targetHandle: dai ? dai.handle : null,
+      });
       const L = pushLang(gslug);
       pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fai.name || fai.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian` });
@@ -4301,5 +5048,5 @@
   try {
     const rows = db.prepare(`
-      SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
+      SELECT i.id AS interaction_id, i.kind, i.actor_uri, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
              i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
              p.slug AS post_slug, p.title AS post_title
@@ -4310,4 +5057,7 @@
     for (const r of rows) out.push({
       type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
+      // Waar een antwoord uit de draad heen moet: het id is de parent voor
+      // deliverReply, de uri het adres voor een direct bericht.
+      interactionId: r.interaction_id, actorUri: r.actor_uri,
       content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
       // When the post was written, for display. created_at (when it reached us)
@@ -4543,4 +5293,8 @@
   getOutboxRow: (id) => iStmts().getO.get(id),
   buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
+  // Rijke directe berichten: dezelfde sanitizer als deliverReply gebruikt, zodat
+  // een antwoord uit Berichten door precies één poort gaat.
+  sanitizeHtml: (h) => HtmlSanitizerService.sanitize(h),
+  htmlToPlainText: (h) => HtmlSanitizerService.toPlainText(h),
 });
 /**
@@ -4579,21 +5333,61 @@
   // Guardian PWA / Berichten push. The kid answers an incoming offer in its
   // own Berichten; an existing guardian and a commit land in the PWA.
+  //
+  // De labels hangen aan dezelfde sleutels als het Guardian-paneel, zodat een
+  // melding en het scherm waar hij heen wijst hetzelfde woord gebruiken.
   onEvent: (slug, ev) => {
-    const L = pushLang(slug);
-    const texts = {
-      offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
-      offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
-      committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
-      // §3.2: a guardian ended the relation. The ward hears that someone who
-      // was looking after them has gone; a co-guardian hears they are one fewer.
-      guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
-      coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
-    }[ev.kind];
-    if (!texts) return;
-    const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
-    const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
-    pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url });
+    const p = guardianEventPush(slug, ev);
+    if (p) pushEvent(slug, p);
   },
 });
+
+/**
+ * Welke melding hoort bij een guardianship-gebeurtenis, of geen.
+ *
+ * Apart en puur, omdat dit een BESLISSING is en geen bezorging: de
+ * guardianship-module zendt veertien soorten uit en deze tabel bepaalt welke
+ * daarvan een mens wakker maken. Dat hoort toetsbaar te zijn zonder web-push
+ * erbij te halen.
+ */
+export function guardianEventPush(slug, ev) {
+  const L = pushLang(slug);
+  const texts = {
+    offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
+    offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
+    committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
+    // §3.2: a guardian ended the relation. The ward hears that someone who
+    // was looking after them has gone; a co-guardian hears they are one fewer.
+    guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
+    coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
+    // 5.6 gated settings. Zonder deze twee is de hele tally stil: een guardian
+    // hoort niet dat er een antwoord van hem gewenst is, en dus loopt het
+    // venster leeg en verloopt het voorstel. Een drempel die niemand ziet is
+    // geen drempel.
+    gated_review: ['push.n_gate_ask_t', 'push.n_gate_ask_b'],      // jij moet antwoorden
+    gated_outcome: ['push.n_gate_done_t', 'push.n_gate_done_b'],   // er is besloten
+  }[ev.kind];
+  if (!texts) return null;
+  const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
+  // Een gate-melding zonder te zeggen WELKE instelling is nutteloos: er zijn er
+  // meer dan een, en ze betekenen heel verschillende dingen voor een kind.
+  const wat = i18nT(L, GATE_LABEL[ev.feature] || 'guardian.prop_embeds');
+  const stand = i18nT(L, ev.value ? 'guardian.prop_on' : 'guardian.prop_off');
+  const uitkomst = i18nT(L, GATE_OUTCOME[ev.outcome] || 'guardian.prop_st_open');
+  const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
+  return { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who, wat, stand, uitkomst }), url };
+}
+
+// Van een gated feature naar het woord dat het Guardian-paneel er al voor
+// gebruikt. Een onbekende feature valt terug op het algemene woord in plaats van
+// de melding te laten vervallen: liever een iets vager bericht dan geen bericht.
+const GATE_LABEL = {
+  'shaer:externalEmbeds': 'guardian.prop_embeds',
+  'shaer:externalPlayback': 'guardian.prop_play',
+};
+const GATE_OUTCOME = {
+  accepted: 'guardian.prop_st_accepted',
+  rejected: 'guardian.prop_st_rejected',
+  expired: 'guardian.prop_st_expired',
+};
 
 // The notification duty of FEP-633c 3.6.2, wired once for every place a
@@ -4625,5 +5419,6 @@
   buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
   followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
-  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
+  feedCursor, feedChangesSince, waitForFeedChange,
+  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
@@ -4631,5 +5426,5 @@
   gateOutgoingFollow, performApprovedFollow,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
-  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
+  autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
   getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
   deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/services/ArchiveExportService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,386 @@
+/**
+ * Export van een draagbaar inhoudsarchief (shaer-1a6).
+ *
+ * Bouwt precies wat docs/EXPORT-FORMAT.md beschrijft. Lees dat eerst; hier staat
+ * alleen wat de code doet, niet waarom het formaat zo is.
+ *
+ * Twee dingen zijn geen implementatiedetail maar eis:
+ *
+ *   REPRODUCEERBAAR  Twee exports van ongewijzigde inhoud horen byte-voor-byte
+ *                    gelijk te zijn, anders is een diff of een checksum nutteloos.
+ *                    Vandaar gesorteerde sleutels, vaste volgorde, geen tijdstip
+ *                    in de postbestanden en een vaste mtime in de zip.
+ *   GEEN CREDENTIALS Dit is niet de storage-zip uit shaer-190t. Hier komt geen
+ *                    sleutel, sessie, hash of DM van een ander in.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import crypto from 'crypto';
+import db from '../config/database.js';
+import { MEDIA_ROOT } from '../config/paths.js';
+
+export const FORMAT_VERSION = 1;
+
+/** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
+export function stableJson(value) {
+  const sorteer = (v) => {
+    if (Array.isArray(v)) return v.map(sorteer);
+    if (v && typeof v === 'object') {
+      const uit = {};
+      for (const k of Object.keys(v).sort()) if (v[k] !== undefined) uit[k] = sorteer(v[k]);
+      return uit;
+    }
+    return v;
+  };
+  return `${JSON.stringify(sorteer(value), null, 2)}\n`;
+}
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+const toISO = (d) => { const t = Date.parse(d); return isNaN(t) ? null : new Date(t).toISOString(); };
+
+const MIME_BY_EXT = {
+  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
+  webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
+  mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
+  mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac',
+};
+const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
+const mimeOf = (u) => MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
+const as2TypeOf = (mime) => (mime.startsWith('video/') ? 'Video' : mime.startsWith('audio/') ? 'Audio' : 'Image');
+
+/**
+ * Van een media-URL naar een bestand op schijf.
+ *
+ * /media is een kale express.static op MEDIA_ROOT, dus het URL-pad IS het pad
+ * onder die map. Een absolute URL naar onze eigen origin telt net zo goed als
+ * een pad -- de content slaat allebei op.
+ *
+ * De ../-controle is geen formaliteit: een verzonnen pad in oude inhoud zou
+ * anders een willekeurig bestand van de schijf het archief in trekken.
+ */
+function localMediaPath(url, origin) {
+  let p = String(url || '');
+  if (!p) return null;
+  if (/^https?:/i.test(p)) {
+    try {
+      const u = new URL(p);
+      if (`${u.protocol}//${u.host}` !== origin) return null;   // andermans host: nooit van onze schijf
+      p = u.pathname;
+    } catch { return null; }
+  }
+  if (!p.startsWith('/media/')) return null;
+  const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(p.slice('/media/'.length)));
+  const root = path.resolve(MEDIA_ROOT);
+  if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return null;
+  return abs;
+}
+
+/** Alle media waar een post naar wijst, in vaste volgorde en zonder dubbelen. */
+function mediaRefsOf(post, origin) {
+  const uit = [];
+  const zie = new Set();
+  const voegToe = (url, name, rol, extra = {}) => {
+    const u = String(url || '').trim();
+    if (!u || zie.has(u)) return;
+    zie.add(u);
+    uit.push({ url: u, name: name || null, rol, ...extra });
+  };
+  // De ROL is niet decoratief. Zonder rol staat er in het archief wel een
+  // bestand, maar niet dat het de cover was of bij de speler hoorde -- en dan
+  // komt de post na een herstel zonder cover en zonder speler terug. Gevonden
+  // door bij de oefenherstel ALLE kolommen te vergelijken in plaats van een
+  // handjevol.
+  voegToe(post.cover_image_url, post.cover_alt, 'cover');
+  voegToe(post.cover_video_url, post.cover_alt, 'coverVideo');
+  for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1], null, 'inline');
+  try {
+    for (const a of JSON.parse(post.c2s_attachments || '[]')) {
+      voegToe(a && a.url, a && a.name, 'c2s');
+      // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
+      // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
+      // pas op bij de export van echte data.
+      voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null, 'poster', { posterFor: a && a.url });
+    }
+  } catch { /* kapotte kolom blokkeert de export niet */ }
+  // Gehoste audio: [[track:id]] verwijst naar een audio_tracks-rij met een media-rij eronder.
+  for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
+    try {
+      const t = db.prepare('SELECT t.title, m.storage_path FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id WHERE t.id = ?').get(m[1]);
+      if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title, 'track');
+    } catch { /* geen audio-tabellen: niets te doen */ }
+  }
+  return uit;
+}
+
+/**
+ * De audio-metadata die alleen in de database staat en nergens anders uit te
+ * halen is: titel, artiest, credit, licentie, externe links.
+ *
+ * `shaer:media` koppelt de track aan zijn bestand in het archief. Zonder die
+ * verwijzing weet een importer wel dát er een track was en hoe hij heette, maar
+ * niet wélk van de bijlagen erbij hoort -- en dan valt [[track:]] bij een
+ * herstel op niets terug.
+ */
+function audioOf(post, attachments) {
+  const uit = [];
+  for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
+    try {
+      const t = db.prepare('SELECT t.*, md.storage_path FROM audio_tracks t LEFT JOIN media md ON md.id = t.media_id WHERE t.id = ?').get(m[1]);
+      if (!t) continue;
+      let bestand;
+      if (t.storage_path) {
+        const rel = `/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`;
+        const bij = attachments.find((a) => String(a['shaer:originalUrl'] || '').endsWith(rel));
+        bestand = bij ? bij.url : undefined;
+      }
+      uit.push({
+        'shaer:ref': `[[track:${t.id}]]`,
+        'shaer:media': bestand,
+        name: t.title, artist: t.artist || undefined, album: t.album || undefined,
+        duration: t.duration || undefined, credit: t.credit || undefined, license: t.license || undefined,
+        url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
+      });
+    } catch { /* idem */ }
+  }
+  return uit.length ? uit : undefined;
+}
+
+/** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
+function postObject(post, site, origin, attachments) {
+  const heeftTitel = !!(post.title && String(post.title).trim());
+  const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
+  const updated = toISO(post.updated_at);
+  const poll = (() => {
+    try {
+      const d = JSON.parse(post.poll_json || 'null');
+      if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
+      const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
+      return { multiple: !!d.multiple, opties, endTime: d.endTime || null, closed: !!d.closed };
+    } catch { return null; }
+  })();
+  const tags = [];
+  try {
+    for (const t of String(post.tags || '').split(',').map((x) => x.trim()).filter(Boolean)) {
+      tags.push({ type: 'Hashtag', name: t.startsWith('#') ? t : `#${t}`, href: `${origin}/tag/${encodeURIComponent(t.replace(/^#/, ''))}` });
+    }
+  } catch { /* tags zijn optioneel */ }
+
+  return {
+    '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
+    id: `${origin}/ap/notes/${encodeURIComponent(post.id)}`,
+    type: poll ? 'Question' : (heeftTitel ? 'Article' : 'Note'),
+    attributedTo: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
+    name: heeftTitel ? post.title : undefined,
+    content: post.content || '',
+    contentMap: post.language ? { [post.language]: post.content || '' } : undefined,
+    summary: post.content_warning || undefined,
+    sensitive: post.nsfw ? true : undefined,
+    published,
+    updated: (updated && updated !== published) ? updated : undefined,
+    url: `${origin}/${encodeURIComponent(post.slug)}`,
+    attachment: attachments.length ? attachments : undefined,
+    tag: tags.length ? tags : undefined,
+    ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
+    endTime: poll ? (poll.endTime || undefined) : undefined,
+    // AS2 kent `closed` op een Question. Zonder dit staat een poll die vroegtijdig
+    // is gesloten na een herstel weer open -- gevonden op echte beta-data.
+    closed: (poll && poll.closed) ? true : undefined,
+    quoteUrl: post.quote_uri || undefined,
+    'shaer:quoteActor': post.quote_actor || undefined,
+    'shaer:slug': post.slug,
+    'shaer:status': post.status || 'draft',
+    'shaer:excerpt': post.excerpt || undefined,
+    'shaer:type': post.type || undefined,
+    'shaer:pinned': post.pinned ? true : undefined,
+    'shaer:noindex': post.noindex ? true : undefined,
+    'shaer:fanOnly': post.fan_only ? true : undefined,
+    'shaer:paid': post.paid ? true : undefined,
+    'shaer:paidMinCents': post.paid ? (post.paid_min_cents || undefined) : undefined,
+    'shaer:apVisibility': post.ap_visibility || undefined,
+    'shaer:publishAt': toISO(post.publish_at) || undefined,
+    'shaer:coverAlt': post.cover_alt || undefined,
+    'shaer:viewCount': post.view_count || undefined,
+    'shaer:audio': audioOf(post, attachments),
+  };
+}
+
+/** De leesbare kopie. Afgeleid, eenrichtingsverkeer -- de importer kijkt hier nooit naar. */
+function readableMarkdown(post, obj) {
+  const fm = [
+    '---',
+    `title: ${JSON.stringify(post.title || post.slug)}`,
+    `slug: ${JSON.stringify(post.slug)}`,
+    `date: ${obj.published || ''}`,
+    `status: ${post.status || 'draft'}`,
+    post.content_warning ? `content_warning: ${JSON.stringify(post.content_warning)}` : null,
+    '---',
+  ].filter((l) => l !== null).join('\n');
+  return `${fm}\n${post.content || ''}\n`;
+}
+
+/**
+ * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
+ *
+ * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
+ * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
+ */
+export function buildArchive(slug, opts = {}) {
+  const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
+  if (!site) throw new Error(`onbekende site: ${slug}`);
+
+  const bestanden = new Map();       // pad -> Buffer
+  const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0 };
+  const ontbrekend = [];             // voor de rapportage van de beller
+
+  // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
+  // is de volgorde van twee posts op dezelfde seconde niet bepaald.
+  const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
+                             ORDER BY COALESCE(published_at, created_at) ASC, id ASC`).all(site.id);
+
+  for (const post of posts) {
+    const attachments = [];
+    for (const ref of mediaRefsOf(post, origin)) {
+      const schijf = localMediaPath(ref.url, origin);
+      const mime = mimeOf(ref.url);
+      let bytes = null;
+      if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
+      if (bytes) {
+        const hash = sha256(bytes);
+        const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
+        if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
+          url: naam, 'shaer:availability': 'included',
+          'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
+          'shaer:sha256': hash,
+          'shaer:role': ref.rol,
+          'shaer:posterFor': ref.posterFor || undefined,
+        });
+      } else {
+        // De derde staat uit het formaat: we weten DAT het bestond en waar het
+        // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
+        tellingen.mediaMissing += 1;
+        const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
+        ontbrekend.push({ post: post.slug, url: orig });
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
+          url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
+          'shaer:role': ref.rol,
+          'shaer:posterFor': ref.posterFor || undefined,
+        });
+      }
+    }
+
+    const obj = postObject(post, site, origin, attachments);
+    bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
+    bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
+    tellingen.posts += 1;
+
+    // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
+    let replies = [];
+    try {
+      replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
+                             ORDER BY COALESCE(published, created_at) ASC, id ASC`).all(post.id);
+    } catch { /* tabel kan ontbreken op een heel oude database */ }
+    if (replies.length) {
+      const coll = {
+        '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
+        type: 'OrderedCollection',
+        'shaer:archive': true,
+        'shaer:inReplyTo': obj.id,
+        totalItems: replies.length,
+        orderedItems: replies.map((r) => ({
+          id: r.object_uri || undefined,
+          type: 'Note',
+          attributedTo: r.actor_uri || undefined,
+          inReplyTo: r.parent_uri || obj.id,
+          content: r.content || '',
+          published: toISO(r.published || r.created_at) || undefined,
+          'shaer:actorName': r.actor_name || undefined,
+          'shaer:actorHandle': r.actor_handle || undefined,
+        })),
+      };
+      bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
+      tellingen.replies += replies.length;
+    }
+  }
+
+  const files = {};
+  for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
+  const manifest = {
+    formatVersion: FORMAT_VERSION,
+    generator: `klonkt/${opts.version || 'dev'}`,
+    exportedAt: opts.exportedAt || new Date().toISOString(),
+    origin,
+    actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
+    site: { slug: site.slug, title: site.title || site.slug },
+    counts: tellingen,
+    files,
+  };
+  bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
+
+  return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
+}
+
+// ── Zip, store-only en deterministisch ────────────────────────────
+// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
+// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
+// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
+
+const _crcTabel = (() => {
+  const t = new Int32Array(256);
+  for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c; }
+  return t;
+})();
+function crc32(buf) {
+  let c = 0 ^ -1;
+  for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
+  return (c ^ -1) >>> 0;
+}
+
+export function zipArchive(files) {
+  const paden = [...files.keys()].sort();
+  const lokaal = [];
+  const centraal = [];
+  let offset = 0;
+  for (const pad of paden) {
+    const naam = Buffer.from(pad, 'utf8');
+    const data = files.get(pad);
+    const crc = crc32(data);
+    const lh = Buffer.alloc(30);
+    lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
+    lh.writeUInt16LE(0, 8);                       // store, geen compressie
+    lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12);   // vaste tijd: 1980-01-01
+    lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
+    lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
+    lokaal.push(lh, naam, data);
+
+    const ch = Buffer.alloc(46);
+    ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
+    ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
+    ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
+    ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
+    ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
+    ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
+    ch.writeUInt32LE(offset, 42);
+    centraal.push(ch, naam);
+    offset += 30 + naam.length + data.length;
+  }
+  const cd = Buffer.concat(centraal);
+  const eocd = Buffer.alloc(22);
+  eocd.writeUInt32LE(0x06054b50, 0);
+  eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
+  eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
+  return Buffer.concat([...lokaal, cd, eocd]);
+}
+
+/** Schrijf het archief als losse bestanden naar een map. */
+export function writeArchiveDir(files, dir) {
+  for (const pad of [...files.keys()].sort()) {
+    const doel = path.join(dir, pad);
+    fs.mkdirSync(path.dirname(doel), { recursive: true });
+    fs.writeFileSync(doel, files.get(pad));
+  }
+}
Index: src/services/ArchiveImportService.js
===================================================================
--- src/services/ArchiveImportService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/services/ArchiveImportService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,318 @@
+/**
+ * Import van een draagbaar inhoudsarchief (shaer-pmr).
+ *
+ * Leest wat docs/EXPORT-FORMAT.md beschrijft. Vier regels uit dat document zijn
+ * geen implementatiekeuze maar eis, en ze staan hier alle vier expliciet:
+ *
+ *   VERSIE EERST   Een hogere onbekende formatVersion wordt in zijn GEHEEL
+ *                  geweigerd. Een half begrepen herstel is erger dan geen
+ *                  herstel, want het ziet eruit alsof het gelukt is.
+ *   IDENTITEIT     De origin uit het manifest bepaalt of de AP-ids behouden
+ *                  blijven. Dat is geen vraag aan de gebruiker: een verkeerd
+ *                  antwoord publiceert objecten onder een id dat je niet beheert.
+ *   NIETS STILS    Ontbrekende media worden geteld en gemeld.
+ *   GEEN UITZENDING Geen Update de fediverse in. Verouderde kopieen elders
+ *                  rechttrekken is een aparte, bewuste actie.
+ *
+ * `readable/` wordt nooit gelezen. Dat is de hele reden dat het afgeleid is.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import zlib from 'zlib';
+import crypto from 'crypto';
+import { randomUUID } from 'crypto';
+import db from '../config/database.js';
+import { MEDIA_ROOT } from '../config/paths.js';
+import { FORMAT_VERSION } from './ArchiveExportService.js';
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+// De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de
+// sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige
+// volgorde. Klonkt schrijft zelf ook ISO in deze kolommen.
+const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
+
+// ── Inlezen ───────────────────────────────────────────────────────
+
+/** Lees een archiefmap in als pad -> Buffer. */
+export function readArchiveDir(dir) {
+  const files = new Map();
+  const loop = (sub) => {
+    for (const naam of fs.readdirSync(path.join(dir, sub), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
+      const rel = sub ? `${sub}/${naam.name}` : naam.name;
+      if (naam.isDirectory()) loop(rel);
+      else files.set(rel, fs.readFileSync(path.join(dir, rel)));
+    }
+  };
+  loop('');
+  return files;
+}
+
+/**
+ * Lees een zip in. Onze eigen export is store-only, maar een archief dat elders
+ * gemaakt is mag deflate gebruiken -- anders is het geen uitwisselformaat.
+ */
+export function readArchiveZip(buf) {
+  const files = new Map();
+  const eocd = (() => {
+    for (let i = buf.length - 22; i >= 0 && i > buf.length - 66000; i--) if (buf.readUInt32LE(i) === 0x06054b50) return i;
+    return -1;
+  })();
+  if (eocd < 0) throw new Error('geen zip: het eind-record ontbreekt');
+  const aantal = buf.readUInt16LE(eocd + 10);
+  let p = buf.readUInt32LE(eocd + 16);
+  for (let n = 0; n < aantal; n++) {
+    if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('beschadigde zip: centrale ingang klopt niet');
+    const methode = buf.readUInt16LE(p + 10);
+    const gecomp = buf.readUInt32LE(p + 20);
+    const naamLen = buf.readUInt16LE(p + 28);
+    const extraLen = buf.readUInt16LE(p + 30);
+    const commentLen = buf.readUInt16LE(p + 32);
+    const lokaalOffset = buf.readUInt32LE(p + 42);
+    const naam = buf.toString('utf8', p + 46, p + 46 + naamLen);
+    const lNaam = buf.readUInt16LE(lokaalOffset + 26);
+    const lExtra = buf.readUInt16LE(lokaalOffset + 28);
+    const start = lokaalOffset + 30 + lNaam + lExtra;
+    const rauw = buf.subarray(start, start + gecomp);
+    if (!naam.endsWith('/')) {
+      files.set(naam, methode === 8 ? zlib.inflateRawSync(rauw) : Buffer.from(rauw));
+    }
+    p += 46 + naamLen + extraLen + commentLen;
+  }
+  return files;
+}
+
+export function readArchive(bron) {
+  const st = fs.statSync(bron);
+  return st.isDirectory() ? readArchiveDir(bron) : readArchiveZip(fs.readFileSync(bron));
+}
+
+// ── Importeren ────────────────────────────────────────────────────
+
+/** Een pad onder MEDIA_ROOT houden. Een archief van elders is invoer, geen vriend. */
+function veiligMediaPad(urlPad) {
+  if (!urlPad || !urlPad.startsWith('/media/')) return null;
+  const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(urlPad.slice('/media/'.length)));
+  const root = path.resolve(MEDIA_ROOT);
+  return (abs !== root && abs.startsWith(`${root}${path.sep}`)) ? abs : null;
+}
+
+/** Het pad-deel van een originele media-URL, of null als het er niet een van ons is. */
+function padVanOrigineel(u) {
+  const s = String(u || '');
+  if (s.startsWith('/media/')) return s;
+  try { const x = new URL(s); return x.pathname.startsWith('/media/') ? x.pathname : null; } catch { return null; }
+}
+
+/**
+ * Waar deze bijlage komt te staan, als site-relatief pad.
+ *
+ * Meestal zijn oorspronkelijke plek en bestemming gelijk. Maar een bestand dat
+ * ELDERS werd geserveerd -- gehoste audio ging via /audio/stream/ -- heeft geen
+ * plek onder /media. Zonder een bestemming zou het bestand wel worden
+ * weggeschreven en toch uit de kolommen verdwijnen. Nu krijgt het een eigen hoek,
+ * en verwijzen de kolommen daarheen.
+ */
+function bestemming(a) {
+  return padVanOrigineel(a && a['shaer:originalUrl']) || `/media/archief/${path.basename(String((a && a.url) || ''))}`;
+}
+
+/**
+ * Zet een archief terug in een site.
+ *
+ * @param {Map<string,Buffer>} files  het ingelezen archief
+ * @param {object} opts  { slug, dryRun, overwrite, origin }
+ */
+export function importArchive(files, opts = {}) {
+  const rapport = {
+    formatVersion: null, origin: null, idsBehouden: null,
+    posts: 0, overgeslagen: 0, overschreven: 0,
+    replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
+  };
+
+  const manifestBuf = files.get('manifest.json');
+  if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
+  const manifest = JSON.parse(manifestBuf.toString('utf8'));
+  rapport.formatVersion = manifest.formatVersion;
+
+  // VERSIE EERST, voordat er ook maar iets gelezen wordt.
+  if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
+  if (manifest.formatVersion > FORMAT_VERSION) {
+    throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
+  }
+
+  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
+  if (!site) throw new Error(`onbekende site: ${opts.slug}`);
+  const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  rapport.origin = manifest.origin || null;
+
+  // IDENTITEIT. Gelijke origin -> de AP-ids blijven, en daarmee vinden de boosts
+  // en antwoorden die er al naar wijzen hun post terug. Anders nieuwe ids, want
+  // een id op andermans domein publiceren is een vervalsingsoppervlak en andere
+  // servers halen het daar toch op.
+  const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
+  rapport.idsBehouden = idsBehouden;
+  if (!idsBehouden) {
+    rapport.waarschuwingen.push(
+      `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): nieuwe AP-ids, de oude blijven als verwijzing staan`,
+    );
+  }
+
+  const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
+  const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
+  const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
+  const idKaart = new Map();     // oud post-id -> nieuw post-id
+
+  const schrijf = [];            // alles eerst uitrekenen, dan in EEN transactie
+
+  for (const pad of postPaden) {
+    const o = JSON.parse(files.get(pad).toString('utf8'));
+    const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
+    const nieuwId = idsBehouden ? oudId : randomUUID();
+    idKaart.set(oudId, nieuwId);
+
+    const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
+    if (botsing && !opts.overwrite) {
+      // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
+      // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
+      // idempotent maakt.
+      rapport.overgeslagen += 1;
+      continue;
+    }
+    if (botsing) rapport.overschreven += 1;
+
+    // Media: terug naar hun oorspronkelijke plek onder /media, want de content
+    // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
+    // op een ander domein.
+    for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
+      if (a['shaer:availability'] === 'missing') {
+        rapport.mediaMissing += 1;
+        rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
+        continue;
+      }
+      const bytes = files.get(a.url);
+      if (!bytes) {
+        // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
+        // archief, geen ontbrekende media -- apart melden, niet stil optellen.
+        rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
+        continue;
+      }
+      if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
+        rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
+        continue;
+      }
+      const doel = veiligMediaPad(bestemming(a));
+      if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
+      schrijf.push({ soort: 'media', doel, bytes });
+      rapport.media += 1;
+    }
+
+    schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o });
+    rapport.posts += 1;
+  }
+
+  // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
+  for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
+    const coll = JSON.parse(files.get(pad).toString('utf8'));
+    if (coll['shaer:archive'] !== true) {
+      rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
+      continue;
+    }
+    const oudId = path.basename(pad, '.json');
+    const postId = idKaart.get(oudId);
+    if (!postId) continue;                       // post overgeslagen -> antwoorden ook
+    for (const it of (coll.orderedItems || [])) {
+      schrijf.push({ soort: 'reply', postId, it });
+      rapport.replies += 1;
+    }
+  }
+
+  if (opts.dryRun) return rapport;
+
+  // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
+  // uitkomst, want dan lijkt het gelukt.
+  const insPost = db.prepare(`INSERT OR REPLACE INTO posts
+    (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
+     pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
+     content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
+    VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
+     @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
+     @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
+  const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
+    (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
+    VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`);
+
+  db.transaction(() => {
+    for (const s of schrijf) {
+      if (s.soort === 'media') {
+        fs.mkdirSync(path.dirname(s.doel), { recursive: true });
+        fs.writeFileSync(s.doel, s.bytes);
+        continue;
+      }
+      if (s.soort === 'reply') {
+        insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
+          s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
+        continue;
+      }
+      const o = s.obj;
+      const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
+      // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
+      // bestand er wel, maar komt de post zonder cover en zonder speler terug --
+      // en dat zie je pas als je alle kolommen vergelijkt.
+      const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
+      const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
+      const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
+      const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
+        const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
+        return {
+          url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
+          poster: poster ? padVan(poster) : undefined,
+        };
+      }).filter((a) => a.url);
+      insPost.run({
+        id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
+        title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
+        status: o['shaer:status'] || 'draft',
+        cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
+        cover_video_url: padVan(metRol('coverVideo')),
+        c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
+        pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
+        tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
+        published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
+        noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
+        fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
+        language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
+        content_warning: o.summary || null,
+        poll_json: opties ? JSON.stringify({ multiple: Array.isArray(o.anyOf), options: opties.map((x) => ({ name: x.name })), endTime: o.endTime || null, closed: !!o.closed }) : null,
+        quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
+        ap_visibility: o['shaer:apVisibility'] || null,
+        paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
+        view_count: o['shaer:viewCount'] || 0,
+      });
+      // Gehoste audio terug: [[track:]] in de content valt anders op niets terug.
+      for (const t of (o['shaer:audio'] || [])) {
+        const trackId = String(t['shaer:ref'] || '').replace(/^\[\[track:|\]\]$/g, '');
+        if (!trackId) continue;
+        let mediaId = null;
+        const bij = (o.attachment || []).find((a) => a.url === t['shaer:media']);
+        const doel = bij && veiligMediaPad(bestemming(bij));
+        if (doel) {
+          mediaId = randomUUID();
+          try {
+            db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+              .run(mediaId, site.id, path.basename(doel), bij.mediaType || 'audio/mpeg', (files.get(bij.url) || []).length || 0, doel);
+          } catch { mediaId = null; }
+        }
+        try {
+          db.prepare(`INSERT OR REPLACE INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, credit, license, link_spotify, link_youtube, link_soundcloud)
+                      VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
+            .run(trackId, site.id, t.name || 'zonder titel', t.artist || null, t.album || null, t.duration || null,
+              mediaId, t.credit || null, t.license || null,
+              ...['spotify', 'youtube', 'soundcloud'].map((k) => (t.url || []).find((u) => String(u).includes(k)) || null));
+        } catch { /* geen audio-tabellen op deze installatie */ }
+      }
+    }
+  })();
+
+  return rapport;
+}
Index: src/services/ArchiveRecoveryService.js
===================================================================
--- src/services/ArchiveRecoveryService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/services/ArchiveRecoveryService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,250 @@
+/**
+ * Herstel uit de tijdlijn-cache van een ANDERE Klonkt (shaer-l1v).
+ *
+ * De aanleiding: boiert.eu verloor zijn database. De posts staan nog in de
+ * ap_timeline van instances die boiert volgen, en die tabel sleutelt op de
+ * OORSPRONKELIJKE AP-object-URI. De identiteiten overleven dus, en dat is het
+ * verschil tussen herstellen en opnieuw posten: boosts, likes en antwoorden
+ * elders wijzen naar die ids.
+ *
+ * Dit maakt geen posts aan. Het maakt een ARCHIEF in het formaat uit
+ * docs/EXPORT-FORMAT.md, zodat het door dezelfde importer gaat als een gewone
+ * export -- inclusief droogloop, versiecontrole en de regel rond AP-ids. Een
+ * apart herstelpad zou een tweede implementatie zijn van iets dat al bestaat.
+ *
+ * WAT ER PRINCIPIEEL NIET IN ZIT, en dat hoort in de verwachting te staan
+ * voordat iemand eraan begint:
+ *
+ *   - ANTWOORDEN van de verloren site zelf. belongsInTimeline() weigert alles
+ *     met een inReplyTo, dus die zijn nooit in een tijdlijn-cache beland.
+ *   - alles van VOOR het moment dat de bron ging volgen.
+ *   - CONCEPTEN. Nooit gefedereerd, dus nergens gecachet.
+ *   - de content is de FEDERATIE-projectie: gesaneerd door de sanitizer van de
+ *     bron, met de titel in de tekst gebakken en de afbeeldingen uit de body
+ *     gehaald. Waar ze in de tekst stonden is niet te herstellen.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import crypto from 'crypto';
+import Database from 'better-sqlite3';
+import { stableJson, FORMAT_VERSION } from './ArchiveExportService.js';
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+const MIME_BY_EXT = {
+  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
+  webp: 'image/webp', avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm',
+  mov: 'video/quicktime', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
+};
+const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
+const mimeOf = (u, opgegeven) => opgegeven || MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
+const as2TypeOf = (m) => (m.startsWith('video/') ? 'Video' : m.startsWith('audio/') ? 'Audio' : 'Image');
+
+/**
+ * De titel terugvissen uit de tekst.
+ *
+ * buildNote() zet de titel als eerste alinea in de content -- `<p><strong>...`
+ * -- omdat Mastodon `name` negeert. In de cache staat dus de gefedereerde vorm,
+ * en zonder deze stap komt elke post titelloos terug met zijn titel als vetgedrukte
+ * eerste regel in de body.
+ *
+ * Er is GEEN sluitend signaal. De slug is niet van de titel afgeleid (op echte
+ * data: titel "Back to 1987!", slug "waiting-on-you"), dus we moeten op de vorm
+ * afgaan: een openende alinea die niets anders bevat dan vetgedrukte platte
+ * tekst. Een post die echt zo begint verliest die regel naar zijn titel. Vandaar
+ * dat de beller een lijst terugkrijgt van alles wat is losgetrokken -- dat hoort
+ * een mens na te lopen, niet een script.
+ */
+export function splitsTitel(html) {
+  const m = String(html || '').match(/^\s*<p>\s*<strong>([^<>]+)<\/strong>\s*<\/p>/i);
+  if (!m) return { titel: null, rest: html || '' };
+  const titel = m[1].replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
+  if (!titel) return { titel: null, rest: html || '' };
+  return { titel, rest: String(html).slice(m[0].length) };
+}
+
+/**
+ * Van een URL naar een bestand op de geredde schijf.
+ *
+ * Twee routes, en de tweede was bijna vergeten: gewone media gaan via /media/ op
+ * MEDIA_ROOT, maar GEHOSTE AUDIO gaat via /audio/stream/<bestandsnaam> op
+ * AUDIO_DIR -- een andere map. Op echte cachedata van een muzieksite is dat geen
+ * randgeval maar de helft van de bijlagen.
+ */
+function schijfPad(url, origin, mediaRoot, audioRoot) {
+  let p = String(url || '');
+  if (/^https?:/i.test(p)) {
+    try {
+      const u = new URL(p);
+      if (origin && `${u.protocol}//${u.host}` !== origin) return null;
+      p = u.pathname;
+    } catch { return null; }
+  }
+  const onder = (root, rest) => {
+    if (!root) return null;
+    const abs = path.resolve(root, decodeURIComponent(rest));
+    const r = path.resolve(root);
+    return (abs !== r && abs.startsWith(`${r}${path.sep}`)) ? abs : null;
+  };
+  if (p.startsWith('/media/')) return onder(mediaRoot, p.slice('/media/'.length));
+  if (p.startsWith('/audio/stream/')) return onder(audioRoot, p.slice('/audio/stream/'.length));
+  return null;
+}
+
+const parse = (s, val = null) => { try { return JSON.parse(s) || val; } catch { return val; } };
+
+/**
+ * Bouw een archief uit een of meer tijdlijn-caches.
+ *
+ * @param {object} opts
+ *   sources   paden naar de database(s) van instances die de verloren site volgen
+ *   actorUri  de actor van de verloren site, bv. https://boiert.eu/ap/users/boiert
+ *   mediaRoot de geredde mediamap van de verloren site (optioneel)
+ *   houdTitelInTekst  laat de titel staan waar hij staat
+ */
+export function recoverFromCache(opts = {}) {
+  const { sources = [], actorUri, mediaRoot = null, houdTitelInTekst = false } = opts;
+  // AUDIO_PATH staat naast MEDIA_PATH, niet erin. Zonder eigen opgave nemen we de
+  // buurmap van de mediamap, want dat is de standaardindeling van storage/.
+  const audioRoot = opts.audioRoot || (mediaRoot ? path.join(path.dirname(path.resolve(mediaRoot)), 'audio') : null);
+  if (!actorUri) throw new Error('actorUri is verplicht: zonder actor weten we niet wiens posts we redden');
+  const origin = (opts.origin || (() => { try { const u = new URL(actorUri); return `${u.protocol}//${u.host}`; } catch { return ''; } })()).replace(/\/+$/, '');
+  if (!origin) throw new Error('kan de origin niet afleiden uit de actorUri');
+
+  const rapport = {
+    bronnen: [], posts: 0, titels: [], media: 0, mediaMissing: 0, gemist: [],
+    overgeslagen: 0, oudste: null, nieuwste: null, waarschuwingen: [],
+  };
+
+  // Beste rij per AP-id. Meerdere bronnen dekken verschillende periodes, en
+  // dezelfde post kan in meer dan een tijdlijn staan; de rijkste versie wint.
+  const beste = new Map();
+  for (const bron of sources) {
+    let n = 0;
+    let sdb;
+    try { sdb = new Database(bron, { readonly: true, fileMustExist: true }); }
+    catch (e) { rapport.waarschuwingen.push(`${bron}: niet te openen (${e.message})`); continue; }
+    let rijen = [];
+    try { rijen = sdb.prepare('SELECT * FROM ap_timeline WHERE author_uri = ?').all(actorUri); }
+    catch (e) { rapport.waarschuwingen.push(`${bron}: geen bruikbare ap_timeline (${e.message})`); }
+    for (const r of rijen) {
+      // Een boost VAN een ander staat op naam van de oorspronkelijke auteur, dus
+      // author_uri filtert die al weg. Een boost van ONZE post door een ander is
+      // wel van ons -- die houden we, maar zonder de booster.
+      const vorige = beste.get(r.id);
+      if (!vorige || String(r.content || '').length > String(vorige.content || '').length) beste.set(r.id, r);
+      n += 1;
+    }
+    sdb.close();
+    rapport.bronnen.push({ pad: bron, rijen: n });
+  }
+
+  const files = new Map();
+  const ids = [...beste.keys()].sort();
+
+  for (const apId of ids) {
+    const r = beste.get(apId);
+    const postId = decodeURIComponent(String(apId).split('/ap/notes/')[1] || '');
+    if (!postId) { rapport.overgeslagen += 1; continue; }
+    let slug = postId;
+    try { const u = new URL(r.url || ''); slug = decodeURIComponent(u.pathname.replace(/^\//, '')) || postId; } catch { /* val terug op het id */ }
+
+    const gesplitst = houdTitelInTekst ? { titel: null, rest: r.content || '' } : splitsTitel(r.content);
+    if (gesplitst.titel) rapport.titels.push({ slug, titel: gesplitst.titel });
+
+    // Bijlagen. De cache bewaart alleen URL's; de VOLGORDE is die van buildNote,
+    // waarin de cover voorop gaat. Meer signaal is er niet, dus de eerste krijgt
+    // de rol cover en de rest wordt bijlage. Waar ze in de tekst stonden is bij
+    // het federeren verloren gegaan en komt niet terug.
+    const attachments = [];
+    const lijst = parse(r.media_json, []) || [];
+    lijst.forEach((m, i) => {
+      const url = m && (m.url || m.href);
+      if (!url) return;
+      const mime = mimeOf(url, m.type && String(m.type).includes('/') ? m.type : null);
+      const rol = i === 0 ? 'cover' : 'c2s';
+      const bestand = schijfPad(url, origin, mediaRoot, audioRoot);
+      let bytes = null;
+      if (bestand) { try { bytes = fs.readFileSync(bestand); } catch { bytes = null; } }
+      if (bytes) {
+        const hash = sha256(bytes);
+        const naam = `media/${hash}${extOf(url) ? `.${extOf(url)}` : ''}`;
+        if (!files.has(naam)) { files.set(naam, bytes); rapport.media += 1; }
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
+          url: naam, 'shaer:availability': 'included',
+          'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
+          'shaer:sha256': hash, 'shaer:role': rol,
+        });
+      } else {
+        rapport.mediaMissing += 1;
+        rapport.gemist.push({ slug, url });
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
+          url: /^https?:/i.test(url) ? url : `${origin}${url}`,
+          'shaer:availability': 'missing',
+          'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
+          'shaer:role': rol,
+        });
+      }
+    });
+
+    const poll = parse(r.poll_json);
+    const quote = parse(r.quote_json);
+    const opties = poll && Array.isArray(poll.options) && poll.options.length >= 2
+      ? poll.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) })) : null;
+
+    const obj = {
+      '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
+      id: apId,
+      type: opties ? 'Question' : (gesplitst.titel ? 'Article' : 'Note'),
+      attributedTo: actorUri,
+      name: gesplitst.titel || undefined,
+      content: gesplitst.rest,
+      summary: r.cw || undefined,
+      sensitive: r.nsfw ? true : undefined,
+      published: r.published || undefined,
+      url: r.url || `${origin}/${encodeURIComponent(slug)}`,
+      attachment: attachments.length ? attachments : undefined,
+      ...(opties ? (poll.multiple ? { anyOf: opties } : { oneOf: opties }) : {}),
+      endTime: opties ? (poll.endTime || undefined) : undefined,
+      closed: (opties && poll.closed) ? true : undefined,
+      quoteUrl: (quote && (quote.url || quote.uri || quote.id)) || undefined,
+      'shaer:slug': slug,
+      'shaer:status': 'published',      // alles wat gefedereerd is, was gepubliceerd
+      'shaer:recoveredFrom': 'timeline-cache',
+    };
+    files.set(`posts/${postId}.json`, Buffer.from(stableJson(obj), 'utf8'));
+    files.set(`readable/${slug}.md`, Buffer.from(
+      `---\ntitle: ${JSON.stringify(gesplitst.titel || slug)}\nslug: ${JSON.stringify(slug)}\ndate: ${r.published || ''}\nrecovered: timeline-cache\n---\n${gesplitst.rest}\n`, 'utf8'));
+    rapport.posts += 1;
+    if (r.published) {
+      if (!rapport.oudste || r.published < rapport.oudste) rapport.oudste = r.published;
+      if (!rapport.nieuwste || r.published > rapport.nieuwste) rapport.nieuwste = r.published;
+    }
+  }
+
+  const bestandsHashes = {};
+  for (const pad of [...files.keys()].sort()) bestandsHashes[pad] = sha256(files.get(pad));
+  const manifest = {
+    formatVersion: FORMAT_VERSION,
+    generator: `klonkt-recovery/${opts.version || 'dev'}`,
+    exportedAt: opts.exportedAt || new Date().toISOString(),
+    origin,
+    actor: actorUri,
+    site: { slug: opts.slug || '', title: opts.title || '' },
+    counts: { posts: rapport.posts, replies: 0, media: rapport.media, mediaMissing: rapport.mediaMissing },
+    files: bestandsHashes,
+    // Zodat niemand dit later voor een gewone export aanziet: dit archief is
+    // gereconstrueerd uit andermans cache en is per definitie onvolledig.
+    'shaer:recovered': {
+      from: 'timeline-cache',
+      sources: rapport.bronnen.map((b) => path.basename(b.pad)),
+      window: { oldest: rapport.oudste, newest: rapport.nieuwste },
+      missing: ['replies by this actor', 'posts from before the source followed', 'drafts'],
+    },
+  };
+  files.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
+
+  return { files, manifest, rapport };
+}
Index: src/services/guardianship/delivery.js
===================================================================
--- src/services/guardianship/delivery.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/delivery.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -41,5 +41,5 @@
 // guardian on any instance receives it as a private mention (the ward
 // call-for-help path).
-export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) {
+export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark }) {
   const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
           getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
@@ -83,6 +83,16 @@
     return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
   }).join('');
+  // Rijk antwoord: `html` is de HTML uit de reply-editor, hier gesaneerd; `text`
+  // blijft de platte versie (het `source`-veld en de no-JS-fallback). Levert de
+  // sanitizer niets bruikbaars op, dan valt hij terug op de escaped tekst --
+  // een leeggepoetste editor mag geen leeg bericht versturen.
+  const richClean = html ? deps.sanitizeHtml(String(html)) : '';
+  const rich = richClean && deps.htmlToPlainText(richClean).trim() ? richClean : '';
   const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
-  const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
+  // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het
+  // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint.
+  const content = rich
+    ? `<p>${mention}</p>${linkUrls(linkHashtags(base, rich))}`
+    : `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
   const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
   // Attachments: same rules as deliverReply (own /media/ uploads only,
@@ -99,4 +109,11 @@
   const row = getOutboxRow(id);
   const note = buildReplyNote(base, site, row);
+  // Markering op een hulpvraag (shaer-lgo): een gewone directe note die er een
+  // shaer:-eigenschap bij draagt, net als de zwaai. Zo reist het over dezelfde
+  // bezorging, ziet de ward het als bericht ("er komt iemand"), en houden de
+  // mede-guardians er staat aan over.
+  if (helpMark && helpMark.noteUri) {
+    note[helpMark.kind === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup'] = helpMark.noteUri;
+  }
   const create = {
     '@context': AP_CONTEXT,
Index: src/services/guardianship/follows.js
===================================================================
--- src/services/guardianship/follows.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/follows.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -81,6 +81,7 @@
     _r = {
       ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews
-        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at)
-        VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
+        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json,
+         direction, target_uri, target_handle, created_at)
+        VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
       get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
       bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
@@ -91,7 +92,55 @@
 }
 
+/**
+ * De guardian-zijdige kopie van een gate-verzoek op een REMOTE ward.
+ *
+ * `direction` is niet cosmetisch (shaer-jdb). Bij een INKOMENDE is de follower
+ * iemand anders en de ward het doel. Bij een UITGAANDE is de ward zelf de
+ * follower en staat het doel in het Follow-object -- die werd hiervoor
+ * opgeslagen als "deze ward wil deze ward volgen", met het doel weggegooid.
+ */
 export function recordReview(guardianSlug, r) {
-  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null);
+  const richting = r.direction === 'outgoing' ? 'outgoing' : 'incoming';
+  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null,
+    r.followerIcon || null, r.followJson || null, richting, r.target || null, r.targetHandle || null);
   return rstmts().get.get(guardianSlug, r.id);
+}
+
+/**
+ * Een openstaande review als wachtrij-item, in dezelfde vorm die de clients al
+ * lezen (offers en outgoing-follows doen het net zo).
+ */
+export function reviewQueueItem(r, me, guardianCount) {
+  // guardianCount blijft WEG als we hem niet kennen. Bij een remote ward wordt
+  // de guardian-set op diens eigen server bijgehouden, en 0 sturen zou lezen als
+  // "dit kind heeft geen guardians" -- het tegenovergestelde van onbekend.
+  const stemmen = (() => {
+    try { return db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(r.id); }
+    catch { return []; }
+  })();
+  const uitgaand = r.direction === 'outgoing';
+  return {
+    id: r.id,
+    type: 'Follow',
+    // Bij een uitgaande is de WARD de volger; bij een inkomende is dat de vreemde.
+    actor: uitgaand ? r.ward_uri : r.follower_uri,
+    object: uitgaand ? (r.target_uri || '') : r.ward_uri,
+    'shaer:direction': uitgaand ? 'outgoing' : 'incoming',
+    'shaer:ward': r.ward_uri,
+    'shaer:target': uitgaand ? (r.target_uri || undefined) : undefined,
+    'shaer:targetHandle': uitgaand ? (r.target_handle || undefined) : undefined,
+    'shaer:follower': uitgaand ? undefined : r.follower_uri,
+    'shaer:followerHandle': uitgaand ? undefined : (r.follower_handle || undefined),
+    'shaer:quorum': 'all',
+    'shaer:approvals': stemmen.filter((x) => x.decision === 'approve').length,
+    'shaer:guardianCount': guardianCount || undefined,
+    'shaer:myVote': stemmen.some((x) => x.guardian_uri === me),
+    published: r.created_at,
+  };
+}
+
+/** De openstaande reviews van een guardian, per richting. */
+export function listReviewsByDirection(guardianSlug, direction) {
+  return listReviews(guardianSlug).filter((r) => (r.direction === 'outgoing' ? 'outgoing' : 'incoming') === direction);
 }
 export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
@@ -102,3 +151,4 @@
   recordPending, getPending, listForWard, decide, remove,
   recordReview, getReview, listReviews, removeReview,
+  listReviewsByDirection, reviewQueueItem,
 };
Index: src/services/guardianship/gated.js
===================================================================
--- src/services/guardianship/gated.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/gated.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -60,4 +60,64 @@
   'shaer:externalPlayback': 'external_playback',
 };
+/**
+ * De gates die deze Klonkt kent, met hun SOORT.
+ *
+ * Wat gated wordt is een ontwerpkeuze van de implementatie: de FEP levert het
+ * mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet de lijst.
+ * Deze catalogus is die lijst, op een plek. Een gate erbij hoort een regel data
+ * te zijn en geen nieuw stuk scherm.
+ *
+ * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag
+ * een guardian niet hoeven raden:
+ *
+ *   setting     een stand, aan of uit, terug te draaien
+ *   perRequest  geen stand maar een stroom beslissingen (5.3 volgverzoeken)
+ *   handover    draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt
+ *
+ * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat
+ * je niet mag zien, dus dat tweede is pas te bewegen als het eerste openstaat.
+ */
+export const GATE_CATALOGUE = [
+  { feature: 'shaer:externalEmbeds', kind: 'setting', reversible: true },
+  { feature: 'shaer:externalPlayback', kind: 'setting', reversible: true, needs: 'shaer:externalEmbeds' },
+  // Altijd aan voor een ward (5.3): niet te verzetten, wel te tonen. Een paneel
+  // dat alleen verstelbare dingen laat zien verzwijgt de helft van wat er geldt.
+  { feature: 'shaer:follows', kind: 'perRequest', reversible: true, fixed: true },
+];
+
+/**
+ * De gates van een ward als rijen voor het paneel. Puur, zodat de regels
+ * getoetst kunnen worden zonder database of scherm.
+ *
+ * @param settings       {feature: true|false|null} -- null is ONBEKEND, niet uit
+ * @param guardianCount  aantal guardians, of null als we het niet weten
+ * @param proposals      [{feature, value, status}] lopende voorstellen
+ * @param waiting        {feature: aantal} wat er per gate op een besluit wacht
+ */
+export function gateRows({ settings = {}, guardianCount = null, proposals = [], waiting = {} } = {}) {
+  return GATE_CATALOGUE.map((g) => {
+    const value = Object.prototype.hasOwnProperty.call(settings, g.feature) ? settings[g.feature] : null;
+    // De trap: het bovenliggende moet OPEN staan. Onbekend telt niet als dicht --
+    // bij een ward elders kennen we de stand niet, en verbergen betekende daar
+    // ooit dat een voorstel nooit geopend kon worden.
+    const blockedBy = g.needs && settings[g.needs] === false ? g.needs : null;
+    return {
+      feature: g.feature,
+      kind: g.kind,
+      reversible: !!g.reversible,
+      value,
+      // Vast staat vast: tonen mag, verzetten niet.
+      adjustable: !g.fixed && !blockedBy,
+      blockedBy: blockedBy || undefined,
+      // Zonder bekend aantal guardians GEEN drempel verzinnen. Nul of een gok
+      // leest als een feit, en dit is precies waar een guardian op afgaat.
+      threshold: (guardianCount && guardianCount > 0)
+        ? { need: thresholdFor(guardianCount), of: guardianCount } : null,
+      proposal: proposals.find((p) => p.feature === g.feature) || undefined,
+      waiting: waiting[g.feature] || undefined,
+    };
+  });
+}
+
 export function featureColumn(feature) {
   return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
@@ -239,4 +299,5 @@
 
 export default {
+  GATE_CATALOGUE, gateRows,
   tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
   parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
Index: src/services/guardianship/help.js
===================================================================
--- src/services/guardianship/help.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/services/guardianship/help.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,125 @@
+/**
+ * Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
+ *
+ * Een hulpvraag (FEP-633c 5.2.1) gaat naar ALLE guardians van een kind, die op
+ * verschillende servers zitten. Zonder gedeelde staat denken er twee dat de
+ * ander het oppakt -- en dat is precies het scenario waar de reddingsboei voor
+ * bestaat.
+ *
+ * DE FAALSTAND IS HIER NIET VEILIG, en dat maakt dit anders dan elke gate. Bij
+ * een gate is "dicht" het veilige antwoord. Hier is de faalstand "iedereen denkt
+ * dat het geregeld is", en dat is gevaarlijker dan geen markering. Daaruit volgt
+ * de regel die overal in dit bestand terugkomt: bij twijfel is een hulpvraag
+ * OPEN.
+ *
+ * Twee besluiten van Bart (7-8) zitten in de vorm:
+ *
+ *   OPGEPIKT mag stapelen en vervalt niet, maar VEROUDERT zichtbaar. Twee mensen
+ *   die tegelijk reageren op een kind is geen probleem; twee die allebei niets
+ *   doen omdat de ander het "geclaimd" had, wel. En een signaal dat vanzelf
+ *   verdwijnt laat een hulpvraag er onaangeroerd uitzien terwijl er iemand mee
+ *   bezig is.
+ *
+ *   AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
+ *   bevestiging, en leeft de vraag daarna nog, dan wordt hij OPNIEUW GESTELD --
+ *   een nieuwe hulpvraag. Er wordt niets herschreven, er wordt toegevoegd.
+ */
+
+import db from '../../config/database.js';
+
+let _s = null;
+function stmts() {
+  if (!_s) {
+    _s = {
+      ins: db.prepare(`INSERT OR IGNORE INTO ap_help_state (note_uri, guardian_uri, kind, guardian_handle)
+                       VALUES (?,?,?,?)`),
+      forNote: db.prepare('SELECT * FROM ap_help_state WHERE note_uri = ? ORDER BY created_at ASC'),
+      forNotes: db.prepare('SELECT * FROM ap_help_state WHERE note_uri IN (SELECT value FROM json_each(?))'),
+    };
+  }
+  return _s;
+}
+
+/** Leg vast dat iemand deze hulpvraag heeft opgepikt of afgesloten. */
+export function record(noteUri, guardianUri, kind, handle = null) {
+  if (!noteUri || !guardianUri) return;
+  const k = kind === 'handled' ? 'handled' : 'pickup';
+  try { stmts().ins.run(noteUri, guardianUri, k, handle); } catch { /* nooit dragend */ }
+}
+
+/**
+ * De staat van een hulpvraag, uit zijn rijen. Puur, zodat de regels te toetsen
+ * zijn zonder database of scherm.
+ *
+ * `ageMs` is de leeftijd van de OUDSTE oppik. Daar tekent het scherm mee dat een
+ * signaal oud wordt -- niets verdwijnt, maar je ziet wel dat er misschien niets
+ * meer gebeurt.
+ */
+export function helpStatus(rows, now = Date.now()) {
+  const list = rows || [];
+  const pickups = list.filter((r) => r.kind === 'pickup');
+  const done = list.find((r) => r.kind === 'handled') || null;
+  const stamp = (r) => { const t = Date.parse(r.created_at); return isNaN(t) ? null : t; };
+  const oudste = pickups.map(stamp).filter((t) => t !== null).sort((a, b) => a - b)[0];
+  return {
+    // Namen erbij: "door wie" was de hele vraag. Zonder dat is het een vinkje.
+    pickedUpBy: pickups.map((r) => ({ uri: r.guardian_uri, handle: r.guardian_handle || null, at: r.created_at })),
+    handled: done ? { uri: done.guardian_uri, handle: done.guardian_handle || null, at: done.created_at } : null,
+    // Alleen betekenisvol zolang er niets is afgesloten.
+    ageMs: (!done && oudste) ? Math.max(0, now - oudste) : null,
+    // Waar het scherm op afgaat. Bij twijfel OPEN: een lege lijst, een rij die we
+    // niet kunnen lezen, wat dan ook -- alles wat geen expliciete afsluiting is,
+    // is een hulpvraag die nog op iemand wacht.
+    open: !done,
+  };
+}
+
+/** De staat van een hulpvraag zoals die nu is opgeslagen. */
+export function statusOf(noteUri, now = Date.now()) {
+  if (!noteUri) return helpStatus([], now);
+  try { return helpStatus(stmts().forNote.all(noteUri), now); } catch { return helpStatus([], now); }
+}
+
+/** Idem voor een hele lijst in een query, zodat een paneel geen N+1 wordt. */
+export function statusFor(noteUris, now = Date.now()) {
+  const uit = new Map();
+  const lijst = [...new Set((noteUris || []).filter(Boolean))];
+  if (!lijst.length) return uit;
+  let rijen = [];
+  try { rijen = stmts().forNotes.all(JSON.stringify(lijst)); } catch { rijen = []; }
+  const perNote = new Map();
+  for (const r of rijen) {
+    if (!perNote.has(r.note_uri)) perNote.set(r.note_uri, []);
+    perNote.get(r.note_uri).push(r);
+  }
+  for (const uri of lijst) uit.set(uri, helpStatus(perNote.get(uri) || [], now));
+  return uit;
+}
+
+/**
+ * De markering als bericht. Bewust een gewone directe note met een
+ * shaer:-markering, zoals de zwaai en de afwezigheidsmelding: dan reist het over
+ * de bestaande bezorging, en de WARD leest het als wat het is -- er komt iemand.
+ */
+export function markerNote({ id, me, noteUri, kind, to }) {
+  const k = kind === 'handled' ? 'handled' : 'pickup';
+  return {
+    id, type: 'Note', attributedTo: me, to,
+    [k === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup']: noteUri,
+    content: k === 'handled'
+      ? '<p>Deze hulpvraag is afgehandeld.</p>'
+      : '<p>Ik kijk hiernaar.</p>',
+  };
+}
+
+/** Leest een binnengekomen note als markering, of null als hij er geen is. */
+export function parseMarker(object) {
+  if (!object || typeof object !== 'object') return null;
+  const pickup = object['shaer:helpPickup'];
+  const handled = object['shaer:helpHandled'];
+  if (typeof handled === 'string' && handled) return { kind: 'handled', noteUri: handled };
+  if (typeof pickup === 'string' && pickup) return { kind: 'pickup', noteUri: pickup };
+  return null;
+}
+
+export default { record, helpStatus, statusOf, statusFor, markerNote, parseMarker };
Index: src/services/guardianship/index.js
===================================================================
--- src/services/guardianship/index.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/index.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -32,2 +32,4 @@
 // §5.6 gated settings (decided by the guardians, enforced by the ward's server)
 export * as gated from './gated.js';
+// 5.2.1: wie er op een hulpvraag af is en wanneer hij is afgesloten (shaer-lgo)
+export * as help from './help.js';
Index: src/services/guardianship/queues.js
===================================================================
--- src/services/guardianship/queues.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/queues.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -6,5 +6,5 @@
  *  - offers:  pending handshake offers where I am a party (§3), with the full
  *             accept tally so the client shows the right action
- *  - follows: pending gated follows for my wards (§5.3) — Fase 2, empty for now
+ *  - follows: pending gated follows ON my wards (§5.3), Fase 2 (shaer-jdb)
  *  - wards:   my committed wards
  */
@@ -13,4 +13,5 @@
 import * as availability from './availability.js';
 import * as outgoing from './outgoing.js';
+import * as follows from './follows.js';
 import * as handshake from './handshake.js';
 
@@ -35,12 +36,57 @@
 }
 
-/** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */
-export function followsCollection(id) {
-  return collection(id, []);
+/**
+ * Gate-verzoeken OP mijn wards die op mijn antwoord wachten (Guardianship Fase 2,
+ * shaer-jdb). Dit was een lege stub: de gating zelf werkt sinds shaer-hxg, maar
+ * werd nooit aan een C2S-client doorgegeven omdat de koers toen op de PWA lag.
+ *
+ * Twee bronnen, want een guardian kan wards op andere servers hebben en (nog)
+ * op deze:
+ *   - ap_follow_reviews: de doorgestuurde kopie van een REMOTE ward
+ *   - ap_pending_follows: een ward op deze instance
+ * Zie shaer-h6u: die tweede hoort op termijn ook over de lijn te gaan.
+ */
+export function followsCollection(id, slug, me) {
+  const items = follows.listReviewsByDirection(slug, 'incoming')
+    .map((r) => follows.reviewQueueItem(r, me));
+  for (const w of relations.listWards(slug)) {
+    const wardSlug = slugOf(w.other_uri);
+    if (!wardSlug) continue;
+    for (const p of follows.listForWard(wardSlug)) {
+      items.push({
+        id: p.id, type: 'Follow', actor: p.follower_uri, object: w.other_uri,
+        'shaer:direction': 'incoming', 'shaer:ward': w.other_uri,
+        'shaer:follower': p.follower_uri, 'shaer:followerHandle': p.follower_handle || undefined,
+        'shaer:quorum': p.quorum || 'any', published: p.created_at,
+      });
+    }
+  }
+  return collection(id, items);
 }
 
-/** §5.3 outbound: this ward's own follow requests, waiting for its guardians. */
+/** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */
+function slugOf(uri) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!base || !String(uri || '').startsWith(`${base}/ap/users/`)) return null;
+  return decodeURIComponent(String(uri).slice(`${base}/ap/users/`.length).split(/[/?#]/)[0]) || null;
+}
+
+/**
+ * §5.3 uitgaand. Twee lezers, een wachtrij, en dat kan omdat §1 een ward en een
+ * guardian wederzijds uitsluit: je bent het een of het ander.
+ *
+ *   ALS WARD      wat IK wil volgen en waar mijn guardians nog over moeten
+ *   ALS GUARDIAN  wat mijn WARDS willen volgen en waar IK over moet (shaer-jdb)
+ *
+ * Dat tweede ontbrak. De wachtrij serveerde alleen listForWard(slug), en voor
+ * een guardian is dat per definitie leeg -- dus het scherm "Your wards want to
+ * follow" kon nooit iets tonen.
+ */
 export function outgoingFollowsCollection(id, slug, me) {
-  return collection(id, outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me)));
+  const items = outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me));
+  for (const r of follows.listReviewsByDirection(slug, 'outgoing')) {
+    items.push(follows.reviewQueueItem(r, me));
+  }
+  return collection(id, items);
 }
 
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/i18n.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -67,5 +67,5 @@
     'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer',
     'push.t': 'Notificaties', 'push.intro': 'Krijg een melding op dit apparaat bij nieuwe volgers, reacties en berichten, ook als de site niet open staat. Versleuteld tot in je browser; wij sturen zo min mogelijk inhoud mee.', 'push.unavailable': 'Push is op deze server niet beschikbaar (sleutel kon niet worden aangemaakt of de dependency ontbreekt).', 'push.unsupported': 'Deze browser ondersteunt geen push-notificaties.', 'push.ios_hint': 'Op iPhone/iPad werkt dit alleen als de site op je beginscherm staat: deel-knop, dan "Zet op beginscherm", en open de site daarna vanaf daar.', 'push.this_device': 'Dit apparaat:', 'push.checking': 'controleren…', 'push.state_on': 'meldingen staan aan', 'push.state_off': 'meldingen staan uit', 'push.state_denied': 'geblokkeerd in de browserinstellingen', 'push.state_unknown': 'status onbekend', 'push.state_unsupported': 'niet ondersteund', 'push.enable': 'Zet aan op dit apparaat', 'push.disable': 'Zet uit', 'push.test': 'Stuur testmelding', 'push.what': 'Waarvoor wil je een melding?', 'push.a_follow': 'Nieuwe volger', 'push.a_reply': 'Reactie of vermelding', 'push.a_like': 'Waardering (ster)', 'push.a_boost': 'Boost', 'push.a_dm': 'Privébericht', 'push.saved': 'Opgeslagen.', 'push.devices': 'Gekoppelde apparaten', 'push.device': 'Apparaat', 'push.since': 'sinds', 'push.remove': 'Verwijder', 'push.enable_failed': 'aanzetten mislukt', 'push.on_short': 'Word supporter',
-    'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.g_available': 'beschikbaar', 'msg.g_away': 'afwezig tot {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
+    'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.g_available': 'beschikbaar', 'msg.g_away': 'afwezig tot {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'msg.reply_sent': 'Antwoord verstuurd.', 'msg.reply_failed': 'Het antwoord kon niet verstuurd worden.', 'msg.reply_empty': 'Een leeg antwoord versturen kan niet.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'guardian.help_archive': '{n} afgehandeld', 'guardian.help_archive_hide': 'verbergen', 'guardian.help_pick': 'Ik kijk hiernaar', 'guardian.help_close': 'Markeer als afgehandeld', 'guardian.help_picked_by': '{who} kijkt hiernaar', 'guardian.help_handled_by': 'Afgehandeld door {who}', 'guardian.help_handled_note': 'Dit blijft staan. Leeft de vraag nog, dan stelt het kind hem opnieuw.', 'guardian.help_close_ask': 'Weet je het zeker? Dit is niet terug te draaien. Speelt het toch nog, dan vraagt het kind opnieuw om hulp.', 'guardian.help_close_yes': 'Ja, afgehandeld', 'guardian.help_just_now': 'zojuist', 'guardian.help_hours': '{n} uur geleden', 'guardian.help_days': '{n} dagen geleden', 'guardian.gate_externalEmbeds': 'Linkvoorbeelden', 'guardian.gate_externalPlayback': 'Afspelen in de app', 'guardian.gate_follows': 'Volgverzoeken', 'guardian.gate_kind_setting': 'stand', 'guardian.gate_kind_perRequest': 'per verzoek', 'guardian.gate_kind_handover': 'draagt gezag over', 'guardian.gate_unknown': 'onbekend', 'guardian.gate_threshold': '{need} van {of} guardians', 'guardian.gate_threshold_unknown': 'drempel onbekend (ander domein)', 'guardian.gate_irreversible': 'niet terug te draaien', 'guardian.gate_waiting': '{n} wacht op jullie', 'guardian.gate_blocked': 'kan pas als {what} aanstaat', 'guardian.gate_propose': 'Voorstellen', 'push.n_gate_ask_t': 'Je antwoord is nodig', 'push.n_gate_ask_b': 'Voorstel voor {who}: {wat} {stand}', 'push.n_gate_done_t': 'Besluit genomen', 'push.n_gate_done_b': '{wat} {stand} voor {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
     'apaid.t': 'Betaalde posts', 'apaid.intro': 'Koppel je eigen Patreon-campagne. Supporters ontgrendelen betaalde posts met een passkey, zonder account en zonder cookie. Wij bewaren geen namen of e-mailadressen van supporters, alleen het versleutelde token van jouw campagne.', 'apaid.saved': 'Opgeslagen.', 'apaid.nokey': 'Let op: de encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?). Zonder sleutel kunnen secrets niet veilig worden opgeslagen.', 'apaid.status': 'Status:', 'apaid.connected': 'verbonden', 'apaid.campaign': 'campagne', 'apaid.configured': 'ingesteld, nog niet verbonden (vul een token in)', 'apaid.notyet': 'nog niet ingesteld', 'apaid.redirect_h': 'Zet deze redirect-URI in je Patreon-client', 'apaid.redirect_p': 'Bij je Patreon API-client, onder Redirect URIs, moet exact deze regel staan. Klopt hij niet, dan geeft Patreon een foutmelding in plaats van je supporters terug te sturen.', 'apaid.copy': 'Kopieer', 'apaid.copied': 'Gekopieerd', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leeg laten = huidige waarde behouden.', 'apaid.campaign_id': 'Campagne-id', 'apaid.public_page': 'Openbare Patreon-pagina', 'apaid.public_help': 'De link waar bezoekers supporter kunnen worden. Getoond als "Word supporter" wanneer iemand nog niet doneert.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'De access + refresh token krijg je op je Patreon API-clientpagina. Wij versleutelen ze en verversen automatisch.', 'apaid.min_eur': 'Standaard-steunbedrag voor een betaalde post (euro)', 'apaid.save': 'Opslaan', 'apaid.disconnect': 'Koppeling verwijderen', 'apaid.disconnect_confirm': 'Patreon-koppeling verwijderen?', 'apaid.unchanged': 'blijft ongewijzigd',
     'pgate.h': 'Voor supporters', 'pgate.sub': 'Deze post is voor supporters van deze site. Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.sub_cents': 'Deze post is voor supporters van deze site (vanaf €{eur} per maand op Patreon). Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.join': 'Word supporter op Patreon', 'pgate.unlock_have': 'Al supporter? Ontgrendelen', 'pgate.unlock': 'Ontgrendelen met Patreon', 'pgate.join_short': 'Word supporter', 'pgate.confirm': 'Bevestig met je passkey…', 'pgate.failed': 'Ontgrendelen mislukt. Probeer opnieuw.', 'pgate.error': 'Er ging iets mis. Probeer opnieuw.',
@@ -1017,5 +1017,5 @@
     'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin',
     'push.t': 'Notifications', 'push.intro': 'Get a notification on this device for new followers, replies and messages, even when the site is closed. Encrypted all the way to your browser; we send as little content as possible.', 'push.unavailable': 'Push is unavailable on this server (the key could not be created or the dependency is missing).', 'push.unsupported': 'This browser does not support push notifications.', 'push.ios_hint': 'On iPhone/iPad this only works when the site is on your home screen: share button, then "Add to Home Screen", and open it from there.', 'push.this_device': 'This device:', 'push.checking': 'checking…', 'push.state_on': 'notifications are on', 'push.state_off': 'notifications are off', 'push.state_denied': 'blocked in the browser settings', 'push.state_unknown': 'status unknown', 'push.state_unsupported': 'not supported', 'push.enable': 'Turn on for this device', 'push.disable': 'Turn off', 'push.test': 'Send a test notification', 'push.what': 'What do you want to be notified about?', 'push.a_follow': 'New follower', 'push.a_reply': 'Reply or mention', 'push.a_like': 'Like (star)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private message', 'push.saved': 'Saved.', 'push.devices': 'Linked devices', 'push.device': 'Device', 'push.since': 'since', 'push.remove': 'Remove', 'push.enable_failed': 'turning on failed',
-    'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.g_available': 'available', 'msg.g_away': 'unavailable till {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
+    'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.g_available': 'available', 'msg.g_away': 'unavailable till {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'msg.reply_sent': 'Reply sent.', 'msg.reply_failed': 'The reply could not be sent.', 'msg.reply_empty': 'An empty reply cannot be sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'guardian.help_archive': '{n} handled', 'guardian.help_archive_hide': 'hide', 'guardian.help_pick': 'I am on it', 'guardian.help_close': 'Mark as handled', 'guardian.help_picked_by': '{who} is looking into this', 'guardian.help_handled_by': 'Handled by {who}', 'guardian.help_handled_note': 'This stays. If it is still going on, the child asks again.', 'guardian.help_close_ask': 'Are you sure? This cannot be undone. If it is still going on, the child will ask for help again.', 'guardian.help_close_yes': 'Yes, handled', 'guardian.help_just_now': 'just now', 'guardian.help_hours': '{n}h ago', 'guardian.help_days': '{n}d ago', 'guardian.gate_externalEmbeds': 'Link previews', 'guardian.gate_externalPlayback': 'In-app playback', 'guardian.gate_follows': 'Follow requests', 'guardian.gate_kind_setting': 'setting', 'guardian.gate_kind_perRequest': 'per request', 'guardian.gate_kind_handover': 'hands over authority', 'guardian.gate_unknown': 'unknown', 'guardian.gate_threshold': '{need} of {of} guardians', 'guardian.gate_threshold_unknown': 'threshold unknown (other domain)', 'guardian.gate_irreversible': 'cannot be undone', 'guardian.gate_waiting': '{n} waiting on you', 'guardian.gate_blocked': 'needs {what} on first', 'guardian.gate_propose': 'Propose', 'push.n_gate_ask_t': 'Your answer is needed', 'push.n_gate_ask_b': 'Proposal for {who}: {wat} {stand}', 'push.n_gate_done_t': 'Decision reached', 'push.n_gate_done_b': '{wat} {stand} for {who}: {uitkomst}', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
     'apaid.t': 'Paid posts', 'apaid.intro': 'Connect your own Patreon campaign. Supporters unlock paid posts with a passkey, no account and no cookie. We store no supporter names or email addresses, only the encrypted token of your campaign.', 'apaid.saved': 'Saved.', 'apaid.nokey': 'Note: the encryption key could not be created or read (write permissions on the storage directory?). Without a key, secrets cannot be stored safely.', 'apaid.status': 'Status:', 'apaid.connected': 'connected', 'apaid.campaign': 'campaign', 'apaid.configured': 'configured, not connected yet (enter a token)', 'apaid.notyet': 'not configured yet', 'apaid.redirect_h': 'Put this redirect URI in your Patreon client', 'apaid.redirect_p': 'In your Patreon API client, under Redirect URIs, exactly this line must be present. If it does not match, Patreon shows an error instead of sending your supporters back.', 'apaid.copy': 'Copy', 'apaid.copied': 'Copied', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leave empty = keep the current value.', 'apaid.campaign_id': 'Campaign id', 'apaid.public_page': 'Public Patreon page', 'apaid.public_help': 'The link where visitors can become a supporter. Shown as "Become a supporter" when someone does not pledge yet.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'You get the access + refresh token on your Patreon API client page. We encrypt them and refresh automatically.', 'apaid.min_eur': 'Default support amount for a paid post (euro)', 'apaid.save': 'Save', 'apaid.disconnect': 'Remove connection', 'apaid.disconnect_confirm': 'Remove the Patreon connection?', 'apaid.unchanged': 'stays unchanged',
     'pgate.h': 'For supporters', 'pgate.sub': 'This post is for supporters of this site. Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.sub_cents': 'This post is for supporters of this site (from €{eur} per month on Patreon). Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.join': 'Become a supporter on Patreon', 'pgate.unlock_have': 'Already a supporter? Unlock', 'pgate.unlock': 'Unlock with Patreon', 'pgate.join_short': 'Become a supporter', 'pgate.confirm': 'Confirm with your passkey…', 'pgate.failed': 'Unlocking failed. Try again.', 'pgate.error': 'Something went wrong. Try again.',
@@ -1961,5 +1961,5 @@
     'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung',
     'push.t': 'Benachrichtigungen', 'push.intro': 'Erhalte auf diesem Gerät eine Meldung bei neuen Followern, Antworten und Nachrichten, auch wenn die Seite geschlossen ist. Verschlüsselt bis in deinen Browser; wir senden so wenig Inhalt wie möglich mit.', 'push.unavailable': 'Push ist auf diesem Server nicht verfügbar (Schlüssel konnte nicht erstellt werden oder die Abhängigkeit fehlt).', 'push.unsupported': 'Dieser Browser unterstützt keine Push-Benachrichtigungen.', 'push.ios_hint': 'Auf iPhone/iPad funktioniert das nur, wenn die Seite auf deinem Home-Bildschirm liegt: Teilen-Knopf, dann "Zum Home-Bildschirm", und öffne sie danach von dort.', 'push.this_device': 'Dieses Gerät:', 'push.checking': 'prüfen…', 'push.state_on': 'Benachrichtigungen sind an', 'push.state_off': 'Benachrichtigungen sind aus', 'push.state_denied': 'in den Browser-Einstellungen blockiert', 'push.state_unknown': 'Status unbekannt', 'push.state_unsupported': 'nicht unterstützt', 'push.enable': 'Auf diesem Gerät einschalten', 'push.disable': 'Ausschalten', 'push.test': 'Testmeldung senden', 'push.what': 'Wofür möchtest du eine Meldung?', 'push.a_follow': 'Neuer Follower', 'push.a_reply': 'Antwort oder Erwähnung', 'push.a_like': 'Like (Stern)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private Nachricht', 'push.saved': 'Gespeichert.', 'push.devices': 'Verbundene Geräte', 'push.device': 'Gerät', 'push.since': 'seit', 'push.remove': 'Entfernen', 'push.enable_failed': 'Einschalten fehlgeschlagen',
-    'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.g_available': 'verfuegbar', 'msg.g_away': 'abwesend bis {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
+    'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.g_available': 'verfuegbar', 'msg.g_away': 'abwesend bis {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'msg.reply_sent': 'Antwort gesendet.', 'msg.reply_failed': 'Die Antwort konnte nicht gesendet werden.', 'msg.reply_empty': 'Eine leere Antwort kann nicht gesendet werden.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'guardian.help_archive': '{n} erledigt', 'guardian.help_archive_hide': 'ausblenden', 'guardian.help_pick': 'Ich kuemmere mich', 'guardian.help_close': 'Als erledigt markieren', 'guardian.help_picked_by': '{who} kuemmert sich darum', 'guardian.help_handled_by': 'Erledigt von {who}', 'guardian.help_handled_note': 'Das bleibt stehen. Geht es noch weiter, fragt das Kind erneut.', 'guardian.help_close_ask': 'Sicher? Das laesst sich nicht rueckgaengig machen. Geht es noch weiter, bittet das Kind erneut um Hilfe.', 'guardian.help_close_yes': 'Ja, erledigt', 'guardian.help_just_now': 'gerade eben', 'guardian.help_hours': 'vor {n} Std.', 'guardian.help_days': 'vor {n} Tagen', 'guardian.gate_externalEmbeds': 'Link-Vorschauen', 'guardian.gate_externalPlayback': 'Abspielen in der App', 'guardian.gate_follows': 'Folgeanfragen', 'guardian.gate_kind_setting': 'Einstellung', 'guardian.gate_kind_perRequest': 'pro Anfrage', 'guardian.gate_kind_handover': 'gibt Zustaendigkeit ab', 'guardian.gate_unknown': 'unbekannt', 'guardian.gate_threshold': '{need} von {of} Guardians', 'guardian.gate_threshold_unknown': 'Schwelle unbekannt (andere Domain)', 'guardian.gate_irreversible': 'nicht rueckgaengig', 'guardian.gate_waiting': '{n} wartet auf euch', 'guardian.gate_blocked': 'geht erst, wenn {what} an ist', 'guardian.gate_propose': 'Vorschlagen', 'push.n_gate_ask_t': 'Deine Antwort wird gebraucht', 'push.n_gate_ask_b': 'Vorschlag fuer {who}: {wat} {stand}', 'push.n_gate_done_t': 'Entscheidung gefallen', 'push.n_gate_done_b': '{wat} {stand} fuer {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
     'apaid.t': 'Bezahlte Beiträge', 'apaid.intro': 'Verbinde deine eigene Patreon-Kampagne. Unterstützer entsperren bezahlte Beiträge mit einem Passkey, ohne Konto und ohne Cookie. Wir speichern keine Namen oder E-Mail-Adressen von Unterstützern, nur das verschlüsselte Token deiner Kampagne.', 'apaid.saved': 'Gespeichert.', 'apaid.nokey': 'Achtung: der Verschlüsselungsschlüssel konnte nicht erstellt oder gelesen werden (Schreibrechte auf dem Speicherordner?). Ohne Schlüssel können Secrets nicht sicher gespeichert werden.', 'apaid.status': 'Status:', 'apaid.connected': 'verbunden', 'apaid.campaign': 'Kampagne', 'apaid.configured': 'eingerichtet, noch nicht verbunden (Token eintragen)', 'apaid.notyet': 'noch nicht eingerichtet', 'apaid.redirect_h': 'Trage diese Redirect-URI in deinen Patreon-Client ein', 'apaid.redirect_p': 'In deinem Patreon-API-Client muss unter Redirect URIs genau diese Zeile stehen. Stimmt sie nicht, zeigt Patreon eine Fehlermeldung statt deine Unterstützer zurückzuschicken.', 'apaid.copy': 'Kopieren', 'apaid.copied': 'Kopiert', 'apaid.client_id': 'Patreon Client-ID', 'apaid.client_secret': 'Patreon Client-Secret', 'apaid.keep': 'Leer lassen = aktuellen Wert behalten.', 'apaid.campaign_id': 'Kampagnen-ID', 'apaid.public_page': 'Öffentliche Patreon-Seite', 'apaid.public_help': 'Der Link, unter dem Besucher Unterstützer werden können. Wird als "Unterstützer werden" gezeigt, wenn jemand noch nicht spendet.', 'apaid.access': 'Creator Access-Token', 'apaid.refresh': 'Creator Refresh-Token', 'apaid.token_help': 'Access- und Refresh-Token bekommst du auf deiner Patreon-API-Client-Seite. Wir verschlüsseln sie und erneuern automatisch.', 'apaid.min_eur': 'Standard-Unterstützungsbetrag für einen bezahlten Beitrag (Euro)', 'apaid.save': 'Speichern', 'apaid.disconnect': 'Verbindung entfernen', 'apaid.disconnect_confirm': 'Patreon-Verbindung entfernen?', 'apaid.unchanged': 'bleibt unverändert',
     'pgate.h': 'Für Unterstützer', 'pgate.sub': 'Dieser Beitrag ist für Unterstützer dieser Seite. Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.sub_cents': 'Dieser Beitrag ist für Unterstützer dieser Seite (ab €{eur} pro Monat auf Patreon). Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.join': 'Unterstützer werden auf Patreon', 'pgate.unlock_have': 'Schon Unterstützer? Entsperren', 'pgate.unlock': 'Mit Patreon entsperren', 'pgate.join_short': 'Unterstützer werden', 'pgate.confirm': 'Bestätige mit deinem Passkey…', 'pgate.failed': 'Entsperren fehlgeschlagen. Versuch es erneut.', 'pgate.error': 'Etwas ist schiefgegangen. Versuch es erneut.',
Index: src/views/pages/admin-audio.ejs
===================================================================
--- src/views/pages/admin-audio.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-audio.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -573,436 +573,4 @@
 <%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
 
-<script>
-(function() {
-
-  // ── Live filename display for custom file inputs ──────────────
-  document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
-    const nameEl = input.parentElement.querySelector('.ax-file-name');
-    if (!nameEl) return;
-    input.addEventListener('change', () => {
-      if (input.files && input.files[0]) {
-        nameEl.textContent = input.files[0].name;
-        nameEl.removeAttribute('data-empty');
-      } else {
-        nameEl.textContent = '<%= t('aaud.no_file') %>';
-        nameEl.setAttribute('data-empty', '');
-      }
-    });
-  });
-
-  // ── Inline track preview (routed through the global mini-player) ──
-  // Each .ax-track-play button is a thin wrapper around the global
-  // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
-  // .is-playing) is synced from the player's own audio element so it
-  // stays accurate even when the user uses the mini-player's controls.
-  (function setupTrackPreview() {
-    const buttons = document.querySelectorAll('.ax-track-play');
-    if (!buttons.length) return;
-
-    function setIcon(btn, playing) {
-      const icon = btn.querySelector('.ax-track-play-icon');
-      if (icon) icon.textContent = playing ? '⏸' : '▶';
-      btn.classList.toggle('is-playing', playing);
-      btn.setAttribute('aria-label', playing ? '<%= t('aaud.pause') %>' : '<%= t('aaud.play') %>');
-    }
-
-    // Resync ALL preview buttons against the current audio element state.
-    // Called on every play/pause/ended event so admins always see the right
-    // icon — including the case where they hit pause on the mini-player
-    // bar instead of the row's button.
-    function resyncAll() {
-      const audio = document.getElementById('audio-element');
-      const player = window.pcmsAudioPlayer;
-      const playing = audio && !audio.paused && !audio.ended;
-      // audio.src is now a blob: URL (Spotify-style playback), so compare
-      // against the player's logical track URL, not the element src.
-      const cur = player && player.currentTrack();
-      const curUrl = cur ? cur.url : '';
-      buttons.forEach(b => {
-        const isThisOne = playing && curUrl === b.dataset.streamUrl;
-        setIcon(b, isThisOne);
-      });
-    }
-
-    buttons.forEach(btn => {
-      btn.addEventListener('click', e => {
-        e.preventDefault();
-        const player = window.pcmsAudioPlayer;
-        if (!player) {
-          console.warn('[admin-audio] miniplayer not available');
-          return;
-        }
-        const url = btn.dataset.streamUrl;
-        if (!url) return;
-
-        // Build track metadata from the row's DOM so the mini-player shows
-        // useful info (title/artist/album/cover) without an extra API call.
-        const row = btn.closest('.ax-track');
-        const titleEl  = row && row.querySelector('[data-cell="title"]');
-        const artistEl = row && row.querySelector('[data-cell="artist"]');
-        const albumEl  = row && row.querySelector('[data-cell="album"]');
-        const coverImg = row && row.querySelector('img[data-cover-thumb]');
-        const track = {
-          url,
-          title:  titleEl  ? titleEl.textContent.trim()  : 'Track',
-          artist: artistEl ? artistEl.textContent.trim() : '',
-          album:  albumEl  ? albumEl.textContent.trim()  : '',
-          cover:  coverImg ? coverImg.src                : '',
-        };
-
-        // If this exact track is already current, toggle pause/play instead
-        // of restarting from zero. Compare logical URLs (audio.src is a blob:).
-        const cur = player.currentTrack();
-        if (cur && cur.url === url) {
-          if (player.isPlaying()) player.pause();
-          else                    player.play();
-          return;
-        }
-
-        player.setQueue([track], 0);
-      });
-    });
-
-    // Hook the global audio element's events to keep the row buttons synced.
-    // We attach lazily after the player has built its DOM. The script in
-    // shell.ejs runs at page-load so #audio-element exists by the time
-    // this IIFE fires (script tag is below the body content).
-    const audio = document.getElementById('audio-element');
-    if (audio) {
-      ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
-        audio.addEventListener(ev, resyncAll);
-      });
-      // Initial state on page load (e.g. user navigated back while a track
-      // was already playing — buttons should reflect that).
-      resyncAll();
-    }
-  })();
-
-
-  // ── Bulk upload (drag-drop + sequential transcoding) ─────────
-  // Files dropped or picked are queued (not uploaded immediately) so the
-  // user can review the list, set shared metadata, then hit "Start upload".
-  // The loop POSTs one file at a time to /admin/audio/upload with
-  // Accept: application/json so the server returns structured per-file
-  // results instead of redirecting.
-  const dropzone   = document.getElementById('audio-dropzone');
-  const fileInput  = document.getElementById('audio-files');
-  const queueEl    = document.getElementById('upload-queue');
-  const actionsEl  = document.getElementById('upload-actions');
-  const startBtn   = document.getElementById('start-upload-btn');
-  const clearBtn   = document.getElementById('clear-queue-btn');
-  const artistInput= document.getElementById('batch-artist');
-  const albumInput = document.getElementById('batch-album');
-  const coverInput = document.getElementById('batch-cover');
-
-  /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
-  const queue = [];
-
-  function fmtBytes(n) {
-    if (n < 1024) return n + ' B';
-    if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
-    return (n / 1024 / 1024).toFixed(1) + ' MB';
-  }
-
-  function setItemStatus(item, status, label) {
-    const labels = {
-      queued:      '<%= t('aaud.st_queued') %>',
-      uploading:   '<%= t('aaud.st_uploading') %>',
-      transcoding: '<%= t('aaud.st_transcoding') %>',
-      done:        '✓ <%= t('aaud.st_done') %>',
-      error:       '✗ <%= t('aaud.st_error') %>',
-    };
-    item.status = status;
-    const badge = item.el.querySelector('.ax-queue-status');
-    badge.className = 'ax-queue-status ax-queue-status--' + status;
-    badge.textContent = label || labels[status] || status;
-    // Restyle the row
-    item.el.classList.remove(
-      'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
-    );
-    if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
-    else if (status === 'done')  item.el.classList.add('ax-queue-item--done');
-    else if (status === 'error') item.el.classList.add('ax-queue-item--error');
-  }
-
-  function addFiles(files) {
-    let added = 0;
-    for (const file of files) {
-      if (!file.type.startsWith('audio/') &&
-          !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
-        // Silently skip non-audio drops; keeps the UX uncluttered.
-        continue;
-      }
-      const li = document.createElement('li');
-      li.className = 'ax-queue-item';
-      li.innerHTML =
-        '<div class="ax-queue-name"></div>' +
-        '<span class="ax-queue-status ax-queue-status--queued"><%= t('aaud.st_queued') %></span>';
-      // Use textContent to avoid HTML-injection if a filename contains markup.
-      li.querySelector('.ax-queue-name').textContent = file.name;
-      // Append size hint inline
-      const size = document.createElement('span');
-      size.className = 'ax-queue-size';
-      size.textContent = fmtBytes(file.size);
-      li.querySelector('.ax-queue-name').appendChild(size);
-      queueEl.appendChild(li);
-      queue.push({ file, el: li, status: 'queued' });
-      added++;
-    }
-    if (added) {
-      queueEl.hidden = false;
-      actionsEl.hidden = false;
-    }
-  }
-
-  // ── Drop-zone events ─────────────────────────────────────────
-  // Page-level guard: a dropped file outside the zone would otherwise
-  // make the browser navigate to it (e.g. opening the audio inline), which
-  // discards typed metadata. We swallow drops anywhere unless the dropzone
-  // explicitly handles them.
-  ['dragover', 'drop'].forEach(ev => {
-    window.addEventListener(ev, e => {
-      // Allow drops INSIDE the dropzone — its own listener handles those.
-      if (dropzone.contains(e.target)) return;
-      e.preventDefault();
-    });
-  });
-
-  ['dragenter', 'dragover'].forEach(ev => {
-    dropzone.addEventListener(ev, e => {
-      e.preventDefault();
-      dropzone.classList.add('is-dragover');
-    });
-  });
-  ['dragleave', 'drop'].forEach(ev => {
-    dropzone.addEventListener(ev, e => {
-      e.preventDefault();
-      dropzone.classList.remove('is-dragover');
-    });
-  });
-  dropzone.addEventListener('drop', e => {
-    if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
-  });
-  fileInput.addEventListener('change', () => {
-    addFiles(fileInput.files);
-    // Reset so the same file can be picked again later if user wants
-    fileInput.value = '';
-  });
-
-  // ── Clear queue button ───────────────────────────────────────
-  clearBtn.addEventListener('click', () => {
-    // Only remove items that aren't currently uploading (anything queued
-    // or already finished). An in-progress upload finishes, then its row
-    // would also disappear once we re-render — but we keep it simple and
-    // just refuse to clear during an active run.
-    if (startBtn.disabled) return;
-    queue.length = 0;
-    queueEl.innerHTML = '';
-    queueEl.hidden = true;
-    actionsEl.hidden = true;
-  });
-
-  // ── Sequential upload loop ───────────────────────────────────
-  startBtn.addEventListener('click', async () => {
-    if (startBtn.disabled) return;
-    startBtn.disabled = true;
-    clearBtn.disabled = true;
-    dropzone.style.pointerEvents = 'none';
-    dropzone.style.opacity = '0.5';
-
-    const sharedArtist = artistInput.value.trim();
-    const sharedAlbum  = albumInput.value.trim();
-    const sharedCover  = coverInput.files && coverInput.files[0];
-
-    // Process queued items one at a time. We iterate via index so that
-    // if more files get dropped during the run they ALSO get processed
-    // (queue.push above mutates the same array we're iterating).
-    for (let i = 0; i < queue.length; i++) {
-      const item = queue[i];
-      if (item.status !== 'queued') continue;
-      try {
-        await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
-      } catch (err) {
-        console.error('upload failed for', item.file.name, err);
-        setItemStatus(item, 'error', '✗ ' + (err.message || '<%= t('aaud.failed') %>'));
-      }
-    }
-
-    startBtn.disabled = false;
-    clearBtn.disabled = false;
-    dropzone.style.pointerEvents = '';
-    dropzone.style.opacity = '';
-
-    // Reload the page so the new tracks appear in the list below.
-    // Could also fetch them and inject, but a full reload is simpler and
-    // ensures position indexes / album-grouping are correct.
-    const anyDone = queue.some(q => q.status === 'done');
-    if (anyDone) {
-      setTimeout(() => location.reload(), 700);
-    }
-  });
-
-  async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
-    setItemStatus(item, 'uploading');
-
-    const fd = new FormData();
-    fd.append('audio', item.file);
-    if (sharedArtist) fd.append('artist', sharedArtist);
-    if (sharedAlbum)  fd.append('album',  sharedAlbum);
-    if (sharedCover)  fd.append('cover',  sharedCover);
-    // Title is intentionally omitted — server uses filename fallback.
-
-    // We can't reliably distinguish "still uploading bytes" from
-    // "uploading done, ffmpeg running" without progress events, but the
-    // status flips to "Converteren…" once the request is past upload phase.
-    // We approximate this by waiting until the response arrives — by then
-    // both phases are complete on the server side. For a smoother feel we
-    // briefly show "transcoding" near the end of the request lifecycle.
-    const transcodeHint = setTimeout(() => {
-      if (item.status === 'uploading') setItemStatus(item, 'transcoding');
-    }, 1500);
-
-    try {
-      const res = await fetch('/admin/audio/upload', {
-        method: 'POST',
-        headers: { 'Accept': 'application/json' },
-        body: fd,
-        credentials: 'same-origin',
-      });
-      clearTimeout(transcodeHint);
-
-      // Server responds with JSON for our Accept header. If it didn't
-      // (e.g. session expired and got an HTML login page), surface that.
-      let data;
-      try { data = await res.json(); }
-      catch (_) { throw new Error('<%= t('aaud.err_unexpected') %> (' + res.status + ')'); }
-
-      if (!res.ok || !data.ok) {
-        throw new Error(data.error || ('HTTP ' + res.status));
-      }
-
-      setItemStatus(item, 'done', '✓ ' + (data.title || '<%= t('aaud.st_done') %>'));
-    } catch (err) {
-      clearTimeout(transcodeHint);
-      throw err;
-    }
-  }
-
-  // ── Click-to-copy embed codes ─────────────────────────────────
-  document.querySelectorAll('[data-copy]').forEach(el => {
-    el.addEventListener('click', async () => {
-      const text = el.dataset.copy;
-      try {
-        await navigator.clipboard.writeText(text);
-        el.classList.add('is-copied');
-        const original = el.textContent;
-        el.textContent = '✓ <%= t('aaud.copied') %>';
-        setTimeout(() => {
-          el.classList.remove('is-copied');
-          el.textContent = original;
-        }, 1200);
-      } catch (_) { /* fall through — selection still works */ }
-    });
-  });
-
-  // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
-  const addLinkBtn = document.getElementById('add-link-track-btn');
-  if (addLinkBtn) {
-    addLinkBtn.addEventListener('click', async () => {
-      addLinkBtn.disabled = true;
-      try {
-        const r = await fetch('/admin/audio/create-link', {
-          method: 'POST', credentials: 'same-origin',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ title: '<%= t('aaud.new_track') %>' }),
-        });
-        const j = await r.json();
-        if (!r.ok || !j.ok) throw new Error(j.error || '<%= t('aaud.create_failed') %>');
-        if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
-        window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
-      } catch (err) {
-        alert('<%= t('aaud.create_failed') %>: ' + err.message);
-      } finally {
-        addLinkBtn.disabled = false;
-      }
-    });
-  }
-
-  // ── Wire all "Edit" buttons to the track-editor modal ─────────
-  // After save we patch the row in-place rather than reloading,
-  // so the user keeps their scroll position on long lists.
-  document.querySelectorAll('[data-track-edit]').forEach(btn => {
-    btn.addEventListener('click', () => {
-      if (typeof window.openTrackEditor !== 'function') {
-        alert('<%= t('aaud.editor_not_loaded') %>');
-        return;
-      }
-      const id = btn.dataset.id;
-      window.openTrackEditor({
-        id,
-        onSaved: (track) => {
-          const row = document.querySelector('li[data-track-id="' + id + '"]');
-          if (!row) return;
-          // Update visible cells
-          const titleEl  = row.querySelector('[data-cell="title"]');
-          const artistEl = row.querySelector('[data-cell="artist"]');
-          const albumEl  = row.querySelector('[data-cell="album"]');
-          if (titleEl)  titleEl.textContent  = track.title  || '<%= t('aaud.untitled') %>';
-          if (artistEl) artistEl.textContent = track.artist || '—';
-          if (albumEl)  {
-            albumEl.textContent = track.album || '';
-            if (track.album) albumEl.removeAttribute('hidden');
-            else albumEl.setAttribute('hidden', '');
-          }
-          // Update cover thumb (replace element if type changed)
-          const oldThumb = row.querySelector('[data-cover-thumb]');
-          if (oldThumb) {
-            const parent = oldThumb.parentElement;
-            if (track.cover_url) {
-              const img = document.createElement('img');
-              img.className = 'ax-track-cover';
-              img.src = track.cover_url;
-              img.alt = '';
-              img.dataset.coverThumb = '';
-              parent.replaceChild(img, oldThumb);
-            } else {
-              const sp = document.createElement('span');
-              sp.className = 'ax-track-cover ax-track-cover-empty';
-              sp.textContent = '♫';
-              sp.dataset.coverThumb = '';
-              parent.replaceChild(sp, oldThumb);
-            }
-          }
-        },
-      });
-    });
-  });
-
-  // Download-voor-email toggle — AJAX (geen pagina-reload meer).
-  document.querySelectorAll('[data-track-dl]').forEach(btn => {
-    btn.addEventListener('click', async () => {
-      if (btn.disabled) return;
-      const want = btn.dataset.on === '1' ? 0 : 1;
-      btn.disabled = true;
-      try {
-        const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ downloadable: !!want }),
-        });
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        btn.dataset.on = String(want);
-        btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
-        btn.style.opacity = want ? '1' : '.5';
-        btn.title = want
-          ? '<%= t('aaud.dl_on') %>'
-          : '<%= t('aaud.dl_off') %>';
-      } catch (e) {
-        alert('<%= t('aaud.change_failed') %>: ' + (e.message || e));
-      } finally {
-        btn.disabled = false;
-      }
-    });
-  });
-})();
-</script>
+<%# Het script staat in assets/js/mod/admin-audio.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
+<%- include('../partials/page-data', { pageData: { change_failed: t('aaud.change_failed'), copied: t('aaud.copied'), create_failed: t('aaud.create_failed'), dl_off: t('aaud.dl_off'), dl_on: t('aaud.dl_on'), editor_not_loaded: t('aaud.editor_not_loaded'), err_unexpected: t('aaud.err_unexpected'), failed: t('aaud.failed'), new_track: t('aaud.new_track'), no_file: t('aaud.no_file'), pause: t('aaud.pause'), play: t('aaud.play'), st_done: t('aaud.st_done'), st_error: t('aaud.st_error'), st_queued: t('aaud.st_queued'), st_transcoding: t('aaud.st_transcoding'), st_uploading: t('aaud.st_uploading'), untitled: t('aaud.untitled') } }) %>
Index: src/views/pages/admin-epk.ejs
===================================================================
--- src/views/pages/admin-epk.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-epk.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -90,16 +90,3 @@
   .admin-page .epk-pick-item input:disabled ~ .epk-pick-title { opacity: 0.45; }
 </style>
-<script>
-(function () {
-  var box = document.querySelector('.epk-track-pick');
-  if (!box) return;
-  var max = parseInt(box.dataset.max, 10) || 5;
-  function sync() {
-    var checks = box.querySelectorAll('input[type=checkbox]');
-    var n = box.querySelectorAll('input[type=checkbox]:checked').length;
-    checks.forEach(function (c) { c.disabled = (!c.checked && n >= max); });
-  }
-  box.addEventListener('change', sync);
-  sync();
-})();
-</script>
+<%# Het script van deze pagina staat in assets/js/mod/admin-epk.js (shaer-bqr). %>
Index: src/views/pages/admin-help.ejs
===================================================================
--- src/views/pages/admin-help.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-help.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -145,25 +145,3 @@
   .hb-empty { color: var(--ink-muted, var(--ink-soft)); padding: 1rem; text-align: center; }
 </style>
-<script>
-(function () {
-  var q = document.getElementById('hb-q');
-  var items = [].slice.call(document.querySelectorAll('.hb-item'));
-  var empty = document.getElementById('hb-empty');
-  var count = document.getElementById('hb-count');
-  if (!q) return;
-  // Bewaar de originele tekst per item (voor highlight-reset).
-  items.forEach(function (it) { it._txt = it.textContent.toLowerCase(); });
-  function run() {
-    var term = q.value.trim().toLowerCase();
-    var shown = 0;
-    items.forEach(function (it) {
-      var hit = !term || it._txt.indexOf(term) >= 0;
-      it.classList.toggle('hb-hidden', !hit);
-      if (hit) shown++;
-    });
-    empty.hidden = shown !== 0;
-    count.textContent = term ? (shown + ' onderwerp' + (shown === 1 ? '' : 'en') + ' gevonden') : '';
-  }
-  q.addEventListener('input', run);
-})();
-</script>
+<%# Het script van deze pagina staat in assets/js/mod/admin-help.js (shaer-bqr). %>
Index: src/views/pages/admin-media.ejs
===================================================================
--- src/views/pages/admin-media.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-media.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -41,34 +41,4 @@
 </div>
 
-<script>
-(function () {
-  if (window.__mediaWired) return; window.__mediaWired = true;
-  var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') }) %>;
-  document.addEventListener('click', function (e) {
-    var c = e.target.closest('[data-copy]');
-    if (c) {
-      var u = location.origin + c.getAttribute('data-copy');
-      var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); };
-      if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); });
-      else window.prompt('URL', u);
-      return;
-    }
-    var d = e.target.closest('[data-del]');
-    if (d) {
-      if (!window.confirm(T.delC)) return;
-      fetch('/admin/media/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) })
-        .then(function (r) { return r.json(); })
-        .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } else window.alert((j && j.error) || 'Error'); })
-        .catch(function () { window.alert('Error'); });
-      return;
-    }
-    if (e.target.closest('#media-cleanup')) {
-      if (!window.confirm(T.cleanC)) return;
-      fetch('/admin/media/cleanup', { method: 'POST', credentials: 'same-origin' })
-        .then(function (r) { return r.json(); })
-        .then(function (j) { location.href = '/admin/media?success=' + encodeURIComponent(((j && j.removed) || 0) + ' file(s) removed'); })
-        .catch(function () { window.alert('Error'); });
-    }
-  });
-})();
-</script>
+<%# Het script staat in assets/js/mod/admin-media.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
+<%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') } }) %>
Index: src/views/pages/admin-paid.ejs
===================================================================
--- src/views/pages/admin-paid.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-paid.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -72,16 +72,3 @@
 </div>
 
-<script>
-(function () {
-  var field = document.getElementById('pd-redirect');
-  var btn = document.getElementById('pd-copy');
-  if (!field || !btn) return;
-  field.addEventListener('focus', function () { field.select(); });
-  btn.addEventListener('click', function () {
-    field.select();
-    var done = function () { var t = btn.textContent; btn.textContent = document.getElementById('pd-copy').getAttribute('data-copied'); setTimeout(function () { btn.textContent = t; }, 1400); };
-    if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(field.value).then(done, function () { try { document.execCommand('copy'); done(); } catch (e) {} }); }
-    else { try { document.execCommand('copy'); done(); } catch (e) {} }
-  });
-})();
-</script>
+<%# Het script van deze pagina staat in assets/js/mod/admin-paid.js (shaer-bqr). %>
Index: src/views/pages/admin-playlists.ejs
===================================================================
--- src/views/pages/admin-playlists.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-playlists.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -222,71 +222,4 @@
 </style>
 
-<script>
-(function() {
-  const csrf = '<%= _csrf %>';
-
-  document.getElementById('pl-new-btn')?.addEventListener('click', () => {
-    if (typeof window.openPlaylistEditor === 'function') {
-      window.openPlaylistEditor({ mode: 'create', onSaved: () => location.reload() });
-    }
-  });
-
-  // Click-to-copy on shortcodes
-  document.querySelectorAll('[data-copy]').forEach(el => {
-    el.addEventListener('click', async () => {
-      try {
-        await navigator.clipboard.writeText(el.dataset.copy);
-        el.classList.add('is-copied');
-        const original = el.textContent;
-        el.textContent = '✓ <%= t('apl.copied') %>';
-        setTimeout(() => { el.classList.remove('is-copied'); el.textContent = original; }, 1200);
-      } catch (_) { /* fall back to selection */ }
-    });
-  });
-
-  document.querySelectorAll('[data-pl-edit]').forEach(btn => {
-    btn.addEventListener('click', () => {
-      if (typeof window.openPlaylistEditor === 'function') {
-        window.openPlaylistEditor({ mode: 'edit', id: btn.dataset.id, onSaved: () => location.reload() });
-      }
-    });
-  });
-
-  document.querySelectorAll('[data-pl-delete]').forEach(btn => {
-    btn.addEventListener('click', async () => {
-      const id = btn.dataset.id;
-      const title = btn.dataset.title || id;
-      if (!confirm('<%= t('apl.delete_confirm') %>'.replace('{title}', title))) return;
-      try {
-        const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
-          method: 'POST',
-          headers: { 'X-CSRF-Token': csrf },
-          credentials: 'same-origin',
-        });
-        const j = await r.json();
-        if (j.ok) location.reload();
-        else alert('<%= t('apl.delete_failed') %>: ' + (j.error || ''));
-      } catch (err) {
-        alert('<%= t('apl.delete_failed') %>: ' + err.message);
-      }
-    });
-  });
-
-  // P52 — deep-link from playlist embed (?edit=<id>) auto-opens the editor.
-  // openPlaylistEditor is defined synchronously by the included partial, so
-  // it's available by the time this IIFE runs.
-  (function deepLinkEdit() {
-    const params = new URLSearchParams(location.search);
-    const editId = params.get('edit');
-    if (!editId) return;
-    if (typeof window.openPlaylistEditor !== 'function') return;
-    // Strip the query param immediately so reload after save doesn't re-open.
-    history.replaceState({}, '', location.pathname);
-    window.openPlaylistEditor({
-      mode: 'edit',
-      id: editId,
-      onSaved: () => location.reload(),
-    });
-  })();
-})();
-</script>
+<%# Het script staat in assets/js/mod/admin-playlists.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
+<%- include('../partials/page-data', { pageData: { csrf: _csrf, copied: t('apl.copied'), delConfirm: t('apl.delete_confirm'), delFailed: t('apl.delete_failed') } }) %>
Index: src/views/pages/admin-push.ejs
===================================================================
--- src/views/pages/admin-push.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-push.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -58,106 +58,4 @@
   i18n: { on: t('push.state_on'), off: t('push.state_off'), denied: t('push.state_denied'), unknown: t('push.state_unknown'), unsupported: t('push.state_unsupported'), failed: t('push.enable_failed') },
 }).replace(/</g, '\\u003c') %></script>
-<script>
-(function () {
-  var cfg = JSON.parse(document.getElementById('np-data').textContent);
-  var elState = document.getElementById('np-state');
-  var btnOn = document.getElementById('np-on'), btnOff = document.getElementById('np-off'), btnTest = document.getElementById('np-test');
-  var alertsBox = document.getElementById('np-alerts'), savedMsg = document.getElementById('np-saved');
-  var currentEndpoint = null;
-
-  var isIos = /iPad|iPhone|iPod/.test(navigator.userAgent);
-  var standalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
-  if (isIos && !standalone) document.getElementById('np-ios-hint').hidden = false;
-
-  if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
-    document.getElementById('np-unsupported').hidden = false;
-    elState.textContent = cfg.i18n.unsupported;
-    return;
-  }
-
-  function b64ToU8(s) {
-    var pad = '='.repeat((4 - (s.length % 4)) % 4);
-    var raw = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/'));
-    var out = new Uint8Array(raw.length);
-    for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
-    return out;
-  }
-  function post(url, body) {
-    return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
-  }
-  function deviceLabel() {
-    var ua = navigator.userAgent;
-    var browser = /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
-    var os = /Android/.test(ua) ? 'Android' : /iPad|iPhone|iPod/.test(ua) ? 'iOS' : /Mac/.test(ua) ? 'macOS' : /Win/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : '';
-    return (browser + (os ? ' op ' + os : ''));
-  }
-  function readAlertBoxes() {
-    var out = {};
-    alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { out[cb.getAttribute('data-alert')] = cb.checked ? 1 : 0; });
-    return out;
-  }
-  function setAlertBoxes(alerts) {
-    alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) {
-      cb.checked = !!alerts[cb.getAttribute('data-alert')];
-    });
-  }
-
-  function render(sub) {
-    currentEndpoint = sub ? sub.endpoint : null;
-    elState.textContent = sub ? cfg.i18n.on : (Notification.permission === 'denied' ? cfg.i18n.denied : cfg.i18n.off);
-    btnOn.hidden = !!sub || Notification.permission === 'denied';
-    btnOff.hidden = !sub;
-    btnTest.hidden = !sub;
-    alertsBox.hidden = !sub;
-  }
-
-  navigator.serviceWorker.ready.then(function (reg) {
-    return reg.pushManager.getSubscription();
-  }).then(function (sub) {
-    // Show this device's SAVED prefs when we know them, defaults otherwise.
-    setAlertBoxes((sub && cfg.saved[sub.endpoint]) ? Object.assign({}, cfg.alerts, cfg.saved[sub.endpoint]) : cfg.alerts);
-    render(sub);
-  }).catch(function () { elState.textContent = cfg.i18n.unknown; });
-
-  btnOn.addEventListener('click', function () {
-    btnOn.disabled = true;
-    Notification.requestPermission().then(function (perm) {
-      if (perm !== 'granted') { btnOn.disabled = false; render(null); return; }
-      navigator.serviceWorker.ready.then(function (reg) {
-        return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToU8(cfg.vapid) });
-      }).then(function (sub) {
-        return post('/push/subscribe', { subscription: sub.toJSON(), alerts: readAlertBoxes(), uaLabel: deviceLabel() })
-          .then(function (r) { if (!r.ok) throw new Error('subscribe failed'); render(sub); location.reload(); });
-      }).catch(function () { btnOn.disabled = false; elState.textContent = cfg.i18n.failed; });
-    });
-  });
-
-  btnOff.addEventListener('click', function () {
-    navigator.serviceWorker.ready.then(function (reg) { return reg.pushManager.getSubscription(); }).then(function (sub) {
-      if (!sub) { render(null); return; }
-      var ep = sub.endpoint;
-      sub.unsubscribe().then(function () { return post('/push/unsubscribe', { endpoint: ep }); })
-        .then(function () { location.reload(); });
-    });
-  });
-
-  btnTest.addEventListener('click', function () {
-    btnTest.disabled = true;
-    post('/push/test').then(function () { setTimeout(function () { btnTest.disabled = false; }, 2000); });
-  });
-
-  alertsBox.addEventListener('change', function () {
-    if (!currentEndpoint) return;
-    post('/push/alerts', { endpoint: currentEndpoint, alerts: readAlertBoxes() }).then(function (r) {
-      if (r.ok) { savedMsg.hidden = false; setTimeout(function () { savedMsg.hidden = true; }, 1500); }
-    });
-  });
-
-  document.querySelectorAll('.np-remove').forEach(function (btn) {
-    btn.addEventListener('click', function () {
-      post('/push/unsubscribe', { endpoint: btn.getAttribute('data-endpoint') }).then(function () { location.reload(); });
-    });
-  });
-})();
-</script>
+<%# Het script van deze pagina staat in assets/js/mod/admin-push.js (shaer-bqr). %>
 <% } %>
Index: src/views/pages/admin-settings.ejs
===================================================================
--- src/views/pages/admin-settings.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-settings.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -27,16 +27,5 @@
       <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div>
     </form>
-    <script>
-    (function () {
-      var f = document.getElementById('mode-form');
-      if (!f || f.__age18wired) return; f.__age18wired = true;
-      f.addEventListener('submit', function (e) {
-        var sel = f.querySelector('input[name=mode]:checked');
-        if (sel && sel.value === 'cirkels' && f.getAttribute('data-was-cirkels') === '0') {
-          if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault();
-        }
-      });
-    })();
-    </script>
+    <%# Het script van deze pagina staat in assets/js/mod/admin-settings.js (shaer-bqr). %>
   </section>
 
Index: src/views/pages/admin-site-edit.ejs
===================================================================
--- src/views/pages/admin-site-edit.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-site-edit.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -126,25 +126,5 @@
     <%# Live preview: selecting an accent / theme / palette re-themes this page
         instantly (reverts on reload; persists on save). %>
-    <script>
-    (function(){
-      if (window.__themePreviewWired) return; window.__themePreviewWired = true;
-      var html = document.documentElement;
-      function applyAccent(hex){
-        if(!/^#[0-9a-fA-F]{6}$/.test(hex)) return;
-        var sa = document.getElementById('pcms-site-accent');
-        if(!sa){ sa = document.createElement('style'); sa.id = 'pcms-site-accent'; document.head.appendChild(sa); }
-        sa.textContent = ':root,[data-palette]{--accent:'+hex+';--accent-soft:color-mix(in srgb,'+hex+' 80%,white);--accent-tint:color-mix(in srgb,'+hex+' 12%,transparent);}';
-      }
-      document.addEventListener('change', function(e){
-        var t = e.target; if(!t || !t.name) return;
-        if(t.name === 'palette'){ html.setAttribute('data-palette', t.value); }
-        else if(t.name === 'accent'){ applyAccent(t.value); }
-        else if(t.name === 'theme_override'){
-          if(t.value === 'light' || t.value === 'dark'){ html.setAttribute('data-theme', t.value); }
-          else { var dk = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; html.setAttribute('data-theme', dk ? 'dark' : 'light'); }
-        }
-      });
-    })();
-    </script>
+    <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %>
 
     <fieldset>
@@ -275,104 +255,5 @@
 </div>
 
-<script>
-(function() {
-  // Slug URL field: show the real host as a dimmed prefix (the slug itself is
-  // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>.
-  var sh = document.getElementById('slug-host');
-  if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/');
-})();
-(function() {
-  // Profile-links repeater: add row from <template>, remove on click.
-  var rows = document.getElementById('profile-links-rows');
-  var tpl  = document.getElementById('profile-link-template');
-  var add  = document.getElementById('profile-link-add');
-  if (!rows || !tpl || !add) return;
-
-  add.addEventListener('click', function() {
-    var clone = tpl.content.cloneNode(true);
-    rows.appendChild(clone);
-  });
-  rows.addEventListener('click', function(e) {
-    if (e.target && e.target.classList.contains('pl-remove')) {
-      var row = e.target.closest('.profile-link-row');
-      if (row) row.remove();
-    }
-  });
-})();
-
-// P63 — Profile photo picker: upload via /admin/sites/upload-photo,
-// then write the returned URL into the visible input. Live thumb preview.
-(function() {
-  var picker  = document.getElementById('photo-picker');
-  if (!picker) return;
-  var thumb   = document.getElementById('photo-thumb');
-  var preview = document.getElementById('photo-preview');
-  var urlEl   = document.getElementById('photo-url');
-  var trigger = document.getElementById('photo-upload-trigger');
-  var clear   = document.getElementById('photo-clear');
-  var field   = document.getElementById('photo-upload-field');
-  var status  = document.getElementById('photo-status');
-
-  function showPreview(url) {
-    if (url) {
-      preview.src = url;
-      preview.hidden = false;
-      thumb.removeAttribute('data-empty');
-      clear.hidden = false;
-    } else {
-      preview.src = '';
-      preview.hidden = true;
-      thumb.setAttribute('data-empty', '');
-      clear.hidden = true;
-    }
-  }
-
-  if (urlEl) {
-    urlEl.addEventListener('input', function() {
-      showPreview(urlEl.value.trim());
-    });
-  }
-
-  if (trigger && field) {
-    trigger.addEventListener('click', function() { field.click(); });
-    field.addEventListener('change', async function() {
-      var file = field.files && field.files[0];
-      if (!file) return;
-      status.classList.remove('is-error');
-      status.textContent = 'Uploaden…';
-      try {
-        var fd = new FormData();
-        fd.append('photo', file);
-        var r = await fetch('/admin/sites/upload-photo', {
-          method: 'POST',
-          body: fd,
-          credentials: 'same-origin',
-        });
-        var j = await r.json();
-        if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')'));
-        urlEl.value = j.url;
-        showPreview(j.url);
-        status.textContent = 'Geüpload ✓';
-        setTimeout(function() { status.textContent = ''; }, 2000);
-      } catch (e) {
-        status.classList.add('is-error');
-        status.textContent = 'Mislukt: ' + e.message;
-      } finally {
-        field.value = '';
-      }
-    });
-  }
-
-  if (clear) {
-    clear.addEventListener('click', function() {
-      urlEl.value = '';
-      showPreview('');
-      status.textContent = '';
-      // Note: doesn't delete the file from disk — saving the form with empty
-      // URL leaves the file orphaned on the server. Acceptable for now.
-    });
-  }
-})();
-</script>
+
 
 <style>
Index: src/views/pages/admin-videos.ejs
===================================================================
--- src/views/pages/admin-videos.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-videos.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -40,26 +40,4 @@
 </div>
 
-<script>
-(function () {
-  if (window.__videosWired) return; window.__videosWired = true;
-  var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') }) %>;
-  document.addEventListener('click', function (e) {
-    var c = e.target.closest('[data-copy]');
-    if (c) {
-      var u = location.origin + c.getAttribute('data-copy');
-      var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); };
-      if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); });
-      else window.prompt('URL', u);
-      return;
-    }
-    var d = e.target.closest('[data-del]');
-    if (d) {
-      if (!window.confirm(T.delC)) return;
-      fetch('/admin/media/videos/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) })
-        .then(function (r) { return r.json(); })
-        .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } })
-        .catch(function () {});
-    }
-  });
-})();
-</script>
+<%# Het script staat in assets/js/mod/admin-videos.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
+<%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') } }) %>
Index: src/views/pages/auth-register.ejs
===================================================================
--- src/views/pages/auth-register.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/auth-register.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -41,14 +41,3 @@
   .setup-handle-hint code { color: var(--accent); font-weight: 600; background: color-mix(in srgb, var(--accent) 12%, transparent); padding: .05rem .35rem; border-radius: 6px; white-space: nowrap; }
 </style>
-<script>
-(function () {
-  var u = document.getElementById('setup-username'), out = document.getElementById('setup-handle');
-  if (!u || !out) return;
-  function upd() {
-    var v = (u.value || '').toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'username';
-    out.textContent = '@' + v + '@' + location.host;
-  }
-  u.addEventListener('input', upd);
-  upd();
-})();
-</script>
+<%# Het script van deze pagina staat in assets/js/mod/auth-register.js (shaer-bqr). %>
Index: src/views/pages/authorize-interaction.ejs
===================================================================
--- src/views/pages/authorize-interaction.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/authorize-interaction.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -12,12 +12,5 @@
       <p class="fedi-bm-help"><%= t('fedi.bm_help') %></p>
     </div>
-    <script>
-    (function () {
-      if (window.__fediBmWired) return; window.__fediBmWired = true;
-      var a = document.getElementById('fedi-bm-btn'); if (!a) return;
-      a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
-      a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); });
-    })();
-    </script>
+    <%# Het script van deze pagina staat in assets/js/mod/authorize-interaction.js (shaer-bqr). %>
     <% if (!manage.length) { %>
       <p class="auth-interact-note"><%= t('fedi.manage_empty') %></p>
@@ -56,19 +49,5 @@
         <% }); %>
       </ul>
-      <script>
-      (function () {
-        if (window.__fediEditWired) return; window.__fediEditWired = true;
-        document.addEventListener('click', function (e) {
-          var b = e.target.closest && e.target.closest('.fedi-edit-btn');
-          if (!b) return;
-          var li = b.closest('.fedi-manage-item'); if (!li) return;
-          var form = li.querySelector('.fedi-edit-form'); if (!form) return;
-          var open = form.classList.toggle('is-open');
-          b.classList.toggle('is-open', open);
-          b.setAttribute('aria-expanded', open ? 'true' : 'false');
-          if (open) { var ed = form.querySelector('.re-editor') || form.querySelector('textarea'); if (ed) ed.focus(); }
-        });
-      })();
-      </script>
+      
     <% } %>
     <p class="auth-interact-note"><a href="/"><%= t('fedi.remote_back') %></a></p>
@@ -331,29 +310,3 @@
 </style>
 
-<script>
-/* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page. */
-(function(){
-  if (window.__fediReactWired) return; window.__fediReactWired = true;
-  document.addEventListener('submit', function(e){
-    var f = e.target.closest && e.target.closest('.fedi-react-form');
-    if (!f) return;
-    e.preventDefault();
-    var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
-    btn.disabled = true;
-    var body = new URLSearchParams();
-    new FormData(f).forEach(function(v, k){ body.append(k, v); });
-    fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
-      .then(function(r){ return r.ok ? r.json() : null; })
-      .then(function(j){
-        if (j) {
-          var on = !!j.on;
-          btn.classList.toggle('is-on', on);
-          var lbl = btn.querySelector('.fedi-bigact-label');
-          if (lbl) lbl.textContent = on ? (btn.getAttribute('data-on') || lbl.textContent) : (btn.getAttribute('data-off') || lbl.textContent);
-        }
-      })
-      .catch(function(){})
-      .then(function(){ btn.disabled = false; });
-  });
-})();
-</script>
+
Index: src/views/pages/download.ejs
===================================================================
--- src/views/pages/download.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/download.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -26,8 +26,5 @@
       <p class="dl-sub"><%= _ready_sub %></p>
       <p><a class="dl-go" href="<%= fileUrl %>"><%= _manual %></a></p>
-      <script>
-        // Auto-start de download (zelfde-origin attachment-link).
-        setTimeout(function(){ try { window.location.href = <%- JSON.stringify(fileUrl) %>; } catch(e){} }, 600);
-      </script>
+      <%# Het script staat in assets/js/mod/download.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
     <% } else { %>
       <h1 class="dl-h1"><%= _download_btn %> <%= tr.title %></h1>
@@ -57,2 +54,3 @@
   .dl-go { padding: 12px 20px; border-radius: 10px; border: none; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; font-size: 15px; cursor: pointer; text-decoration: none; display: inline-block; }
 </style>
+<%- include('../partials/page-data', { pageData: { fileUrl: fileUrl } }) %>
Index: src/views/pages/messages.ejs
===================================================================
--- src/views/pages/messages.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/messages.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -2,6 +2,6 @@
   <%- include('../partials/fedi-tabs', { active: 'berichten' }) %>
   <h1 class="msg-title"><%= t('msg.title') %></h1>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : success)) %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : error %></div><% } %>
+  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : (success === 'reply_sent' ? t('msg.reply_sent') : success))) %></div><% } %>
+  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : (error === 'reply_empty' ? t('msg.reply_empty') : ((error === 'reply_failed' || error === 'reply_target') ? t('msg.reply_failed') : error)) %></div><% } %>
 
   <%# FEP-633c: a pending guardianship offer is a special message: the kid
@@ -25,10 +25,11 @@
 
   <div class="msg-filters" role="tablist" aria-label="<%= t('msg.title') %>">
+    <%# Vier chips, geen zes: Berichten, Gesprekken en Verzonden gingen op in
+        een enkele Gesprekken-view, waarin verzonden en ontvangen in dezelfde
+        draad staan. Activiteit en Moderatie blijven wat ze waren. %>
     <button type="button" class="msg-chip is-on" data-show="all"><%= t('msg.filter_all') %></button>
-    <button type="button" class="msg-chip" data-show="msgs"><%= t('msg.filter_msgs') %></button>
     <button type="button" class="msg-chip" data-show="conv"><%= t('msg.filter_conv') %></button>
     <button type="button" class="msg-chip" data-show="act"><%= t('msg.filter_act') %></button>
     <button type="button" class="msg-chip" data-show="mod"><%= t('msg.filter_mod') %></button>
-    <button type="button" class="msg-chip" data-show="sent"><%= t('msg.filter_sent') %></button>
   </div>
   <div class="msg-search">
@@ -59,62 +60,8 @@
 </div>
 
-<script>
-(function () {
-  if (window.__msgWired) return; window.__msgWired = true;
-
-  // Filtering: kind chip AND free-text search, combined in JS (search over the
-  // sender and the rendered message text). Empty search + "all" = show all.
-  var list = document.querySelector('.msg-list');
-  var noMatch = document.querySelector('.msg-nomatch');
-  var q = document.getElementById('msg-q');
-  var kind = 'all';
-  var items = [];
-  function indexItem(li) {
-    // Index once: sender + message body + linked post title + poll text.
-    var body = li.querySelector('.msg-content');
-    var post = li.querySelector('.msg-post');
-    var poll = li.querySelector('.msg-poll');
-    li._search = ((li.getAttribute('data-who') || '') + ' ' +
-      (body ? body.textContent : '') + ' ' + (post ? post.textContent : '') + ' ' +
-      (poll ? poll.textContent : '')).toLowerCase();
-  }
-  // Re-collect + index; called on load and after each "Load more" append so new
-  // items join the filter/search (and inherit the active chip via apply()).
-  function reindex() {
-    items = list ? Array.prototype.slice.call(list.querySelectorAll('.msg-item')) : [];
-    items.forEach(function (li) { if (!li._search) indexItem(li); });
-  }
-  reindex();
-  function apply() {
-    if (!list) return;
-    var term = (q && q.value || '').trim().toLowerCase();
-    var shown = 0;
-    items.forEach(function (li) {
-      var ok = (kind === 'all' || li.getAttribute('data-kind') === kind) &&
-        (!term || li._search.indexOf(term) !== -1);
-      li.style.display = ok ? '' : 'none';
-      if (ok) shown++;
-    });
-    if (noMatch) noMatch.hidden = shown !== 0;
-  }
-  document.addEventListener('click', function (e) {
-    var chip = e.target.closest('.msg-chip'); if (!chip) return;
-    kind = chip.getAttribute('data-show');
-    document.querySelectorAll('.msg-chip').forEach(function (c) { c.classList.toggle('is-on', c === chip); });
-    apply();
-  });
-  if (q) q.addEventListener('input', apply);
-  // After a "Load more" append (htmx), index the new rows and re-apply the filter.
-  document.body.addEventListener('htmx:afterSettle', function (e) {
-    if (e.target && e.target.id === 'msg-list') { reindex(); apply(); }
-  });
-
-  var a = document.getElementById('fedi-bm-btn');
-  if (a) {
-    a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
-    a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); });
-  }
-})();
-</script>
+<%# Filteren, zoeken en het in-/uitklappen zitten in assets/js/mod/messages.js.
+    Inline script hier wordt door de CSP geweigerd zodra je deze pagina via een
+    link BINNEN de site opent -- zie shaer-0i6. De shell laadt de module op
+    body[data-js]; deze pagina vraagt erom met pageJs (routes/posts.js). %>
 
 <style>
@@ -138,4 +85,5 @@
 
   .msg-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .55rem; }
+
 
   .msg-item { display: flex; gap: .8rem; padding: .85rem .95rem; border-radius: 14px; align-items: flex-start;
@@ -148,4 +96,78 @@
     background: color-mix(in srgb, var(--accent, #06c) 4%, transparent); }
   .msg-sent { border-left: 3px solid color-mix(in srgb, var(--accent, #06c) 55%, transparent); }
+
+  /* ── Gesprekken ──────────────────────────────────────────────────────
+     NA .msg-item, en dat is geen smaak: .msg-item zet display:flex, en bij
+     gelijke specificiteit wint de laatste regel. Stond dit ervoor, dan kwam de
+     draadkop NAAST de bubbels te staan in plaats van erboven. */
+  .msg-thread { display: block; }
+  .msg-thread-head { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-bottom: .4rem; }
+  /* De tegenpartij is een labeltje bij het gesprek, geen kop erboven: een
+     kleine verzonken uitsparing, zodat de bubbels eronder de ruimte krijgen.
+     Het aantal zit in dezelfde uitsparing in plaats van ernaast. */
+  /* Ook de knop die het gesprek in- en uitklapt. Een button erft geen font en
+     geen kleur, dus die staan hier expliciet; zonder dat wordt het ineens
+     systeemblauw in 13px. */
+  .msg-thread-who { display: inline-flex; align-items: center; gap: .3rem; min-width: 0;
+    font: inherit; font-size: .78em; line-height: 1.5; font-weight: 500;
+    padding: .05rem .5rem; border-radius: 999px; border: 0; cursor: pointer;
+    color: color-mix(in srgb, var(--ink, #000) 62%, transparent);
+    background: color-mix(in srgb, var(--ink, #000) 5%, transparent);
+    box-shadow: inset 0 1px 2px color-mix(in srgb, var(--ink, #000) 9%, transparent);
+    max-width: 100%; overflow: hidden; white-space: nowrap;
+    /* Ruimer aanraakvlak dan de tekst: een chip van 18px hoog is op een telefoon
+       niet te raken. De uitsparing blijft klein, het doel wordt groter. */
+    min-height: 32px; }
+  .msg-thread-who:hover { background: color-mix(in srgb, var(--ink, #000) 9%, transparent); }
+  .msg-thread-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .msg-thread-chevron { width: 12px; height: 12px; flex: none; opacity: .55;
+    transition: transform .18s ease; }
+  /* Ingeklapt: de bubbels en het antwoordveld verdwijnen, de uitsparing met de
+     naam en het AANTAL blijft staan -- dat aantal is juist ingeklapt het meest
+     waard. */
+  .msg-thread.is-collapsed .msg-thread-msgs,
+  .msg-thread.is-collapsed .msg-thread-foot { display: none; }
+  /* Tijdens het zoeken wint de treffer van de dichtgeklapte stand. */
+  .msg-thread.is-collapsed.is-search-open .msg-thread-msgs { display: flex; }
+  .msg-thread.is-collapsed.is-search-open .msg-thread-foot { display: flex; }
+  .msg-thread.is-collapsed .msg-thread-chevron { transform: rotate(-90deg); }
+  .msg-thread.is-collapsed .msg-thread-head { margin-bottom: 0; }
+  @media (prefers-reduced-motion: reduce) { .msg-thread-chevron { transition: none; } }
+  .msg-thread-count { font-variant-numeric: tabular-nums; opacity: .75; flex: 0 0 auto;
+    padding-inline-start: .3rem; border-inline-start: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent); }
+  .msg-thread-post { display: inline-flex; align-items: center; gap: .3rem; font-size: .85em;
+    margin-inline-start: auto; min-width: 0; max-width: 100%;
+    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .msg-thread-post svg { width: 13px; height: 13px; flex: none; }
+  .msg-thread-msgs { list-style: none; margin: 0; padding: 0 0 0 .7rem; display: flex; flex-direction: column; gap: .5rem;
+    border-inline-start: 2px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); }
+  .msg-sub { display: flex; gap: .6rem; align-items: flex-start; min-width: 0; }
+  .msg-sub > .msg-body { min-width: 0; }
+  /* Jouw eigen bijdrage onderscheidt zich met een tint en de accentlijn die
+     .msg-sent al had -- niet met row-reverse: dat duwde de avatar buiten de
+     kaart zodra de regel smaller werd dan zijn inhoud. */
+  .msg-sub.msg-sent { border-radius: 10px; padding: .3rem .45rem;
+    background: color-mix(in srgb, var(--accent, #06c) 5%, transparent); }
+  /* Een lange naam of tijd mag de bubbel niet uit de kaart duwen. */
+  .msg-sub .msg-line { flex-wrap: wrap; }
+
+  /* De voet van een gesprek: antwoorden en zwaaien naast elkaar, ingeklapt.
+     Een open editor onder elk gesprek maakt de lijst weer onleesbaar -- precies
+     wat deze weergave moest oplossen. */
+  .msg-thread-foot { display: flex; align-items: flex-start; gap: .5rem; margin-top: .5rem; flex-wrap: wrap; }
+  .msg-reply { flex: 1 1 12rem; min-width: 0; }
+  .msg-reply > summary { cursor: pointer; font-size: .82rem; color: var(--ink-soft, #888); }
+  .msg-reply[open] > summary { margin-bottom: .4rem; }
+  .msg-reply-form { min-width: 0; }
+  .msg-wave button { line-height: 1.2; }
+
+  @media (max-width: 560px) {
+    /* Op een telefoon is 40px avatar per bubbel puur verlies: de naam staat er
+       al naast, en de inhoud heeft de breedte harder nodig. */
+    .msg-sub > .msg-av { display: none; }
+    .msg-thread-msgs { padding-inline-start: .55rem; }
+    .msg-thread-post { margin-inline-start: 0; }
+    .msg-item { padding: .7rem .6rem; gap: .6rem; }
+  }
 
   .msg-av { position: relative; flex: 0 0 40px; width: 40px; height: 40px; border-radius: 50%;
Index: src/views/pages/news.ejs
===================================================================
--- src/views/pages/news.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/news.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -41,42 +41,5 @@
     .tl-paste-go { background: var(--accent); color: var(--paper); }
   </style>
-  <script>
-  (function () {
-    if (window.__tlPasteWired) return; window.__tlPasteWired = true;
-    function modal() { return document.getElementById('tl-paste'); }
-    function openM() {
-      var m = modal(); if (!m) return;
-      var inp = m.querySelector('.tl-paste-input');
-      m.classList.add('is-open');
-      if (inp) {
-        inp.value = ''; inp.focus();
-        if (navigator.clipboard && navigator.clipboard.readText) {
-          navigator.clipboard.readText().then(function (t) {
-            t = (t || '').trim();
-            if (/^https?:\/\//i.test(t) && !inp.value) { inp.value = t; inp.select(); }
-          }).catch(function () {});
-        }
-      }
-    }
-    function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
-    function go() {
-      var m = modal(); if (!m) return;
-      var inp = m.querySelector('.tl-paste-input'), v = (inp ? inp.value : '').trim();
-      if (!/^https?:\/\//i.test(v)) { if (inp) inp.focus(); return; }
-      location.href = '/authorize_interaction?uri=' + encodeURIComponent(v);
-    }
-    document.addEventListener('click', function (e) {
-      if (e.target.closest && e.target.closest('#tl-paste-btn')) { e.preventDefault(); openM(); return; }
-      if (e.target.closest && e.target.closest('.tl-paste-go')) { e.preventDefault(); go(); return; }
-      if (e.target.closest && (e.target.closest('.tl-paste-cancel') || e.target.closest('.tl-paste-x'))) { e.preventDefault(); closeM(); return; }
-      var op = document.querySelector('.tl-paste.is-open'); if (op && e.target === op) closeM();
-    });
-    document.addEventListener('keydown', function (e) {
-      var m = document.querySelector('.tl-paste.is-open'); if (!m) return;
-      if (e.key === 'Escape') closeM();
-      else if (e.key === 'Enter' && e.target.closest && e.target.closest('.tl-paste')) { e.preventDefault(); go(); }
-    });
-  })();
-  </script>
+  <%# Het script staat in assets/js/mod/news.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
   <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
   <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
@@ -184,86 +147,3 @@
   .tl-act-block:hover { background: color-mix(in srgb, #d9534f 14%, transparent); border-color: color-mix(in srgb, #d9534f 50%, transparent); }
 </style>
-
-<script>
-(function(){
-  if (window.__fediRemoteWired) return; window.__fediRemoteWired = true;
-  var current = null, currentBtn = null;
-  function close(){ if (current) { current.remove(); current = null; currentBtn = null; } }
-  function go(raw, uri){ var d=(raw||'').trim().replace(/^@?[^@\s]*@/,'').replace(/^https?:\/\//i,'').replace(/\/.*$/,'').trim(); if(d){ try{ localStorage.setItem('pcmsFediServer', d); }catch(e){} location.href='https://'+d+'/authorize_interaction?uri='+encodeURIComponent(uri||''); } }
-  function place(f, b){ var r=b.getBoundingClientRect(); f.style.top=(r.bottom+window.scrollY+6)+'px'; f.style.left=Math.max(8, Math.min(r.left+window.scrollX, window.scrollX+window.innerWidth-340))+'px'; }
-  document.addEventListener('click', function(e){
-    if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; }
-    if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return;
-    var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn');
-    if (b) {
-      e.preventDefault();
-      if (currentBtn === b) { close(); return; }
-      close();
-      var f = document.createElement('form'); f.className='fedi-remote-form'; f.dataset.uri = b.getAttribute('data-fedi-uri')||'';
-      f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false"><button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">&rarr;</button><button type="button" class="fedi-remote-cancel" aria-label="x">&times;</button>';
-      var _inp = f.querySelector('input'); _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social';
-      try{ var _sv = localStorage.getItem('pcmsFediServer'); if(_sv) _inp.value = _sv; }catch(e){}
-      document.body.appendChild(f); place(f, b); current=f; currentBtn=b; _inp.focus(); _inp.select();
-      return;
-    }
-    if (current) close();
-  });
-  document.addEventListener('submit', function(e){ var f=e.target.closest && e.target.closest('.fedi-remote-form'); if(!f) return; e.preventDefault(); go(f.querySelector('input').value, f.dataset.uri); });
-  document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
-  window.addEventListener('scroll', close, true);
-})();
-
-/* Short feed videos (gif/cover loops like an animated cover) autoplay + loop muted like a GIF;
-   longer real videos keep their controls. Decided on the actual duration once metadata loads. */
-(function(){
-  document.querySelectorAll('.tl-media-video').forEach(function(v){
-    if (v.dataset.gifWired) return; v.dataset.gifWired = '1';
-    var decide = function(){
-      if (v.duration && v.duration <= 30) {
-        v.removeAttribute('controls'); v.loop = true; v.muted = true; v.play().catch(function(){});
-      }
-    };
-    if (v.readyState >= 1) decide(); else v.addEventListener('loadedmetadata', decide, { once: true });
-  });
-})();
-
-/* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page (no reload, no banner). */
-(function(){
-  if (window.__newsReactWired) return; window.__newsReactWired = true;
-  document.addEventListener('submit', function(e){
-    var f = e.target.closest && e.target.closest('.tl-react-form');
-    if (!f) return;
-    e.preventDefault();
-    var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
-    btn.disabled = true;
-    var body = new URLSearchParams();
-    new FormData(f).forEach(function(v, k){ body.append(k, v); });
-    fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
-      .then(function(r){ return r.ok ? r.json() : null; })
-      .then(function(j){ if (j) btn.classList.toggle('is-on', !!j.on); })
-      .catch(function(){})
-      .then(function(){ btn.disabled = false; });
-  });
-})();
-</script>
-
-<script>
-// Collapse long post bodies to a max height with a "read more" toggle — only when the content
-// actually overflows. Runs on every /news render (full load + htmx swap); a per-element flag
-// prevents double-wiring.
-(function(){
-  var RM = '<%= t("tl.read_more") %>', SL = '<%= t("tl.show_less") %>';
-  document.querySelectorAll('.tl-content:not(.nsfw-media):not([data-rm])').forEach(function(c){
-    c.setAttribute('data-rm', '1');
-    if (c.scrollHeight > 360) {
-      c.classList.add('tl-clamp');
-      var b = document.createElement('button');
-      b.type = 'button'; b.className = 'tl-readmore'; b.textContent = RM;
-      b.addEventListener('click', function(){
-        b.textContent = c.classList.toggle('tl-clamp') ? RM : SL;
-      });
-      c.insertAdjacentElement('afterend', b);
-    }
-  });
-})();
-</script>
+<%- include('../partials/page-data', { pageData: { readMore: t('tl.read_more'), showLess: t('tl.show_less') } }) %>
Index: src/views/pages/paid-gate.ejs
===================================================================
--- src/views/pages/paid-gate.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/paid-gate.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -31,58 +31,5 @@
 
 <script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script>
-<script>
-(function () {
-  var base = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>";
-  var slug = "<%= pgSlug %>";
-  var hasPatron = <%= _hasPatron ? 'true' : 'false' %>;
-  var I = { join: "<%= t('pgate.join_short') %>", confirm: "<%= t('pgate.confirm') %>", failed: "<%= t('pgate.failed') %>", error: "<%= t('pgate.error') %>" };
-  var btn = document.getElementById('pg-unlock');
-  var status = document.getElementById('pg-status');
-  function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
-  function toLink() { location.href = base + '/paid/link?post=' + encodeURIComponent(slug); }
-
-  // No WebAuthn here: an assertion is impossible. With a Patreon page there is
-  // already a "Word supporter" button, so hide the (dead) unlock button rather
-  // than turn it into a second "Word supporter". Without one, this IS the button.
-  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
-    if (hasPatron) { btn.style.display = 'none'; }
-    else { btn.textContent = I.join; btn.addEventListener('click', toLink); }
-    return;
-  }
-
-  btn.addEventListener('click', function () {
-    btn.disabled = true;
-    say(I.confirm);
-    fetch(base + '/paid/challenge?post=' + encodeURIComponent(slug))
-      .then(function (r) { if (!r.ok) throw { link: true }; return r.json(); })
-      .then(function (data) {
-        return window.SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: data.options })
-          .then(function (response) {
-            return fetch(base + '/paid/unlock', {
-              method: 'POST', headers: { 'Content-Type': 'application/json' },
-              body: JSON.stringify({ response: response, blob: data.blob }),
-            });
-          });
-      })
-      .then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })
-      .then(function (res) {
-        if (res.j && res.j.ok && res.j.redirect) {
-          // Reload the real post page via the one-shot unlock capability, so it
-          // renders through its normal template (layout, styles, audio).
-          location.href = res.j.redirect;
-        } else if (res.status === 403) {
-          toLink();   // no valid passkey yet (or lapsed tier): link via Patreon
-        } else {
-          btn.disabled = false; say(I.failed, true);
-        }
-      })
-      .catch(function (e) {
-        if (e && e.link) { toLink(); return; }
-        if (e && e.name === 'NotAllowedError') { toLink(); return; }   // cancelled / no passkey -> link
-        btn.disabled = false; say(I.error, true);
-      });
-  });
-})();
-</script>
+<%# Het script staat in assets/js/mod/paid-gate.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
 
 <style>
@@ -105,2 +52,3 @@
   .pg-status.is-err { color: #c0392b; }
 </style>
+<%- include('../partials/page-data', { pageData: { base: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : ''), slug: pgSlug, hasPatron: !!_hasPatron, i18n: { join: t('pgate.join_short'), confirm: t('pgate.confirm'), failed: t('pgate.failed'), error: t('pgate.error') } } }) %>
Index: src/views/pages/paid-passkey.ejs
===================================================================
--- src/views/pages/paid-passkey.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/paid-passkey.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -12,38 +12,5 @@
 
 <script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script>
-<script>
-(function () {
-  var options = <%- optionsJson %>;
-  var blob = "<%= regBlob %>";
-  var I = { unsupported: "<%= t('ppk.unsupported') %>", follow: "<%= t('ppk.follow') %>", done: "<%= t('ppk.done') %>", failed: "<%= t('ppk.failed') %>", cancelled: "<%= t('ppk.cancelled') %>", error: "<%= t('pgate.error') %>" };
-  var postUrl = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>";
-  var btn = document.getElementById('pk-go');
-  var status = document.getElementById('pk-status');
-  function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
-
-  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
-    btn.disabled = true;
-    say(I.unsupported, true);
-    return;
-  }
-  btn.addEventListener('click', function () {
-    btn.disabled = true;
-    say(I.follow);
-    window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: options })
-      .then(function (response) {
-        return fetch('/paid/register', {
-          method: 'POST', headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ response: response, blob: blob }),
-        });
-      })
-      .then(function (r) { return r.json(); })
-      .then(function (j) {
-        if (j && j.ok) { say(I.done); setTimeout(function () { location.href = postUrl; }, 900); }
-        else { btn.disabled = false; say(I.failed.replace('{err}', (j && j.error) || '?'), true); }
-      })
-      .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? I.cancelled : I.error, true); });
-  });
-})();
-</script>
+<%# Het script staat in assets/js/mod/paid-passkey.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
 
 <style>
@@ -58,2 +25,3 @@
   .pk-status.is-err { color: #c0392b; }
 </style>
+<%- include('../partials/page-data', { pageData: { options: JSON.parse(optionsJson), blob: regBlob, i18n: { unsupported: t('ppk.unsupported'), follow: t('ppk.follow'), done: t('ppk.done'), failed: t('ppk.failed'), cancelled: t('ppk.cancelled'), error: t('pgate.error') }, postUrl: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') + '/' + postSlug } }) %>
Index: src/views/pages/post-edit.ejs
===================================================================
--- src/views/pages/post-edit.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/post-edit.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -272,20 +272,7 @@
         <% } else { %>
           <p id="pe-fedi-audio-warn" style="font-size:12px;color:#c0392b;margin:2px 0 0 26px" hidden>⚠️ <%= t('pedit.fedi_audio_oneway') %></p>
-          <script>
-          (function () {
-            var cb = document.getElementById('pe-fedi-audio'), w = document.getElementById('pe-fedi-audio-warn');
-            if (cb && w && !cb.__wired) { cb.__wired = true; cb.addEventListener('change', function () { w.hidden = !cb.checked; }); }
-          })();
-          </script>
+          <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
         <% } %>
-        <script>
-        (function () {
-          var cw = document.getElementById('pe-cw'), nsfw = document.getElementById('pe-nsfw');
-          // Typing a warning text implies the post is sensitive → auto-tick NSFW.
-          if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true;
-            cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; });
-          }
-        })();
-        </script>
+        
         <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen.
            var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; }
@@ -335,31 +322,5 @@
           </select>
         </div>
-        <script>
-        (function () {
-          var box = document.getElementById('pe-poll-fields');
-          var tog = document.getElementById('pe-poll-toggle');
-          var opts = document.getElementById('pe-poll-opts');
-          var add = document.getElementById('pe-poll-add');
-          if (!box || !opts) return;
-          if (tog && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); }
-          var PH = opts.getAttribute('data-ph') || '', DEL = opts.getAttribute('data-del') || '';
-          function rows() { return opts.querySelectorAll('.pe-poll-row'); }
-          // A poll needs at least 2 options: hide the ✕ at the minimum, and cap adding at 8.
-          function refresh() {
-            var n = rows().length;
-            opts.querySelectorAll('.pe-poll-del').forEach(function (b) { b.hidden = n <= 2; });
-            if (add) add.disabled = n >= 8;
-          }
-          function makeRow() {
-            var row = document.createElement('div'); row.className = 'pe-poll-row';
-            var i = document.createElement('input'); i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100; i.placeholder = PH;
-            var d = document.createElement('button'); d.type = 'button'; d.className = 'pe-poll-del'; d.setAttribute('aria-label', DEL); d.title = DEL; d.innerHTML = '&times;';
-            row.appendChild(i); row.appendChild(d); return row;
-          }
-          if (add && !add.__wired) { add.__wired = true; add.addEventListener('click', function () { if (rows().length >= 8) return; opts.appendChild(makeRow()); refresh(); }); }
-          if (!opts.__wired) { opts.__wired = true; opts.addEventListener('click', function (e) { var d = e.target.closest('.pe-poll-del'); if (!d || rows().length <= 2) return; d.closest('.pe-poll-row').remove(); refresh(); }); }
-          refresh();
-        })();
-        </script>
+        
         <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
           <label class="pe-checkbox">
@@ -377,8 +338,5 @@
             </label>
           </div>
-          <script>
-            (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price');
-              if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })();
-          </script>
+          
           <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
           <label class="pe-checkbox" style="margin-top:8px">
@@ -392,62 +350,5 @@
             <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
           </div>
-          <script>
-            (function () {
-              var cb = document.getElementById('pe-sched-toggle');
-              var box = document.getElementById('pe-sched-fields');
-              if (!cb || !box) return;
-              var inp = document.getElementById('pe-publish-at');
-              var SITE_TZ = '<%= timezone || '' %>'; // configured site timezone; empty = browser local
-              var pad = function (n) { return String(n).padStart(2, '0'); };
-              // Offset (ms) between a timezone and UTC at a given moment.
-              function tzOffset(date, tz) {
-                var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
-                var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; });
-                return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime();
-              }
-              // datetime-local "wall time" (in the site zone) → UTC Date.
-              function wallToUtc(wall) {
-                if (!SITE_TZ) return new Date(wall);
-                var guess = new Date(wall + ':00Z').getTime();
-                return new Date(guess - tzOffset(new Date(guess), SITE_TZ));
-              }
-              // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone.
-              function utcToWall(iso) {
-                var d = new Date(iso); if (isNaN(d)) return '';
-                if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
-                var f = new Intl.DateTimeFormat('en-CA', { timeZone: SITE_TZ, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
-                var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; });
-                return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
-              }
-              // Prefill: stored UTC → wall time in the site zone.
-              if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso);
-              // "Scheduled for" in human-readable time in the site zone.
-              var when = document.getElementById('pe-sched-when');
-              if (when && when.dataset.iso) {
-                var dw = new Date(when.dataset.iso);
-                if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined);
-              }
-              function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; }
-              cb.addEventListener('change', sync); sync();
-              // On save: wall time in the site zone → UTC-ISO via a hidden field.
-              var form = cb.closest('form');
-              if (form) {
-                form.addEventListener('submit', function () {
-                  if (inp) inp.removeAttribute('name');
-                  var old = form.querySelector('input[data-pa-utc]');
-                  if (old) old.remove();
-                  if (cb.checked && inp && inp.value) {
-                    var d2 = wallToUtc(inp.value);
-                    if (!isNaN(d2)) {
-                      var h = document.createElement('input');
-                      h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', '');
-                      h.value = d2.toISOString();
-                      form.appendChild(h);
-                    }
-                  }
-                });
-              }
-            })();
-          </script>
+          
         <% } %>
       </div>
@@ -1281,921 +1182,5 @@
 </style>
 
-<script>
-(function() {
-
-  // ── Cover upload ────────────────────────────────────────────────
-  const coverField   = document.getElementById('cover-upload-field');
-  const coverTrigger = document.getElementById('cover-upload-trigger');
-  const coverUrl     = document.getElementById('cover-url-field');
-  const coverVideo   = document.getElementById('cover-video-field');
-  const coverStatus  = document.getElementById('cover-upload-status');
-  const coverWrap    = document.getElementById('cover-preview-wrap');
-  const coverImg     = document.getElementById('cover-preview-img');
-
-  async function uploadImage(file) {
-    const fd = new FormData();
-    fd.append('image', file);
-    const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
-    if (!res.ok) {
-      const j = await res.json().catch(() => ({}));
-      throw new Error(j.error || ('Upload failed (' + res.status + ')'));
-    }
-    return await res.json();   // {url, size, mime}
-  }
-
-  // ── Image editor (rotate / crop / mirror) ──────────
-  // Lazy-load Cropper.js (locally vendored) on first use.
-  let _cropperReady = null;
-  function ensureCropper() {
-    if (window.Cropper) return Promise.resolve();
-    if (_cropperReady) return _cropperReady;
-    _cropperReady = new Promise((resolve, reject) => {
-      if (!document.querySelector('link[data-cropper-css]')) {
-        const l = document.createElement('link');
-        l.rel = 'stylesheet'; l.href = '/assets/vendor/cropper.min.css'; l.setAttribute('data-cropper-css', '');
-        document.head.appendChild(l);
-      }
-      const s = document.createElement('script');
-      s.src = '/assets/vendor/cropper.min.js';
-      s.onload = () => resolve();
-      s.onerror = () => reject(new Error('cropper load failed'));
-      document.head.appendChild(s);
-    });
-    return _cropperReady;
-  }
-
-  // True for an animated WebP (VP8X chunk with the animation flag set) — like a GIF it must skip
-  // the canvas editor, otherwise it'd be flattened to a single static frame.
-  async function isAnimatedWebpFile(file) {
-    if (!file || file.type !== 'image/webp') return false;
-    try {
-      const b = new Uint8Array(await file.slice(0, 40).arrayBuffer());
-      return b.length >= 21 && String.fromCharCode(b[12], b[13], b[14], b[15]) === 'VP8X' && (b[20] & 0x02) !== 0;
-    } catch (_) { return false; }
-  }
-
-  // Opens the editor for a chosen file; resolves with an edited File,
-  // or null if the user cancels. Animated images (GIF / animated WebP) are NOT sent through the
-  // canvas editor (they would become static) — those upload directly.
-  async function openImageEditor(file) {
-    if (!file || !file.type || !file.type.startsWith('image/')) return file;
-    if (file.type === 'image/gif') return file;               // preserve animation
-    if (await isAnimatedWebpFile(file)) return file;          // animated WebP → preserve animation
-    try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly
-
-    return new Promise((resolve) => {
-      const back = document.createElement('div');
-      back.className = 'imed-backdrop';
-      back.innerHTML =
-        '<div class="imed-modal" role="dialog" aria-modal="true" aria-label="<%= t('imed.title') %>">' +
-          '<div class="imed-stage"><img alt=""></div>' +
-          '<div class="imed-tools">' +
-            '<button type="button" data-act="rl" title="<%= t('imed.rotate_left') %>">⟲</button>' +
-            '<button type="button" data-act="rr" title="<%= t('imed.rotate_right') %>">⟳</button>' +
-            '<button type="button" data-act="fh" title="<%= t('imed.flip_h') %>">⇆</button>' +
-            '<button type="button" data-act="fv" title="<%= t('imed.flip_v') %>">⇅</button>' +
-            '<button type="button" data-act="zi" title="<%= t('imed.zoom_in') %>">＋</button>' +
-            '<button type="button" data-act="zo" title="<%= t('imed.zoom_out') %>">－</button>' +
-            '<button type="button" data-act="reset" title="<%= t('imed.reset') %>">↺</button>' +
-          '</div>' +
-          '<div class="imed-actions">' +
-            '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary"><%= t('imed.cancel') %></button>' +
-            '<button type="button" data-act="apply" class="pe-btn pe-btn-primary"><%= t('imed.apply') %></button>' +
-          '</div>' +
-        '</div>';
-      document.body.appendChild(back);
-      const img = back.querySelector('img');
-      const url = URL.createObjectURL(file);
-      let cropper = null, sx = 1, sy = 1;
-
-      function cleanup() {
-        try { if (cropper) cropper.destroy(); } catch (_) {}
-        URL.revokeObjectURL(url);
-        back.remove();
-        document.removeEventListener('keydown', onKey);
-      }
-      function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } }
-      document.addEventListener('keydown', onKey);
-
-      img.onload = () => {
-        cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true });
-      };
-      img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original
-      img.src = url;
-
-      back.addEventListener('click', (e) => {
-        const btn = e.target.closest('[data-act]');
-        if (e.target === back) { cleanup(); resolve(null); return; }
-        if (!btn || !cropper) return;
-        const a = btn.getAttribute('data-act');
-        if (a === 'rl') cropper.rotate(-90);
-        else if (a === 'rr') cropper.rotate(90);
-        else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); }
-        else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); }
-        else if (a === 'zi') cropper.zoom(0.1);
-        else if (a === 'zo') cropper.zoom(-0.1);
-        else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); }
-        else if (a === 'cancel') { cleanup(); resolve(null); }
-        else if (a === 'apply') {
-          const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' });
-          const png = (file.type === 'image/png' || file.type === 'image/webp');
-          const mime = png ? 'image/png' : 'image/jpeg';
-          const ext = png ? '.png' : '.jpg';
-          canvas.toBlob((blob) => {
-            cleanup();
-            if (!blob) { resolve(file); return; }
-            const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, '');
-            resolve(new File([blob], base + ext, { type: mime }));
-          }, mime, 0.92);
-        }
-      });
-    });
-  }
-
-  function showCoverPreview(url) {
-    if (!coverWrap || !coverImg) return;
-    if (url) {
-      coverImg.src = url;
-      coverImg.hidden = false;
-      coverWrap.removeAttribute('data-empty');
-      const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
-      if (emptyIcon) emptyIcon.remove();
-    } else {
-      coverImg.hidden = true;
-      coverImg.src = '';
-      coverWrap.setAttribute('data-empty', '');
-      if (!coverWrap.querySelector('.pe-cover-empty')) {
-        const span = document.createElement('span');
-        span.className = 'pe-cover-empty';
-        span.textContent = '🖼';
-        coverWrap.appendChild(span);
-      }
-    }
-  }
-
-  if (coverTrigger && coverField) {
-    coverTrigger.addEventListener('click', () => coverField.click());
-  }
-  if (coverField) {
-    coverField.addEventListener('change', async () => {
-      if (!coverField.files[0]) return;
-      const edited = await openImageEditor(coverField.files[0]);
-      coverField.value = '';
-      if (!edited) return; // cancelled
-      coverStatus.classList.remove('is-error');
-      coverStatus.textContent = '<%= t('pedit.js_uploading') %>';
-      try {
-        const j = await uploadImage(edited);
-        coverUrl.value = j.url;
-        if (coverVideo) coverVideo.value = j.video || ''; // muted loop MP4 for an animated cover
-        showCoverPreview(j.url);
-        coverStatus.textContent = (j.video ? '🎬 ' : '') + '<%= t('pedit.js_uploaded') %> ✓';
-        setTimeout(() => { coverStatus.textContent = ''; }, 2000);
-      } catch (e) {
-        coverStatus.classList.add('is-error');
-        coverStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message;
-      }
-    });
-  }
-  // Live-update preview when user pastes a URL manually
-  if (coverUrl) {
-    coverUrl.addEventListener('input', () => {
-      const v = coverUrl.value.trim();
-      if (v) showCoverPreview(v); else showCoverPreview('');
-    });
-  }
-
-  // ── WYSIWYG editor (P58) ────────────────────────────────────────
-  // Architecture:
-  //   - Visible <div contenteditable> (`#content-editor`) is what the user
-  //     types in; it shows real HTML (formatted, not raw markup).
-  //   - Hidden <input name="content"> (`#content-hidden`) is what submits.
-  //     On submit we serialize the editor's HTML into it, with shortcode
-  //     chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
-  //   - Initial content comes from a <script type="application/json"> tag
-  //     to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
-  //     and walk text nodes to render shortcode tokens as chips.
-  const contentField  = document.getElementById('content-upload-field');
-  const contentBtn    = document.getElementById('insert-image-btn');
-  const contentStatus = document.getElementById('content-upload-status');
-  const editor        = document.getElementById('content-editor');
-  const hiddenField   = document.getElementById('content-hidden');
-  const charCountEl   = document.getElementById('char-count');
-  const initialEl     = document.getElementById('initial-content');
-  const toolbar       = document.getElementById('pe-toolbar');
-  const form          = editor && editor.closest('form');
-
-  if (!editor) return;
-
-  // Auto-focus the title only on desktop (mouse/trackpad). On touch this would
-  // immediately open the keyboard when the editor opens — not desired.
-  try {
-    const titleInput = form && form.querySelector('input[name="title"]');
-    if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
-      titleInput.focus({ preventScroll: true });
-    }
-  } catch (_) {}
-
-  // ── Shortcode chip rendering / serialization ────────────────────
-  // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
-  // — but we DON'T want to chipify text the user is mid-typing inside an
-  // HTML attribute; since chipify only walks text nodes (never attribute
-  // values) that's already safe.
-  const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g;
-
-  const SC_ICONS = {
-    track:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>',
-    album:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>',
-    playlist: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>',
-    embed:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 9 15.5 12 10 15"/></svg>',
-  };
-
-  function chipLabel(kind, value) {
-    if (kind === 'track') {
-      // UUIDs are noisy — show a 6-char prefix for visual hint
-      const v = String(value || '');
-      return '<%= t('pedit.chip_track') %> ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
-    }
-    if (kind === 'album')    return '<%= t('pedit.chip_album') %> ' + value;
-    if (kind === 'playlist') return '<%= t('pedit.chip_playlist') %> ' + value;
-    if (kind === 'embed') {
-      const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, '');
-      return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean);
-    }
-    return value;
-  }
-
-  function makeChip(kind, value) {
-    const span = document.createElement('span');
-    span.className = 'sc-chip';
-    span.contentEditable = 'false';
-    span.setAttribute('data-sc', kind + ':' + value);
-    span.innerHTML =
-      '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
-      '<span class="sc-chip-label"></span>';
-    span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
-    return span;
-  }
-
-  // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
-  function chipifyShortcodes(root) {
-    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
-    const targets = [];
-    while (walker.nextNode()) {
-      const n = walker.currentNode;
-      // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
-      if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
-      if (SC_RE.test(n.nodeValue)) targets.push(n);
-      SC_RE.lastIndex = 0;
-    }
-    for (const node of targets) {
-      const txt = node.nodeValue;
-      const frag = document.createDocumentFragment();
-      let last = 0;
-      let m;
-      SC_RE.lastIndex = 0;
-      while ((m = SC_RE.exec(txt)) !== null) {
-        if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
-        frag.appendChild(makeChip(m[1], m[2].trim()));
-        last = m.index + m[0].length;
-      }
-      if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
-      node.parentNode.replaceChild(frag, node);
-    }
-  }
-
-  // Inverse of chipify: clone the editor, replace every chip with its text.
-  function serializeChips(rootClone) {
-    const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
-    for (const c of chips) {
-      const txt = '[[' + c.getAttribute('data-sc') + ']]';
-      c.replaceWith(document.createTextNode(txt));
-    }
-  }
-
-  // ── Boot: load initial content as HTML, then render shortcodes as chips
-  try {
-    const initial = JSON.parse(initialEl.textContent || '""');
-    editor.innerHTML = initial || '';
-    chipifyShortcodes(editor);
-  } catch (e) {
-    console.error('[editor] could not parse initial content', e);
-    editor.innerHTML = '';
-  }
-
-  // ── Char counter
-  function updateCharCount() {
-    const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
-    if (charCountEl) charCountEl.textContent = String(text.length);
-  }
-  updateCharCount();
-  editor.addEventListener('input', updateCharCount);
-
-  // ── Toolbar wiring
-  // Lock the scroll position around an edit command. execCommand/insert scrolls
-  // the caret into view by default → the view "jumps" when clicking a formatting
-  // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …)
-  // + the page and restore them — sync and over a few frames, because Chrome
-  // sometimes scrolls a frame later. The user scrolls themselves.
-  function scrollableAncestors(el) {
-    const list = [];
-    let node = el;
-    while (node && node !== document.body && node !== document.documentElement) {
-      const oy = getComputedStyle(node).overflowY;
-      if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node);
-      node = node.parentElement;
-    }
-    return list;
-  }
-  function keepScroll(fn) {
-    // In fullscreen the page is locked (body overflow:hidden) and the field may
-    // scroll to the caret freely — no page jump possible, so nothing to fix.
-    const frame = document.querySelector('.pe-editor-frame');
-    if (frame && frame.classList.contains('pe-fs')) { fn(); return; }
-    const wx = window.scrollX, wy = window.scrollY;
-    const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; });
-    const restore = function () {
-      window.scrollTo(wx, wy);
-      anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; });
-    };
-    fn();
-    restore();
-    requestAnimationFrame(restore);
-  }
-  function execCmd(cmd, arg) {
-    keepScroll(function () {
-      editor.focus({ preventScroll: true });
-      document.execCommand(cmd, false, arg);
-    });
-    updateToolbarState();
-    updateCharCount();
-  }
-  function wrapCode() {
-    const sel = window.getSelection();
-    if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
-    keepScroll(function () {
-      const range = sel.getRangeAt(0);
-      const code = document.createElement('code');
-      code.textContent = sel.toString();
-      range.deleteContents();
-      range.insertNode(code);
-      // Move caret after the new node
-      range.setStartAfter(code);
-      range.collapse(true);
-      sel.removeAllRanges();
-      sel.addRange(range);
-      editor.focus({ preventScroll: true });
-    });
-  }
-  function linkPrompt() {
-    const url = window.prompt('<%= t('pedit.js_link_prompt') %>');
-    if (!url) return;
-    execCmd('createLink', url);
-  }
-  // Is the current selection inside a <blockquote> within the editor? Return it.
-  function blockquoteAncestor() {
-    const sel = window.getSelection();
-    if (!sel || sel.rangeCount === 0) return null;
-    let node = sel.anchorNode;
-    while (node && node !== editor) {
-      if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node;
-      node = node.parentNode;
-    }
-    return null;
-  }
-  // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but
-  // can never turn it OFF (browser quirk). If the caret is already in a quote →
-  // unwrap it; otherwise apply blockquote.
-  function toggleBlockquote() {
-    keepScroll(function () {
-      editor.focus({ preventScroll: true });
-      const bq = blockquoteAncestor();
-      if (bq) {
-        const parent = bq.parentNode;
-        // Extract content from the quote in place, then remove the empty wrapper.
-        const ref = bq;
-        let firstMoved = null;
-        while (bq.firstChild) {
-          const child = bq.firstChild;
-          if (!firstMoved) firstMoved = child;
-          parent.insertBefore(child, ref);
-        }
-        parent.removeChild(bq);
-        // Restore the caret inside the unwrapped content.
-        if (firstMoved) {
-          const sel = window.getSelection();
-          const range = document.createRange();
-          range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent);
-          range.collapse(false);
-          sel.removeAllRanges();
-          sel.addRange(range);
-        }
-      } else {
-        document.execCommand('formatBlock', false, 'blockquote');
-      }
-    });
-    updateToolbarState();
-    updateCharCount();
-  }
-
-  if (toolbar) {
-    // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection
-    // from the editor field. Without this the selection is lost on tap
-    // → execCommand operates on an empty selection (bold can no longer be toggled OFF)
-    // and the browser scrolls the caret back into view (the "jump down"). preventDefault
-    // on mousedown keeps focus in the editor; the click still fires normally.
-    toolbar.addEventListener('mousedown', (e) => {
-      if (e.target.closest('button')) e.preventDefault();
-    });
-    toolbar.addEventListener('click', (e) => {
-      const btn = e.target.closest('button[data-cmd]');
-      if (!btn) return;
-      e.preventDefault();
-      const cmd = btn.dataset.cmd;
-      const arg = btn.dataset.arg || null;
-      if (cmd === 'link-prompt') linkPrompt();
-      else if (cmd === 'code-wrap') wrapCode();
-      else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote();
-      else execCmd(cmd, arg);
-    });
-  }
-
-  // ── Full-screen writing mode: the writing field fills the whole page.
-  const fsBtn = document.getElementById('pe-fullscreen-btn');
-  const editorFrame = document.querySelector('.pe-editor-frame');
-  const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
-
-  // On mobile the keyboard pushes the visible (visual) viewport up while
-  // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar
-  // slides out of view. Keep the fullscreen frame aligned to the visual
-  // viewport (top + height) so the toolbar stays visible at the top.
-  function syncFsViewport() {
-    if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
-    const vv = window.visualViewport;
-    if (!vv) return;
-    editorFrame.style.top = vv.offsetTop + 'px';
-    editorFrame.style.height = vv.height + 'px';
-  }
-  function clearFsViewport() {
-    if (!editorFrame) return;
-    editorFrame.style.top = '';
-    editorFrame.style.height = '';
-  }
-  function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); }
-  function applyFs(on) {
-    if (!editorFrame) return;
-    editorFrame.classList.toggle('pe-fs', on);
-    document.body.classList.toggle('pe-fs-open', on);
-    document.documentElement.classList.toggle('pe-fs-open', on);
-    if (fsBtn) {
-      fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
-      fsBtn.title = on ? '<%= t('pedit.tb_done') %>' : '<%= t('pedit.tb_fullscreen') %>';
-    }
-    if (window.visualViewport) {
-      if (on) {
-        window.visualViewport.addEventListener('resize', syncFsViewport);
-        window.visualViewport.addEventListener('scroll', syncFsViewport);
-        syncFsViewport();
-      } else {
-        window.visualViewport.removeEventListener('resize', syncFsViewport);
-        window.visualViewport.removeEventListener('scroll', syncFsViewport);
-        clearFsViewport();
-      }
-    }
-    // On touch the field is NOT editable inline; only in fullscreen.
-    if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false');
-    if (on) {
-      editor.focus({ preventScroll: true });
-    } else {
-      if (isTouch) editor.blur();
-      // On close: scroll to the TOP of the content instead of staying
-      // somewhere at the bottom (footer).
-      requestAnimationFrame(function () {
-        try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {}
-      });
-    }
-  }
-  // The fullscreen writing "page": opening pushes a history state so the browser
-  // back button (and the Done button) closes it and returns you to the form — feels
-  // like a separate page, but all form fields remain intact (same DOM).
-  function openFs() {
-    if (isFs()) return;
-    try { history.pushState({ peFs: true }, ''); } catch (_) {}
-    applyFs(true);
-  }
-  function closeFs() {
-    if (!isFs()) return;
-    if (history.state && history.state.peFs) history.back(); // → popstate closes it
-    else applyFs(false);
-  }
-  function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); }
-  window.addEventListener('popstate', function () { if (isFs()) applyFs(false); });
-  if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
-  var fsDoneBtn = document.getElementById('pe-fs-done');
-  if (fsDoneBtn) fsDoneBtn.addEventListener('click', closeFs);
-  document.addEventListener('keydown', (e) => {
-    if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); }
-  });
-
-  // On mobile/tablet (touch): the content field is NOT editable inline — it is
-  // not a text field there. One tap → fullscreen, where it becomes editable
-  // (toggleFullscreen toggles contenteditable). This prevents inline typing.
-  if (isTouch) {
-    editor.setAttribute('contenteditable', 'false');
-    editor.classList.add('pe-tap-to-edit');
-    editor.addEventListener('click', function () {
-      if (!isFs()) openFs();
-    });
-  }
-
-  // Reflect bold/italic/list state on the toolbar buttons
-  function updateToolbarState() {
-    if (!toolbar) return;
-    const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
-    for (const cmd of cmds) {
-      const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
-      if (!btn) continue;
-      try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
-    }
-    // Quote button: active when the caret is inside a <blockquote> (toggle feedback).
-    const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
-    if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
-  }
-  document.addEventListener('selectionchange', () => {
-    if (document.activeElement === editor) updateToolbarState();
-  });
-
-  // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
-  editor.addEventListener('keydown', (e) => {
-    const mod = e.ctrlKey || e.metaKey;
-    if (!mod) return;
-    const k = e.key.toLowerCase();
-    if (k === 'b') { e.preventDefault(); execCmd('bold'); }
-    else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
-    else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
-    else if (k === 'k') { e.preventDefault(); linkPrompt(); }
-  });
-
-  // Paste: keep it simple — strip formatting unless user wants it. Default
-  // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
-  // styles from clipboard only when shift is held — otherwise plain text.
-  editor.addEventListener('paste', (e) => {
-    if (e.shiftKey) return; // user wants formatted paste
-    const text = (e.clipboardData || window.clipboardData).getData('text/plain');
-    if (text == null) return;
-    e.preventDefault();
-    document.execCommand('insertText', false, text);
-  });
-
-  // ── Image upload (button + drag-drop into the editor)
-  async function uploadAndInsertImage(file) {
-    const edited = await openImageEditor(file);
-    if (!edited) return; // cancelled
-    contentStatus.classList.remove('is-error');
-    contentStatus.textContent = '<%= t('pedit.js_uploading') %>';
-    try {
-      const j = await uploadImage(edited);
-      const img = '<img src="' + j.url + '" alt="">';
-      editor.focus({ preventScroll: true });
-      document.execCommand('insertHTML', false, img);
-      contentStatus.textContent = '<%= t('pedit.js_inserted') %> ✓';
-      setTimeout(() => { contentStatus.textContent = ''; }, 2000);
-      updateCharCount();
-    } catch (e) {
-      contentStatus.classList.add('is-error');
-      contentStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message;
-    }
-  }
-
-  if (contentBtn && contentField) {
-    contentBtn.addEventListener('click', () => contentField.click());
-    contentField.addEventListener('change', () => {
-      if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
-      contentField.value = '';
-    });
-
-    editor.addEventListener('dragover', (e) => {
-      if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
-        e.preventDefault();
-        editor.classList.add('is-dragover');
-      }
-    });
-    editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
-    editor.addEventListener('drop', async (e) => {
-      editor.classList.remove('is-dragover');
-      const files = e.dataTransfer && e.dataTransfer.files;
-      if (!files || !files.length) return;
-      e.preventDefault();
-      for (const f of files) {
-        if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
-      }
-    });
-  }
-
-  // ── Insert chip helpers (track / playlist)
-  function insertChip(kind, value) {
-    editor.focus({ preventScroll: true });
-    const chip = makeChip(kind, value);
-    // Insert at caret using the Selection API (execCommand insertNode)
-    const sel = window.getSelection();
-    if (sel && sel.rangeCount > 0) {
-      const range = sel.getRangeAt(0);
-      range.deleteContents();
-      range.insertNode(chip);
-      // Insert a trailing space so the user can keep typing after the chip
-      const space = document.createTextNode('\u00A0');
-      chip.after(space);
-      range.setStartAfter(space);
-      range.collapse(true);
-      sel.removeAllRanges();
-      sel.addRange(range);
-    } else {
-      editor.appendChild(chip);
-      editor.appendChild(document.createTextNode('\u00A0'));
-    }
-    updateCharCount();
-  }
-
-  // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes
-  //    an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
-  const embedBtn = document.getElementById('insert-embed-btn');
-  if (embedBtn) {
-    embedBtn.addEventListener('click', () => {
-      const raw = window.prompt('<%= t('pedit.js_embed_prompt') %>');
-      if (!raw) return;
-      const url = raw.trim();
-      if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; }
-      insertChip('embed', url);
-    });
-  }
-
-  // ── Track insert: opens the track-picker modal (P59)
-  const trackBtn = document.getElementById('insert-track-btn');
-  const trackPicker = document.getElementById('track-picker');
-  if (trackBtn && trackPicker) {
-    const tpList   = document.getElementById('tp-list');
-    const tpEmpty  = document.getElementById('tp-empty');
-    const tpSearch = document.getElementById('tp-search');
-    let tpCache = null;       // cached track list (fetched once per page load)
-    let tpLastFocus = null;   // element to restore focus to on close
-
-    const SVG_NOTE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>';
-
-    function fmtDur(sec) {
-      sec = Math.max(0, Math.floor(sec || 0));
-      const m = Math.floor(sec / 60), s = sec % 60;
-      return m + ':' + String(s).padStart(2, '0');
-    }
-    function escAttr(s) {
-      return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
-        '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
-      }[c]));
-    }
-
-    function renderList(filter) {
-      if (!Array.isArray(tpCache)) return;
-      const q = (filter || '').trim().toLowerCase();
-      const filtered = q
-        ? tpCache.filter(t =>
-            (t.title  || '').toLowerCase().includes(q) ||
-            (t.artist || '').toLowerCase().includes(q))
-        : tpCache;
-
-      if (!filtered.length) {
-        tpList.innerHTML = '';
-        tpEmpty.textContent = q ? '<%= t('pedit.js_no_tracks_found') %> ' + q : '<%= t('pedit.js_no_tracks_yet') %>';
-        tpList.appendChild(tpEmpty);
-        return;
-      }
-
-      tpList.innerHTML = filtered.map(t => {
-        const cov = t.cover
-          ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
-          : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
-        const dis = t.playable ? '' : ' aria-disabled="true"';
-        const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
-        return (
-          '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
-            cov +
-            '<span class="tp-meta">' +
-              '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
-              sub +
-            '</span>' +
-            '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
-          '</button>'
-        );
-      }).join('');
-    }
-
-    async function loadTracks() {
-      if (Array.isArray(tpCache)) return tpCache;
-      tpEmpty.textContent = '<%= t('pedit.js_tracks_loading') %>';
-      try {
-        const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
-        const j = await r.json();
-        tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
-      } catch (e) {
-        tpCache = [];
-        tpEmpty.textContent = '<%= t('pedit.js_tracks_load_fail') %>: ' + e.message;
-      }
-      return tpCache;
-    }
-
-    function openPicker() {
-      tpLastFocus = document.activeElement;
-      trackPicker.hidden = false;
-      trackPicker.setAttribute('aria-hidden', 'false');
-      document.body.classList.add('tp-locked');
-      tpSearch.value = '';
-      renderList('');
-      // Defer focus so the open animation doesn't get jumped
-      setTimeout(() => tpSearch.focus(), 30);
-    }
-    function closePicker() {
-      trackPicker.hidden = true;
-      trackPicker.setAttribute('aria-hidden', 'true');
-      document.body.classList.remove('tp-locked');
-      if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
-        try { tpLastFocus.focus(); } catch(_) {}
-      }
-    }
-
-    trackBtn.addEventListener('click', async () => {
-      openPicker();
-      await loadTracks();
-      renderList(tpSearch.value);
-    });
-
-    // Close: backdrop click, [data-tp-close], or Escape
-    trackPicker.addEventListener('click', (e) => {
-      if (e.target.closest('[data-tp-close]')) {
-        closePicker();
-        return;
-      }
-      const row = e.target.closest('.tp-row[data-track-id]');
-      if (row) {
-        if (row.getAttribute('aria-disabled') === 'true') return;
-        const id = row.dataset.trackId;
-        if (id) {
-          insertChip('track', id);
-          closePicker();
-        }
-      }
-    });
-    document.addEventListener('keydown', (e) => {
-      if (!trackPicker.hidden && e.key === 'Escape') {
-        e.preventDefault();
-        closePicker();
-      }
-    });
-
-    // Live filter
-    tpSearch.addEventListener('input', () => renderList(tpSearch.value));
-  }
-
-  // ── Playlist insert (open existing or create new via modal)
-  const playlistBtn = document.getElementById('insert-playlist-btn');
-  if (playlistBtn) {
-    playlistBtn.addEventListener('click', async () => {
-      if (typeof window.openPlaylistEditor !== 'function') {
-        alert('<%= t('pedit.js_playlist_editor_missing') %>');
-        return;
-      }
-      try {
-        const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
-        const j = await r.json();
-        if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
-          const choice = prompt(
-            '<%= t('pedit.js_playlist_existing') %>\n\n' +
-            j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
-            '\n\n<%= t('pedit.js_playlist_choose') %>'
-          );
-          if (choice && /^\d+$/.test(choice.trim())) {
-            const idx = parseInt(choice.trim(), 10) - 1;
-            if (idx >= 0 && idx < j.playlists.length) {
-              insertChip('playlist', j.playlists[idx].id);
-              return;
-            }
-          }
-          if (choice === null) return;
-        }
-      } catch (_) { /* fall through to create */ }
-
-      window.openPlaylistEditor({
-        mode: 'create',
-        onSaved: ({ id }) => insertChip('playlist', id),
-      });
-    });
-  }
-
-  // ── Post type: segmented control + type-aware panels ──────────
-  (function () {
-    const typeInput = document.getElementById('pe-type-input');
-    const card = document.querySelector('.pe-type-card');
-    if (!typeInput || !card) return;
-    const seg = card.querySelector('.pe-typeseg');
-    const panels = card.querySelectorAll('.pe-type-panel');
-
-    function applyType(tt) {
-      typeInput.value = tt;
-      seg.querySelectorAll('.pe-typeseg-btn').forEach(b => {
-        const on = b.dataset.type === tt;
-        b.classList.toggle('is-active', on);
-        b.setAttribute('aria-checked', on ? 'true' : 'false');
-      });
-      panels.forEach(p => { p.hidden = (p.dataset.panel !== tt); });
-    }
-    seg.addEventListener('click', (e) => {
-      const btn = e.target.closest('.pe-typeseg-btn');
-      if (btn) applyType(btn.dataset.type);
-    });
-    applyType(typeInput.value || 'post');
-
-    // Video URL → [[embed:url]] chip
-    const vBtn = document.getElementById('pe-video-insert');
-    const vUrl = document.getElementById('pe-video-url');
-    if (vBtn && vUrl) {
-      const doInsert = () => {
-        const url = (vUrl.value || '').trim();
-        if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; }
-        insertChip('embed', url);
-        vUrl.value = '';
-      };
-      vBtn.addEventListener('click', doInsert);
-      vUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doInsert(); } });
-    }
-
-    // Audio: inline upload → transcodes server-side → [[track:id]] chip
-    const drop = document.getElementById('pe-audio-drop');
-    const fileInput = document.getElementById('pe-audio-file');
-    const list = document.getElementById('pe-audio-list');
-    if (drop && fileInput && list) {
-      const pick = () => fileInput.click();
-      drop.addEventListener('click', pick);
-      drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
-      ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('is-drag'); }));
-      ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('is-drag'); }));
-      drop.addEventListener('drop', (e) => { if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); });
-      fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; });
-
-      function clientDuration(f) {
-        return new Promise((resolve) => {
-          try {
-            const u = URL.createObjectURL(f);
-            const a = document.createElement('audio');
-            a.preload = 'metadata';
-            a.onloadedmetadata = () => { URL.revokeObjectURL(u); resolve(Number.isFinite(a.duration) ? Math.round(a.duration) : null); };
-            a.onerror = () => { URL.revokeObjectURL(u); resolve(null); };
-            a.src = u;
-          } catch (_) { resolve(null); }
-        });
-      }
-      async function handleFiles(files) {
-        for (const f of Array.from(files || [])) await uploadOne(f);
-      }
-      async function uploadOne(f) {
-        const li = document.createElement('li');
-        li.className = 'pe-audio-item';
-        const nameEl = document.createElement('span');
-        nameEl.className = 'pe-audio-item-name';
-        nameEl.textContent = f.name;
-        const stateEl = document.createElement('span');
-        stateEl.className = 'pe-audio-item-state';
-        stateEl.textContent = '⏳ <%= t('pedit.audio_up_busy') %>';
-        li.appendChild(nameEl); li.appendChild(stateEl);
-        list.appendChild(li);
-        try {
-          const dur = await clientDuration(f);
-          const fd = new FormData();
-          fd.append('audio', f);
-          if (dur) fd.append('duration', String(dur));
-          const res = await fetch('/admin/audio/upload', {
-            method: 'POST', body: fd,
-            headers: { 'Accept': 'application/json' },
-            credentials: 'same-origin',
-          });
-          const j = await res.json().catch(() => ({}));
-          if (!res.ok || !j.ok || !j.id) throw new Error(j.error || ('HTTP ' + res.status));
-          insertChip('track', j.id);
-          stateEl.textContent = '✓ <%= t('pedit.audio_up_done') %>';
-          li.classList.add('is-done');
-        } catch (err) {
-          stateEl.textContent = '✕ <%= t('pedit.audio_up_fail') %>: ' + err.message;
-          li.classList.add('is-fail');
-        }
-      }
-    }
-  })();
-
-  // ── Submit: serialize editor contents into the hidden field
-  if (form && hiddenField) {
-    form.addEventListener('submit', () => {
-      const clone = editor.cloneNode(true);
-      serializeChips(clone);
-      hiddenField.value = clone.innerHTML;
-    });
-  }
-})();
-</script>
+
 
 <%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
@@ -2229,61 +1214,3 @@
   <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
 <% } %>
-
-<script>
-(function () {
-  // Pin: checkbox toggles the hidden rank field (0 = not pinned),
-  // ▲▼ shifts the position, with a readable description instead of a raw number.
-  var toggle = document.getElementById('pin-toggle');
-  var rank   = document.getElementById('pin-rank');
-  var pos    = document.getElementById('pin-pos');
-  var label  = document.getElementById('pin-label');
-  var up     = document.getElementById('pin-up');    // higher = lower number (towards 1/top)
-  var down   = document.getElementById('pin-down');
-  if (!toggle || !rank || !pos) return;
-
-  function descr(n) {
-    n = Number(n) || 0;
-    if (n <= 1) return '<%= t('pedit.pin_top') %>';
-    return n + '<%= t('pedit.pin_nth_suffix') %>';
-  }
-  function render() {
-    var on = toggle.checked;
-    pos.hidden = !on;
-    if (on && Number(rank.value) < 1) rank.value = 1;
-    if (!on) rank.value = 0;
-    if (label) label.textContent = on ? descr(rank.value) : '';
-    if (up) up.disabled = Number(rank.value) <= 1;
-  }
-  toggle.addEventListener('change', render);
-  if (up)   up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
-  if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
-  render();
-})();
-
-(function () {
-  // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible
-  // obstacles by setting a dynamic bottom offset = the greater of:
-  //  1) the height of the keyboard area NOT covered by the layout viewport
-  //     (on iOS the visual viewport shifts; on Android the layout viewport shrinks
-  //     due to interactive-widget=resizes-content → offset ≈ 0);
-  //  2) the height of the playing audio player (fixed, z-index 1000).
-  // We stick with sticky (no fixed/top tricks → no bar floating in the middle).
-  var bar = document.querySelector('.pe-actions');
-  if (!bar) return;
-  var vv = window.visualViewport;
-  function position() {
-    var ap = document.querySelector('.audio-player');
-    var playing = document.body.classList.contains('has-audio-player') &&
-                  ap && getComputedStyle(ap).display !== 'none';
-    var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0;
-    var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
-    var offset = Math.max(audioOffset, kbCovered);
-    bar.style.bottom = offset ? offset + 'px' : '';
-  }
-  position();
-  window.addEventListener('resize', position);
-  if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); }
-  // has-audio-player is toggled via a body class → observe it.
-  try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
-})();
-</script>
+<%- include('../partials/page-data', { pageData: { _timezone: (typeof timezone !== 'undefined' ? timezone : ''), apply: t('imed.apply'), audio_up_busy: t('pedit.audio_up_busy'), audio_up_done: t('pedit.audio_up_done'), audio_up_fail: t('pedit.audio_up_fail'), cancel: t('imed.cancel'), chip_album: t('pedit.chip_album'), chip_playlist: t('pedit.chip_playlist'), chip_track: t('pedit.chip_track'), flip_h: t('imed.flip_h'), flip_v: t('imed.flip_v'), js_embed_invalid: t('pedit.js_embed_invalid'), js_embed_prompt: t('pedit.js_embed_prompt'), js_failed: t('pedit.js_failed'), js_inserted: t('pedit.js_inserted'), js_link_prompt: t('pedit.js_link_prompt'), js_no_tracks_found: t('pedit.js_no_tracks_found'), js_no_tracks_yet: t('pedit.js_no_tracks_yet'), js_playlist_choose: t('pedit.js_playlist_choose'), js_playlist_editor_missing: t('pedit.js_playlist_editor_missing'), js_playlist_existing: t('pedit.js_playlist_existing'), js_tracks_load_fail: t('pedit.js_tracks_load_fail'), js_tracks_loading: t('pedit.js_tracks_loading'), js_uploaded: t('pedit.js_uploaded'), js_uploading: t('pedit.js_uploading'), pin_nth_suffix: t('pedit.pin_nth_suffix'), pin_top: t('pedit.pin_top'), reset: t('imed.reset'), rotate_left: t('imed.rotate_left'), rotate_right: t('imed.rotate_right'), tb_done: t('pedit.tb_done'), tb_fullscreen: t('pedit.tb_fullscreen'), title: t('imed.title'), zoom_in: t('imed.zoom_in'), zoom_out: t('imed.zoom_out') } }) %>
Index: src/views/pages/post.ejs
===================================================================
--- src/views/pages/post.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/post.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -147,51 +147,7 @@
     </section>
   <% } %>
-  <script>
-  (function(){
-    if (window.__fediRemoteWired) return; window.__fediRemoteWired = true;
-    var current = null, currentBtn = null;
-    function close(){ if (current) { current.remove(); current = null; currentBtn = null; } }
-    function go(raw, uri){
-      var d = (raw||'').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
-      if (d) { try { localStorage.setItem('pcmsFediServer', d); } catch(e){} location.href = 'https://' + d + '/authorize_interaction?uri=' + encodeURIComponent(uri||''); }
-    }
-    function place(f, b){
-      var r = b.getBoundingClientRect();
-      f.style.top = (r.bottom + window.scrollY + 6) + 'px';
-      f.style.left = Math.max(8, Math.min(r.left + window.scrollX, window.scrollX + window.innerWidth - 340)) + 'px';
-    }
-    document.addEventListener('click', function(e){
-      if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; }
-      if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return; // click inside → keep
-      var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn');
-      if (b) {
-        e.preventDefault();
-        if (currentBtn === b) { close(); return; }   // toggle off
-        close();
-        var f = document.createElement('form');
-        f.className = 'fedi-remote-form';
-        f.dataset.uri = b.getAttribute('data-fedi-uri') || '';
-        f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false">'
-          + '<button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">&rarr;</button>'
-          + '<button type="button" class="fedi-remote-cancel" aria-label="x">&times;</button>';
-        var _inp = f.querySelector('input');
-        _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social';
-        try { var _sv = localStorage.getItem('pcmsFediServer'); if (_sv) _inp.value = _sv; } catch(e){}
-        document.body.appendChild(f);            // floating popover → no layout reflow
-        place(f, b); current = f; currentBtn = b;
-        _inp.focus(); _inp.select();
-        return;
-      }
-      if (current) close(); // click anywhere else closes it
-    });
-    document.addEventListener('submit', function(e){
-      var f = e.target.closest && e.target.closest('.fedi-remote-form');
-      if (!f) return; e.preventDefault();
-      go(f.querySelector('input').value, f.dataset.uri);
-    });
-    document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
-    window.addEventListener('scroll', close, true);
-  })();
-  </script>
+  <%# Het remote-reply-veld zit in assets/js/mod/post.js. Inline script hier wordt
+    door de CSP geweigerd zodra je deze pagina via een link binnen de site opent
+    -- zie shaer-0i6. %>
 
 
Index: src/views/partials/bottom-tab.ejs
===================================================================
--- src/views/partials/bottom-tab.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/bottom-tab.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -302,17 +302,5 @@
 </style>
 
-<script>
-// Bottom-tab search button → opens #search-overlay. Using event delegation + looking
-// up the overlay on EVERY click: the overlay lives in the chrome that is OOB-replaced
-// during htmx navigation, so a once-captured reference goes stale
-// (button did nothing afterwards). Guard against double-wiring.
-(function() {
-  if (window.__btSearchWired) return;
-  window.__btSearchWired = true;
-  document.addEventListener('click', function(e) {
-    if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return;
-    var o = document.getElementById('search-overlay');
-    if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); }
-  });
-})();
-</script>
+<%# De zoekknop zit nu in de chrome-module (assets/js/mod/chrome.js).
+    Inline script hier wordt door de CSP geweigerd zodra deze partial via htmx
+    binnenkomt, en dat is bij elke navigatie -- zie shaer-0i6. %>
Index: src/views/partials/msg-item.ejs
===================================================================
--- src/views/partials/msg-item.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/msg-item.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -1,2 +1,82 @@
+<% if (n.type === 'thread') { %>
+<%
+           var _tseen = (typeof seen !== 'undefined' && seen) ? seen : 0;
+           // Een draad is nieuw zodra EEN bericht erin nieuw is.
+           var _tnew = !!_tseen && (n.messages || []).some(function (m) {
+             return m.created_at && Date.parse(m.created_at) > _tseen;
+           });
+           var _tpeople = (n.people || []).map(function (p) { return p.name || p.handle || '?'; });
+           // Alleen jij aan het woord: dan is de tegenpartij het adres waaraan
+           // je schreef, niet een lege naam.
+           var _tto = ((n.messages || []).find(function (m) { return m.to_handle; }) || {}).to_handle;
+           var _tlabel = _tpeople.length ? _tpeople.join(', ') : (_tto || t('msg.you'));
+      %>
+        <li class="msg-item msg-thread<%= _tnew ? ' is-new' : '' %>" data-kind="conv" data-who="<%= (_tpeople.join(' ') + ' ' + (_tto || '')).toLowerCase() %>">
+          <div class="msg-thread-head">
+            <%# Tegenpartij en aantal in EEN uitsparing: het is een label bij het
+                gesprek, geen kop erboven. Zo houdt de inhoud de ruimte.
+
+                Deze uitsparing is ook de knop die het gesprek in- en uitklapt
+                (Barts aanwijzing): tikken op de tegenpartij is waar je van
+                nature naar wijst als je "dit gesprek" bedoelt. Een BUTTON en
+                geen span met een klikhandler, want dan werkt hij ook met het
+                toetsenbord en leest een schermlezer de stand voor.
+
+                Zonder JavaScript doet hij niets, en dan staat het gesprek
+                gewoon open -- dat is ook de stand waar we mee beginnen. %>
+            <button type="button" class="msg-thread-who" aria-expanded="true">
+              <span class="msg-thread-name"><%= _tlabel %></span>
+              <% if (n.count > 1) { %><span class="msg-thread-count"><%= n.count %></span><% } %>
+              <svg class="msg-thread-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
+            </button>
+            <% if (_tnew) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
+            <%# De context bij de draad: waar gaat dit gesprek over? Zonder deze
+                link is een antwoord in een lijst niet te plaatsen. %>
+            <% if (n.post) { %>
+              <a class="msg-thread-post" href="/<%= n.post.slug %>">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
+                <%= n.post.title || n.post.slug %>
+              </a>
+            <% } %>
+          </div>
+          <ul class="msg-thread-msgs">
+            <% (n.messages || []).forEach(function (m) { %><%- include('msg-item', { n: m, seen: _tseen, inThread: true }) %><% }); %>
+          </ul>
+          <%# Antwoorden vanuit de draad zelf. Ingeklapt, want een open editor
+              onder elk gesprek maakt de lijst onleesbaar -- juist het probleem
+              dat deze weergave moest oplossen. De route kiest het pad: een
+              draad aan een post krijgt een reply, een draad aan een persoon een
+              direct bericht. %>
+          <% var _rt = n.replyTo || {}; var _kan = canMutate && ((_rt.interactionId && _rt.postSlug) || _rt.actorUri); %>
+          <% if (_kan) { %>
+            <div class="msg-thread-foot">
+              <details class="msg-reply">
+                <summary><%= t('fedi.reply') %></summary>
+                <div class="msg-reply-form">
+                  <%- include('../partials/reply-editor', {
+                    action: (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') + '/messages/reply',
+                    hiddenFields: [
+                      { name: 'interaction_id', value: _rt.interactionId || '' },
+                      { name: 'post_slug', value: _rt.postSlug || '' },
+                      { name: 'to', value: _rt.actorUri || '' },
+                    ],
+                    placeholder: t('fedi.reply_ph'),
+                    submitLabel: t('fedi.reply'),
+                    rows: 2,
+                  }) %>
+                </div>
+              </details>
+              <%# De zwaai blijft een eigen knop: het is geen antwoord maar een
+                  seintje (FEP-633c), en die twee horen niet op een hoop. %>
+              <% if (_rt.actorUri) { %>
+                <form method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" class="msg-wave">
+                  <input type="hidden" name="to" value="<%= _rt.actorUri %>">
+                  <button class="btn small" name="text" value="&#128075;" title="<%= t('msg.wave_back') %>">&#128075;</button>
+                </form>
+              <% } %>
+            </div>
+          <% } %>
+        </li>
+<% } else { %>
 <%
            var _seen = (typeof seen !== 'undefined' && seen) ? seen : 0;
@@ -5,16 +85,22 @@
            var _who = n.name || n.handle || '';
            var _priv0 = (n.visibility === 'followers' || n.visibility === 'direct');
-           // Buckets (klonkt-demo-3jf): Messages = @mentions + private (DM) replies;
-           // Activity = likes/boosts/follows; Moderation = reports; Sent = your
-           // replies; Conversations = every other (public) reply.
-           var _kind = _t === 'sent' ? 'sent'
-             : (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act'
+           // Emmers: Activiteit = likes/boosts/follows/polls, Moderatie =
+           // reports, en al het overige is gesprek. De oude emmers 'msgs' en
+           // 'sent' bestaan niet meer sinds Berichten, Gesprekken en Verzonden
+           // samenvielen -- en dat is geen cosmetische hernoeming: een
+           // gespreksregel die om wat voor reden dan ook geen draad werd (geen
+           // post, geen herkenbare tegenpartij) hield anders een data-kind
+           // waarvoor geen chip meer bestond, en was daarmee onder ELKE chip
+           // behalve Alles onzichtbaar.
+           var _kind = (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act'
              : _t === 'report' ? 'mod'
-             : (_t === 'mention' || _priv0) ? 'msgs'
              : 'conv';
+           // Binnen een draad is dit geen zelfstandige regel: de chip filtert op
+           // de draad, niet op de losse zinnen erin.
+           var _sub = (typeof inThread !== 'undefined' && inThread);
            var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase();
            var _priv = (n.visibility === 'followers' || n.visibility === 'direct');
       %>
-        <li class="msg-item msg-<%= _t %><%= _new ? ' is-new' : '' %>" data-kind="<%= _kind %>" data-who="<%= String(_who).toLowerCase() %>">
+        <li class="<%= _sub ? 'msg-sub' : 'msg-item' %> msg-<%= _t %><%= _new ? ' is-new' : '' %>"<% if (!_sub) { %> data-kind="<%= _kind %>"<% } %> data-who="<%= String(_who).toLowerCase() %>">
           <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true">
             <% if (_t === 'sent') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
@@ -38,8 +124,10 @@
               <% if (_t === 'sent') { %>
                 <span class="msg-who"><%= t('msg.you') %></span>
-                <% if (n.to_handle) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>
+                <% if (n.to_handle && !_sub) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>
               <% } else { %>
                 <a class="msg-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%- emojiName(_who, n.actor_emoji_json) %></a>
-                <% if (n.handle && n.name) { %><span class="msg-handle"><%= n.handle %></span><% } %>
+                <% /* De naam blijft: binnen een draad moet je zien WIE wat zei.
+                      De handle erachter niet -- die staat in de kop. */ %>
+                <% if (n.handle && n.name && !_sub) { %><span class="msg-handle"><%= n.handle %></span><% } %>
               <% } %>
               <% if (_new) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
@@ -50,4 +138,8 @@
             </div>
 
+            <% var _hasWhat = !_sub || _priv
+                 || (_t === 'mention' && (n.help_request || n.wave || n.note_url))
+                 || (_t === 'sent' && n.in_reply_to); %>
+            <% if (_hasWhat) { %>
             <div class="msg-what">
               <% if (n.count && n.count > 1) { %>
@@ -60,4 +152,5 @@
               <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %>
               <% } else if (_t === 'mention' && n.wave) { %>👋 <%= t('msg.waved_at_you') %>
+              <% } else if (_sub) { %><%# binnen een draad zegt de kop al dat dit een gesprek is %>
               <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %>
               <% } else if (_t === 'sent') { %><%= t('msg.sent_reply') %>
@@ -65,8 +158,9 @@
               <% } else { %><%= t('notif.replied') %><% } %>
               <% if (_priv) { %><span class="msg-priv" title="<%= t('msg.private_hint') %>">🔒 <%= t('msg.private') %></span><% } %>
-              <% if (n.post_slug) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
+              <% if (n.post_slug && !_sub) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
               <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
               <% if (_t === 'sent' && n.in_reply_to) { %><a class="msg-post" href="<%= n.in_reply_to %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
             </div>
+            <% } %>
 
             <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div>
@@ -116,2 +210,3 @@
           </div>
         </li>
+<% } %>
Index: src/views/partials/page-data.ejs
===================================================================
--- src/views/partials/page-data.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/views/partials/page-data.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,17 @@
+<%#
+  Servergegevens naar een module (shaer-bqr).
+
+  Een module is een statisch bestand, dus er kan geen EJS-tag in. Alles wat een
+  script van de server nodig heeft -- vertalingen, ids, instellingen -- komt
+  hierlangs, en de module leest het met pageData() uit mod/lib.js.
+
+  Waarom dit GEEN CSP-probleem is terwijl het een script-element is: een script
+  met een ander type dan JavaScript wordt niet uitgevoerd en niet opgehaald, dus
+  script-src heeft er niets over te zeggen. Het is een gegevensblok dat toevallig
+  in een script-tag staat, en het werkt daarmee ook in inhoud die via htmx
+  binnenkomt.
+
+  De kleiner-dan wordt ontsnapt: zonder dat sluit een sluitende script-tag IN de
+  gegevens dit blok voortijdig af, en dan staat de rest als HTML op de pagina.
+-%>
+<% if (typeof pageData !== 'undefined' && pageData) { %><script type="application/json" data-page-data><%- JSON.stringify(pageData).replace(/</g, '\\u003c') %></script><% } %>
Index: src/views/partials/playlist-editor.ejs
===================================================================
--- src/views/partials/playlist-editor.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/playlist-editor.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -280,408 +280,3 @@
 </style>
 
-<script>
-(function() {
-  // Idempotency: if window.openPlaylistEditor already defined (multiple
-  // partial includes on a single page), skip re-binding.
-  if (typeof window.openPlaylistEditor === 'function') return;
-
-  const CSRF = '<%= _csrf %>';
-
-  function esc(s) {
-    return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
-      '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
-    }[c]));
-  }
-  function fmtDur(sec) {
-    if (!sec) return '';
-    const m = Math.floor(sec / 60), s = sec % 60;
-    return `${m}:${String(s).padStart(2, '0')}`;
-  }
-
-  async function api(method, url, body) {
-    const opts = {
-      method, credentials: 'same-origin',
-      headers: { 'X-CSRF-Token': CSRF },
-    };
-    if (body !== undefined) {
-      opts.headers['Content-Type'] = 'application/json';
-      opts.body = JSON.stringify(body);
-    }
-    const r = await fetch(url, opts);
-    return r.json();
-  }
-
-  /**
-   * Public entry point: open the editor.
-   *  opts: { mode: 'create'|'edit', id?, onSaved? }
-   *  onSaved is called with { id, playlist } after a successful save.
-   */
-  window.openPlaylistEditor = async function(opts) {
-    opts = opts || {};
-    const mode = opts.mode === 'edit' ? 'edit' : 'create';
-    const isEdit = mode === 'edit';
-
-    // Load all audio tracks for the picker
-    let tracks = [];
-    try {
-      const j = await api('GET', '/admin/playlists/api/tracks');
-      if (Array.isArray(j.tracks)) tracks = j.tracks;
-    } catch (e) {
-      alert('Tracks ophalen mislukt');
-      return;
-    }
-    if (!tracks.length) {
-      alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');
-      return;
-    }
-
-    // Existing playlist data when editing
-    let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] };
-    if (isEdit && opts.id) {
-      try {
-        const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
-        if (j.ok) initial = { ...initial, ...j.playlist };
-      } catch (e) {}
-    }
-
-    // ── DOM ────────────────────────────────────────────────────────
-    const backdrop = document.createElement('div');
-    backdrop.className = 'pl-modal-backdrop';
-    backdrop.innerHTML = `
-      <div class="pl-modal" role="dialog" aria-label="Playlist editor">
-        <div class="pl-modal-header">
-          <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3>
-          <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button>
-        </div>
-        <div class="pl-modal-body">
-          <div class="pl-editor-cols">
-            <div class="pl-editor-left">
-              <div class="pl-meta-grid">
-                <label class="pl-field pl-field-full">
-                  <span>Titel *</span>
-                  <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
-                </label>
-                <label class="pl-field">
-                  <span>Artiest</span>
-                  <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
-                </label>
-                <label class="pl-field">
-                  <span>Jaar</span>
-                  <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
-                </label>
-                <label class="pl-field">
-                  <span>Type</span>
-                  <select id="pli-kind">
-                    <option value="album"    ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option>
-                    <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option>
-                  </select>
-                </label>
-                <div class="pl-field pl-field-full">
-                  <span>Cover</span>
-                  <div class="pl-cover-row">
-                    <span class="pl-cover-thumb" id="pli-cover-thumb">
-                      ${initial.cover
-                        ? `<img src="${esc(initial.cover)}" alt="">`
-                        : `<span class="pl-cover-empty">🎨</span>`}
-                    </span>
-                    <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1">
-                  </div>
-                  <div class="pl-cover-upload-row">
-                    <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
-                    <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
-                    <span class="pl-cover-status" id="pli-cover-status"></span>
-                  </div>
-                </div>
-              </div>
-              <div class="pl-section-title">
-                Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
-                <small>(sleep ⠿ om te ordenen)</small>
-              </div>
-              <div id="pli-selected" class="pl-selected-list"></div>
-            </div>
-            <div class="pl-editor-right">
-              <div class="pl-section-title">Beschikbare tracks</div>
-              <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
-              <div id="pli-available" class="pl-available-list"></div>
-            </div>
-          </div>
-        </div>
-        <div class="pl-modal-footer">
-          <button type="button" class="btn" id="pli-cancel">Annuleren</button>
-          <button type="button" class="btn btn-primary" id="pli-save" disabled>
-            ${isEdit ? 'Opslaan' : 'Aanmaken'}
-          </button>
-        </div>
-      </div>
-    `;
-    document.body.appendChild(backdrop);
-
-    const $ = sel => backdrop.querySelector(sel);
-    const close = () => backdrop.remove();
-    backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
-    $('.pl-modal-close').addEventListener('click', close);
-    $('#pli-cancel').addEventListener('click', close);
-
-    // Index tracks by id for fast lookup
-    const trackById = new Map(tracks.map(t => [t.id, t]));
-    let selected = (initial.track_ids || []).filter(id => trackById.has(id));
-
-    const titleEl = $('#pli-title');
-    const artistEl = $('#pli-artist');
-    const yearEl = $('#pli-year');
-    const kindEl = $('#pli-kind');
-    const coverEl = $('#pli-cover');
-    const coverThumb = $('#pli-cover-thumb');
-    const saveBtn = $('#pli-save');
-    const selectedEl = $('#pli-selected');
-    const availEl = $('#pli-available');
-    const searchEl = $('#pli-search');
-    const countEl = $('#pli-count');
-
-    coverEl.addEventListener('input', () => {
-      const u = coverEl.value.trim();
-      coverThumb.innerHTML = u
-        ? `<img src="${esc(u)}" alt="" data-fallback>`
-        : `<span class="pl-cover-empty">🎨</span>`;
-    });
-
-    // ── Cover file upload (werkt in create ÉN edit) ───────────
-    // We uploaden naar het generieke image-endpoint (/posts/upload-image,
-    // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
-    // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het
-    // cover-veld en wordt bij 'Aanmaken'/'Opslaan' met de playlist meegestuurd.
-    const coverFileInput = $('#pli-cover-file');
-    const coverPickBtn   = $('#pli-cover-pick');
-    const coverStatus    = $('#pli-cover-status');
-    if (coverFileInput && coverPickBtn) {
-      coverPickBtn.addEventListener('click', () => coverFileInput.click());
-      coverFileInput.addEventListener('change', async (e) => {
-        const file = e.target.files && e.target.files[0];
-        coverFileInput.value = '';
-        if (!file) return;
-        if (!/^image\//.test(file.type)) {
-          coverStatus.textContent = 'Alleen afbeeldingen';
-          coverStatus.className = 'pl-cover-status is-error';
-          return;
-        }
-        coverStatus.textContent = 'Uploaden…';
-        coverStatus.className = 'pl-cover-status';
-        const fd = new FormData();
-        fd.append('image', file);
-        try {
-          const r = await fetch('/posts/upload-image',
-            { method: 'POST', body: fd, credentials: 'same-origin' }
-          );
-          const j = await r.json();
-          if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt');
-          const url = j.url || '';
-          coverEl.value = url;
-          coverThumb.innerHTML = url
-            ? `<img src="${esc(url)}" alt="">`
-            : `<span class="pl-cover-empty">🎨</span>`;
-          coverStatus.textContent = '✓ Geüpload';
-          coverStatus.className = 'pl-cover-status is-ok';
-        } catch (err) {
-          coverStatus.textContent = 'Mislukt: ' + err.message;
-          coverStatus.className = 'pl-cover-status is-error';
-        }
-      });
-    }
-
-    function updateSaveBtn() {
-      saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
-    }
-
-    function renderSelected() {
-      countEl.textContent = selected.length;
-      if (selected.length === 0) {
-        selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
-        updateSaveBtn();
-        return;
-      }
-      selectedEl.innerHTML = selected.map((id, i) => {
-        const t = trackById.get(id);
-        if (!t) return '';
-        const cover = t.cover
-          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
-          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
-        return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
-          <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
-          <span class="pl-row-num">${i + 1}</span>
-          ${cover}
-          <span class="pl-row-info">
-            <span class="pl-row-title">${esc(t.title)}</span>
-            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
-          </span>
-          <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
-        </div>`;
-      }).join('');
-
-      selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
-        b.addEventListener('click', () => {
-          selected = selected.filter(x => x !== b.dataset.id);
-          renderSelected();
-          renderAvailable();
-        });
-      });
-      bindDrag();
-      updateSaveBtn();
-    }
-
-    function renderAvailable() {
-      const q = searchEl.value.trim().toLowerCase();
-      const matches = tracks.filter(t => {
-        if (!q) return true;
-        return (t.title || '').toLowerCase().includes(q)
-            || (t.artist || '').toLowerCase().includes(q);
-      });
-      if (matches.length === 0) {
-        availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
-        return;
-      }
-      availEl.innerHTML = matches.map(t => {
-        const isAdded = selected.includes(t.id);
-        const cls = ['pl-avail-row'];
-        if (isAdded) cls.push('is-added');
-        if (!t.playable) cls.push('is-unplayable');
-        const cover = t.cover
-          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
-          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
-        return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
-          ${cover}
-          <span class="pl-row-info">
-            <span class="pl-row-title">${esc(t.title)}</span>
-            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
-          </span>
-          <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
-        </div>`;
-      }).join('');
-
-      availEl.querySelectorAll('.pl-avail-row').forEach(row => {
-        if (row.classList.contains('is-unplayable')) return;
-        row.addEventListener('click', () => {
-          const id = row.dataset.id;
-          if (selected.includes(id)) selected = selected.filter(x => x !== id);
-          else selected.push(id);
-          renderSelected();
-          renderAvailable();
-        });
-      });
-    }
-
-    // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
-    function bindDrag() {
-      selectedEl.querySelectorAll('.pl-row').forEach(row => {
-        const handle = row.querySelector('.pl-row-handle');
-        if (!handle) return;
-        let dragging = false, originalIdx = -1;
-
-        handle.addEventListener('pointerdown', e => {
-          e.preventDefault();
-          handle.setPointerCapture(e.pointerId);
-          dragging = true;
-          originalIdx = parseInt(row.dataset.pos, 10);
-          row.classList.add('is-dragging');
-        });
-        handle.addEventListener('pointermove', e => {
-          if (!dragging) return;
-          e.preventDefault();
-          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
-          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
-          let targetIdx = -1, above = false;
-          for (let i = 0; i < rows.length; i++) {
-            const r = rows[i]; if (r === row) continue;
-            const rect = r.getBoundingClientRect();
-            const mid = rect.top + rect.height / 2;
-            if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
-            if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
-              targetIdx = i; above = e.clientY < mid; break;
-            }
-          }
-          if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
-        });
-        const finish = e => {
-          if (!dragging) return;
-          dragging = false;
-          try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
-          row.classList.remove('is-dragging');
-          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
-          let targetIdx = -1, above = false;
-          for (let i = 0; i < rows.length; i++) {
-            if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
-            if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
-          }
-          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
-          if (targetIdx === -1 || targetIdx === originalIdx) return;
-          const moved = selected[originalIdx];
-          selected.splice(originalIdx, 1);
-          let newIdx = targetIdx;
-          if (originalIdx < targetIdx) newIdx--;
-          if (!above) newIdx++;
-          newIdx = Math.max(0, Math.min(selected.length, newIdx));
-          selected.splice(newIdx, 0, moved);
-          renderSelected();
-        };
-        handle.addEventListener('pointerup', finish);
-        handle.addEventListener('pointercancel', () => {
-          dragging = false;
-          row.classList.remove('is-dragging');
-          selectedEl.querySelectorAll('.drop-above, .drop-below')
-            .forEach(r => r.classList.remove('drop-above', 'drop-below'));
-        });
-      });
-    }
-
-    titleEl.addEventListener('input', updateSaveBtn);
-    searchEl.addEventListener('input', renderAvailable);
-    renderSelected();
-    renderAvailable();
-
-    saveBtn.addEventListener('click', async () => {
-      if (saveBtn.disabled) return;
-      const orig = saveBtn.textContent;
-      saveBtn.disabled = true;
-      saveBtn.textContent = 'Opslaan...';
-
-      const payload = {
-        title:  titleEl.value.trim(),
-        artist: artistEl.value.trim(),
-        year:   parseInt(yearEl.value, 10) || 0,
-        cover:  coverEl.value.trim(),
-        kind:   kindEl.value === 'playlist' ? 'playlist' : 'album',
-        tracks: selected.slice(),
-      };
-
-      try {
-        const url = isEdit
-          ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
-          : '/admin/playlists/api';
-        const j = await api('POST', url, payload);
-        if (!j.ok) {
-          alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
-          saveBtn.disabled = false;
-          saveBtn.textContent = orig;
-          return;
-        }
-        const savedId = isEdit ? initial.id : j.id;
-        close();
-        if (typeof opts.onSaved === 'function') {
-          opts.onSaved({ id: savedId, playlist: payload });
-        }
-      } catch (err) {
-        alert('Opslaan mislukt: ' + err.message);
-        saveBtn.disabled = false;
-        saveBtn.textContent = orig;
-      }
-    });
-
-    // ESC to close
-    document.addEventListener('keydown', function onEsc(e) {
-      if (e.key === 'Escape' && document.body.contains(backdrop)) {
-        close();
-        document.removeEventListener('keydown', onEsc);
-      }
-    });
-  };
-})();
-</script>
+<%# Het script staat in assets/js/mod/playlist-editor.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
Index: src/views/partials/profile-header.ejs
===================================================================
--- src/views/partials/profile-header.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/profile-header.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -160,61 +160,7 @@
 </div>
 <% } %>
-<script>
-(function () {
-  if (window.__pcmsFediFollowWired) return;
-  window.__pcmsFediFollowWired = true;
-  function modal() { return document.querySelector('.pf-follow'); }
-  function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
-  function go() {
-    var m = modal(); if (!m) return;
-    var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : '';
-    // Strip a full @user@host handle (and any scheme/path) down to the server host.
-    var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
-    if (!server) { if (inp) inp.focus(); return; }
-    try { localStorage.setItem('pcmsFediServer', server); } catch (e) {}
-    var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || '');
-    location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor);
-  }
-  document.addEventListener('click', function (e) {
-    var b = e.target.closest && e.target.closest('.profile-fedi-link');
-    if (b) { e.preventDefault();
-      var m = modal(); if (!m) return;
-      m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || '');
-      var inp = m.querySelector('.pf-follow-input');
-      try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {}
-      m.classList.add('is-open');
-      setTimeout(function () { if (inp) inp.focus(); }, 30);
-      return;
-    }
-    if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; }
-    if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; }
-    var open = document.querySelector('.pf-follow.is-open');
-    if (open && e.target === open) closeM(); /* click on backdrop */
-  });
-  document.addEventListener('keydown', function (e) {
-    var m = document.querySelector('.pf-follow.is-open'); if (!m) return;
-    if (e.key === 'Escape') { closeM(); }
-    else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); }
-  });
-})();
-
-/* Profile summary: click the avatar to open a modal with photo + details. */
-(function () {
-  if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true;
-  function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); }
-  document.addEventListener('click', function (e) {
-    if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; }
-    var open = document.querySelector('.pf-summary.is-open');
-    if (open && e.target === open) { close(); return; } /* click on backdrop */
-    var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]');
-    if (!t) return;
-    e.preventDefault();
-    var root = (t.closest && t.closest('#pcms-chrome')) || document;
-    var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary');
-    if (modal) modal.classList.add('is-open');
-  });
-  document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
-})();
-</script>
+<%# Het script van de profielkop staat in assets/js/mod/chrome.js: deze partial
+    zit in de OOB-chrome en wordt bij elke htmx-navigatie opnieuw ingevoegd
+    (shaer-bqr). %>
 <% } %>
 
Index: src/views/partials/profile-sheet.ejs
===================================================================
--- src/views/partials/profile-sheet.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/profile-sheet.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -105,5 +105,5 @@
           <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
           <span class="psi-label"><%= t('nav.theme_label') %></span>
-          <span class="psi-meta" id="profile-sheet-theme-state"><%= t('nav.dark') %></span>
+          <span class="psi-meta" id="profile-sheet-theme-state" data-dark="<%= t('nav.dark') %>" data-light="<%= t('nav.light') %>"><%= t('nav.dark') %></span>
         </button>
       </li>
@@ -326,98 +326,3 @@
 </style>
 
-<script>
-(function() {
-  const sheet = document.getElementById('profile-sheet');
-  if (!sheet) return;
-
-  const backdrop  = document.getElementById('profile-sheet-backdrop');
-  const panel     = sheet.querySelector('.profile-sheet-panel');
-  const closeBtn  = document.getElementById('profile-sheet-close');
-  const dragZone  = document.getElementById('profile-sheet-drag-zone');
-  const themeBtn  = document.getElementById('profile-sheet-theme');
-  const themeLbl  = document.getElementById('profile-sheet-theme-state');
-
-  function openSheet() {
-    sheet.classList.add('is-open');
-    sheet.setAttribute('aria-hidden', 'false');
-    document.body.classList.add('profile-sheet-locked');
-    syncTheme();
-  }
-  function closeSheet() {
-    sheet.classList.remove('is-open');
-    sheet.setAttribute('aria-hidden', 'true');
-    document.body.classList.remove('profile-sheet-locked');
-    panel.style.removeProperty('--pcms-drag-y');
-  }
-
-  // Open: any element with [data-profile-sheet-toggle]
-  document.addEventListener('click', function(e) {
-    const trigger = e.target.closest('[data-profile-sheet-toggle]');
-    if (trigger) {
-      e.preventDefault();
-      openSheet();
-    }
-  });
-
-  // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item
-  if (backdrop) backdrop.addEventListener('click', closeSheet);
-  if (closeBtn) closeBtn.addEventListener('click', closeSheet);
-  document.addEventListener('keydown', (e) => {
-    if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
-  });
-
-  // Menu items that navigate: close synchronously before nav fires
-  sheet.querySelectorAll('[data-close-sheet]').forEach((el) => {
-    el.addEventListener('click', closeSheet);
-  });
-
-  // Drag-down-to-close on touch.
-  // Uses --pcms-drag-y custom property rather than overwriting the panel's
-  // transform string. This composes correctly with any horizontal centering
-  // (currently none here, but the pattern matches audio-sheet for safety).
-  let startY = 0, lastY = 0, dragging = false;
-  function onDown(e) {
-    if (e.pointerType !== 'touch') return;
-    if (panel.scrollTop > 0) return;
-    startY = lastY = e.clientY;
-    dragging = true;
-    panel.classList.add('is-dragging');
-  }
-  function onMove(e) {
-    if (!dragging) return;
-    lastY = e.clientY;
-    const dy = Math.max(0, lastY - startY);
-    panel.style.setProperty('--pcms-drag-y', dy + 'px');
-  }
-  function onUp() {
-    if (!dragging) return;
-    dragging = false;
-    panel.classList.remove('is-dragging');
-    const dy = lastY - startY;
-    if (dy > 80) closeSheet();
-    else panel.style.removeProperty('--pcms-drag-y');
-  }
-  if (window.PointerEvent && dragZone) {
-    dragZone.addEventListener('pointerdown', onDown);
-    document.addEventListener('pointermove', onMove);
-    document.addEventListener('pointerup', onUp);
-    document.addEventListener('pointercancel', onUp);
-  }
-
-  // Theme toggle inside sheet — syncs label
-  function syncTheme() {
-    if (!themeLbl) return;
-    const t = document.documentElement.getAttribute('data-theme') || 'dark';
-    themeLbl.textContent = t === 'dark' ? '<%= t('nav.dark') %>' : '<%= t('nav.light') %>';
-  }
-  if (themeBtn) {
-    themeBtn.addEventListener('click', function() {
-      const cur = document.documentElement.getAttribute('data-theme') || 'dark';
-      const next = cur === 'dark' ? 'light' : 'dark';
-      document.documentElement.setAttribute('data-theme', next);
-      try { localStorage.setItem('pcms-theme', next); } catch (e) {}
-      syncTheme();
-    });
-  }
-})();
-</script>
+<%# Het script van het profielblad staat in assets/js/mod/chrome.js (shaer-bqr). %>
Index: src/views/partials/topnav.ejs
===================================================================
--- src/views/partials/topnav.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/topnav.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -176,5 +176,8 @@
 
 <!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) -->
-<div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search">
+<%# De teksten staan op het element dat ze gebruikt, niet in het script: een
+    module is een statisch bestand en kan geen vertaling interpoleren. %>
+<div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search"
+     data-i18n="<%= JSON.stringify({ posts: t('search.section_posts'), tracks: t('search.section_tracks'), events: t('search.section_events'), pages: t('search.section_pages'), all: t('search.suggest_all'), none: t('search.suggest_empty') }) %>">
   <div class="container">
     <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search">
@@ -191,85 +194,5 @@
 </div>
 
-<script>
-(function() {
-  // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation
-  // → without this guard the script would stack EXTRA listeners on every navigation,
-  // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working").
-  // Everything below uses event delegation on body/document, so it also works for
-  // buttons that appear after this run (OOB).
-  if (window.__pcmsChromeWired) return;
-  window.__pcmsChromeWired = true;
-
-  function toggleTheme() {
-    var cur = document.documentElement.getAttribute('data-theme') || 'dark';
-    var next = cur === 'dark' ? 'light' : 'dark';
-    document.documentElement.setAttribute('data-theme', next);
-    try { localStorage.setItem('pcms-theme', next); } catch (e) {}
-  }
-  function overlay() { return document.getElementById('search-overlay'); }
-  function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } }
-  function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } }
-
-  document.body.addEventListener('click', function(e) {
-    if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; }
-    if (e.target.closest('#search-toggle')) { openSearch(); return; }
-    if (e.target.closest('#search-close')) { closeSearch(); return; }
-    // Close open dropdowns (user menu + language picker) on click outside.
-    document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) {
-      if (!d.contains(e.target)) d.removeAttribute('open');
-    });
-  });
-
-  document.addEventListener('keydown', function(e) {
-    if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); }
-  });
-
-  // ── Live results while typing ───────────────────────────────────
-  var _ST = {
-    posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>',
-    events: '<%= t('search.section_events') %>', pages: '<%= t('search.section_pages') %>',
-    all: '<%= t('search.suggest_all') %>', none: '<%= t('search.suggest_empty') %>'
-  };
-  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
-  function renderSuggest(box, d, q, ov) {
-    var html = '';
-    function grp(title, items, fmt) {
-      if (!items || !items.length) return;
-      html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>';
-    }
-    grp(_ST.posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; });
-    grp(_ST.tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; });
-    grp(_ST.events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; });
-    grp(_ST.pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; });
-    var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length);
-    if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_ST.none) + '</div>'; return; }
-    var action = (ov && ov.getAttribute('data-action')) || '/search';
-    html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_ST.all) + '</a>';
-    box.innerHTML = html;
-  }
-  var _sTimer;
-  document.addEventListener('input', function(e) {
-    var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]');
-    if (!inp) return;
-    var box = document.getElementById('search-suggest');
-    if (!box) return;
-    var q = inp.value.trim();
-    clearTimeout(_sTimer);
-    if (q.length < 2) { box.innerHTML = ''; return; }
-    _sTimer = setTimeout(function() {
-      var ov = document.getElementById('search-overlay');
-      var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest';
-      fetch(url + '?q=' + encodeURIComponent(q))
-        .then(function(r){ return r.ok ? r.json() : null; })
-        .then(function(d){ if (d) renderSuggest(box, d, q, ov); })
-        .catch(function(){});
-    }, 200);
-  });
-  // Click on a suggestion → close the overlay (the link/boost handles navigation).
-  document.addEventListener('click', function(e) {
-    if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; }
-  });
-})();
-</script>
+<%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %>
 
 <style>
Index: src/views/partials/track-editor.ejs
===================================================================
--- src/views/partials/track-editor.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/track-editor.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -284,477 +284,3 @@
 </style>
 
-<script>
-(function() {
-
-  function esc(s) {
-    return String(s == null ? '' : s)
-      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
-      .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
-  }
-
-  async function api(method, url, body) {
-    const opts = { method, credentials: 'same-origin', headers: {} };
-    if (body !== undefined) {
-      if (body instanceof FormData) {
-        opts.body = body;
-      } else {
-        opts.headers['Content-Type'] = 'application/json';
-        opts.body = JSON.stringify(body);
-      }
-    }
-    const res = await fetch(url, opts);
-    let data;
-    try { data = await res.json(); }
-    catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); }
-    if (!res.ok) data.ok = false;
-    return data;
-  }
-
-  /**
-   * Open the track editor.
-   * @param {object} opts
-   * @param {string} opts.id        — track id to edit
-   * @param {function?} opts.onSaved — called with updated track on success
-   */
-  window.openTrackEditor = async function openTrackEditor({ id, onSaved }) {
-    if (!id) return;
-    let track, albumSuggestions = [];
-    try {
-      const [trackJson, listJson] = await Promise.all([
-        api('GET', '/admin/audio/api/' + encodeURIComponent(id)),
-        api('GET', '/admin/audio/api/albums'),
-      ]);
-      if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden');
-      track = trackJson.track;
-      if (listJson.ok && Array.isArray(listJson.albums)) {
-        albumSuggestions = listJson.albums.filter(Boolean);
-      }
-    } catch (err) {
-      alert('Track ophalen mislukt: ' + err.message);
-      return;
-    }
-
-    const backdrop = document.createElement('div');
-    backdrop.className = 'te-backdrop';
-    backdrop.innerHTML = `
-      <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken">
-        <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div>
-
-        <div class="te-header">
-          <h3>✎ Track bewerken</h3>
-          <button type="button" class="te-close" aria-label="Sluiten">×</button>
-        </div>
-
-        <div class="te-body">
-
-          ${track.stream_url ? `
-          <div class="te-preview">
-            <div class="te-preview-cover">
-              ${track.cover_url
-                ? `<img src="${esc(track.cover_url)}" alt="">`
-                : `🎵`}
-            </div>
-            <div class="te-preview-meta">
-              <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div>
-              <div class="te-preview-sub">
-                ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''}
-              </div>
-            </div>
-            <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button>
-          </div>
-          ` : ''}
-
-          <div class="te-form">
-
-            <label class="te-field">
-              <span>Titel <span class="te-required" aria-hidden="true">*</span></span>
-              <input type="text" id="te-title" maxlength="200" required
-                     autocomplete="off" autocapitalize="words" spellcheck="false"
-                     value="${esc(track.title || '')}">
-            </label>
-
-            <div class="te-row te-row-2">
-              <label class="te-field">
-                <span>Artiest</span>
-                <input type="text" id="te-artist" maxlength="200"
-                       autocomplete="off" autocapitalize="words" spellcheck="false"
-                       value="${esc(track.artist || '')}">
-              </label>
-              <label class="te-field">
-                <span>Album</span>
-                <input type="text" id="te-album" maxlength="200"
-                       autocomplete="off" autocapitalize="words" spellcheck="false"
-                       list="te-album-list" value="${esc(track.album || '')}">
-                <datalist id="te-album-list">
-                  ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')}
-                </datalist>
-              </label>
-            </div>
-
-            <label class="te-field">
-              <span>Duur <small>(seconden — automatisch bepaald, hier te overschrijven)</small></span>
-              <input type="number" id="te-duration" min="0" step="1"
-                     inputmode="numeric" pattern="[0-9]*"
-                     value="${track.duration || ''}" placeholder="auto">
-            </label>
-
-            <div class="te-row te-row-2">
-              <label class="te-field">
-                <span>Eigenaar / credit <small>(copyright-houder)</small></span>
-                <div class="te-credit-row">
-                  <input type="text" id="te-credit" maxlength="200"
-                         autocomplete="off" spellcheck="false"
-                         placeholder="bv. © 2025 Mara Vos"
-                         value="${esc(track.credit || '')}">
-                  <button type="button" class="te-sym-btn" id="te-credit-copyr"
-                          title="© invoegen" aria-label="Copyright-teken invoegen">©</button>
-                </div>
-              </label>
-              <label class="te-field">
-                <span>Licentie</span>
-                <input type="text" id="te-license" maxlength="120"
-                       autocomplete="off" spellcheck="false" list="te-license-list"
-                       placeholder="Alle rechten voorbehouden"
-                       value="${esc(track.license || '')}">
-                <datalist id="te-license-list">
-                  <option value="Alle rechten voorbehouden"></option>
-                  <option value="CC BY 4.0"></option>
-                  <option value="CC BY-SA 4.0"></option>
-                  <option value="CC BY-NC 4.0"></option>
-                  <option value="CC BY-NC-SA 4.0"></option>
-                  <option value="CC BY-ND 4.0"></option>
-                  <option value="CC0 1.0 (publiek domein)"></option>
-                </datalist>
-              </label>
-            </div>
-
-            <div class="te-field">
-              <span>Open in <small>(links naar dezelfde track elders)</small></span>
-              <input type="url" id="te-link-spotify" inputmode="url" autocomplete="off" spellcheck="false"
-                     placeholder="Spotify-URL (https://open.spotify.com/…)" value="${esc(track.link_spotify || '')}">
-              <input type="url" id="te-link-youtube" inputmode="url" autocomplete="off" spellcheck="false"
-                     placeholder="YouTube-URL (https://youtu.be/…)" value="${esc(track.link_youtube || '')}">
-              <input type="url" id="te-link-soundcloud" inputmode="url" autocomplete="off" spellcheck="false"
-                     placeholder="SoundCloud-URL (https://soundcloud.com/…)" value="${esc(track.link_soundcloud || '')}">
-            </div>
-
-            <div class="te-field">
-              <span>Audiobestand</span>
-              <div class="te-cover-btn-row">
-                <input type="file" id="te-audio-file" accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg,.aac" hidden>
-                <button type="button" class="te-cover-btn" id="te-audio-pick">${track.stream_url ? '🔁 Vervang audiobestand' : '🎵 Audiobestand kiezen'}</button>
-                <span class="te-cover-status" id="te-audio-status"></span>
-              </div>
-            </div>
-
-            <div class="te-field">
-              <span>Cover</span>
-              <div class="te-cover-row">
-                <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button"
-                     aria-label="Klik om cover te kiezen">
-                  ${track.cover_url
-                    ? `<img src="${esc(track.cover_url)}" alt="">`
-                    : `<span class="te-cover-empty">🎨</span>`}
-                </div>
-                <input type="file" id="te-cover-file"
-                       accept="image/jpeg,image/png,image/webp,image/gif" hidden>
-                <div class="te-cover-actions">
-                  <div class="te-cover-btn-row">
-                    <button type="button" class="te-cover-btn" id="te-cover-pick">
-                      📷 Foto kiezen
-                    </button>
-                    ${track.cover_url ? `
-                    <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove">
-                      × Verwijder
-                    </button>` : ''}
-                  </div>
-                  <input type="text" id="te-cover-url" inputmode="url"
-                         placeholder="/media/… of https://…"
-                         autocomplete="off" autocapitalize="none" spellcheck="false"
-                         value="${esc(track.cover_url || '')}">
-                  <div class="te-cover-status" id="te-cover-status"></div>
-                </div>
-              </div>
-            </div>
-
-          </div>
-        </div>
-
-        <div class="te-footer">
-          <button type="button" class="te-btn" id="te-cancel">Annuleren</button>
-          <div class="te-footer-spacer"></div>
-          <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button>
-        </div>
-      </div>
-    `;
-
-    document.body.appendChild(backdrop);
-    document.body.classList.add('te-modal-open');
-
-    const $ = sel => backdrop.querySelector(sel);
-
-    // ── Inline preview player (routed through global mini-player) ──
-    // We don't build our own <audio>; instead we tell the global
-    // window.pcmsAudioPlayer to load this single track. Visual state
-    // syncs against the global audio element so toggle works correctly
-    // even if the user pauses from the mini-bar.
-    const previewBtn = $('#te-preview-play');
-    if (previewBtn) {
-      const setPreviewState = (playing) => {
-        previewBtn.textContent = playing ? '⏸' : '▶';
-        previewBtn.classList.toggle('is-playing', playing);
-        previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
-      };
-      const isOurTrack = () => {
-        // audio.src is a blob: URL (Spotify-style playback) — compare against
-        // the player's logical current-track URL instead.
-        const player = window.pcmsAudioPlayer;
-        const cur = player && player.currentTrack();
-        return !!(cur && track.stream_url && cur.url === track.stream_url);
-      };
-      const resync = () => {
-        const audio = document.getElementById('audio-element');
-        const playing = audio && !audio.paused && !audio.ended && isOurTrack();
-        setPreviewState(!!playing);
-      };
-
-      previewBtn.addEventListener('click', () => {
-        const player = window.pcmsAudioPlayer;
-        if (!player || !track.stream_url) {
-          console.warn('preview: miniplayer or url missing');
-          return;
-        }
-        const audio = document.getElementById('audio-element');
-        if (audio && isOurTrack()) {
-          // Same track loaded — toggle
-          if (audio.paused) player.play(); else player.pause();
-        } else {
-          player.setQueue([{
-            url:    track.stream_url,
-            title:  track.title  || '(zonder titel)',
-            artist: track.artist || '',
-            album:  track.album  || '',
-            cover:  track.cover_url || '',
-          }], 0);
-        }
-      });
-
-      const audio = document.getElementById('audio-element');
-      if (audio) {
-        const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied'];
-        evs.forEach(ev => audio.addEventListener(ev, resync));
-        // Detach listeners on close so we don't leak them
-        backdrop._previewCleanup = () => {
-          evs.forEach(ev => audio.removeEventListener(ev, resync));
-        };
-        resync();   // initial state
-      }
-    }
-
-    function close() {
-      // Detach our resync listeners (mini-player stays running)
-      if (backdrop._previewCleanup) backdrop._previewCleanup();
-      document.body.classList.remove('te-modal-open');
-      document.removeEventListener('keydown', onEsc);
-      backdrop.remove();
-    }
-    function onEsc(e) { if (e.key === 'Escape') close(); }
-    document.addEventListener('keydown', onEsc);
-
-    // A click on the backdrop does NOT close the editor — too easy to lose edits by
-    // mis-clicking outside. Close deliberately via ×, Cancel or Esc.
-    $('.te-close').addEventListener('click', close);
-    $('#te-cancel').addEventListener('click', close);
-
-    // ── Cover picking + URL paste + drag-drop ────────────────
-    const thumb     = $('#te-cover-thumb');
-    const fileInput = $('#te-cover-file');
-    const urlInput  = $('#te-cover-url');
-    const status    = $('#te-cover-status');
-    const pickBtn   = $('#te-cover-pick');
-    const removeBtn = $('#te-cover-remove');
-
-    function setStatus(text, kind) {
-      status.textContent = text || '';
-      status.className = 'te-cover-status' + (kind ? ' is-' + kind : '');
-    }
-    function setThumb(url) {
-      if (url) {
-        thumb.innerHTML = `<img src="${esc(url)}" alt="">`;
-      } else {
-        thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`;
-      }
-    }
-
-    pickBtn.addEventListener('click', () => fileInput.click());
-    thumb.addEventListener('click', () => fileInput.click());
-    thumb.addEventListener('keydown', e => {
-      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
-    });
-
-    async function uploadCoverFile(file) {
-      if (!file) return;
-      if (!/^image\//.test(file.type)) {
-        setStatus('Alleen afbeeldingen toegestaan', 'error'); return;
-      }
-      setStatus('Uploaden…', null);
-      const fd = new FormData();
-      fd.append('cover', file);
-      try {
-        const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', {
-          method: 'POST', body: fd, credentials: 'same-origin',
-        });
-        const j = await r.json();
-        if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
-        urlInput.value = j.cover_url || '';
-        setThumb(j.cover_url);
-        setStatus('✓ Geüpload', 'ok');
-      } catch (err) {
-        setStatus('Mislukt: ' + err.message, 'error');
-      }
-    }
-
-    fileInput.addEventListener('change', e => {
-      const f = e.target.files && e.target.files[0];
-      if (f) uploadCoverFile(f);
-      fileInput.value = '';
-    });
-
-    // Drag-drop on thumb (desktop nicety)
-    ['dragenter', 'dragover'].forEach(ev =>
-      thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); }));
-    ['dragleave', 'drop'].forEach(ev =>
-      thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); }));
-    thumb.addEventListener('drop', e => {
-      const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
-      if (f) uploadCoverFile(f);
-    });
-
-    // URL paste auto-preview
-    urlInput.addEventListener('input', () => {
-      const v = urlInput.value.trim();
-      setThumb(v);
-    });
-
-    if (removeBtn) {
-      removeBtn.addEventListener('click', () => {
-        urlInput.value = '';
-        setThumb('');
-        removeBtn.remove();
-      });
-    }
-
-    // ── Replace the audio file of this track ──────────────────
-    const aPick = $('#te-audio-pick');
-    const aFile = $('#te-audio-file');
-    const aStatus = $('#te-audio-status');
-    if (aPick && aFile) {
-      aPick.addEventListener('click', () => aFile.click());
-      aFile.addEventListener('change', async (e) => {
-        const f = e.target.files && e.target.files[0];
-        aFile.value = '';
-        if (!f) return;
-        aStatus.textContent = '⏳ Converteren… (kan even duren)'; aStatus.className = 'te-cover-status';
-        aPick.disabled = true;
-        try {
-          const fd = new FormData();
-          fd.append('audio', f);
-          const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id) + '/replace-audio', fd);
-          if (!j.ok) throw new Error(j.error || 'mislukt');
-          aStatus.textContent = '✓ Vervangen'; aStatus.className = 'te-cover-status is-ok';
-          track.stream_url = j.stream_url || track.stream_url;
-          if (j.duration) { const d = $('#te-duration'); if (d) d.value = j.duration; }
-        } catch (err) {
-          aStatus.textContent = 'Mislukt: ' + err.message; aStatus.className = 'te-cover-status is-error';
-        } finally { aPick.disabled = false; }
-      });
-    }
-
-    // ── Insert © symbol into the credit field ─────────────────
-    const copyrBtn = $('#te-credit-copyr');
-    if (copyrBtn) {
-      copyrBtn.addEventListener('click', () => {
-        const inp = $('#te-credit');
-        if (!inp) return;
-        const sym = '© ';
-        const start = inp.selectionStart != null ? inp.selectionStart : inp.value.length;
-        const end = inp.selectionEnd != null ? inp.selectionEnd : inp.value.length;
-        inp.value = inp.value.slice(0, start) + sym + inp.value.slice(end);
-        inp.focus();
-        const pos = start + sym.length;
-        try { inp.setSelectionRange(pos, pos); } catch (e) {}
-      });
-    }
-
-    // ── Save ────────────────────────────────────────────────
-    $('#te-save').addEventListener('click', async () => {
-      const titleEl = $('#te-title');
-      const title = titleEl.value.trim();
-      if (!title) {
-        titleEl.focus();
-        alert('Titel is verplicht');
-        return;
-      }
-      const saveBtn = $('#te-save');
-      saveBtn.disabled = true;
-      saveBtn.textContent = '⏳ Opslaan…';
-
-      try {
-        const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), {
-          title,
-          artist: $('#te-artist').value.trim() || null,
-          album:  $('#te-album').value.trim() || null,
-          credit:  $('#te-credit').value.trim() || null,
-          license: $('#te-license').value.trim() || null,
-          link_spotify:    $('#te-link-spotify').value.trim() || null,
-          link_youtube:    $('#te-link-youtube').value.trim() || null,
-          link_soundcloud: $('#te-link-soundcloud').value.trim() || null,
-          duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
-          cover_url: urlInput.value.trim() || null,
-        });
-        if (!j.ok) throw new Error(j.error || 'Opslaan mislukt');
-        if (typeof onSaved === 'function') onSaved(j.track || { id, title,
-          artist: $('#te-artist').value.trim() || null,
-          album:  $('#te-album').value.trim() || null,
-          cover_url: urlInput.value.trim() || null });
-        close();
-      } catch (err) {
-        alert('Opslaan mislukt: ' + err.message);
-        saveBtn.disabled = false;
-        saveBtn.textContent = '💾 Opslaan';
-      }
-    });
-
-    // ── Auto-duration ───────────────────────────────────────────
-    // The server already determines duration automatically on upload. This is the
-    // fallback/UX layer: if an admin opens an existing track without a duration,
-    // we read it from the audio metadata and fill the field — so you never need
-    // to type seconds manually. An existing value is never overwritten. We fetch
-    // the bytes via the same header gate as the player (X-Audio-Player).
-    (async function autoDuration() {
-      const durEl = $('#te-duration');
-      if (!durEl || !track.stream_url) return;
-      if (durEl.value && Number(durEl.value) > 0) return;  // already filled → leave it alone
-      let objUrl = null;
-      try {
-        const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } });
-        if (!r.ok) return;
-        objUrl = URL.createObjectURL(await r.blob());
-        const probe = new Audio();
-        probe.preload = 'metadata';
-        probe.addEventListener('loadedmetadata', () => {
-          if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) {
-            durEl.value = Math.round(probe.duration);
-          }
-          if (objUrl) URL.revokeObjectURL(objUrl);
-        });
-        probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); });
-        probe.src = objUrl;
-      } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); }
-    })();
-
-    // Focus title for fast typing
-    setTimeout(() => $('#te-title').focus(), 60);
-  };
-})();
-</script>
+<%# Het script van deze pagina staat in assets/js/mod/track-editor.js (shaer-bqr). %>
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/shell.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -339,5 +339,5 @@
 </head>
 
-<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
+<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-js="chrome<%= (typeof pageJs !== 'undefined' && pageJs) ? ' ' + pageJs : '' %>" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
 
 <% if (typeof isViewer !== 'undefined' && isViewer) { %>
@@ -449,4 +449,10 @@
     }
 
+    // Welke modules deze pagina wil (shaer-bqr). De bootstrap hieronder leest
+    // dit zodra deze handler klaar is.
+    if (typeof d.js === 'string' && /^[a-z0-9 -]*$/.test(d.js)) {
+      document.body.setAttribute('data-js', d.js);
+    }
+
     var next = d.bodyClass;
     if (!next) return;
@@ -467,4 +473,41 @@
   //  de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere
   //  htmx:historyRestore-handler is vervallen nu htmx-history uitstaat.)
+})();
+</script>
+
+<!-- Modules laden (shaer-bqr). Inline script in gewisselde inhoud wordt door de
+     CSP geweigerd: de nonce rouleert per verzoek, dus een script dat via htmx
+     binnenkomt draagt er een die dit document niet kent (shaer-0i6). Alles wat
+     bij een pagina hoort komt daarom uit een module, en die wordt HIER geladen —
+     vanuit de shell, die alleen bij een volledige laadbeurt binnenkomt en dus
+     wél de goede nonce heeft.
+
+     Een dynamische import vanuit een vertrouwd (genonced) script is precies waar
+     'strict-dynamic' voor bedoeld is, dus de module zelf heeft geen nonce nodig. -->
+<script>
+(function () {
+  if (window.__modBoot) return;
+  window.__modBoot = true;
+
+  var loaded = {};
+  function load() {
+    var names = (document.body.getAttribute('data-js') || '').trim().split(/\s+/);
+    names.forEach(function (name) {
+      // Streng: deze waarde komt uit een template en wordt een PAD. Alleen
+      // kleine letters, cijfers en streepjes; nooit een punt of een schuine
+      // streep.
+      if (!name || loaded[name] || !/^[a-z0-9-]+$/.test(name)) return;
+      loaded[name] = 1;
+      import('/assets/js/mod/' + name + '.js').catch(function (e) {
+        console.warn('[mod] ' + name + ' laadde niet:', e && e.message);
+      });
+    });
+  }
+  load();
+  // Bij een htmx-navigatie wisselt de INHOUD, niet de body. De nav-trigger
+  // hieronder zet data-js opnieuw; daarna halen we op wat er nieuw bij staat.
+  // Een module die er al is wordt niet opnieuw geimporteerd -- vandaar dat elke
+  // module gedelegeerd moet werken en tegen een tweede aanroep moet kunnen.
+  document.body.addEventListener('pcmsNav', load);
 })();
 </script>
