Index: src/services/AudioEmbedService.js
===================================================================
--- src/services/AudioEmbedService.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/AudioEmbedService.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -11,5 +11,5 @@
  */
 
-// "Open in"-iconen (brand-gekleurd via CSS .pat-link--*).
+// "Open in" icons (brand-colored via CSS .pat-link--).
 const OPEN_IN_SVG = {
   spotify: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm4.6 14.42a.62.62 0 01-.86.21c-2.35-1.44-5.3-1.76-8.79-.96a.62.62 0 11-.28-1.21c3.8-.87 7.07-.5 9.71 1.11.3.18.39.57.22.85zm1.23-2.73a.78.78 0 01-1.07.26c-2.69-1.66-6.79-2.14-9.97-1.17a.78.78 0 11-.45-1.49c3.63-1.1 8.15-.56 11.24 1.33.36.22.48.7.25 1.07zm.1-2.85C14.66 8.95 9.4 8.78 6.3 9.72a.93.93 0 11-.54-1.79c3.56-1.08 9.37-.87 13.07 1.33a.94.94 0 01-.96 1.61z"/></svg>',
@@ -19,8 +19,8 @@
 
 class AudioEmbedService {
-  // Kleine "open in"-links voor een track (Spotify/YouTube/SoundCloud). De hrefs
-  // zijn server-side al gevalideerd (alleen https + juiste host). Geeft '' als er
-  // geen links zijn. Wordt naast de play-knop gezet (buiten de knop → geen
-  // conflict met afspelen).
+  // Small "open in" links for a track (Spotify/YouTube/SoundCloud). The hrefs
+  // are already validated server-side (https + correct host only). Returns ''
+  // when no links exist. Placed next to the play button (outside the button →
+  // no conflict with playback).
   static openInLinks(t) {
     if (!t) return '';
@@ -40,9 +40,9 @@
     url = url.trim();
 
-    // Alleen http(s)-URL's embedden. De provider-regexes hieronder zijn NIET
-    // verankerd, dus zonder deze check zou bv. `javascript:alert(1)//youtu.be/x`
-    // matchen en als embed-URL belanden (stored XSS via een [[embed:...]]-
-    // shortcode — die tekst gaat niet langs de HTML-sanitizer omdat 'ie in een
-    // text-node zit). De scheme-guard sluit javascript:/data:/vbscript: enz. uit.
+    // Only embed http(s) URLs. The provider regexes below are NOT anchored,
+    // so without this check e.g. `javascript:alert(1)//youtu.be/x` would match
+    // and land as an embed URL (stored XSS via an [[embed:...]] shortcode —
+    // that text never passes through the HTML sanitizer because it lives in a
+    // text node). The scheme guard excludes javascript:/data:/vbscript: etc.
     if (!/^https?:\/\//i.test(url)) return null;
 
@@ -68,6 +68,6 @@
     }
 
-    // YouTube — video-id is altijd exact 11 tekens (lijnt uit met de client-side
-    // ytId() in embed-player.js, die ook {11} verwacht).
+    // YouTube — video id is always exactly 11 characters (aligns with the client-side
+    // ytId() in embed-player.js, which also expects {11}).
     if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([A-Za-z0-9_-]{11})/i.test(url)) {
       const match = url.match(/(?:v=|youtu\.be\/|embed\/|shorts\/|live\/)([A-Za-z0-9_-]{11})/i);
@@ -86,7 +86,7 @@
   static generateIframe(provider, config) {
     switch (provider) {
-      // Eigen custom-spelers (client-side via embed-player.js + de echte
-      // platform-API's). We renderen een placeholder met data-attributen i.p.v.
-      // het kale platform-iframe, zodat de embed in ÓNZE huisstijl verschijnt.
+      // Custom players (client-side via embed-player.js + the real platform APIs).
+      // We render a placeholder with data attributes instead of the bare platform
+      // iframe, so the embed appears in OUR brand style.
       case 'youtube':
         return this.embedPlaceholder('youtube', config.id, 'video',
@@ -97,6 +97,6 @@
         return this.embedPlaceholder('spotify', `spotify:${config.type}:${config.id}`,
           config.type, config.url || `https://open.spotify.com/${config.type}/${config.id}`);
-      // Geen JS-API (Bandcamp/Apple) of niet-prioritair (Vimeo): blijven een
-      // iframe; mutual-exclusion loopt voor deze via de blur-fallback.
+      // No JS API (Bandcamp/Apple) or low priority (Vimeo): remain as iframes;
+      // mutual exclusion for these runs via the blur fallback.
       case 'bandcamp':
         return this.bandcampIframe(config);
@@ -111,7 +111,7 @@
 
   /**
-   * Placeholder voor een eigen custom-speler. embed-player.js pikt
-   * .folio-embed[data-embed-provider] op en bouwt de kaart + speler client-side.
-   * ALLE waarden via escape() — post.content_html wordt ongeescaped uitgevoerd.
+   * Placeholder for a custom player. embed-player.js picks up
+   * .folio-embed[data-embed-provider] and builds the card + player client-side.
+   * ALL values go through escape() — post.content_html is executed unescaped.
    */
   static embedPlaceholder(provider, ref, type, url) {
@@ -244,8 +244,8 @@
 
   /**
-   * Replace [[embed:<url>]] shortcodes met de platform-iframe (YouTube, Spotify,
-   * SoundCloud, Apple Music, Bandcamp, Vimeo). De editor-knop voegt deze
-   * shortcode in; losse URL-regels embedden ook automatisch via autoembed().
-   * Niet-ondersteunde/ongeldige URLs krijgen een nette inline-melding.
+   * Replace [[embed:<url>]] shortcodes with the platform iframe (YouTube, Spotify,
+   * SoundCloud, Apple Music, Bandcamp, Vimeo). The editor button inserts this
+   * shortcode; bare URL lines also embed automatically via autoembed().
+   * Unsupported/invalid URLs get a clean inline notice.
    */
   static embedMediaShortcodes(html) {
@@ -274,5 +274,5 @@
       const artistH0 = this.escape(t.artist || '');
       const creditBits0 = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
-      // Link-only track (geen audiobestand): geen afspeelknop, wel info + open-in.
+      // Link-only track (no audio file): no play button, but info + open-in links.
       if (!t.url) {
         const coverH0 = this.escape(t.cover || '');
@@ -302,9 +302,9 @@
       const artistH = this.escape(t.artist || '');
       const urlH = this.escape(t.url);
-      // Zichtbare eigenaar/licentie-regel onder de track.
+      // Visible owner/license line below the track.
       const creditBits = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
       const dataAttr = trackJson
         .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
-      // id="track-<id>" = anker zodat de mini-speler hierheen kan scrollen.
+      // id="track-<id>" = anchor so the mini-player can scroll to this element.
       return `<div class="post-audio-track" id="track-${id}" data-pcms-track-id="${id}" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
   <button type="button" class="pat-play" aria-label="Play ${titleH}">
@@ -337,6 +337,6 @@
       // Stable DOM id for this rendering — used as data-pcms-album-id on tracks
       const albumDomId = 'album-' + Math.random().toString(36).slice(2, 10);
-      // Alleen afspeelbare tracks (met url) in de queue; link-only tracks staan
-      // wel in de lijst maar niet in de afspeel-JSON.
+      // Only playable tracks (with url) in the queue; link-only tracks appear
+      // in the list but not in the playback JSON.
       const albumJson = JSON.stringify(album.tracks.filter((t) => t.url))
         .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
@@ -348,5 +348,5 @@
         const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
         const tArtist = this.escape(t.artist || '');
-        // Link-only track: geen afspeelknop, wel nummer + info + open-in.
+        // Link-only track: no play button, but track number + info + open-in links.
         if (!t.url) {
           return `    <li class="post-audio-track post-audio-track--static"${t.id ? ` id="track-${t.id}"` : ''}>
@@ -436,6 +436,6 @@
       // Audio-player.js reads data-pcms-album for queue. Same shape as
       // embedAlbumShortcodes — keep both in sync.
-      // Alleen afspeelbare tracks in de queue; link-only tracks staan wel in de
-      // lijst maar niet in de afspeel-JSON.
+      // Only playable tracks in the queue; link-only tracks appear in the list
+      // but not in the playback JSON.
       const tracksData = pl.tracks.filter(t => t.url).map(t => ({
         id:     t.id,
@@ -479,5 +479,5 @@
           : `<span class="pat-num">${i + 1}</span>`;
 
-        // Link-only track: geen klikbare afspeel-rij (statische div), wel open-in.
+        // Link-only track: no clickable play row (static div), but open-in links.
         if (!t.url) {
           return `    <li class="post-album-track-compact post-album-track-compact--static"${t.id ? ` id="track-${t.id}"` : ''}>
@@ -594,5 +594,5 @@
    * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a
    * branded "Open in <Platform>" anchor (no iframe). Opens in new tab.
-   * Per Robin's v9: "Externe link, klik = open platform (target _blank)".
+   * Per Robin's v9: "External link, click = open platform (target _blank)".
    */
   static embedExternalLinkShortcodes(html) {
Index: src/services/AudioTranscoder.js
===================================================================
--- src/services/AudioTranscoder.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/AudioTranscoder.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -126,5 +126,5 @@
       size: outStat.size,
       mimeType: 'audio/mpeg',
-      durationSec,   // hele seconden uit ffmpeg's codecData (null als onbekend)
+      durationSec,   // whole seconds from ffmpeg's codecData (null if unknown)
     };
 
@@ -138,12 +138,12 @@
 
 /**
- * Herschrijf de ID3-tags van een BESTAANDE mp3 zonder her-encoden (`-c copy`).
- * Gebruikt bij het bewerken van track-metadata (titel/artiest/album/credit/licentie)
- * zodat de eigendomsinfo in het bestand zelf meereist bij een download.
- * ffmpeg kan niet in-place editen → schrijf naar tmp en hernoem atomisch terug.
+ * Rewrite the ID3 tags of an EXISTING mp3 without re-encoding (`-c copy`).
+ * Used when editing track metadata (title/artist/album/credit/license) so that
+ * ownership info travels with the file on download.
+ * ffmpeg cannot edit in-place → write to tmp and atomically rename back.
  */
 export async function retagMp3({ filePath, tags = {} }) {
   if (!filePath) throw new Error('retagMp3: filePath required');
-  await stat(filePath); // throws als 't bestand mist
+  await stat(filePath); // throws if file is missing
   const dir = path.dirname(filePath);
   const base = path.basename(filePath, path.extname(filePath));
@@ -152,5 +152,5 @@
     await new Promise((resolve, reject) => {
       const cmd = ffmpeg(filePath)
-        .audioCodec('copy')        // geen her-encode → snel, geen kwaliteitsverlies
+        .audioCodec('copy')        // no re-encode → fast, no quality loss
         .format('mp3')
         .outputOptions('-id3v2_version', '3')
@@ -167,9 +167,9 @@
     });
     const s = await stat(tmpPath);
-    if (s.size === 0) throw new Error('retag output is leeg');
+    if (s.size === 0) throw new Error('retag output is empty');
     await rename(tmpPath, filePath);
     return { filePath, size: s.size };
   } catch (err) {
-    try { await unlink(tmpPath); } catch { /* tmp bestaat mogelijk niet */ }
+    try { await unlink(tmpPath); } catch { /* tmp may not exist */ }
     throw err;
   }
@@ -209,10 +209,10 @@
     if (tags.artist)    cmd.outputOptions('-metadata', `artist=${tags.artist}`);
     if (tags.album)     cmd.outputOptions('-metadata', `album=${tags.album}`);
-    if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`); // ID3 TCOP — eigenaar/credit
-    if (tags.comment)   cmd.outputOptions('-metadata', `comment=${tags.comment}`);     // ID3 COMM — licentie
+    if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`); // ID3 TCOP — owner/credit
+    if (tags.comment)   cmd.outputOptions('-metadata', `comment=${tags.comment}`);     // ID3 COMM — license
 
     cmd
-      // codecData geeft de duur van de INPUT als "HH:MM:SS.xx" — zo bepalen we
-      // de tracklengte automatisch zonder aparte ffprobe-binary.
+      // codecData gives the INPUT duration as "HH:MM:SS.xx" — this lets us
+      // determine the track length automatically without a separate ffprobe binary.
       .on('codecData', (data) => { durationSec = parseHmsToSeconds(data && data.duration); })
       .on('error', (err, stdout, stderr) => {
@@ -230,6 +230,6 @@
 
 /**
- * Parse een ffmpeg-duurstring "HH:MM:SS.xx" naar hele seconden. Geeft null bij
- * "N/A" of een onverwacht formaat.
+ * Parse an ffmpeg duration string "HH:MM:SS.xx" to whole seconds. Returns null
+ * for "N/A" or an unexpected format.
  */
 function parseHmsToSeconds(hms) {
@@ -242,8 +242,8 @@
 
 /**
- * Lees de duur (hele seconden) van een audiobestand ZONDER te transcoderen.
- * Start een ffmpeg-pass en leest enkel het codecData-event (duur), waarna we het
- * proces direct stoppen — snel en zonder aparte ffprobe-binary (ffmpeg-static
- * levert alleen ffmpeg). Bedoeld voor het backfill-script.
+ * Read the duration (whole seconds) of an audio file WITHOUT transcoding.
+ * Starts an ffmpeg pass and reads only the codecData event (duration), then
+ * kills the process immediately — fast and without a separate ffprobe binary
+ * (ffmpeg-static ships only ffmpeg). Intended for the backfill script.
  * @returns {Promise<number|null>}
  */
@@ -255,5 +255,5 @@
       .on('codecData', (data) => {
         durationSec = parseHmsToSeconds(data && data.duration);
-        try { cmd.kill('SIGKILL'); } catch { /* al klaar */ }
+        try { cmd.kill('SIGKILL'); } catch { /* already done */ }
         finish();
       })
Index: src/services/CircleFederation.js
===================================================================
--- src/services/CircleFederation.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/CircleFederation.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -1,14 +1,14 @@
-// CircleFederation.js — eigen publicatie-kant van "Cirkels" (v1).
+// CircleFederation.js — publication side of "Circles" (v1).
 //
-// Publiceert deze instance als een ActivityStreams-actor met een Ed25519-
-// sleutel, plus een outbox van publieke posts. De outbox wordt getekend zodat
-// consumenten (andere Klonkt-instances) de herkomst kunnen verifiëren.
+// Publishes this instance as an ActivityStreams actor with an Ed25519 key,
+// plus an outbox of public posts. The outbox is signed so that consumers
+// (other Klonkt instances) can verify the origin.
 //
-// v1 = alleen PUBLICEREN + tekenen. Het pullen/verifiëren van remote cirkels
-// (CircleService.sync) komt in een volgende stap. Zie docs/cirkels-v1-spec.md.
+// v1 = PUBLISH + sign only. Pulling/verifying remote circles
+// (CircleService.sync) comes in a later step. See docs/cirkels-v1-spec.md.
 //
-// (Het idee om je netjes aan de bestaande standaarden te houden fluisterde een
-//  zekere Bart ons in. Wie hij is, waar hij vandaan komt — niemand die het zeker
-//  weet. Hij verscheen, sprak van ActivityStreams, en was weer weg.)
+// (The idea of sticking neatly to existing standards was whispered to us by
+//  a certain Bart. Who he is, where he came from — nobody knows for sure.
+//  He appeared, spoke of ActivityStreams, and was gone.)
 
 import crypto from 'crypto';
@@ -16,12 +16,13 @@
 import { getSetting, setSetting } from './SettingsService.js';
 
-// ── Protocol-versie (federatie) ───────────────────────────────
-// KLONKT_PROTO zit IN de ondertekende grondslag (zie signingInput): een instance
-// die niet op deze proto draait kan onze getekende outbox NIET verifiëren, en wij
-// de hare niet. Bijblijven is dus geen beleefde check die je wegpatcht, maar
-// cryptografisch afgedwongen — de enige manier om mee te doen is dezelfde proto
-// draaien (= de update). Bump KLONKT_PROTO bij elke release die federatie/security
-// raakt, en koppel een securityfix aan elke bump → outdated = buiten + onveilig.
-// MIN_PROTO = de laagste proto waarmee we nog federeren.
+// ── Protocol version (federation) ────────────────────────────
+// KLONKT_PROTO is embedded IN the signed input (see signingInput): an instance
+// not running this proto CANNOT verify our signed outbox, and we cannot verify
+// theirs. Staying current is therefore not a polite check you can patch away,
+// but cryptographically enforced — the only way to participate is to run the
+// same proto (= apply the update). Bump KLONKT_PROTO for every release that
+// touches federation/security, and attach a security fix to each bump →
+// outdated = excluded + insecure.
+// MIN_PROTO = the lowest proto we still federate with.
 export const KLONKT_PROTO = 2;
 export const MIN_PROTO = 2;
@@ -31,8 +32,8 @@
 }
 
-// ── Sleutelbeheer ─────────────────────────────────────────────
-// Per-instance Ed25519-keypair, eenmalig gegenereerd en in app_settings
-// bewaard. Privé = PKCS8-PEM (nooit serveren). Publiek = SPKI-DER base64
-// (gepubliceerd in de actor; round-trip via createPublicKey).
+// ── Key management ────────────────────────────────────────────
+// Per-instance Ed25519 keypair, generated once and stored in app_settings.
+// Private = PKCS8 PEM (never served). Public = SPKI DER base64
+// (published in the actor; round-tripped via createPublicKey).
 function getKeys() {
   let priv = getSetting('circle_privkey_pem', null);
@@ -52,5 +53,5 @@
 }
 
-/** Tekent een body-string, gebonden aan de protocol-versie (Ed25519). */
+/** Signs a body string, bound to the protocol version (Ed25519). */
 export function signBody(rawString, proto = KLONKT_PROTO) {
   const key = crypto.createPrivateKey(getKeys().priv);
@@ -58,6 +59,6 @@
 }
 
-/** Verifieert een body tegen een SPKI-DER-base64 publieke sleutel, voor de gegeven
- *  proto. Een mismatch in proto = mismatch in grondslag = ongeldige handtekening. */
+/** Verifies a body against an SPKI-DER-base64 public key for the given proto.
+ *  A proto mismatch = a signing-input mismatch = invalid signature. */
 export function verifyBody(rawString, sigB64, pubDerB64, proto = KLONKT_PROTO) {
   try {
@@ -73,5 +74,5 @@
 // ── Helpers ───────────────────────────────────────────────────
 function primarySite() {
-  // Solo: de primaire/owner-site (eerst aangemaakt) — zelfde keuze als resolveSite.
+  // Solo: the primary/owner site (oldest) — same choice as resolveSite.
   return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
 }
@@ -80,14 +81,14 @@
   return String(s || '')
     .replace(/<[^>]+>/g, ' ')
-    .replace(/\[\[[^\]]*\]\]/g, ' ')   // [[playlist:..]]/[[track:..]]/[[album:..]]-shortcodes weg
+    .replace(/\[\[[^\]]*\]\]/g, ' ')   // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes
     .replace(/\s+/g, ' ')
     .trim();
 }
 
-// Tags-kolom (JSON-array of comma-separated) -> nette string-array.
+// Tags column (JSON array or comma-separated) -> clean string array.
 function parseTags(raw) {
   if (!raw) return [];
   if (Array.isArray(raw)) return raw.map((t) => String(t).trim()).filter(Boolean);
-  try { const j = JSON.parse(raw); if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean); } catch { /* geen JSON */ }
+  try { const j = JSON.parse(raw); if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean); } catch { /* not JSON */ }
   return String(raw).split(',').map((t) => t.trim()).filter(Boolean);
 }
@@ -103,6 +104,6 @@
 }
 
-// allow_circle: een site mag in cirkels van anderen verschijnen. v1 koppelt dit
-// aan is_public (aparte expliciete flag volgt in de Beheer-UX-stap).
+// allow_circle: a site may appear in other instances' circles. v1 ties this
+// to is_public (a separate explicit flag follows in the admin UX step).
 function allowsCircle(site) {
   return !!site && site.is_public !== 0 && site.allow_circle !== 0;
@@ -170,5 +171,5 @@
         published,
         ...(p.cover_image_url ? { image: { type: 'Image', url: abs(base, p.cover_image_url) } } : {}),
-        // ActivityStreams: tags als Hashtag-objecten (href naar de bron-tagpagina).
+        // ActivityStreams: tags as Hashtag objects (href points to the source tag page).
         ...(tags.length ? { tag: tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/^#/, ''), href: `${base}/tag/${encodeURIComponent(t)}` })) } : {}),
       },
Index: src/services/CircleService.js
===================================================================
--- src/services/CircleService.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/CircleService.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -1,7 +1,7 @@
-// CircleService.js — pull-kant van Cirkels (v1).
+// CircleService.js — pull side of Circles (v1).
 //
-// Haalt per circle_link de remote actor + outbox op, verifieert de Ed25519-
-// handtekening, sanitiseert en cachet publieke posts in remote_actors/remote_posts.
-// Alleen LEZEN van remote; nooit schrijven. Zie docs/cirkels-v1-spec.md §5b.
+// Per circle_link: fetches the remote actor + outbox, verifies the Ed25519
+// signature, sanitizes, and caches public posts in remote_actors/remote_posts.
+// READ ONLY from remote; never write. See docs/cirkels-v1-spec.md §5b.
 
 import db from '../config/database.js';
@@ -16,5 +16,5 @@
   return String(s || '')
     .replace(/<[^>]+>/g, ' ')
-    .replace(/\[\[[^\]]*\]\]/g, ' ')   // [[playlist:..]]/[[track:..]]/[[album:..]]-shortcodes weg
+    .replace(/\[\[[^\]]*\]\]/g, ' ')   // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes
     .replace(/\s+/g, ' ')
     .trim();
@@ -31,5 +31,5 @@
 }
 
-// AS Hashtag-array -> comma-separated tagnamen (zonder #), gesanitized.
+// AS Hashtag array -> comma-separated tag names (without #), sanitized.
 function extractTags(tag) {
   if (!Array.isArray(tag)) return null;
@@ -41,10 +41,10 @@
 }
 
-// Bron buiten de cirkel zetten met een leesbare reden (geen stille mislukking).
-// Aparte status 'outdated' zodat de Beheer-UI er een nette "update vereist"-
-// melding van kan maken i.p.v. een generieke fout.
+// Mark a source as outside the circle with a readable reason (no silent failure).
+// Separate 'outdated' status so the admin UI can show a clean "update required"
+// notice instead of a generic error.
 function markOutdated(link, msg) {
-  // Gecachte posts van deze bron weghalen: we kunnen ze niet meer verifiëren of
-  // verversen (proto-mismatch), dus ze horen niet meer in de cirkel-feed.
+  // Remove cached posts from this source: we can no longer verify or refresh
+  // them (proto mismatch), so they no longer belong in the circle feed.
   if (link.remote_actor_id) {
     try { db.prepare('DELETE FROM remote_posts WHERE actor_id = ?').run(link.remote_actor_id); } catch {}
@@ -55,5 +55,5 @@
 }
 
-// Robuuste, defensieve fetch: alleen https, timeout, body-cap, redirect-follow.
+// Robust, defensive fetch: https only, timeout, body cap, redirect follow.
 async function fetchText(url) {
   if (!/^https:\/\//i.test(url)) throw new Error('alleen https toegestaan');
@@ -66,5 +66,5 @@
       headers: {
         Accept: 'application/activity+json, application/json',
-        // Vertel de publisher onze proto → die kan ons met 426 weren als we te oud zijn.
+        // Tell the publisher our proto → they can reject us with 426 if we are too old.
         'Klonkt-Proto': String(KLONKT_PROTO),
       },
@@ -72,5 +72,5 @@
     if (!res.ok) throw new Error(`HTTP ${res.status}`);
     const buf = Buffer.from(await res.arrayBuffer());
-    if (buf.length > MAX_BODY_BYTES) throw new Error('body te groot');
+    if (buf.length > MAX_BODY_BYTES) throw new Error('body too large');
     return { text: buf.toString('utf8'), headers: res.headers, finalUrl: res.url };
   } finally {
@@ -79,6 +79,6 @@
 }
 
-// Lazy prepares — de tabellen bestaan pas ná initializeDatabase(); dit module
-// wordt geïmporteerd vóór die call, dus niet op module-niveau prepare'n.
+// Lazy prepares — tables only exist after initializeDatabase(); this module is
+// imported before that call, so do not prepare at module level.
 let _stmts = null;
 function stmts() {
@@ -107,5 +107,5 @@
   const base = baseOf(link.remote_url);
 
-  // 1. Actor ophalen + valideren
+  // 1. Fetch + validate actor
   const actorUrl = `${base}/.klonkt/actor.json`;
   const a = await fetchText(actorUrl);
@@ -117,8 +117,8 @@
   if (originOf(actorId) !== originOf(actorUrl)) throw new Error('actor.id heeft andere origin dan de actor-URL');
 
-  // Protocol-versie-gate. De proto zit óók in de outbox-handtekening-grondslag,
-  // dus liegen in de (ongetekende) actor helpt niet: bij een echte mismatch faalt
-  // de verificatie verderop alsnog. Hier vooral voor een DUIDELIJKE melding +
-  // buitensluiten zonder stille mislukking.
+  // Protocol version gate. The proto is also embedded in the outbox signing
+  // input, so lying in the (unsigned) actor does not help: a real mismatch
+  // will still fail verification later. This check is mainly for a CLEAR
+  // message + exclusion without silent failure.
   const remoteProto = Number(actor.klonkt && actor.klonkt.proto) || 1;
   if (remoteProto > KLONKT_PROTO) {
@@ -131,5 +131,5 @@
   }
 
-  // TOFU: een sleutelwissel vereist expliciete herbevestiging (anti-hijack)
+  // TOFU: a key change requires explicit re-confirmation (anti-hijack)
   const existing = db.prepare('SELECT public_key FROM remote_actors WHERE id = ?').get(actorId);
   if (existing && existing.public_key !== pubKey) {
@@ -146,5 +146,5 @@
   });
 
-  // 2. Outbox ophalen + handtekening verifiëren
+  // 2. Fetch outbox + verify signature
   const outboxUrl = actor.outbox || `${base}/.klonkt/outbox.json`;
   const o = await fetchText(outboxUrl);
@@ -158,5 +158,5 @@
   const items = Array.isArray(outbox.orderedItems) ? outbox.orderedItems.slice(0, MAX_ITEMS) : [];
 
-  // 3. Objecten sanitizen + cachen (same-origin als de actor = anti-impersonatie)
+  // 3. Sanitize + cache objects (same origin as actor = anti-impersonation)
   const actorOrigin = originOf(actorId);
   const seen = new Set();
@@ -186,5 +186,5 @@
   }
 
-  // 4. Pruning: posts die niet meer in de outbox staan opruimen
+  // 4. Pruning: remove posts that are no longer in the outbox
   const known = db.prepare('SELECT id FROM remote_posts WHERE actor_id = ?').all(actorId).map((r) => r.id);
   const stale = known.filter((id) => !seen.has(id));
@@ -194,6 +194,6 @@
   }
 
-  // Naam automatisch overnemen van de remote actor (geen handmatige invoer nodig).
-  // COALESCE: heeft de actor geen naam, dan blijft een evt. bestaand label staan.
+  // Automatically adopt the name from the remote actor (no manual entry needed).
+  // COALESCE: if the actor has no name, any existing label is preserved.
   db.prepare(
     "UPDATE circle_links SET remote_actor_id=?, label=COALESCE(?, label), last_synced=CURRENT_TIMESTAMP, status='active', last_error=NULL WHERE id=?"
@@ -221,9 +221,9 @@
 
 let _timer = null;
-/** Periodieke achtergrond-sync (gated op tenancy='circle' binnen sync()). */
+/** Periodic background sync (gated on tenancy='circle' inside sync()). */
 export function startCircleSyncLoop(intervalMs = 15 * 60 * 1000) {
   if (_timer) return;
   const run = () => { sync().catch((e) => console.error('[cirkels] sync-fout:', e.message)); };
-  setTimeout(run, 30 * 1000); // korte delay na boot
+  setTimeout(run, 30 * 1000); // short delay after boot
   _timer = setInterval(run, intervalMs);
   if (_timer.unref) _timer.unref();
Index: src/services/ImageWebpService.js
===================================================================
--- src/services/ImageWebpService.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/ImageWebpService.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -1,11 +1,11 @@
 /**
- * Zet een zojuist-geüploade afbeelding om naar WebP (kleiner, modern).
+ * Convert a freshly uploaded image to WebP (smaller, modern format).
  *
- * Gebruikt het systeem-`cwebp` (libwebp). Aanwezig → converteer + verwijder het
- * origineel, geef de nieuwe .webp-bestandsnaam terug. Niet aanwezig of fout →
- * geef de originele bestandsnaam terug (graceful fallback, niks breekt).
+ * Uses the system `cwebp` (libwebp). Present → convert + delete the original,
+ * return the new .webp filename. Not present or error → return the original
+ * filename (graceful fallback, nothing breaks).
  *
- * GIF blijft GIF (cwebp maakt geen geanimeerde webp van een gif); reeds-webp
- * wordt overgeslagen.
+ * GIF stays GIF (cwebp cannot produce animated WebP from a GIF); already-WebP
+ * files are skipped.
  */
 import { execFileSync } from 'child_process';
@@ -17,5 +17,5 @@
 /**
  * @param {{path:string, filename:string, destination?:string}} file  multer file
- * @returns {string} de definitieve bestandsnaam (basename) — .webp of het origineel
+ * @returns {string} the final filename (basename) — .webp or the original
  */
 export function toWebp(file) {
@@ -28,11 +28,11 @@
   try {
     execFileSync('cwebp', ['-quiet', '-q', QUALITY, file.path, '-o', outPath], { stdio: 'ignore' });
-    if (!fs.existsSync(outPath) || fs.statSync(outPath).size === 0) throw new Error('lege output');
-    try { fs.unlinkSync(file.path); } catch { /* origineel weg, niet kritisch */ }
+    if (!fs.existsSync(outPath) || fs.statSync(outPath).size === 0) throw new Error('empty output');
+    try { fs.unlinkSync(file.path); } catch { /* original gone, not critical */ }
     return outName;
   } catch (e) {
-    console.warn('[webp] conversie overgeslagen (cwebp niet beschikbaar/fout):', e.message);
-    try { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); } catch {} // ruim halve output op
-    return file.filename; // behoud origineel
+    console.warn('[webp] conversion skipped (cwebp not available/error):', e.message);
+    try { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); } catch {} // clean up partial output
+    return file.filename; // keep original
   }
 }
Index: src/services/NotificationService.js
===================================================================
--- src/services/NotificationService.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/NotificationService.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -1,12 +1,12 @@
 /**
- * Meldingen — antwoord op je reactie, reactie op je post, like op je post.
- * Voor élke ingelogde gebruiker (Google-bezoekers/fans én admin). Snapshots van
- * actor-naam + post-titel zodat de lijst zonder joins te tonen is.
+ * Notifications — reply to your comment, comment on your post, like on your post.
+ * For every logged-in user (Google visitors/fans and admins). Snapshots of
+ * actor name + post title so the list can be rendered without joins.
  */
 import { randomUUID } from 'crypto';
 import db from '../config/database.js';
 
-// Maakt een melding aan. Doet niets als er geen ontvanger is of als je jezelf
-// zou notificeren (eigen reactie/like op eigen post/reactie).
+// Creates a notification. Does nothing if there is no recipient or if you
+// would notify yourself (your own comment/like on your own post/comment).
 export function notify({ userId, actorId, actorName, type, postSlug, postTitle, url }) {
   if (!userId || userId === actorId) return;
@@ -16,5 +16,5 @@
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
     `).run(randomUUID(), userId, type, actorId || null, actorName || null, postSlug || null, postTitle || null, url || null);
-  } catch { /* meldingen zijn niet-fataal */ }
+  } catch { /* notifications are non-fatal */ }
 }
 
@@ -33,5 +33,5 @@
 export function markAllRead(userId) {
   if (!userId) return;
-  try { db.prepare('UPDATE user_notifications SET read = 1 WHERE user_id = ? AND read = 0').run(userId); } catch { /* noop */ }
+  try { db.prepare('UPDATE user_notifications SET read = 1 WHERE user_id = ? AND read = 0').run(userId); } catch { /* no-op */ }
 }
 
Index: src/services/PatreonService.js
===================================================================
--- src/services/PatreonService.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/PatreonService.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -1,16 +1,15 @@
-// Patreon-entitlement (premium-laag).
+// Patreon entitlement (premium layer).
 //
-// Model (Klonkt, 2026-06): de app + alle updates zijn gratis. Een paar premium-
-// modules (Hub-modus, Statistieken, Fan-login) zitten achter een $10-lifetime
-// Patreon-supporter-status. De centrale license-server (license.klonkt.com)
-// checkt Patreon en tekent een Ed25519-JWT "entitlement-token". DEZE instance
-// verifieert dat token OFFLINE met de publieke sleutel van de server — een
-// gekraakte/geforkte self-host kan dus geen geldig token verzinnen (alleen de
-// license-server kan tekenen). Dat is het echte slot; de feature-flags zelf zijn
-// op self-host wel te patchen (bewust geaccepteerd: $10 < moeite om te kraken).
+// Model (Klonkt, 2026-06): the app + all updates are free. A few premium
+// modules (Hub mode, Statistics, Fan login) are gated behind a $10 lifetime
+// Patreon supporter status. The central license server (license.klonkt.com)
+// checks Patreon and signs an Ed25519 JWT "entitlement token". THIS instance
+// verifies that token OFFLINE using the server's public key — a cracked/forked
+// self-host cannot forge a valid token (only the license server can sign).
+// That is the real lock; feature flags themselves can be patched on self-host
+// (deliberately accepted: $10 < effort to crack).
 //
-// Premium staat STANDAARD UIT (KLONKT_PREMIUM_ENABLED != 'on'): dan is er geen
-// premium-UI en wordt er niets gegate. De self-hoster zet 'm aan zodra Patreon
-// geregeld is.
+// Premium is OFF by default (KLONKT_PREMIUM_ENABLED != 'on'): no premium UI
+// is shown and nothing is gated. Self-hosters enable it once Patreon is set up.
 
 import crypto from 'node:crypto';
@@ -25,10 +24,10 @@
 export function licenseBase() { return LICENSE_URL; }
 
-// --- Publieke sleutel van de license-server cachen (voor offline verificatie) ---
+// --- Cache the license-server public key (for offline verification) ---
 let _pubKey = null;
 async function licensePublicKey() {
   if (_pubKey) return _pubKey;
   const res = await fetch(`${LICENSE_URL}/pubkey`);
-  if (!res.ok) throw new Error('pubkey fetch faalde: ' + res.status);
+  if (!res.ok) throw new Error('pubkey fetch failed: ' + res.status);
   const pem = await res.text();
   _pubKey = crypto.createPublicKey(pem); // SPKI-PEM -> Ed25519 public key
@@ -40,6 +39,6 @@
 }
 
-// Verifieer een entitlement-token (EdDSA-JWT van de license-server). Gooit bij
-// ongeldige handtekening/issuer/verlooptijd. Geeft de claims terug.
+// Verify an entitlement token (EdDSA JWT from the license server). Throws on
+// invalid signature, issuer, or expiry. Returns the claims on success.
 export async function verifyEntitlementToken(token) {
   const parts = String(token || '').split('.');
@@ -47,11 +46,11 @@
   const [h, p, s] = parts;
   const header = JSON.parse(b64urlToBuf(h).toString('utf8'));
-  if (header.alg !== 'EdDSA') throw new Error('onverwacht alg');
+  if (header.alg !== 'EdDSA') throw new Error('unexpected alg');
   const key = await licensePublicKey();
   const ok = crypto.verify(null, Buffer.from(`${h}.${p}`), key, b64urlToBuf(s));
-  if (!ok) throw new Error('ongeldige handtekening');
+  if (!ok) throw new Error('invalid signature');
   const payload = JSON.parse(b64urlToBuf(p).toString('utf8'));
-  if (payload.iss !== ISSUER) throw new Error('onverwachte issuer');
-  if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('verlopen token');
+  if (payload.iss !== ISSUER) throw new Error('unexpected issuer');
+  if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('expired token');
   return payload; // { sub, entitled, plan, lifetime_support_cents, exp, ... }
 }
@@ -71,7 +70,7 @@
 }
 
-// Is deze instance premium? Premium-laag aan + een geldig, niet-verlopen,
-// entitled opgeslagen token. Patreon-lifetime daalt nooit, dus opnieuw koppelen
-// na verloop slaagt altijd.
+// Is this instance premium? Premium layer enabled + a valid, non-expired,
+// entitled stored token. Patreon lifetime never decreases, so re-linking
+// after expiry always succeeds.
 export function isPremium() {
   if (!premiumEnabled()) return false;
@@ -82,7 +81,7 @@
 }
 
-// Is een premium-feature beschikbaar? True als de premium-laag UIT staat (dan is
-// niets gegate — huidige gedrag), of AAN én deze instance is entitled. False alleen
-// als premium aan staat maar er geen geldige Patreon-koppeling is (= betaalmuur).
+// Is a premium feature available? True if the premium layer is OFF (nothing is
+// gated — current behavior), or ON and this instance is entitled. False only
+// if premium is on but there is no valid Patreon connection (= paywall).
 export function premiumUnlocked() {
   return !premiumEnabled() || isPremium();
Index: src/services/PermissionsService.js
===================================================================
--- src/services/PermissionsService.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/PermissionsService.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -46,5 +46,5 @@
     if (!user) return false;
     if (user.role === 'god') return true;
-    if (!site) return false; // geen site-context (bv. hub-landing) -> niets te posten
+    if (!site) return false; // no site context (e.g. hub landing) -> nothing to post to
     if (user.id === site.owner_id) return true; // Site owner
     if (this.canAdminSite(user, site)) return true;
@@ -59,7 +59,7 @@
     if (user.role === 'god') return true;
     if (user.id === site.owner_id) return true;
-    // Toegewezen mede-beheerder (collaborator) via site_members. Dit werd
-    // voorheen via een nooit-gevulde user.siteRoles gelezen → dode code; nu
-    // direct op de tabel (paar checks per pagina, indexed = goedkoop).
+    // Assigned co-admin (collaborator) via site_members. Previously read from
+    // a never-populated user.siteRoles → dead code; now queried directly on
+    // the table (a few checks per page, indexed = cheap).
     return !!db.prepare(
       "SELECT 1 FROM site_members WHERE site_id = ? AND user_id = ? AND role = 'admin' LIMIT 1"
Index: src/services/PlaylistService.js
===================================================================
--- src/services/PlaylistService.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/PlaylistService.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -104,5 +104,5 @@
 
     const mappedTracks = tracks
-      // Link-only tracks (geen media-bestand) blijven in de lijst staan met url ''.
+      // Link-only tracks (no media file) remain in the list with url ''.
       .map(t => ({
         id: t.id,
@@ -116,5 +116,5 @@
         url: (t.filename && urlFor) ? urlFor(t.filename) : '',
       }));
-    // Geen eigen cover? Val terug op de eerste track-cover, zodat de kaart niet leeg is.
+    // No playlist cover? Fall back to the first track cover so the card isn't empty.
     const fallbackCover = (mappedTracks.find(t => t.cover) || {}).cover || '';
     return {
@@ -220,5 +220,5 @@
    * Delete a playlist. Track references in playlist_tracks are removed
    * automatically via ON DELETE CASCADE. Posts that embed this playlist
-   * will render a "playlist niet gevonden" placeholder.
+   * will render a "playlist not found" placeholder.
    */
   static delete(siteId, id) {
Index: src/services/Scheduler.js
===================================================================
--- src/services/Scheduler.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/Scheduler.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -1,9 +1,9 @@
 /**
- * Scheduler — release-planning (premium #3).
+ * Scheduler — release planning (premium #3).
  *
- * Geplande posts hebben status 'scheduled' + publish_at (toekomst). Een lichte
- * timer zet ze op 'published' zodra publish_at bereikt is. Zo hoeven de publieke
- * queries (status='published') NIET aangepast te worden — een geplande post is
- * gewoon nog niet 'published' en dus nergens publiek zichtbaar tot het moment.
+ * Scheduled posts have status 'scheduled' + publish_at (future). A lightweight
+ * timer flips them to 'published' once publish_at is reached. This means public
+ * queries (status='published') need NO changes — a scheduled post simply isn't
+ * 'published' yet and therefore invisible until that moment.
  */
 
@@ -25,5 +25,5 @@
     for (const p of due) {
       upd.run(p.id);
-      try { fts.run(HtmlSanitizerService.toPlainText(p.content || ''), p.title || '', p.username || '', p.id); } catch { /* FTS niet-fataal */ }
+      try { fts.run(HtmlSanitizerService.toPlainText(p.content || ''), p.title || '', p.username || '', p.id); } catch { /* FTS failure is non-fatal */ }
     }
     return due.length;
@@ -33,7 +33,7 @@
 let _timer = null;
 export function startScheduler() {
-  flipScheduledPosts();                 // direct bij boot
+  flipScheduledPosts();                 // run immediately on boot
   if (_timer) return;
-  _timer = setInterval(flipScheduledPosts, 60 * 1000); // elke minuut
+  _timer = setInterval(flipScheduledPosts, 60 * 1000); // every minute
   if (_timer.unref) _timer.unref();
 }
Index: src/services/SettingsService.js
===================================================================
--- src/services/SettingsService.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/SettingsService.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -1,9 +1,9 @@
-// Globale app-instellingen (key/value, gecached). Nu vooral de tenancy-modus.
+// Global app settings (key/value, cached). Primarily used for the tenancy mode.
 //
-//   tenancy = 'solo'  -> precies één site (de primaire/owner-site)
-//   tenancy = 'hub'   -> hoofdsite (bedrijf) + /user/, admin wijst Klonkt-site's toe
+//   tenancy = 'solo'  -> exactly one site (the primary/owner site)
+//   tenancy = 'hub'   -> main site (company) + /user/, admin assigns Klonkt sites
 //
-// De cache wordt bij setSetting meteen ververst, dus een toggle in Beheer werkt
-// live zonder herstart.
+// The cache is updated immediately on setSetting, so a toggle in admin
+// takes effect live without a restart.
 
 import db from '../config/database.js';
Index: src/services/StatsService.js
===================================================================
--- src/services/StatsService.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/StatsService.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -1,11 +1,11 @@
-// StatsService — cookievrije statistieken (premium-module).
+// StatsService — cookie-free statistics (premium module).
 //
-// Tellers: posts.view_count, audio_tracks.play_count, en per dag/site het aantal
-// pageviews (stat_daily) + unieke bezoekers (stat_visitor_day).
+// Counters: posts.view_count, audio_tracks.play_count, and per day/site the number
+// of pageviews (stat_daily) + unique visitors (stat_visitor_day).
 //
-// Unieke bezoekers ZONDER cookie: een sha256 van IP+UA+dag-salt. De salt roteert
-// elke dag en wordt nooit langer bewaard → je kunt iemand niet over dagen heen
-// volgen, het ruwe IP wordt niet opgeslagen. Geen persistente identifier, geen
-// toestemmingsbanner nodig (Plausible/Fathom-aanpak).
+// Unique visitors WITHOUT cookies: a sha256 of IP+UA+daily-salt. The salt rotates
+// every day and is never stored longer → you cannot track someone across days,
+// and the raw IP is never persisted. No persistent identifier, no consent
+// banner required (Plausible/Fathom approach).
 
 import crypto from 'node:crypto';
@@ -17,6 +17,6 @@
 }
 
-// Dagelijks roterende salt (gecachet in proces, persistent in app_settings zodat
-// een herstart binnen dezelfde dag dezelfde salt houdt).
+// Daily rotating salt (cached in process, persisted in app_settings so that
+// a restart within the same day reuses the same salt).
 let _salt = null, _saltDay = null;
 function dailySalt() {
@@ -39,5 +39,5 @@
 }
 
-// De eigenaar/beheerder niet meetellen — anders inflate je je eigen cijfers.
+// Don't count the owner/admin — otherwise you inflate your own numbers.
 function isOperator(req) {
   const u = req && req.session && req.session.user;
@@ -45,15 +45,15 @@
 }
 
-// Bekende bots/crawlers + link-preview-fetchers + scripts overslaan, zodat ze de
-// weergaven/bezoeker-dagen niet opblazen. Geen UA = vrijwel altijd geautomatiseerd.
+// Skip known bots/crawlers + link-preview fetchers + scripts so they don't inflate
+// view/visitor-day counts. Empty UA = almost always automated.
 const BOT_RE = /bot|crawl|spider|slurp|mediapartners|bingpreview|facebookexternalhit|whatsapp|telegram|discord|twitter|linkedin|embedly|pinterest|redditbot|applebot|petalbot|yandex|baidu|duckduckbot|semrush|ahrefs|mj12|dotbot|uptimerobot|pingdom|statuscake|headless|lighthouse|gptbot|claude|ccbot|perplexity|bytespider|amazonbot|googleother|google-read-aloud|python-requests|scrapy|curl|wget|axios|node-fetch|go-http|java\/|okhttp|libwww|httpclient/i;
 function isBot(req) {
   const ua = (req && req.headers && req.headers['user-agent']) || '';
-  if (!ua) return true;          // lege UA = script/bot
+  if (!ua) return true;          // empty UA = script/bot
   return BOT_RE.test(ua);
 }
 
-// Lazy prepares — tabellen bestaan pas ná initializeDatabase(); dit module wordt
-// geïmporteerd vóór die call.
+// Lazy prepares — tables only exist after initializeDatabase(); this module is
+// imported before that call.
 let _s = null;
 function stmts() {
@@ -75,6 +75,6 @@
 }
 
-// Externe referrer-host uit de Referer-header (pro-stats #5). Lege/eigen-site/
-// ongeldige referrers worden overgeslagen → alleen echte externe bronnen tellen.
+// External referrer host from the Referer header (pro stats #5). Empty/own-site/
+// invalid referrers are skipped → only genuine external sources are counted.
 function recordReferrer(siteId, req) {
   try {
@@ -84,7 +84,7 @@
     if (!host) return;
     const own = ((req.headers && req.headers.host) || '').replace(/^www\./, '').toLowerCase();
-    if (host === own) return; // interne navigatie telt niet als bron
+    if (host === own) return; // internal navigation does not count as a source
     stmts().bumpReferrer.run(siteId, host.slice(0, 120));
-  } catch { /* geen geldige referrer-URL → overslaan */ }
+  } catch { /* not a valid referrer URL → skip */ }
 }
 
@@ -96,5 +96,5 @@
     stmts().addVisitor.run(siteId, d, visitorHash(req));
     recordReferrer(siteId, req);
-  } catch { /* statistieken mogen nooit een request breken */ }
+  } catch { /* stats must never break a request */ }
 }
 
@@ -112,5 +112,5 @@
 }
 
-// Instance-brede statistieken (solo = de site, hub = alle sites samen).
+// Instance-wide statistics (solo = the site, hub = all sites combined).
 export function getStats(days = 14) {
   days = [7, 14, 30, 90].includes(Number(days)) ? Number(days) : 14;
@@ -129,6 +129,6 @@
   }
   const totals = {
-    pageviews: series.reduce((s, r) => s + r.pageviews, 0), // laatste N dagen
-    visitors: series.reduce((s, r) => s + r.visitors, 0),   // som van dag-uniques (cookieless kan niet anders)
+    pageviews: series.reduce((s, r) => s + r.pageviews, 0), // last N days
+    visitors: series.reduce((s, r) => s + r.visitors, 0),   // sum of daily uniques (cookieless has no alternative)
     plays: db.prepare('SELECT COALESCE(SUM(play_count), 0) AS n FROM audio_tracks').get().n,
     postViews: db.prepare('SELECT COALESCE(SUM(view_count), 0) AS n FROM posts').get().n,
@@ -142,5 +142,5 @@
     ORDER BY play_count DESC LIMIT 5
   `).all();
-  // Top externe bronnen (pro #5) — instance-breed geaggregeerd per host.
+  // Top external sources (pro #5) — aggregated instance-wide per host.
   let referrers = [];
   try {
@@ -149,5 +149,5 @@
     ).all();
   } catch { referrers = []; }
-  // All-time totalen (cookieloze unieke bezoekers = som van dag-uniques).
+  // All-time totals (cookieless unique visitors = sum of daily uniques).
   const allTime = {
     pageviews: db.prepare('SELECT COALESCE(SUM(pageviews),0) AS n FROM stat_daily').get().n,
Index: src/services/SubscriberService.js
===================================================================
--- src/services/SubscriberService.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/SubscriberService.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -1,9 +1,9 @@
 /**
- * SubscriberService — nieuwsbrief-abonnees per site (premium feature #1).
+ * SubscriberService — newsletter subscribers per site (premium feature #1).
  *
- * Double opt-in als SMTP er is (status 'pending' → 'confirmed' via confirm-link),
- * anders single opt-in ('confirmed' meteen). Elke abonnee heeft een token dat zowel
- * de confirm- als de unsubscribe-link draagt. Hergebruikt door #2 (download-voor-
- * email) en #8 (notify-me) als gedeelde abonnee-opslag.
+ * Double opt-in when SMTP is configured (status 'pending' → 'confirmed' via
+ * confirm link), otherwise single opt-in ('confirmed' immediately). Each
+ * subscriber has a token used for both the confirm and unsubscribe links.
+ * Reused by #2 (download-for-email) and #8 (notify-me) as shared subscriber storage.
  */
 
@@ -23,8 +23,8 @@
 
 /**
- * Voeg een abonnee toe (of heractiveer een uitgeschreven/bestaande).
+ * Add a subscriber (or reactivate an unsubscribed/existing one).
  * @returns {{ok:boolean, status?:string, token?:string, created?:boolean, error?:string}}
- *   status 'pending'  → er moet nog bevestigd worden (stuur confirm-mail)
- *   status 'confirmed'→ direct actief (single opt-in)
+ *   status 'pending'  → confirmation still required (send confirm email)
+ *   status 'confirmed'→ immediately active (single opt-in)
  */
 export function addSubscriber(siteId, email, source = 'widget', { doubleOptin = false } = {}) {
@@ -37,7 +37,7 @@
 
   if (existing) {
-    // Al actief → niets te doen (idempotent, geen dubbele mail).
+    // Already confirmed → nothing to do (idempotent, no duplicate email).
     if (existing.status === 'confirmed') return { ok: true, status: 'confirmed', token: existing.token, created: false };
-    // Pending of uitgeschreven → opnieuw uitnodigen/activeren met een verse token.
+    // Pending or unsubscribed → re-invite/reactivate with a fresh token.
     const token = newToken();
     db.prepare("UPDATE subscribers SET status = ?, token = ?, source = ?, confirmed_at = CASE WHEN ? = 'confirmed' THEN CURRENT_TIMESTAMP ELSE NULL END WHERE id = ?")
@@ -69,6 +69,6 @@
 }
 
-/** Bevestigde abonnees (email + token) voor een site — voor het versturen.
- * Optioneel filteren op bron (bv. 'notify' voor show-aankondigingen). */
+/** Confirmed subscribers (email + token) for a site — for sending newsletters.
+ * Optionally filter by source (e.g. 'notify' for show announcements). */
 export function confirmedFor(siteId, source) {
   if (source) {
Index: src/services/ThemeService.js
===================================================================
--- src/services/ThemeService.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/ThemeService.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -16,8 +16,8 @@
 
 class ThemeService {
-  // Paper/ink komen 1-op-1 uit de [data-palette]-CSS in style.css (= wat ECHT
-  // wordt toegepast); de accent-stip is een representatieve kleur per palet.
+  // Paper/ink values map 1-to-1 from the [data-palette] CSS in style.css (= what
+  // is ACTUALLY applied); the accent dot is a representative color per palette.
   static PALETTES = {
-    // Merk-standaard — komt overeen met klonkt.com (donkerblauw + geel).
+    // Brand default — matches klonkt.com (dark blue + gold).
     klonkt: {
       name: 'Klonkt',
@@ -65,5 +65,5 @@
       dark: { paper: '#1f0a0f', ink: '#fce4ea', accent: '#f06b9a' }
     },
-    // key blijft 'mint' (DB-veilig), maar omgekleurd naar warm Terracotta — minder groen.
+    // key stays 'mint' (DB-safe), but recolored to warm Terracotta — less green.
     mint: {
       name: 'Terracotta',
@@ -83,6 +83,6 @@
    * Each color works against both light and dark themes.
    */
-  // Evenwichtig over het kleurenwiel — minder groen/blauw (4 van de 12),
-  // meer warme + paars/roze variatie. Allemaal leesbaar op licht én donker.
+  // Balanced across the color wheel — fewer greens/blues (4 of 12),
+  // more warm + purple/pink variation. All readable on both light and dark.
   static ACCENTS = [
     { key: 'klonkt',  name: 'Klonkt-geel', color: '#e8b04b' },
Index: src/services/ensurePrimarySite.js
===================================================================
--- src/services/ensurePrimarySite.js	(revision 475eeda4d9fd076c58564f8e83535d4df89f46fd)
+++ src/services/ensurePrimarySite.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -2,13 +2,13 @@
 import db from '../config/database.js';
 
-// Een Klonkt-instance hoort ALTIJD een primaire site te hebben — die draagt de
-// identiteit (titel, thema, profiel) en is het ankerpunt in solo/hub/circle.
-// De register-flow maakt er al één aan, maar een via een script aangemaakte
-// beheerder (of een om wat voor reden dan ook lege sites-tabel) liet de
-// instance zonder site achter: geen instellingen, dashboard liep dood.
+// A Klonkt instance should ALWAYS have a primary site — it carries the identity
+// (title, theme, profile) and is the anchor point in solo/hub/circle mode.
+// The register flow already creates one, but an admin created via a script
+// (or an empty sites table for any reason) left the instance without a site:
+// no settings, dashboard would crash.
 //
-// Deze helper draait bij boot (en is idempotent): zodra er een beheerder is
-// maar nog geen enkele site, maakt 'ie een standaard-site aan, eigendom van de
-// eerste god/admin. Tenancy-onafhankelijk — geldt voor solo, hub én circle.
+// This helper runs at boot (and is idempotent): as soon as there is an admin
+// but no site yet, it creates a default site owned by the first god/admin.
+// Tenancy-agnostic — applies to solo, hub, and circle.
 
 function defaultTitle() {
@@ -20,5 +20,5 @@
       if (label) return label.charAt(0).toUpperCase() + label.slice(1);
     }
-  } catch { /* val terug op generiek */ }
+  } catch { /* fall back to generic */ }
   return 'Mijn site';
 }
@@ -26,13 +26,13 @@
 export function ensurePrimarySite() {
   const count = db.prepare('SELECT COUNT(*) AS c FROM sites').get().c;
-  if (count > 0) return null; // er is al een site — niets te doen
+  if (count > 0) return null; // a site already exists — nothing to do
 
   const owner = db.prepare(
     "SELECT id FROM users WHERE role IN ('god','admin') ORDER BY created_at LIMIT 1"
   ).get();
-  if (!owner) return null; // nog geen beheerder -> geen eigenaar, niets aanmaken
+  if (!owner) return null; // no admin yet -> no owner, nothing to create
 
   const siteId = uuid();
-  const slug = 'main'; // niet gereserveerd; in solo wordt de primaire site sowieso gepind
+  const slug = 'main'; // not reserved; in solo mode the primary site is always pinned anyway
   db.prepare(`
     INSERT INTO sites (
