Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
+++ src/routes/posts.js	(revision 6cbd0142768e2cd623437742cd6c08cc33fda44f)
@@ -648,528 +648,7 @@
 // post render and the fan gate (premium fan_only) so navigation is consistent
 // everywhere. Solo: within the site (pinned first, then date). Hub: globally by date.
-// A short public teaser for a paid post: its excerpt, else the first ~280 chars
-// of the (stripped) content. Shared by the web gate and federation.
-function paidTeaser(post, max = 280) {
-  if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
-  // Only the FIRST paragraph: a paid teaser must never spill later content.
-  const html = String((post && post.content) || '');
-  const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
-  const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
-  return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
-}
-
-function postNeighbors(site, post, isHub) {
-  const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
-  const ordered = isHub
-    ? db.prepare(`
-        SELECT p.id, p.slug, p.title, p.pinned, s.slug AS site_slug
-        FROM posts p JOIN sites s ON s.id = p.site_id
-        WHERE p.status = 'published'
-        ORDER BY p.published_at DESC
-      `).all()
-    : db.prepare(`
-        SELECT id, slug, title, pinned FROM posts
-        WHERE site_id = ? AND status = 'published'
-        ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
-      `).all(site.id);
-  const idx = ordered.findIndex((p) => p.id === post.id);
-  const newerPost = idx > 0 ? ordered[idx - 1] : null;
-  const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
-  if (newerPost) newerPost._urlBase = urlBaseFor(newerPost);
-  if (olderPost) olderPost._urlBase = urlBaseFor(olderPost);
-  return { newerPost, olderPost };
-}
-
-// ==================== REMOTE INTERACTION (reply to a fediverse post as your site) ====================
-// Standard fediverse "reply from your own server" landing endpoint. A post page
-// elsewhere bounces the visitor here with ?uri=<remote post>; the site owner
-// composes a reply that federates back to that post.
-router.get('/authorize_interaction', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.query.uri || '').toString();
-  const sent = !!req.query.sent;
-  const followed = !!req.query.followed;
-  const voted = !!req.query.voted;
-  const reported = !!req.query.reported;
-  let target = null, followTarget = null;
-  if (!sent && !followed && !voted && !reported && uri) {
-    try { target = await ActivityPubService.resolveRemoteNote(uri); } catch { /* ignore */ }
-    // Not a post? Maybe the URI is a profile/actor → offer Follow, not reply.
-    if (!target) { try { followTarget = await ActivityPubService.resolveRemoteActor(uri); } catch { /* ignore */ } }
-  }
-  renderPage(req, res, 'pages/authorize-interaction', {
-    pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
-    bodyClass: 'on-special',
-    uri,
-    target,
-    followTarget,
-    sent,
-    followed,
-    voted: !!req.query.voted,
-    reported: !!req.query.reported,
-    liked: !!req.query.liked,
-    boosted: !!req.query.boosted,
-    reacted: (site && uri) ? ActivityPubService.getMyReactions(site.slug, uri) : { liked: false, boosted: false },
-    siteTitle: site ? site.title : '',
-  });
-});
-
-// 📊 Vote on a remote fediverse poll from the interact page (any poll by URL, not just
-// followed ones). Casts the Mastodon-standard ballot straight to the poll's author.
-router.post('/authorize_interaction/vote', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  let choice = req.body.choice;
-  if (choice == null) choice = [];
-  if (!Array.isArray(choice)) choice = [choice];
-  if (site && uri && choice.length) { try { await ActivityPubService.voteOnRemotePoll(site, uri, choice.map(String)); } catch { /* ignore */ } }
-  res.redirect('/authorize_interaction?voted=1&uri=' + encodeURIComponent(uri));
-});
-
-// 🚩 Report a remote post/account to its home instance (sends an AS2 Flag).
-router.post('/authorize_interaction/report', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  const actorUri = (req.body.actor_uri || '').toString();
-  const reason = (req.body.reason || '').toString();
-  if (site && (uri || actorUri)) { try { await ActivityPubService.sendReport(site, { objectUri: uri, actorUri, reason }); } catch { /* ignore */ } }
-  res.redirect('/authorize_interaction?reported=1&uri=' + encodeURIComponent(uri || actorUri));
-});
-
-// ⭐ Like / unlike a remote post from your own site (toggle on the interact page).
-router.post('/authorize_interaction/like', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  let on = false;
-  if (site && uri) {
-    on = !ActivityPubService.getMyReactions(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);
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
-});
-
-// 🔁 Boost / unboost a remote post from your own site (toggle on the interact page).
-// Also flags it for the Cirkel (markBoosted is a no-op if the post isn't in your timeline).
-router.post('/authorize_interaction/boost', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  let on = false;
-  if (site && uri) {
-    on = !ActivityPubService.getMyReactions(site.slug, uri).boosted;
-    ActivityPubService.resolveRemoteNote(uri)
-      .then((note) => {
-        if (!note) return;
-        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));
-      })
-      .catch((e) => console.warn('[AP] remote boost failed:', e.message));
-    ActivityPubService.setMyReaction(site.slug, uri, 'boost', on);
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
-});
-
-// Follow a remote actor from your own site (when the target is a profile, not a post).
-router.post('/authorize_interaction/follow', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  if (site && uri) {
-    ActivityPubService.followActor(site, uri)
-      .catch((e) => console.warn('[AP] remote follow failed:', e.message));
-  }
-  res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
-});
-
-router.post('/authorize_interaction', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const uri = (req.body.uri || '').toString();
-  const text = (req.body.text || '').toString();
-  const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
-  const language = (req.body.language || '').toString();
-  let attachments = [];
-  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
-  let mentions;   // undefined = geen balk meegestuurd (legacy addressing)
-  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
-  if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
-    // Resolve + deliver in the background so Send responds instantly.
-    ActivityPubService.resolveRemoteNote(uri)
-      .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
-      .catch((e) => console.warn('[AP] remote reply failed:', e.message));
-  }
-  res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
-});
-
-// Manage / delete your own outbound fediverse replies (site owner only).
-// Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
-// The old /fediverse (manage) and /notifications pages redirect here.
-router.get('/messages', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const append = req.query.append === '1';
-  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const page = site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : [];
-  const hasMore = page.length > FEED_PAGE;
-  const items = page.slice(0, FEED_PAGE);
-  // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
-  const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
-  // Only stamp "seen" on the first page load (not on Load-more appends).
-  if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
-  const moreBase = res.locals.siteUrlBase || '';
-  if (append) {
-    return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
-  }
-  renderPage(req, res, 'pages/messages', {
-    pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
-    hasMore, nextOffset: offset + FEED_PAGE, moreBase,
-    success: req.query.success || null, error: req.query.error || null,
-  });
-});
-router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
-
-router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (site) {
-    try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
-    catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
-});
-
-// Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
-// object URI so re-delivery and thread-crawling never bring it back. Works for private
-// notes too (acts on the local copy; no remote fetch involved).
-router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  if (site) {
-    const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
-    if (r.error) console.warn('[AP] interaction remove failed:', r.error);
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
-});
-
-// Moderation: report an INCOMING reply to its home instance (owner only). Uses the
-// locally stored object/actor URIs, so it also works for private notes that
-// authorize_interaction cannot fetch (401/404).
-router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  if (site) {
-    const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
-    if (tgt && (tgt.objectUri || tgt.actorUri)) {
-      try {
-        const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
-        if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
-      } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
-    }
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
-});
-
-// Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
-router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const text = String(req.body.text || '');
-  const html = String(req.body.content || '');   // rich reply editor HTML (sanitized in deliverOutboxUpdate)
-  if (site && (text.trim() || html.trim())) {
-    try {
-      await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
-        html, language: String(req.body.language || ''),
-      });
-    } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
-  }
-  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
-});
-
-// ==================== FEDIVERSE CLIENT: home timeline + following ====================
-// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
-// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
-function timelineEmbedHtml(html) {
-  if (!html) return null;
-  const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
-  while ((m = re.exec(html))) {
-    const u = m[1]; if (seen.has(u)) continue; seen.add(u);
-    let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
-    if (!p) {
-      // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
-      // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
-      // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
-      const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
-      if (pt) return `<iframe class="tl-embed-frame" src="https://${pt[1]}/videos/embed/${pt[2]}" title="PeerTube" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
-      continue;
-    }
-    if (p.provider === 'youtube') return `<iframe class="tl-embed-frame" src="https://www.youtube-nocookie.com/embed/${p.id}" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
-    if (p.provider === 'spotify') return `<iframe class="tl-embed-frame tl-embed-spotify" src="https://open.spotify.com/embed/${p.type}/${p.id}" title="Spotify" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
-    if (p.provider === 'soundcloud') return `<iframe class="tl-embed-frame tl-embed-sc" src="https://w.soundcloud.com/player/?url=${encodeURIComponent(p.url)}&color=%23ff5500&visual=false" title="SoundCloud" loading="lazy" frameborder="0" allow="autoplay" scrolling="no"></iframe>`;
-    if (p.provider === 'vimeo') return `<iframe class="tl-embed-frame" src="https://player.vimeo.com/video/${p.id}" title="Vimeo" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
-    if (p.provider === 'bandcamp') return `<iframe class="tl-embed-frame tl-embed-bandcamp" src="https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(u)}/size=large/bgcol=faf8f3/linkcol=c2410c/tracklist=false/transparent=true/" title="Bandcamp" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
-    if (p.provider === 'applemusic') { const am = u.match(/music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/[0-9]+)/i); if (am) return `<iframe class="tl-embed-frame tl-embed-apple" src="https://embed.music.apple.com/${am[1]}" title="Apple Music" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>`; }
-  }
-  return null;
-}
-
-// A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
-// Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
-// (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
-function klonktAudioEmbed(html, url) {
-  if (!html || !url || html.indexOf('🎵') < 0) return null;
-  let u; try { u = new URL(url); } catch { return null; }
-  if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
-  const slug = u.pathname.replace(/^\/+|\/+$/g, '');
-  if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
-  const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
-  // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
-  const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
-  return { origin: u.origin, embedUrl: src, content, html: `<iframe class="tl-embed-frame tl-embed-klonkt" src="${src}" title="Audio" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>` };
-}
-
-router.get('/news', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const append = req.query.append === '1';
-  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const cspOrigins = new Set();
-  // Fetch one extra to know whether a "Load more" button belongs on this page.
-  const rows = site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : [];
-  const hasMore = rows.length > FEED_PAGE;
-  const timeline = rows.slice(0, FEED_PAGE).map((p) => {
-    let embedHtml = timelineEmbedHtml(p.content);
-    let content = p.content;
-    let embedUrl = null;
-    if (!embedHtml) {
-      const k = klonktAudioEmbed(p.content, p.url);
-      if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
-    }
-    // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
-    // top-level "open the player" link that works even when a browser shield/CSP blocks
-    // the cross-site iframe (a full-page navigation is not a cross-site frame).
-    let poll = null;
-    if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
-    return { ...p, content, embedHtml, embedUrl, poll };
-  });
-  // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
-  // extending ONLY this response's CSP frame-src. The global policy stays locked down.
-  if (cspOrigins.size) {
-    const csp = res.getHeader('Content-Security-Policy');
-    if (csp) {
-      const extra = [...cspOrigins].join(' ');
-      res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
-    }
-  }
-  const moreBase = res.locals.siteUrlBase || '';
-  if (append) {
-    return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
-  }
-  renderPage(req, res, 'pages/news', {
-    pageTitle: 'News', bodyClass: 'on-special',
-    timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
-    success: req.query.success || null, error: req.query.error || null,
-  });
-});
-
-// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
-// Connect = who you follow + who follows you, merged into one page with direction
-// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
-// separate Following/Followers pages, which redirect here so old links keep working.
-router.get('/connect', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const connections = site ? ActivityPubService.listConnections(site.slug) : [];
-  renderPage(req, res, 'pages/connect', {
-    pageTitle: 'Connect', bodyClass: 'on-special',
-    connections,
-    success: req.query.success || null, error: req.query.error || null,
-  });
-});
-router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
-router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
-
-router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const base = res.locals.siteUrlBase || '';
-  if (!site) return res.redirect(`${base}/connect`);
-  const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
-  return res.redirect(`${base}/connect?` + (ok
-    ? 'success=' + encodeURIComponent('Volger verwijderd')
-    : 'error=' + encodeURIComponent('Volger niet gevonden')));
-});
-
-router.post('/news/follow', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const handle = (req.body.handle || '').toString();
-  let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
-  if (site && handle.trim()) {
-    try {
-      const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
-      if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
-      else {
-        q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
-      }
-    } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
-  }
-  res.redirect('/following?' + q);
-});
-
-router.post('/news/unfollow', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const actorUri = (req.body.actor_uri || '').toString();
-  if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
-  res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
-});
-
-// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
-router.post('/news/autoboost', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const actorUri = (req.body.actor_uri || '').toString();
-  if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
-  res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
-});
-
-// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
-// no banner); no-JS → redirect back.
-router.post('/news/like', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const note = (req.body.note || '').toString();
-  let on = false;
-  if (site && note) {
-    on = !ActivityPubService.getTimelineReaction(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);
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/news');
-});
-
-// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
-router.post('/news/boost', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const note = (req.body.note || '').toString();
-  let on = false;
-  if (site && note) {
-    on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
-    try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
-    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); })
-        .catch(() => { /* best-effort */ });
-    } else {
-      ActivityPubService.unmarkBoosted(site.slug, note);
-    }
-  }
-  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
-  res.redirect('/news');
-});
-
-// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
-router.post('/news/vote', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  const note = (req.body.note || '').toString();
-  let choice = req.body.choice;
-  if (choice == null) choice = [];
-  if (!Array.isArray(choice)) choice = [choice];
-  if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
-  res.redirect('/news');
-});
-
-// Notifications inbox (new followers + replies/likes/boosts on your posts).
-router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
-
-// Blocking / defederation (owner-only).
-router.get('/blocking', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
-  renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
-});
-
-router.post('/blocking/add', requireSiteManager, async (req, res) => {
-  const site = res.locals.site;
-  let q = 'success=' + encodeURIComponent('Geblokkeerd');
-  if (site) {
-    try {
-      const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
-      if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
-      else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
-    } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
-  }
-  const ref = req.get('Referer') || '';
-  res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
-});
-
-router.post('/blocking/remove', requireSiteManager, (req, res) => {
-  const site = res.locals.site;
-  if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
-  res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
-});
-
-// ==================== VIEW POST (last route â€” catches /:slug) ====================
-router.get('/:slug', (req, res, next) => {
-  if (RESERVED_SLUGS.has(req.params.slug)) return next();
-
-  const site = res.locals.site;
-  if (!site) return next(); // -> nette 404 catch-all
-
-  const post = db.prepare(`
-    SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
-    FROM posts p JOIN users u ON p.author_id = u.id
-    WHERE p.site_id = ? AND p.slug = ?
-  `).get(site.id, req.params.slug);
-
-  if (!post) return next(); // unknown slug -> clean 404 catch-all
-
-  // Permission to view: published OR (logged in + can edit)
-  if (post.status !== 'published') {
-    const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
-    if (!canEdit) return res.status(403).send('Not published');
-  }
-
-  // Fan-only preview (premium #3): full content only for logged-in fans.
-  // Anonymous visitors get a clean login gate instead of the content (the title/
-  // teaser may still appear elsewhere as a teaser).
-  if (post.fan_only && !(req.session && req.session.user)) {
-    // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
-    // stuck on the fan gate but can keep browsing.
-    const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
-    return renderPage(req, res, 'pages/fan-gate', {
-      pageTitle: post.title || 'Alleen voor fans',
-      bodyClass: 'on-special',
-      fgTitle: post.title || '',
-      fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
-      newerPost,
-      olderPost,
-    });
-  }
-
-  // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
-  // is not the owner/editor. The passkey unlock arrives in slices 3-4; for now
-  // the owner previews the full post, everyone else sees the teaser + notice.
-  const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
-  if (post.paid && !canEditThis) {
-    const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
-    return renderPage(req, res, 'pages/paid-gate', {
-      pageTitle: post.title || 'Voor supporters',
-      bodyClass: 'on-special',
-      pgTitle: post.title || '',
-      pgTeaser: paidTeaser(post),
-      pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
-      pgSlug: post.slug,
-      newerPost,
-      olderPost,
-    });
-  }
-
-  // Statistics: count the view (skips admins + unpublished own-preview).
-  if (post.status === 'published') recordPostView(post, req);
-
-  // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
-  // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
-  // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
-  // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
-  // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
+// Renders a post's display HTML: baked content + the dynamic audio/embed layer.
+// Extracted so the paid unlock (slice 4) serves the exact same body as the page.
+export function renderPostBodyHtml(site, post, req) {
   let html = (post.content_rendered != null && post.content_rendered !== '')
     ? post.content_rendered
@@ -1269,6 +748,532 @@
     html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
   }
-  // (linkify is baked into content_rendered at save now, not re-run here.)
-  post.content_html = html;
+  return html;
+}
+
+// A short public teaser for a paid post: its excerpt, else the first ~280 chars
+// of the (stripped) content. Shared by the web gate and federation.
+function paidTeaser(post, max = 280) {
+  if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
+  // Only the FIRST paragraph: a paid teaser must never spill later content.
+  const html = String((post && post.content) || '');
+  const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
+  const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
+  return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
+}
+
+function postNeighbors(site, post, isHub) {
+  const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
+  const ordered = isHub
+    ? db.prepare(`
+        SELECT p.id, p.slug, p.title, p.pinned, s.slug AS site_slug
+        FROM posts p JOIN sites s ON s.id = p.site_id
+        WHERE p.status = 'published'
+        ORDER BY p.published_at DESC
+      `).all()
+    : db.prepare(`
+        SELECT id, slug, title, pinned FROM posts
+        WHERE site_id = ? AND status = 'published'
+        ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC
+      `).all(site.id);
+  const idx = ordered.findIndex((p) => p.id === post.id);
+  const newerPost = idx > 0 ? ordered[idx - 1] : null;
+  const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null;
+  if (newerPost) newerPost._urlBase = urlBaseFor(newerPost);
+  if (olderPost) olderPost._urlBase = urlBaseFor(olderPost);
+  return { newerPost, olderPost };
+}
+
+// ==================== REMOTE INTERACTION (reply to a fediverse post as your site) ====================
+// Standard fediverse "reply from your own server" landing endpoint. A post page
+// elsewhere bounces the visitor here with ?uri=<remote post>; the site owner
+// composes a reply that federates back to that post.
+router.get('/authorize_interaction', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  const uri = (req.query.uri || '').toString();
+  const sent = !!req.query.sent;
+  const followed = !!req.query.followed;
+  const voted = !!req.query.voted;
+  const reported = !!req.query.reported;
+  let target = null, followTarget = null;
+  if (!sent && !followed && !voted && !reported && uri) {
+    try { target = await ActivityPubService.resolveRemoteNote(uri); } catch { /* ignore */ }
+    // Not a post? Maybe the URI is a profile/actor → offer Follow, not reply.
+    if (!target) { try { followTarget = await ActivityPubService.resolveRemoteActor(uri); } catch { /* ignore */ } }
+  }
+  renderPage(req, res, 'pages/authorize-interaction', {
+    pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
+    bodyClass: 'on-special',
+    uri,
+    target,
+    followTarget,
+    sent,
+    followed,
+    voted: !!req.query.voted,
+    reported: !!req.query.reported,
+    liked: !!req.query.liked,
+    boosted: !!req.query.boosted,
+    reacted: (site && uri) ? ActivityPubService.getMyReactions(site.slug, uri) : { liked: false, boosted: false },
+    siteTitle: site ? site.title : '',
+  });
+});
+
+// 📊 Vote on a remote fediverse poll from the interact page (any poll by URL, not just
+// followed ones). Casts the Mastodon-standard ballot straight to the poll's author.
+router.post('/authorize_interaction/vote', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  const uri = (req.body.uri || '').toString();
+  let choice = req.body.choice;
+  if (choice == null) choice = [];
+  if (!Array.isArray(choice)) choice = [choice];
+  if (site && uri && choice.length) { try { await ActivityPubService.voteOnRemotePoll(site, uri, choice.map(String)); } catch { /* ignore */ } }
+  res.redirect('/authorize_interaction?voted=1&uri=' + encodeURIComponent(uri));
+});
+
+// 🚩 Report a remote post/account to its home instance (sends an AS2 Flag).
+router.post('/authorize_interaction/report', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  const uri = (req.body.uri || '').toString();
+  const actorUri = (req.body.actor_uri || '').toString();
+  const reason = (req.body.reason || '').toString();
+  if (site && (uri || actorUri)) { try { await ActivityPubService.sendReport(site, { objectUri: uri, actorUri, reason }); } catch { /* ignore */ } }
+  res.redirect('/authorize_interaction?reported=1&uri=' + encodeURIComponent(uri || actorUri));
+});
+
+// ⭐ Like / unlike a remote post from your own site (toggle on the interact page).
+router.post('/authorize_interaction/like', requireSiteManager, (req, res) => {
+  const site = res.locals.site;
+  const uri = (req.body.uri || '').toString();
+  let on = false;
+  if (site && uri) {
+    on = !ActivityPubService.getMyReactions(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);
+  }
+  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
+  res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
+});
+
+// 🔁 Boost / unboost a remote post from your own site (toggle on the interact page).
+// Also flags it for the Cirkel (markBoosted is a no-op if the post isn't in your timeline).
+router.post('/authorize_interaction/boost', requireSiteManager, (req, res) => {
+  const site = res.locals.site;
+  const uri = (req.body.uri || '').toString();
+  let on = false;
+  if (site && uri) {
+    on = !ActivityPubService.getMyReactions(site.slug, uri).boosted;
+    ActivityPubService.resolveRemoteNote(uri)
+      .then((note) => {
+        if (!note) return;
+        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));
+      })
+      .catch((e) => console.warn('[AP] remote boost failed:', e.message));
+    ActivityPubService.setMyReaction(site.slug, uri, 'boost', on);
+  }
+  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
+  res.redirect('/authorize_interaction?uri=' + encodeURIComponent(uri));
+});
+
+// Follow a remote actor from your own site (when the target is a profile, not a post).
+router.post('/authorize_interaction/follow', requireSiteManager, (req, res) => {
+  const site = res.locals.site;
+  const uri = (req.body.uri || '').toString();
+  if (site && uri) {
+    ActivityPubService.followActor(site, uri)
+      .catch((e) => console.warn('[AP] remote follow failed:', e.message));
+  }
+  res.redirect('/authorize_interaction?followed=1&uri=' + encodeURIComponent(uri));
+});
+
+router.post('/authorize_interaction', requireSiteManager, (req, res) => {
+  const site = res.locals.site;
+  const uri = (req.body.uri || '').toString();
+  const text = (req.body.text || '').toString();
+  const html = (req.body.content || '').toString();      // rich reply editor HTML (sanitized in deliverReply)
+  const language = (req.body.language || '').toString();
+  let attachments = [];
+  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
+  let mentions;   // undefined = geen balk meegestuurd (legacy addressing)
+  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
+  if (site && uri && (text.trim() || html.trim() || (Array.isArray(attachments) && attachments.length))) {
+    // Resolve + deliver in the background so Send responds instantly.
+    ActivityPubService.resolveRemoteNote(uri)
+      .then((parent) => parent && ActivityPubService.deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text, html, language, attachments, mentions }))
+      .catch((e) => console.warn('[AP] remote reply failed:', e.message));
+  }
+  res.redirect('/authorize_interaction?sent=1&uri=' + encodeURIComponent(uri));
+});
+
+// Manage / delete your own outbound fediverse replies (site owner only).
+// Messages = Reacties + Meldingen in ONE inbox (your sent replies join the stream).
+// The old /fediverse (manage) and /notifications pages redirect here.
+router.get('/messages', requireSiteManager, (req, res) => {
+  const site = res.locals.site;
+  const append = req.query.append === '1';
+  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
+  const page = site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : [];
+  const hasMore = page.length > FEED_PAGE;
+  const items = page.slice(0, FEED_PAGE);
+  // Read the watermark BEFORE marking seen → unread dots on items newer than last visit.
+  const seenAt = site ? ActivityPubService.notificationsSeenAt(site.slug) : 0;
+  // Only stamp "seen" on the first page load (not on Load-more appends).
+  if (site && !append && !isViewer(req.session.user)) ActivityPubService.markNotificationsSeen(site.slug);
+  const moreBase = res.locals.siteUrlBase || '';
+  if (append) {
+    return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
+  }
+  renderPage(req, res, 'pages/messages', {
+    pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
+    hasMore, nextOffset: offset + FEED_PAGE, moreBase,
+    success: req.query.success || null, error: req.query.error || null,
+  });
+});
+router.get('/fediverse', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
+
+router.post('/fediverse/:id/delete', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  if (site) {
+    try { await ActivityPubService.deliverOutboxDelete(site, req.params.id); }
+    catch (e) { console.warn('[AP] outbox delete failed:', e.message); }
+  }
+  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
+});
+
+// Moderation: remove an INCOMING reply from your thread (owner only). Tombstones the
+// object URI so re-delivery and thread-crawling never bring it back. Works for private
+// notes too (acts on the local copy; no remote fetch involved).
+router.post('/interactions/:id/remove', requireSiteManager, (req, res) => {
+  const site = res.locals.site;
+  if (site) {
+    const r = ActivityPubService.rejectInteraction(site, parseInt(req.params.id, 10) || 0, 'removed by site owner');
+    if (r.error) console.warn('[AP] interaction remove failed:', r.error);
+  }
+  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
+});
+
+// Moderation: report an INCOMING reply to its home instance (owner only). Uses the
+// locally stored object/actor URIs, so it also works for private notes that
+// authorize_interaction cannot fetch (401/404).
+router.post('/interactions/:id/report', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  if (site) {
+    const tgt = ActivityPubService.interactionReportTarget(site, parseInt(req.params.id, 10) || 0);
+    if (tgt && (tgt.objectUri || tgt.actorUri)) {
+      try {
+        const r = await ActivityPubService.sendReport(site, { objectUri: tgt.objectUri, actorUri: tgt.actorUri, reason: (req.body.reason || '').toString().slice(0, 500) });
+        if (r && r.error) console.warn('[AP] interaction report failed:', r.error);
+      } catch (e) { console.warn('[AP] interaction report failed:', e.message); }
+    }
+  }
+  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/`);
+});
+
+// Edit one of your own outbound fediverse replies (owner only) → sends an Update(Note).
+router.post('/fediverse/:id/edit', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  const text = String(req.body.text || '');
+  const html = String(req.body.content || '');   // rich reply editor HTML (sanitized in deliverOutboxUpdate)
+  if (site && (text.trim() || html.trim())) {
+    try {
+      await ActivityPubService.deliverOutboxUpdate(site, req.params.id, text, {
+        html, language: String(req.body.language || ''),
+      });
+    } catch (e) { console.warn('[AP] outbox edit failed:', e.message); }
+  }
+  res.redirect(req.get('Referer') || `${res.locals.siteUrlBase || ''}/fediverse`);
+});
+
+// ==================== FEDIVERSE CLIENT: home timeline + following ====================
+// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
+// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
+function timelineEmbedHtml(html) {
+  if (!html) return null;
+  const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
+  while ((m = re.exec(html))) {
+    const u = m[1]; if (seen.has(u)) continue; seen.add(u);
+    let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
+    if (!p) {
+      // PeerTube is decentralised (any instance), so it's not in detectProvider — match its watch URL
+      // (/w/<id> or /videos/watch/<id>) and embed the player. Host is validated (safe chars only), so
+      // it's safe to inline into the iframe src; a non-PeerTube /w/ URL just yields an empty iframe.
+      const pt = u.match(/^https?:\/\/([\w.-]+(?::\d+)?)\/(?:w|videos\/watch)\/([\w-]{6,})/i);
+      if (pt) return `<iframe class="tl-embed-frame" src="https://${pt[1]}/videos/embed/${pt[2]}" title="PeerTube" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
+      continue;
+    }
+    if (p.provider === 'youtube') return `<iframe class="tl-embed-frame" src="https://www.youtube-nocookie.com/embed/${p.id}" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
+    if (p.provider === 'spotify') return `<iframe class="tl-embed-frame tl-embed-spotify" src="https://open.spotify.com/embed/${p.type}/${p.id}" title="Spotify" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
+    if (p.provider === 'soundcloud') return `<iframe class="tl-embed-frame tl-embed-sc" src="https://w.soundcloud.com/player/?url=${encodeURIComponent(p.url)}&color=%23ff5500&visual=false" title="SoundCloud" loading="lazy" frameborder="0" allow="autoplay" scrolling="no"></iframe>`;
+    if (p.provider === 'vimeo') return `<iframe class="tl-embed-frame" src="https://player.vimeo.com/video/${p.id}" title="Vimeo" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
+    if (p.provider === 'bandcamp') return `<iframe class="tl-embed-frame tl-embed-bandcamp" src="https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(u)}/size=large/bgcol=faf8f3/linkcol=c2410c/tracklist=false/transparent=true/" title="Bandcamp" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
+    if (p.provider === 'applemusic') { const am = u.match(/music\.apple\.com\/([a-z]{2}\/(?:album|playlist|song)\/[^/?#]+\/[0-9]+)/i); if (am) return `<iframe class="tl-embed-frame tl-embed-apple" src="https://embed.music.apple.com/${am[1]}" title="Apple Music" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>`; }
+  }
+  return null;
+}
+
+// A federated Klonkt audio post renders as "🎵 … listen on <link>". Embed the remote
+// Klonkt player (its /embed?post=<slug>). A single-segment path = a Klonkt post slug
+// (skips Mastodon /@user/123). The origin is whitelisted in the response CSP frame-src.
+function klonktAudioEmbed(html, url) {
+  if (!html || !url || html.indexOf('🎵') < 0) return null;
+  let u; try { u = new URL(url); } catch { return null; }
+  if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
+  const slug = u.pathname.replace(/^\/+|\/+$/g, '');
+  if (!slug || slug.indexOf('/') >= 0) return null; // single segment only
+  const src = u.origin + '/embed?post=' + encodeURIComponent(slug);
+  // Drop the now-redundant "🎵 … listen on <site>" line — the embedded player below shows it.
+  const content = html.replace(/<p>🎵[\s\S]*?<\/p>\s*/i, '');
+  return { origin: u.origin, embedUrl: src, content, html: `<iframe class="tl-embed-frame tl-embed-klonkt" src="${src}" title="Audio" loading="lazy" frameborder="0" allow="autoplay; encrypted-media"></iframe>` };
+}
+
+router.get('/news', requireSiteManager, (req, res) => {
+  const site = res.locals.site;
+  const append = req.query.append === '1';
+  const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
+  const cspOrigins = new Set();
+  // Fetch one extra to know whether a "Load more" button belongs on this page.
+  const rows = site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : [];
+  const hasMore = rows.length > FEED_PAGE;
+  const timeline = rows.slice(0, FEED_PAGE).map((p) => {
+    let embedHtml = timelineEmbedHtml(p.content);
+    let content = p.content;
+    let embedUrl = null;
+    if (!embedHtml) {
+      const k = klonktAudioEmbed(p.content, p.url);
+      if (k) { embedHtml = k.html; content = k.content; embedUrl = k.embedUrl; cspOrigins.add(k.origin); }
+    }
+    // embedUrl = the player's direct /embed?post=… URL. Surfaced so the view can offer a
+    // top-level "open the player" link that works even when a browser shield/CSP blocks
+    // the cross-site iframe (a full-page navigation is not a cross-site frame).
+    let poll = null;
+    if (p.poll_json) { try { poll = JSON.parse(p.poll_json); } catch { /* ignore */ } }
+    return { ...p, content, embedHtml, embedUrl, poll };
+  });
+  // Option A: allow the followed Klonkt sites' player iframes (you follow them) by
+  // extending ONLY this response's CSP frame-src. The global policy stays locked down.
+  if (cspOrigins.size) {
+    const csp = res.getHeader('Content-Security-Policy');
+    if (csp) {
+      const extra = [...cspOrigins].join(' ');
+      res.setHeader('Content-Security-Policy', String(csp).replace(/frame-src ([^;]*)/i, (m, g) => `frame-src ${g} ${extra}`));
+    }
+  }
+  const moreBase = res.locals.siteUrlBase || '';
+  if (append) {
+    return renderPage(req, res, 'partials/news-append', { timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
+  }
+  renderPage(req, res, 'pages/news', {
+    pageTitle: 'News', bodyClass: 'on-special',
+    timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
+    success: req.query.success || null, error: req.query.error || null,
+  });
+});
+
+// Volgend — manage the accounts you follow (+ per-account auto-boost toggles).
+// Connect = who you follow + who follows you, merged into one page with direction
+// (following →, follower ←, mutual ↔) and per-account delivery health. Replaces the
+// separate Following/Followers pages, which redirect here so old links keep working.
+router.get('/connect', requireSiteManager, (req, res) => {
+  const site = res.locals.site;
+  const connections = site ? ActivityPubService.listConnections(site.slug) : [];
+  renderPage(req, res, 'pages/connect', {
+    pageTitle: 'Connect', bodyClass: 'on-special',
+    connections,
+    success: req.query.success || null, error: req.query.error || null,
+  });
+});
+router.get('/following', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
+router.get('/followers', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/connect`));
+
+router.post('/followers/:id/remove', requireSiteManager, (req, res) => {
+  const site = res.locals.site;
+  const base = res.locals.siteUrlBase || '';
+  if (!site) return res.redirect(`${base}/connect`);
+  const ok = ActivityPubService.removeFollower(site.slug, parseInt(req.params.id, 10) || 0);
+  return res.redirect(`${base}/connect?` + (ok
+    ? 'success=' + encodeURIComponent('Volger verwijderd')
+    : 'error=' + encodeURIComponent('Volger niet gevonden')));
+});
+
+router.post('/news/follow', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  const handle = (req.body.handle || '').toString();
+  let q = 'success=' + encodeURIComponent('Volgverzoek verstuurd');
+  if (site && handle.trim()) {
+    try {
+      const r = await ActivityPubService.followActor(site, handle, !!req.body.auto_boost);
+      if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : (r.error === 'unreachable' ? 'Server onbereikbaar' : 'Volgen mislukt'));
+      else {
+        q = 'success=' + encodeURIComponent('Je volgt nu ' + ((r && r.name) || handle));
+      }
+    } catch (e) { q = 'error=' + encodeURIComponent('Volgen mislukt'); }
+  }
+  res.redirect('/following?' + q);
+});
+
+router.post('/news/unfollow', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  const actorUri = (req.body.actor_uri || '').toString();
+  if (site && actorUri) { try { await ActivityPubService.unfollowActor(site, actorUri); } catch (e) { /* ignore */ } }
+  res.redirect('/following?success=' + encodeURIComponent('Ontvolgd'));
+});
+
+// Toggle "Featured" (show this account's posts in your Cirkel) on an account you follow.
+router.post('/news/autoboost', requireSiteManager, (req, res) => {
+  const site = res.locals.site;
+  const actorUri = (req.body.actor_uri || '').toString();
+  if (site && actorUri) ActivityPubService.setAutoBoost(site.slug, actorUri, !!req.body.auto_boost);
+  res.redirect('/following?success=' + encodeURIComponent(req.body.auto_boost ? 'Uitgelicht ✨' : 'Niet meer uitgelicht'));
+});
+
+// Like / unlike a feed post — a toggle. Fetch request → JSON {on} (stay on the page,
+// no banner); no-JS → redirect back.
+router.post('/news/like', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  const note = (req.body.note || '').toString();
+  let on = false;
+  if (site && note) {
+    on = !ActivityPubService.getTimelineReaction(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);
+  }
+  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
+  res.redirect('/news');
+});
+
+// Boost / unboost a feed post — a toggle. markBoosted also surfaces it in the Cirkel.
+router.post('/news/boost', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  const note = (req.body.note || '').toString();
+  let on = false;
+  if (site && note) {
+    on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
+    try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
+    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); })
+        .catch(() => { /* best-effort */ });
+    } else {
+      ActivityPubService.unmarkBoosted(site.slug, note);
+    }
+  }
+  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
+  res.redirect('/news');
+});
+
+// Vote on a fediverse poll (a Question in the feed). Owner-only, like the other interactions.
+router.post('/news/vote', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  const note = (req.body.note || '').toString();
+  let choice = req.body.choice;
+  if (choice == null) choice = [];
+  if (!Array.isArray(choice)) choice = [choice];
+  if (site && note && choice.length) { try { await ActivityPubService.voteOnPoll(site, note, choice.map(String)); } catch (e) { /* ignore */ } }
+  res.redirect('/news');
+});
+
+// Notifications inbox (new followers + replies/likes/boosts on your posts).
+router.get('/notifications', requireSiteManager, (req, res) => res.redirect(`${res.locals.siteUrlBase || ''}/messages`));
+
+// Blocking / defederation (owner-only).
+router.get('/blocking', requireSiteManager, (req, res) => {
+  const site = res.locals.site;
+  const blocks = site ? ActivityPubService.listBlocks(site.slug) : [];
+  renderPage(req, res, 'pages/blocks', { pageTitle: 'Blokkeren', bodyClass: 'on-special', blocks, success: req.query.success || null, error: req.query.error || null });
+});
+
+router.post('/blocking/add', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  let q = 'success=' + encodeURIComponent('Geblokkeerd');
+  if (site) {
+    try {
+      const r = await ActivityPubService.blockTarget(site, (req.body.target || '').toString());
+      if (r && r.error) q = 'error=' + encodeURIComponent(r.error === 'not_found' ? 'Account niet gevonden' : 'Voer een @handle of domein in');
+      else q = 'success=' + encodeURIComponent(((r && r.label) || '') + ' geblokkeerd');
+    } catch (e) { q = 'error=' + encodeURIComponent('Blokkeren mislukt'); }
+  }
+  const ref = req.get('Referer') || '';
+  res.redirect((ref.includes('/news') ? '/news?' : '/blocking?') + q);
+});
+
+router.post('/blocking/remove', requireSiteManager, (req, res) => {
+  const site = res.locals.site;
+  if (site) { try { ActivityPubService.unblock(site, (req.body.target || '').toString()); } catch (e) { /* ignore */ } }
+  res.redirect('/blocking?success=' + encodeURIComponent('Deblokkeerd'));
+});
+
+// ==================== VIEW POST (last route â€” catches /:slug) ====================
+router.get('/:slug', (req, res, next) => {
+  if (RESERVED_SLUGS.has(req.params.slug)) return next();
+
+  const site = res.locals.site;
+  if (!site) return next(); // -> nette 404 catch-all
+
+  const post = db.prepare(`
+    SELECT p.*, u.username as author_username, u.avatar_url as author_avatar
+    FROM posts p JOIN users u ON p.author_id = u.id
+    WHERE p.site_id = ? AND p.slug = ?
+  `).get(site.id, req.params.slug);
+
+  if (!post) return next(); // unknown slug -> clean 404 catch-all
+
+  // Permission to view: published OR (logged in + can edit)
+  if (post.status !== 'published') {
+    const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
+    if (!canEdit) return res.status(403).send('Not published');
+  }
+
+  // Fan-only preview (premium #3): full content only for logged-in fans.
+  // Anonymous visitors get a clean login gate instead of the content (the title/
+  // teaser may still appear elsewhere as a teaser).
+  if (post.fan_only && !(req.session && req.session.user)) {
+    // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
+    // stuck on the fan gate but can keep browsing.
+    const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
+    return renderPage(req, res, 'pages/fan-gate', {
+      pageTitle: post.title || 'Alleen voor fans',
+      bodyClass: 'on-special',
+      fgTitle: post.title || '',
+      fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
+      newerPost,
+      olderPost,
+    });
+  }
+
+  // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
+  // is not the owner/editor. The passkey unlock arrives in slices 3-4; for now
+  // the owner previews the full post, everyone else sees the teaser + notice.
+  const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
+  if (post.paid && !canEditThis) {
+    const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
+    return renderPage(req, res, 'pages/paid-gate', {
+      pageTitle: post.title || 'Voor supporters',
+      bodyClass: 'on-special',
+      pgTitle: post.title || '',
+      pgTeaser: paidTeaser(post),
+      pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
+      pgSlug: post.slug,
+      newerPost,
+      olderPost,
+    });
+  }
+
+  // Statistics: count the view (skips admins + unpublished own-preview).
+  if (post.status === 'published') recordPostView(post, req);
+
+  // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
+  // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
+  // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
+  // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
+  // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
+  post.content_html = renderPostBodyHtml(site, post, req);
 
   if (post.tags) {
