Index: src/assets/js/mod/admin-epk.js
===================================================================
--- src/assets/js/mod/admin-epk.js	(revision 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
+++ src/assets/js/mod/admin-epk.js	(revision 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
@@ -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 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
+++ src/assets/js/mod/admin-help.js	(revision 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
@@ -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-paid.js
===================================================================
--- src/assets/js/mod/admin-paid.js	(revision 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
+++ src/assets/js/mod/admin-paid.js	(revision 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
@@ -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-push.js
===================================================================
--- src/assets/js/mod/admin-push.js	(revision 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
+++ src/assets/js/mod/admin-push.js	(revision 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
@@ -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 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
+++ src/assets/js/mod/admin-settings.js	(revision 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
@@ -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 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
+++ src/assets/js/mod/admin-site-edit.js	(revision 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
@@ -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/auth-register.js
===================================================================
--- src/assets/js/mod/auth-register.js	(revision 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
+++ src/assets/js/mod/auth-register.js	(revision 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
@@ -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 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
+++ src/assets/js/mod/authorize-interaction.js	(revision 0475b134ee78a96bfc7991c65992ddb6eb3d0f19)
@@ -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; });
+  });
+})();
