Index: scripts/backfill-durations.mjs
===================================================================
--- scripts/backfill-durations.mjs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ scripts/backfill-durations.mjs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,12 +1,12 @@
 #!/usr/bin/env node
 /**
- * Backfill audio_tracks.duration voor bestaande tracks die nog géén duur hebben.
- * Leest de duur uit het mp3-bestand via ffmpeg (probeDuration — geen aparte
- * ffprobe-binary nodig). Idempotent: pakt alleen rijen met duration NULL of 0,
- * dus veilig herhaalbaar.
+ * Backfill audio_tracks.duration for existing tracks that have no duration yet.
+ * Reads the duration from the mp3 file via ffmpeg (probeDuration — no separate
+ * ffprobe binary required). Idempotent: only touches rows with duration NULL or 0,
+ * so it is safe to run repeatedly.
  *
  *   npm run backfill:durations
  *
- * Respecteert AUDIO_PATH (env) net als de upload-route.
+ * Respects AUDIO_PATH (env) just like the upload route.
  */
 import path from 'path';
@@ -21,9 +21,9 @@
 );
 
-// storage_path is absoluut (opgeslagen bij upload); val terug op AUDIO_DIR/filename.
+// storage_path is absolute (stored at upload time); fall back to AUDIO_DIR/filename.
 function resolveFile(t) {
   const candidates = [t.storage_path, t.filename ? path.join(AUDIO_DIR, t.filename) : null].filter(Boolean);
   for (const c of candidates) {
-    try { if (fs.statSync(c).isFile()) return c; } catch { /* volgende kandidaat */ }
+    try { if (fs.statSync(c).isFile()) return c; } catch { /* try next candidate */ }
   }
   return null;
Index: scripts/reset-admin.mjs
===================================================================
--- scripts/reset-admin.mjs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ scripts/reset-admin.mjs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,12 +1,12 @@
 #!/usr/bin/env node
-// Break-glass: reset (of zet) het wachtwoord van een beheerder. Werkt altijd,
-// zonder e-mail — de self-hoster heeft immers shell-/servertoegang.
+// Break-glass: reset (or set) the password of an admin account. Always works,
+// no email required — the self-hoster has shell/server access.
 //
-// Gebruik:
-//   npm run reset-admin                       # reset de (eerste) god-user, print nieuw wachtwoord
-//   npm run reset-admin -- <user|email>       # reset specifieke user, print nieuw wachtwoord
-//   npm run reset-admin -- <user|email> <pw>  # zet een gekozen wachtwoord
+// Usage:
+//   npm run reset-admin                       # reset the (first) god user, print new password
+//   npm run reset-admin -- <user|email>       # reset a specific user, print new password
+//   npm run reset-admin -- <user|email> <pw>  # set a chosen password
 //
-// Draai dit vanuit de projectroot zodat DATABASE_PATH/.env correct geladen wordt.
+// Run from the project root so DATABASE_PATH/.env is loaded correctly.
 
 import 'dotenv/config';
@@ -22,5 +22,5 @@
   user = db.prepare('SELECT * FROM users WHERE username = ? OR LOWER(email) = LOWER(?)').get(arg, arg);
 } else {
-  // Geen arg: pak de beheerder (god of admin), anders de allereerste user.
+  // No arg: pick the admin (god or admin role), otherwise the very first user.
   user =
     db.prepare("SELECT * FROM users WHERE role IN ('god','admin') ORDER BY created_at LIMIT 1").get() ||
Index: src/assets/js/audio-player.js
===================================================================
--- src/assets/js/audio-player.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/assets/js/audio-player.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -230,6 +230,6 @@
     }
     currentObjectUrl = objUrl;
-    // Schone overgang: pause + load forceert reset van internal state na
-    // meerdere src-changes (voorkomt state-corruption van het audio-element).
+    // Clean transition: pause + load forces a reset of internal state after
+    // multiple src changes (prevents state corruption of the audio element).
     try { audio.pause(); } catch (e) {}
     audio.src = objUrl;
@@ -276,5 +276,5 @@
   // Used by the site pre-seed so opening a page doesn't auto-download audio;
   // the blob is fetched lazily on the first play().
-  // Markeer de huidige track persistent (blijvende highlight zolang 'ie actief is).
+  // Persistently mark the current track (stays highlighted as long as it's active).
   function markPlaying(trackId) {
     document.querySelectorAll('.pat-playing').forEach((e) => e.classList.remove('pat-playing'));
@@ -283,5 +283,5 @@
     if (el) el.classList.add('pat-playing');
   }
-  // Na een htmx-navigatie is de post-DOM vervangen → highlight opnieuw zetten.
+  // After an htmx navigation the post DOM is replaced → reapply the highlight.
   document.body.addEventListener('htmx:afterSettle', () => {
     const t = queue[currentIndex];
@@ -341,8 +341,8 @@
   // (@media max-width:719.98px). On wider/desktop widths the sheet is a centered
   // panel, so we do NOT auto-open it there.
-  // Drie weergaves (Robin 2026-06-15): telefoon (<768) = fullscreen sheet;
-  // tablet/auto (768–1199) = grote landscape full-player (tablet + tablet-in-de-
-  // auto); desktop (≥1200) = alleen mini-speler, GEEN full player. matchMedia
-  // zodat dit exact de CSS-breekpunten volgt.
+  // Three layouts (Robin 2026-06-15): phone (<768) = fullscreen sheet;
+  // tablet/car (768–1199) = large landscape full-player (tablet + car-mode);
+  // desktop (≥1200) = mini-player only, NO full player. matchMedia so this
+  // exactly follows the CSS breakpoints.
   function playerTier() {
     if (window.matchMedia('(min-width: 1200px)').matches) return 'desktop';
@@ -359,8 +359,8 @@
     loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
     // Mobile: a track press from an album/playlist auto-opens the full
-    // now-playing sheet (Spotify-style) i.p.v. alleen de dunne mini-strip.
-    // Alleen hier (setQueue = een verse, door-de-gebruiker-gestarte queue) —
-    // niet bij next/prev of de site-pre-seed — zodat een sheet die de gebruiker
-    // bewust sloot niet vanzelf terugkomt.
+    // now-playing sheet (Spotify-style) instead of just the thin mini-strip.
+    // Only here (setQueue = a fresh, user-initiated queue) —
+    // not on next/prev or the site pre-seed — so a sheet the user
+    // deliberately closed doesn't reappear by itself.
     if (hasFullPlayer()) openSheet();
   }
@@ -377,7 +377,7 @@
       p.catch((err) => {
         console.warn('[pcms-audio] play() rejected:', err.name, err.message);
-        // Browser-autoplay-policy heeft 't gestopt (typisch na 3-4
-        // automatische plays op iOS Safari, of als tab tijdelijk inactive
-        // was). Visuele hint dat user op play moet tappen.
+        // Browser autoplay policy blocked it (typically after 3-4
+        // auto-plays on iOS Safari, or when the tab was temporarily inactive).
+        // Show a visual hint for the user to tap play.
         if (err && err.name === 'NotAllowedError') {
           root.classList.add('audio-needs-tap');
@@ -440,11 +440,11 @@
 
   // ============================================================
-  // 4b. Mutual exclusion — gedeelde media-registry (zie embed-player.js).
-  // ============================================================
-  // Alle spelers (deze site-speler + de YouTube/SoundCloud/Spotify-embeds)
-  // registreren zich in window.pcmsMediaRegistry. Start er één, dan pauzeert de
-  // vorige. Dit is de precieze vervanger van de oude focus/blur-heuristiek voor
-  // de embeds met een echte JS-API. (De blur-fallback hieronder blijft staan
-  // voor iframe-only embeds zonder API: Bandcamp/Apple Music/Vimeo.)
+  // 4b. Mutual exclusion — shared media registry (see embed-player.js).
+  // ============================================================
+  // All players (this site player + YouTube/SoundCloud/Spotify embeds)
+  // register themselves in window.pcmsMediaRegistry. Starting one pauses
+  // the previous. This is the precise replacement for the old focus/blur
+  // heuristic for embeds with a real JS API. (The blur fallback below stays
+  // for iframe-only embeds without an API: Bandcamp/Apple Music/Vimeo.)
   function mediaRegistry() {
     if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
@@ -467,5 +467,5 @@
   // 5. Audio element events → UI sync
   // ============================================================
-  // Error-counter voorkomt infinite-loop als ALLE tracks broken zijn.
+  // Error counter prevents an infinite loop when ALL tracks are broken.
   let consecutiveErrors = 0;
 
@@ -473,12 +473,11 @@
     isPlaying = true;
     root.classList.add('is-playing');
-    root.classList.remove('audio-needs-tap');  // verstop tap-hint
-    mediaRegistry().setActive(registrySelf);   // pauzeer eventueel spelende embeds
-  });
-  // Reset de error-teller pas bij ECHTE playback-start (`playing`), niet bij
-  // het eager `play`-event. `play` vuurt vóór een eventuele netwerk-/decode-
-  // fout, dus resetten daar zou de 3-strikes-stop nooit laten triggeren bij
-  // een kapotte track → infinite "next"-loop. `playing` vuurt alleen als er
-  // daadwerkelijk audio speelt.
+    root.classList.remove('audio-needs-tap');  // hide tap hint
+    mediaRegistry().setActive(registrySelf);   // pause any currently playing embeds
+  });
+  // Reset the error counter only on a REAL playback start (`playing`), not the
+  // eager `play` event. `play` fires before any network/decode error, so resetting
+  // there would prevent the 3-strikes stop from ever triggering on a broken
+  // track → infinite "next" loop. `playing` only fires when audio is actually playing.
   audio.addEventListener('playing', () => { consecutiveErrors = 0; preloadNext(); });
   audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
@@ -488,8 +487,8 @@
     console.error('[pcms-audio] playback error', code, audio.src, e);
     consecutiveErrors++;
-    // Bij netwerk/decode-fout: skip naar volgende track ipv stilstaan.
-    // Max 3 fouten op rij voordat we opgeven (anders infinite loop).
+    // On network/decode error: skip to next track instead of stalling.
+    // Max 3 consecutive errors before giving up (otherwise infinite loop).
     if (consecutiveErrors < 3 && queue.length > 1) {
-      console.warn('[pcms-audio] auto-skip naar volgende na error', consecutiveErrors);
+      console.warn('[pcms-audio] auto-skip to next after error', consecutiveErrors);
       setTimeout(next, 400);
     }
@@ -545,16 +544,16 @@
   // 7. Sheet expand/close + drag-down-to-close
   // ============================================================
-  // Back-knop sluit de sheet op mobiel: bij openen pushen we een history-entry,
-  // zodat de telefoon-terugknop (popstate) eerst de sheet sluit i.p.v. de pagina
-  // te verlaten. We balanceren 'm bij een UI-sluiting via history.back().
+  // Back button closes the sheet on mobile: on open we push a history entry
+  // so the phone back button (popstate) closes the sheet first instead of
+  // leaving the page. We balance it on a UI-initiated close via history.back().
   let sheetHistoryPushed = false;
 
   function openSheet() {
-    if (!hasFullPlayer()) return; // desktop (≥1200): geen full player, alleen mini-speler
+    if (!hasFullPlayer()) return; // desktop (≥1200): no full player, mini-player only
     if (sheet.classList.contains('is-open')) return;
     sheet.classList.add('is-open');
     sheet.setAttribute('aria-hidden', 'false');
     document.body.classList.add('audio-sheet-locked');
-    // Telefoon + tablet: history-entry zodat de terug-knop eerst de sheet sluit.
+    // Phone + tablet: push a history entry so the back button closes the sheet first.
     try { history.pushState({ pcmsSheet: true }, ''); sheetHistoryPushed = true; } catch (e) {}
   }
@@ -566,7 +565,7 @@
     sheetPanel.style.removeProperty('--pcms-drag-y');
     sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
-    // UI-sluiting (X / swipe / backdrop / Esc): pop onze eigen history-entry zodat
-    // de volgende terug-knop weer normaal navigeert. Bij een popstate-sluiting
-    // (de terug-knop zelf) is de entry al gepopt.
+    // UI close (X / swipe / backdrop / Esc): pop our own history entry so the
+    // next back button navigates normally. On a popstate close (back button itself)
+    // the entry is already popped.
     const wasPushed = sheetHistoryPushed;
     sheetHistoryPushed = false;
@@ -576,8 +575,8 @@
     if (sheet.classList.contains('is-open')) closeSheet(true);
   });
-  // Mini-player-info aanklikken:
-  //  - DESKTOP (≥768px): spring naar de post waar de track vandaan komt (als bekend),
-  //    via htmx zodat de audio blijft spelen. Geen post bekend → val terug op de sheet.
-  //  - MOBIEL: altijd de uitgebreide speler-sheet openen (huidig gedrag).
+  // Clicking the mini-player track info:
+  //  - DESKTOP (≥1200px): jump to the post the track came from (if known),
+  //    via htmx so audio keeps playing. No post known → fall back to the sheet.
+  //  - MOBILE/TABLET: always open the full now-playing sheet.
   function scrollToTrack(trackId) {
     if (!trackId) { window.scrollTo(0, 0); return; }
@@ -594,10 +593,10 @@
         const p = window.htmx.ajax('GET', url, { target: '#pcms-main', swap: 'innerHTML' });
         history.pushState({}, '', url + hash);
-        // Na de swap naar de track scrollen (klein uitstel zodat de globale
-        // afterSwap-top-scroll eerst is geweest); val terug op top als niet gevonden.
+        // Scroll to the track after the swap (small delay so the global
+        // afterSwap scroll-to-top runs first); fall back to top if not found.
         const go = () => setTimeout(() => scrollToTrack(trackId), 60);
         if (p && typeof p.then === 'function') p.then(go); else setTimeout(go, 150);
         return;
-      } catch (e) { /* val terug op volledige navigatie */ }
+      } catch (e) { /* fall back to full navigation */ }
     }
     location.href = url + hash;
@@ -605,11 +604,11 @@
   expandTrigger.addEventListener('click', () => {
     const t = queue[currentIndex];
-    // Alleen op échte desktop (≥1200, geen full-player) springen we naar de post.
-    // Tablet + telefoon hebben een full-player → open die (zoals mobiel).
+    // Only on true desktop (≥1200, no full-player) do we jump to the post.
+    // Tablet + phone have a full-player → open it (same as mobile behaviour).
     const jumpToPost = !hasFullPlayer();
     if (jumpToPost && t) {
-      // 1) Track speelde vanuit een post → die URL kennen we al.
+      // 1) Track played from a post → we already know that URL.
       if (t.postUrl) { goToPost(t.postUrl, t.id); return; }
-      // 2) Site-brede track (geen postUrl) → zoek de post op via de track-id.
+      // 2) Site-wide track (no postUrl) → look up the post via the track id.
       if (t.id) {
         fetch('/audio/track/' + encodeURIComponent(t.id) + '/post')
@@ -627,22 +626,22 @@
     if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
   });
-  // Naar desktop-breedte (≥1200) gesleept terwijl de full player open is? Sluiten —
-  // op desktop bestaat de full player niet.
+  // Resized to desktop width (≥1200) while the full player is open? Close it —
+  // the full player doesn't exist on desktop.
   window.addEventListener('resize', () => {
     if (!hasFullPlayer() && sheet.classList.contains('is-open')) closeSheet();
   });
 
-  // Vangnet voor mutual exclusion. Voor YouTube/SoundCloud/Spotify-embeds doet de
-  // registry dit al precies (echte play-events). Maar voor iframe-only embeds
-  // ZONDER JS-API (Bandcamp/Apple/Vimeo) én voor de iframe-FALLBACK (als een
-  // ad-blocker de player-API blokkeert) is er geen play-event: daar vangen we het
-  // af via focus. Klikt de gebruiker zo'n iframe aan → window 'blur' → pauzeer
-  // onze speler. (Voor de API-embeds is dit hooguit een onschadelijke dubbele
-  // pauze.)
+  // Fallback for mutual exclusion. For YouTube/SoundCloud/Spotify embeds the
+  // registry already handles this precisely (real play events). But for
+  // iframe-only embeds WITHOUT a JS API (Bandcamp/Apple/Vimeo) and for the
+  // iframe FALLBACK (when an ad-blocker blocks the player API) there is no
+  // play event: we catch those via focus. User clicks such an iframe →
+  // window 'blur' → pause our player. (For API embeds this is at worst a
+  // harmless double-pause.)
   window.addEventListener('blur', () => {
     setTimeout(() => {
       const el = document.activeElement;
-      // Alleen embed-iframes (binnen .folio-embed) pauzeren de speler — niet een
-      // willekeurig iframe (captcha/reclame/kaart) dat per ongeluk focus krijgt.
+      // Only embed iframes (inside .folio-embed) pause the player — not a
+      // random iframe (captcha/ad/map) that happens to receive focus.
       if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) {
         pause();
@@ -710,9 +709,9 @@
   // wrapper. For the other three the data is on the button itself. The
   // handler reads from button-first, falls back to wrapper.
-  // Event-delegation op document.body i.p.v. per-knop listeners. Dit overleeft
-  // HTMX history-restores: de mobiele terug-knop (popstate) laat HTMX #pcms-main
-  // terugzetten uit z'n snapshot; een per-element `data-pcms-attached`-vlag zou
-  // dan dode knoppen geven (vlag ingebakken in de snapshot, listener weg). Eén
-  // gedelegeerde listener werkt ongeacht hoe vaak de DOM ge(her)swapt wordt.
+  // Event delegation on document.body instead of per-button listeners. This
+  // survives HTMX history-restores: the mobile back button (popstate) lets HTMX
+  // restore #pcms-main from its snapshot; a per-element `data-pcms-attached` flag
+  // would leave dead buttons (flag baked into the snapshot, listener gone). One
+  // delegated listener works regardless of how many times the DOM is (re)swapped.
   const PLAY_SELECTOR =
     '.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall';
@@ -722,7 +721,7 @@
     e.preventDefault();
     e.stopPropagation();
-    // De post waar je VANAF afspeelt = de pagina waar je nu bent (de embeds staan
-    // in de post-content). Bewaar 'm op de track(s) zodat de mini-player er op
-    // desktop naartoe kan springen — overleeft ook de sessionStorage-resume.
+    // The post you're playing FROM = the current page (embeds live in post content).
+    // Store it on the track(s) so the desktop mini-player can jump back to it —
+    // survives the sessionStorage resume as well.
     const postUrl = location.pathname + location.search;
     // Resolve metadata: button-first, then closest .post-audio-track wrapper
@@ -804,12 +803,12 @@
 
   // ============================================================
-  // 10. Sessie-persistentie — speler "blijft" over page-navigaties heen
-  // ============================================================
-  // Een <audio> overleeft geen volledige page-load (en cross-context navigatie
-  // — bv. naar de headerloze hub-overview — is bewust full-nav). We bewaren de
-  // sessie in sessionStorage en herstellen + hervatten 'm op de volgende pagina:
-  // de speler staat er weer met dezelfde track op dezelfde positie. In Chrome
-  // (hoge media-engagement) speelt 'ie meteen door; staat de browser autoplay
-  // niet toe, dan staat 'ie klaar op die plek (één tik = verder).
+  // 10. Session persistence — player "survives" across page navigations
+  // ============================================================
+  // An <audio> element doesn't survive a full page load (and cross-context
+  // navigation — e.g. to the headerless hub overview — is intentionally a
+  // full-nav). We save the session to sessionStorage and restore + resume it
+  // on the next page: the player comes back with the same track at the same
+  // position. In Chrome (high media engagement) it resumes immediately;
+  // if the browser blocks autoplay it waits at that position (one tap = play).
   const PLAYER_STATE_KEY = 'pcms-player-state';
   let pendingSeek = 0;
@@ -830,5 +829,5 @@
   audio.addEventListener('ended', savePlayerState);
   setInterval(() => { if (audio.src && !audio.paused) savePlayerState(); }, 5000);
-  // Herstelde positie toepassen zodra de track-metadata binnen is.
+  // Apply the restored position once track metadata is available.
   audio.addEventListener('loadedmetadata', () => {
     if (pendingSeek > 0 && isFinite(audio.duration) && audio.duration > 0) {
@@ -845,5 +844,5 @@
     pendingSeek = s.time || 0;
     const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
-    // playing -> fetch + (poging tot) hervatten; gepauzeerd -> alleen metadata.
+    // playing → fetch + (attempt to) resume; paused → meta-only.
     loadTrack(idx, !!s.playing, !s.playing);
     return true;
@@ -853,6 +852,6 @@
   // 11. Site-level pre-seed (window.PCMS_SITE_TRACKS)
   // ============================================================
-  // Een actieve sessie (restore) wint van de pagina-seed, zodat lopende muziek
-  // doorgaat i.p.v. vervangen te worden door de tracks van de nieuwe pagina.
+  // An active session (restore) wins over the page seed, so music that is
+  // already playing continues instead of being replaced by the new page's tracks.
   if (!restorePlayerState()) {
     if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
@@ -864,7 +863,7 @@
         cover:  t.cover_url || t.cover || '',
       }));
-      // Queue alleen klaarzetten — de speler-balk verschijnt PAS bij de eerste
-      // audio-klik (een .post-audio-track of de mini-speler-play roept setQueue/
-      // loadTrack → die toont de balk). Geen pre-seed-balk meer op page-load.
+      // Only prime the queue — the player bar appears only on the first audio click
+      // (.post-audio-track or the mini-player play button calls setQueue/loadTrack,
+      // which shows the bar). No more pre-seed bar on page load.
       currentIndex = 0;
     }
Index: src/assets/js/embed-player.js
===================================================================
--- src/assets/js/embed-player.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/assets/js/embed-player.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,23 +1,23 @@
 /**
- * Klonkt Embed Player — eigen, in-huisstijl media-embeds bovenop de ÉCHTE
- * player-API's (YouTube IFrame API, SoundCloud Widget API, Spotify iFrame API).
+ * Klonkt Embed Player — custom, on-brand media embeds on top of the REAL
+ * player APIs (YouTube IFrame API, SoundCloud Widget API, Spotify iFrame API).
  *
- * De server (AudioEmbedService) rendert per embed een placeholder:
+ * The server (AudioEmbedService) renders a placeholder per embed:
  *   <div class="folio-embed folio-embed--<provider> pcms-embed-card"
  *        data-embed-provider data-embed-ref data-embed-type data-embed-url></div>
- * Dit script bouwt daar onze eigen kaart omheen (cover/poster + onze play-knop +
- * voortgangsbalk in huisstijl) en bestuurt de onderliggende speler via de
- * platform-API, zodat play/pause/voortgang in ÓNZE handen liggen.
+ * This script wraps it in our own card (cover/poster + our play button +
+ * progress bar in brand style) and controls the underlying player via the
+ * platform API, so play/pause/progress are in OUR hands.
  *
- * Capability-eerlijkheid:
- *  - youtube/soundcloud  → volledig eigen controls (native chrome verborgen).
- *  - spotify             → besturing + onze frame eromheen; Spotify's eigen UI
- *                          blijft binnenin (kan niet anders zonder Premium+OAuth).
+ * Capability honesty:
+ *  - youtube/soundcloud  → fully custom controls (native chrome hidden).
+ *  - spotify             → controls + our frame around it; Spotify's own UI
+ *                          stays inside (no alternative without Premium+OAuth).
  *
- * Mutual exclusion: elke speler (incl. de site-audiospeler in audio-player.js)
- * registreert zich in window.pcmsMediaRegistry. Start er één → de vorige pauzeert.
- * Dit vervangt de oude focus/blur-heuristiek door echte play-events.
+ * Mutual exclusion: every player (incl. the site audio player in audio-player.js)
+ * registers itself in window.pcmsMediaRegistry. Starting one pauses the previous.
+ * This replaces the old focus/blur heuristic with real play events.
  *
- * Singleton — guard tegen dubbel-init (HTMX laadt scripts soms opnieuw).
+ * Singleton — guard against double-init (HTMX can reload scripts).
  */
 (function () {
@@ -25,5 +25,5 @@
 
   // ============================================================
-  // 0. Gedeelde playback-registry (ook door audio-player.js gebruikt)
+  // 0. Shared playback registry (also used by audio-player.js)
   // ============================================================
   function registry() {
@@ -31,5 +31,5 @@
     const r = {
       _active: null,
-      // Markeer `player` als de enige actieve; pauzeer de vorige.
+      // Mark `player` as the sole active one; pause the previous.
       setActive(player) {
         if (this._active && this._active !== player && this._active.pause) {
@@ -45,7 +45,7 @@
 
   // ============================================================
-  // 1. Lazy script-loaders — 1 promise per platform, gedeeld over N embeds.
-  //    We laden een platform-script PAS als er een embed van dat platform op de
-  //    pagina daadwerkelijk wordt gestart.
+  // 1. Lazy script loaders — 1 promise per platform, shared across N embeds.
+  //    A platform script is only loaded when an embed from that platform
+  //    is actually started on the page.
   // ============================================================
   const scripts = {};
@@ -55,14 +55,14 @@
       s.src = src; s.async = true;
       s.onload = () => resolve();
-      s.onerror = () => reject(new Error('embed-script faalde: ' + src));
+      s.onerror = () => reject(new Error('embed script failed: ' + src));
       document.head.appendChild(s);
     });
   }
-  // Belangrijk: alle loaders REJECTEN bij een script-fout (bv. een ad-blocker die
-  // het API-script blokkeert) én na een time-out — zodat de aanroeper netjes kan
-  // terugvallen op het kale platform-iframe i.p.v. eeuwig te hangen.
+  // Important: all loaders REJECT on script errors (e.g. an ad-blocker blocking
+  // the API script) and on timeout — so the caller can gracefully fall back to
+  // the plain platform iframe instead of hanging indefinitely.
   const API_TIMEOUT = 8000;
 
-  // YouTube: globale callback onYouTubeIframeAPIReady (1×) → in promise wikkelen.
+  // YouTube: global callback onYouTubeIframeAPIReady (once) → wrap in a promise.
   function ytApi() {
     if (scripts.yt) return scripts.yt;
@@ -79,6 +79,6 @@
     return scripts.yt;
   }
-  // SoundCloud: geen globale ready-callback; resolve op script-onload, daarna
-  // wacht elke widget op z'n eigen SC.Widget.Events.READY.
+  // SoundCloud: no global ready callback; resolve on script onload, then
+  // each widget waits for its own SC.Widget.Events.READY.
   function scApi() {
     if (scripts.sc) return scripts.sc;
@@ -91,5 +91,5 @@
     return scripts.sc;
   }
-  // Spotify: globale callback onSpotifyIframeApiReady(IFrameAPI) (1×) → wrappen.
+  // Spotify: global callback onSpotifyIframeApiReady(IFrameAPI) (once) → wrap in a promise.
   function spotifyApi() {
     if (scripts.sp) return scripts.sp;
@@ -101,6 +101,6 @@
       };
       loadScript('https://open.spotify.com/embed/iframe-api/v1').catch(reject);
-      // Korter dan API_TIMEOUT: Spotify's bundle initialiseert snel óf helemaal
-      // niet (CDN 503 / origin-gating). Niet 8s wachten vóór de iframe-fallback.
+      // Shorter than API_TIMEOUT: Spotify's bundle initialises quickly or not at all
+      // (CDN 503 / origin-gating). No need to wait 8 s before the iframe fallback.
       setTimeout(() => reject(new Error('Spotify API timeout')), 4000);
     });
@@ -108,7 +108,7 @@
   }
 
-  // Kaal platform-iframe als fallback wanneer de JS-API geblokkeerd/onbereikbaar
-  // is. Ad-blockers laten de embed-iframes doorgaans wél door. Autoplay-param
-  // omdat we hier altijd in een user-gesture-context zitten (klik op play).
+  // Plain platform iframe as fallback when the JS API is blocked/unreachable.
+  // Ad-blockers generally let embed iframes through. Autoplay parameter
+  // is safe here because we're always in a user-gesture context (play click).
   function fallbackIframe(provider, ref, url) {
     if (provider === 'youtube') {
@@ -138,7 +138,7 @@
     const m = (url || '').match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
     if (m) return m[1];
-    return null;  // geen blinde slice — een ongeldige ref geeft liever niets dan een kapotte id
-  }
-  // Alleen http(s) als href toelaten (defense-in-depth tegen javascript:/data:).
+    return null;  // no blind slice — an invalid ref returns nothing rather than a broken id
+  }
+  // Only allow http(s) as href (defense-in-depth against javascript:/data: URIs).
   function safeHref(u) {
     try { const p = new URL(u, location.href); return (p.protocol === 'http:' || p.protocol === 'https:') ? u : '#'; }
@@ -154,5 +154,5 @@
 
   // ============================================================
-  // 3. Card controller — bouwt de huisstijl-kaart + delegeert naar een adapter
+  // 3. Card controller — builds the on-brand card + delegates to an adapter
   // ============================================================
   function buildCard(el) {
@@ -165,12 +165,12 @@
     el.classList.remove('pcms-embed-loading');
 
-    // Onze speler-zijde (registry-peer). pause() wijst naar de adapter zodra die
-    // gemount is; ervoor is 't een no-op.
+    // Our player side (registry peer). pause() points to the adapter once
+    // mounted; before that it's a no-op.
     let adapter = null;
     const self = { pause() { if (adapter && adapter.pause) { try { adapter.pause(); } catch (e) {} } } };
 
-    // Teardown-hook: aangeroepen door de MutationObserver als deze kaart uit de
-    // DOM verdwijnt (HTMX-swap) → adapter opruimen (timers/iframes) + registry
-    // vrijgeven, zodat er geen poll-timers of spelers blijven lekken.
+    // Teardown hook: called by the MutationObserver when this card leaves the
+    // DOM (HTMX swap) → clean up the adapter (timers/iframes) + release the registry,
+    // so no poll timers or players keep leaking.
     el._pcmsDestroy = function () {
       try { if (adapter && adapter.destroy) adapter.destroy(); } catch (e) {}
@@ -182,5 +182,5 @@
     let dur = 0;
 
-    // --- UI ophangen (verschilt per provider-type) ---
+    // --- Mount the UI (differs per provider type) ---
     const isVideo = provider === 'youtube';
     const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls
@@ -220,5 +220,5 @@
     const volEl = el.querySelector('.pcms-embed-vol');
     const muteBtn = el.querySelector('.pcms-embed-mute');
-    let vol = 100;       // huidige volume 0-100 (wordt op de adapter toegepast)
+    let vol = 100;       // current volume 0-100 (applied to the adapter)
     let preMuteVol = 100;
 
@@ -251,7 +251,7 @@
     };
 
-    // API geblokkeerd/onbereikbaar → kaal platform-iframe (graceful degradation).
-    // De mutual-exclusion loopt voor deze fallback via de blur-heuristiek
-    // (audio-player.js), want de kaart krijgt geen .is-mounted.
+    // API blocked/unreachable → plain platform iframe (graceful degradation).
+    // Mutual exclusion for this fallback runs via the blur heuristic
+    // (audio-player.js), because the card never gets .is-mounted.
     function renderFallback() {
       const fb = fallbackIframe(provider, ref, url);
@@ -271,10 +271,10 @@
       el.appendChild(iframe);
 
-      // Mutual exclusion óók voor de fallback-iframe. Een cross-origin iframe
-      // kunnen we niet via een API pauzeren, dus 'pauze' = herladen ZONDER
-      // autoplay (= stopt het geluid, speler blijft zichtbaar/herstartbaar).
-      // We registreren 'm als actief: nu (= gebruiker start de embed) pauzeert de
-      // site-speler/andere embeds; en als de site-speler later start, pauzeert de
-      // registry deze fallback.
+      // Mutual exclusion also for the fallback iframe. A cross-origin iframe
+      // cannot be paused via an API, so 'pause' = reload WITHOUT
+      // autoplay (= stops the audio, player stays visible/restartable).
+      // We register it as active: now (= user starts the embed) the
+      // site player/other embeds pause; and when the site player starts later,
+      // the registry pauses this fallback.
       self.pause = function () {
         try {
@@ -294,11 +294,11 @@
     }
 
-    // Eerste interactie → adapter mounten + spelen. Daarna toggelt de knop.
+    // First interaction → mount the adapter + play. The button toggles after that.
     async function ensureMountedAndPlay() {
       if (mounted) { if (adapter) adapter.play(); return; }
       mounted = true;
-      // Spotify: hun speler is toch niet te skinnen (besturing-only) én de
-      // iFrame-API-bundle initialiseert in de praktijk vaak niet (CDN-gating/503)
-      // → niet op de API wachten, meteen het kale Spotify-iframe tonen. Instant.
+      // Spotify: their player cannot be skinned (controls-only) and the
+      // iFrame API bundle often fails to initialise in practice (CDN-gating/503)
+      // → don't wait for the API, show the plain Spotify iframe immediately.
       if (provider === 'spotify') { renderFallback(); return; }
       el.classList.add('pcms-embed-busy');
@@ -306,11 +306,11 @@
         adapter = await MOUNTERS[provider](mountEl, { ref, url }, hooks);
         if (el._pcmsApplyVol) el._pcmsApplyVol();  // onthouden volume toepassen
-        // is-mounted: CSS verbergt de poster (video) of de Spotify-facade en
-        // toont de echte speler. Voor SoundCloud blijft onze kaart+balk staan en
-        // blijft het (functionele) iframe off-screen verborgen.
+        // is-mounted: CSS hides the poster (video) or Spotify facade and
+        // shows the real player. For SoundCloud our card+bar stays visible and
+        // the (functional) iframe remains hidden off-screen.
         el.classList.add('is-mounted');
         adapter.play();
       } catch (err) {
-        console.warn('[pcms-embed] API niet beschikbaar, val terug op kaal iframe', provider, err);
+        console.warn('[pcms-embed] API unavailable, falling back to plain iframe', provider, err);
         renderFallback();
       }
@@ -328,6 +328,6 @@
       adapter.seek(ratio * dur);
     });
-    // Volume: schuif zet vol (0-100) en past 'm toe op de adapter (YT/SC hebben
-    // setVolume). De waarde wordt onthouden en na het mounten opnieuw toegepast.
+    // Volume: slider sets vol (0-100) and applies it to the adapter (YT/SC support
+    // setVolume). The value is remembered and reapplied after mounting.
     function applyVol() {
       if (adapter && adapter.setVolume) { try { adapter.setVolume(vol); } catch (e) {} }
@@ -341,5 +341,5 @@
       applyVol();
     });
-    el._pcmsApplyVol = applyVol;  // door ensureMountedAndPlay aangeroepen na mount
+    el._pcmsApplyVol = applyVol;  // called by ensureMountedAndPlay after mount
   }
 
@@ -349,7 +349,7 @@
 
   // ============================================================
-  // 4. Adapters — normaliseren de 3 zeer verschillende API's naar 1 vorm.
-  //    Elke mounter geeft { play, pause, seek } terug en roept hooks aan met
-  //    seconden (units worden hier rechtgetrokken).
+  // 4. Adapters — normalise the 3 very different APIs to one common shape.
+  //    Each mounter returns { play, pause, seek } and calls hooks with
+  //    seconds (units are normalised here).
   // ============================================================
   const MOUNTERS = {
@@ -403,8 +403,8 @@
     },
 
-    // ---- SoundCloud: Widget API, visual=false, eigen controls ----
+    // ---- SoundCloud: Widget API, visual=false, custom controls ----
     async soundcloud(mountEl, { ref, url }, hooks) {
       const SC = await scApi();
-      // Iframe zelf bouwen (kale balk) en daarna SC.Widget eraan hangen.
+      // Build the iframe ourselves (bare bar) and then attach SC.Widget to it.
       const iframe = document.createElement('iframe');
       iframe.allow = 'autoplay';
@@ -453,5 +453,5 @@
     },
 
-    // ---- Spotify: iFrame API — besturing + onze frame; geen eigen skin ----
+    // ---- Spotify: iFrame API — controls + our frame; no custom skin ----
     async spotify(mountEl, { ref, url }, hooks) {
       const IFrameAPI = await spotifyApi();
@@ -473,7 +473,7 @@
             const d = e && e.data ? e.data : {};
             hooks.onProgress((d.position || 0) / 1000, (d.duration || 0) / 1000);
-            // Geen betrouwbare 'ended'-event bij Spotify; we behandelen elke
-            // isPaused-overgang als play/pause. Einde = gewoon een pauze (de balk
-            // blijft op de eindpositie i.p.v. misleidend naar 0 te springen).
+            // No reliable 'ended' event from Spotify; we treat every
+            // isPaused transition as play/pause. End = just a pause (the bar
+            // stays at the end position rather than misleadingly jumping to 0).
             if (d.isPaused === false && lastPaused) { lastPaused = false; hooks.onPlay(); }
             else if (d.isPaused === true && !lastPaused) { lastPaused = true; hooks.onPause(); }
@@ -486,5 +486,5 @@
 
   // ============================================================
-  // 5. Scan + HTMX-/DOM-mutatie-aware (de site navigeert deels via HTMX-swaps)
+  // 5. Scan + HTMX/DOM-mutation-aware (the site partially navigates via HTMX swaps)
   // ============================================================
   function scan(root) {
@@ -492,5 +492,5 @@
       if (el.dataset.embedInit) return;
       el.dataset.embedInit = '1';
-      try { buildCard(el); } catch (e) { console.error('[pcms-embed] buildCard faalde', e); }
+      try { buildCard(el); } catch (e) { console.error('[pcms-embed] buildCard failed', e); }
     });
   }
@@ -501,13 +501,12 @@
     scan(document);
   }
-  // HTMX vervangt #pcms-main bij interne navigatie → opnieuw scannen.
+  // HTMX replaces #pcms-main on internal navigation → rescan.
   document.body.addEventListener('htmx:afterSwap', (e) => scan(e.target || document));
   document.body.addEventListener('htmx:load', (e) => scan(e.target || document));
 
-  // Teardown bij verwijdering uit de DOM (HTMX vervangt #pcms-main innerHTML, of
-  // een SPA-achtige swap). Zonder dit blijven YouTube-poll-timers + adapters/
-  // iframes hangen als je wegnavigeert terwijl een embed speelt → CPU/geheugenlek
-  // dat per navigatie opstapelt. We roepen el._pcmsDestroy() aan voor elke kaart
-  // die echt uit het document verdwijnt.
+  // Teardown on DOM removal (HTMX replaces #pcms-main innerHTML, or an SPA-like
+  // swap). Without this, YouTube poll timers + adapters/iframes linger when
+  // navigating away while an embed is playing → CPU/memory leak that accumulates
+  // per navigation. We call el._pcmsDestroy() for every card that truly leaves the document.
   const teardownObserver = new MutationObserver((muts) => {
     for (const m of muts) {
Index: src/assets/js/smooth-scroll.js
===================================================================
--- src/assets/js/smooth-scroll.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/assets/js/smooth-scroll.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -2,5 +2,5 @@
 (function () {
   if (typeof Lenis === "undefined") return;
-  // Skip op touch/mobile — native momentum is beter dan JS lib
+  // Skip on touch/mobile — native momentum scroll is better than a JS lib
   var isMobile = window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 1024;
   if (isMobile) return;
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/config/database.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -39,19 +39,19 @@
   ensureColumn('users', 'reset_token', 'TEXT');
   ensureColumn('users', 'reset_token_expires', 'DATETIME');
-  // Google OAuth: koppel een Google-account aan een user (login via Google).
+  // Google OAuth: link a Google account to a user (login via Google).
   ensureColumn('users', 'google_sub', 'TEXT');
-  // Read-only/kijk-account: kan alles bekijken maar geen wijzigingen doen.
+  // Read-only/viewer account: can view everything but make no changes.
   ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0');
-  // Persoonlijke interface-taal (nl|en|de). Null = volg de standaard (site/env/browser).
+  // Personal interface language (nl|en|de). Null = follow the default (site/env/browser).
   ensureColumn('users', 'lang', 'TEXT');
   // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
   ensureColumn('sites', 'comments_moderation_mode', "TEXT DEFAULT 'moderate'");
-  // Cirkels: mag deze site in cirkels van anderen verschijnen (surfacing opt-out).
+  // Circles: whether this site may appear in other sites' circles (surfacing opt-out).
   ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1');
 
-  // Eén EXPLICIETE primaire/hoofd-site (= de bedrijfs-/labelsite in hub-modus,
-  // de enige site in solo) i.p.v. de fragiele "oudste = hoofd"-conventie die op
-  // 4 plekken gedupliceerd stond. Backfill: markeer de oudste als er nog geen
-  // primaire site is, zodat bestaand gedrag exact behouden blijft.
+  // One EXPLICIT primary/main site (= the company/label site in hub mode,
+  // the only site in solo) instead of the fragile "oldest = main" convention
+  // that was duplicated in 4 places. Backfill: mark the oldest if no primary
+  // site exists yet, so existing behaviour is preserved exactly.
   ensureColumn('sites', 'is_primary', 'INTEGER DEFAULT 0');
   try {
@@ -61,5 +61,5 @@
       if (oldest) db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(oldest.id);
     }
-  } catch (e) { /* sites-tabel nog leeg/afwezig bij verse init — ensurePrimarySite regelt 't */ }
+  } catch (e) { /* sites table still empty/absent on fresh init — ensurePrimarySite handles it */ }
 
   // v9 audit additions —————————————————————————————————————————
@@ -82,15 +82,15 @@
   // Per-post noindex + type
   ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
-  ensureColumn('posts', 'publish_at', 'DATETIME');         // release-planning (premium #3): geplande go-live
+  ensureColumn('posts', 'publish_at', 'DATETIME');         // release planning (premium #3): scheduled go-live
   ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0');  // fan-only preview (premium #3)
   ensureColumn('posts', 'type',    "TEXT DEFAULT 'post'");  // post | foto | video | audio
 
-  // Statistieken (premium-module) — kale tellers, cookievrij.
-  ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0');         // weergaven per post
+  // Statistics (premium module) — bare counters, cookie-free.
+  ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0');         // views per post
   ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0');  // plays per track
-  ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-voor-email (premium #2)
-  ensureColumn('audio_tracks', 'credit', 'TEXT');   // eigenaar/credit (copyright-houder)
-  ensureColumn('audio_tracks', 'license', 'TEXT');  // licentie (bv. "CC BY 4.0", "Alle rechten voorbehouden")
-  ensureColumn('audio_tracks', 'link_spotify',    'TEXT');  // "open in"-links per track
+  ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-for-email (premium #2)
+  ensureColumn('audio_tracks', 'credit', 'TEXT');   // owner/credit (copyright holder)
+  ensureColumn('audio_tracks', 'license', 'TEXT');  // license (e.g. "CC BY 4.0", "All rights reserved")
+  ensureColumn('audio_tracks', 'link_spotify',    'TEXT');  // "open in" links per track
   ensureColumn('audio_tracks', 'link_youtube',    'TEXT');
   ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
@@ -123,6 +123,6 @@
   `);
 
-  // Globale app-instellingen (key/value singleton). O.a. de tenancy-modus
-  // (solo = één site, hub = bedrijfssite + /user/). Default = solo.
+  // Global app settings (key/value singleton). Includes the tenancy mode
+  // (solo = one site, hub = company site + /user/). Default = solo.
   db.exec(`
     CREATE TABLE IF NOT EXISTS app_settings (
@@ -134,9 +134,9 @@
   db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES ('tenancy', 'solo')").run();
 
-  // ── Statistieken (premium) — cookievrij ─────────────────────
-  // stat_daily: per dag per site het aantal pageviews (kale teller).
-  // stat_visitor_day: per dag per site een rij per UNIEKE bezoeker-hash
-  //   (sha256 van IP+UA+dag-salt; de salt roteert dagelijks en wordt nooit
-  //   bewaard → geen persistente identifier, geen cookie, geen toestemming nodig).
+  // ── Statistics (premium) — cookie-free ──────────────────────
+  // stat_daily: pageview count per day per site (bare counter).
+  // stat_visitor_day: one row per UNIQUE visitor hash per day per site
+  //   (sha256 of IP+UA+day-salt; the salt rotates daily and is never stored
+  //   → no persistent identifier, no cookie, no consent required).
   db.exec(`
     CREATE TABLE IF NOT EXISTS stat_daily (
@@ -161,6 +161,6 @@
   `);
 
-  // ── Cirkels (federatie) ─────────────────────────────────────
-  // Decentrale, asymmetrische verbindingen tussen solo-instances.
+  // ── Circles (federation) ────────────────────────────────────
+  // Decentralised, asymmetric connections between solo instances.
   db.exec(`
     CREATE TABLE IF NOT EXISTS circle_links (
@@ -200,10 +200,10 @@
   `);
 
-  // Tags van de originele post — getoond in de cirkel (comma-separated string).
+  // Tags from the original post — shown in the circle feed (comma-separated string).
   ensureColumn('remote_posts', 'tags', 'TEXT');
 
-  // Nieuwsbrief / mailinglijst (premium). Abonnees per site; double opt-in als SMTP
-  // er is (status 'pending' tot bevestigd), anders single opt-in ('confirmed').
-  // 'unsub' = uitgeschreven. token = confirm/unsubscribe-sleutel (in de e-maillinks).
+  // Newsletter / mailing list (premium). Subscribers per site; double opt-in when SMTP
+  // is configured (status 'pending' until confirmed), otherwise single opt-in ('confirmed').
+  // 'unsub' = unsubscribed. token = confirm/unsubscribe key (used in email links).
   db.exec(`
     CREATE TABLE IF NOT EXISTS subscribers (
@@ -221,5 +221,5 @@
   `);
 
-  // Verstuurde nieuwsbrieven (historie + aantallen).
+  // Sent newsletters (history + counts).
   db.exec(`
     CREATE TABLE IF NOT EXISTS newsletters (
@@ -233,5 +233,5 @@
   `);
 
-  // Show-agenda (premium #8): tourdata/optredens per site.
+  // Show agenda (premium #8): tour dates / gigs per site.
   db.exec(`
     CREATE TABLE IF NOT EXISTS shows (
@@ -250,8 +250,8 @@
   `);
 
-  // Meldingen: iemand reageert op je reactie / post, of liket je post. Snapshots
-  // van naam/titel zodat de lijst goedkoop te tonen is zonder joins.
-  // NB: bewust 'user_notifications' — sommige oudere DBs hebben nog een stale,
-  // ongebruikte 'notifications'-tabel met een ander schema (geen read-kolom).
+  // Notifications: someone replies to your comment / post, or likes your post. Snapshots
+  // of name/title so the list can be shown cheaply without joins.
+  // NB: deliberately named 'user_notifications' — some older DBs still have a stale,
+  // unused 'notifications' table with a different schema (no read column).
   db.exec(`
     CREATE TABLE IF NOT EXISTS user_notifications (
@@ -270,6 +270,6 @@
   `);
 
-  // Link-in-bio klikstatistiek (premium #6). Per (site, url) een teller; de
-  // link-in-bio-pagina linkt via /links/go/:i dat de klik telt en doorstuurt.
+  // Link-in-bio click statistics (premium #6). One counter per (site, url); the
+  // link-in-bio page links via /links/go/:i which counts the click and redirects.
   db.exec(`
     CREATE TABLE IF NOT EXISTS link_clicks (
@@ -282,7 +282,7 @@
   `);
 
-  // Likes / favorieten: een ingelogde gebruiker kan een post liken. De set van
-  // posts die een gebruiker likte = z'n favorieten (/favorieten-pagina). Eén rij
-  // per (post, user); uniek zodat liken idempotent is.
+  // Likes / favourites: a logged-in user can like a post. The set of
+  // posts a user liked = their favourites (/favorieten page). One row
+  // per (post, user); unique so that liking is idempotent.
   db.exec(`
     CREATE TABLE IF NOT EXISTS post_likes (
Index: src/config/features.js
===================================================================
--- src/config/features.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/config/features.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,11 +1,11 @@
-// Feature-flags (boot-tijd, via env).
+// Feature flags (boot-time, via env).
 //
-// Lite-modus: zet KLONKT_AUDIO=off in .env om de HELE audio-feature uit te
-// schakelen — geen audio-/playlist-/download-/embed-routes, geen ffmpeg-aanroep,
-// geen speler en geen [[track]]/[[playlist]]-shortcodes. Zo draait Klonkt als
-// lichte blog/foto/EPK-site op een omgeving zónder ffmpeg/exec. Hub én Cirkels
-// blijven gewoon werken (die hangen niet van audio af).
+// Lite mode: set KLONKT_AUDIO=off in .env to disable the ENTIRE audio feature —
+// no audio/playlist/download/embed routes, no ffmpeg calls, no player, and no
+// [[track]]/[[playlist]] shortcodes. This lets Klonkt run as a lightweight
+// blog/photo/EPK site on environments without ffmpeg/exec. Hub and Circles
+// keep working (they have no audio dependency).
 //
-// Default = aan (volledige versie). Alleen de letterlijke waarde 'off' schakelt uit.
+// Default = on (full version). Only the literal value 'off' disables it.
 export function audioEnabled() {
   return String(process.env.KLONKT_AUDIO ?? 'on').toLowerCase() !== 'off';
Index: src/config/google.js
===================================================================
--- src/config/google.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/config/google.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,12 +1,12 @@
-// Google OAuth2 voor LUISTERAARS (reageren). Per-instance: de self-hoster zet
-// z'n EIGEN Google-client. Zo hangt elke site aan z'n eigen Google Cloud project
-// — geen centrale afhankelijkheid, geen gedeelde aansprakelijkheid.
+// Google OAuth2 for LISTENERS (commenting). Per-instance: each self-hoster sets
+// their OWN Google client. This way every site is tied to its own Google Cloud
+// project — no central dependency, no shared liability.
 //
-// Config-bron (in deze volgorde): app_settings (ingesteld via Beheer → Instellingen),
-// anders de env-vars. Niet ingesteld → geen "Login met Google"-knop; de rest van
-// de site werkt door. Google-login geeft NOOIT beheerrechten.
+// Config source (in this order): app_settings (set via Admin → Settings),
+// otherwise env vars. Not configured → no "Login with Google" button; the rest
+// of the site keeps working. Google login NEVER grants admin rights.
 //
-// De redirect-URI wordt afgeleid van PUBLIC_BASE_URL (<base>/auth/google/callback),
-// of expliciet via GOOGLE_REDIRECT_URI. Die exacte URL moet in Google Cloud staan.
+// The redirect URI is derived from PUBLIC_BASE_URL (<base>/auth/google/callback),
+// or explicitly via GOOGLE_REDIRECT_URI. That exact URL must be listed in Google Cloud.
 
 import { getSetting } from '../services/SettingsService.js';
@@ -16,5 +16,5 @@
 const USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo';
 
-// Dynamisch lezen (UI-wijziging werkt zonder herstart). app_settings wint, env = fallback.
+// Read dynamically (UI changes take effect without a restart). app_settings wins, env = fallback.
 function clientId() {
   return getSetting('google_client_id', '') || process.env.GOOGLE_CLIENT_ID || '';
@@ -29,5 +29,5 @@
 }
 
-export function currentClientId() { return clientId(); } // niet-geheim, voor het formulier
+export function currentClientId() { return clientId(); } // not secret, used for the settings form
 export function clientSecretSet() { return !!clientSecret(); }
 export function googleConfigured() {
@@ -61,5 +61,5 @@
     body,
   });
-  if (!r.ok) throw new Error(`Google token-exchange faalde: ${r.status}`);
+  if (!r.ok) throw new Error(`Google token exchange failed: ${r.status}`);
   return r.json(); // { access_token, id_token, ... }
 }
@@ -68,5 +68,5 @@
 export async function fetchUserinfo(accessToken) {
   const r = await fetch(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
-  if (!r.ok) throw new Error(`Google userinfo faalde: ${r.status}`);
+  if (!r.ok) throw new Error(`Google userinfo failed: ${r.status}`);
   return r.json();
 }
Index: src/config/mailer.js
===================================================================
--- src/config/mailer.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/config/mailer.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,8 +1,8 @@
-// E-mail versturen (wachtwoord-reset, nieuwsbrief, notify). Optioneel: alleen actief
-// als SMTP is ingesteld — via Beheer → Instellingen (app_settings) OF env-vars.
+// Send email (password reset, newsletter, notify). Optional: only active
+// when SMTP is configured — via Admin → Settings (app_settings) OR env vars.
 //
-// Config-bron (in deze volgorde): app_settings (ingesteld in de UI), anders env:
+// Config source (in this order): app_settings (set via the UI), otherwise env:
 //   SMTP_HOST, SMTP_PORT (default 587), SMTP_USER, SMTP_PASS, SMTP_FROM (default = USER)
-// Niet ingesteld → versturen valt terug op CLI (reset-admin) / wordt overgeslagen.
+// Not configured → sending falls back to CLI (reset-admin) / is skipped.
 
 import nodemailer from 'nodemailer';
@@ -23,5 +23,5 @@
 }
 
-// Status voor de UI (zonder het wachtwoord te lekken).
+// Status for the UI (without leaking the password).
 export function mailerStatus() {
   const c = cfg();
@@ -33,10 +33,10 @@
     from: c.from,
     passSet: !!c.pass,
-    // bron: handig om te tonen dat env nog actief is
+    // source: useful to show that env vars are still active
     fromEnv: !getSetting('smtp_host', '') && !!process.env.SMTP_HOST,
   };
 }
 
-// Transport cachen, maar herbouwen zodra de config wijzigt (UI-edit zonder herstart).
+// Cache the transport, but rebuild it whenever the config changes (UI edit without restart).
 let _transport = null, _key = null;
 function transport() {
@@ -47,5 +47,5 @@
       host: c.host,
       port: c.port,
-      secure: c.port === 465, // 465 = impliciete TLS; 587 = STARTTLS
+      secure: c.port === 465, // 465 = implicit TLS; 587 = STARTTLS
       auth: { user: c.user, pass: c.pass },
     });
@@ -56,5 +56,5 @@
 
 export async function sendMail({ to, subject, text, html }) {
-  if (!mailerConfigured()) throw new Error('SMTP niet geconfigureerd');
+  if (!mailerConfigured()) throw new Error('SMTP not configured');
   const c = cfg();
   return transport().sendMail({ from: c.from, to, subject, text, html });
Index: src/middleware/auth.js
===================================================================
--- src/middleware/auth.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/middleware/auth.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -38,8 +38,8 @@
 }
 
-// Een 'kijker' mag ALLES bekijken (incl. Beheer) maar NIETS wijzigen. De
-// schrijf-blokkade zit in de globale guard in server.js; deze helper bepaalt
-// alleen "is dit een alleen-lezen account?". `readonly` is de legacy-vlag die
-// we nog meenemen zodat niet-gemigreerde demo-accounts geblokkeerd blijven.
+// A 'kijker' (viewer) may VIEW everything (incl. Admin) but CHANGE nothing. The
+// write block lives in the global guard in server.js; this helper only determines
+// "is this a read-only account?". `readonly` is the legacy flag we still include
+// so unmigrated demo accounts remain blocked.
 export function isViewer(user) {
   return !!user && (user.role === 'kijker' || !!user.readonly);
@@ -49,6 +49,6 @@
   if (!req.session?.user) return loginRedirect(req, res);
   const role = req.session.user.role;
-  // god beheert; een kijker mág het Beheer-paneel zien (alleen-lezen) — de
-  // globale guard 403't elke write, dus dit geeft enkel kijk-toegang.
+  // god manages; a viewer MAY see the Admin panel (read-only) — the
+  // global guard 403s every write, so this only grants view access.
   if (role !== 'god' && role !== 'kijker') {
     return res.status(403).send('God role required');
@@ -57,23 +57,23 @@
 }
 
-// Mag de ingelogde user de HUIDIGE site (res.locals.site) beheren? god altijd;
-// anders alleen de owner van die site. Gebruikt voor site-gescopete beheerroutes
-// die een artiest via /user/<eigen-slug>/admin/... bereikt (res.locals.site is dan
-// z'n eigen site; een vreemde slug levert een andere site -> 403).
+// Can the logged-in user manage the CURRENT site (res.locals.site)? god always;
+// otherwise only the owner of that site. Used for site-scoped admin routes
+// that an artist reaches via /user/<own-slug>/admin/... (res.locals.site is then
+// their own site; a foreign slug yields a different site -> 403).
 export function requireSiteManager(req, res, next) {
   if (!req.session?.user) return loginRedirect(req, res);
   const u = req.session.user;
-  if (u.role === 'god' || u.role === 'kijker') return next(); // kijker = alleen-lezen kijk-toegang
+  if (u.role === 'god' || u.role === 'kijker') return next(); // viewer = read-only view access
   const site = res.locals.site;
-  // owner OF toegewezen mede-beheerder (site_members) — canAdminSite dekt beide.
+  // owner OR assigned co-admin (site_members) — canAdminSite covers both.
   if (site && PermissionsService.canAdminSite(u, site)) return next();
   return res.status(403).send('Geen toegang tot deze site.');
 }
 
-// Idem, maar de site wordt bepaald door de :slug-parameter (bv. site-edit).
+// Same, but the site is determined by the :slug parameter (e.g. site-edit).
 export function requireSiteManagerBySlug(req, res, next) {
   if (!req.session?.user) return loginRedirect(req, res);
   const u = req.session.user;
-  if (u.role === 'god' || u.role === 'kijker') return next(); // kijker = alleen-lezen kijk-toegang
+  if (u.role === 'god' || u.role === 'kijker') return next(); // viewer = read-only view access
   const site = db.prepare('SELECT id, owner_id FROM sites WHERE slug = ?').get(req.params.slug);
   if (site && PermissionsService.canAdminSite(u, site)) return next();
Index: src/middleware/rate-limit.js
===================================================================
--- src/middleware/rate-limit.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/middleware/rate-limit.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -14,9 +14,9 @@
 import { renderPage } from './render.js';
 
-// Achter Cloudflare/Caddy kan req.ip binnenkomen als "1.2.3.4:11046" (IPv4 met
-// poort). express-rate-limit v7 valideert het IP en gooit anders
-// ERR_ERL_INVALID_IP_ADDRESS — onafgevangen async → het proces crasht (en pm2
-// loopt in een restart-loop). Strip een trailing IPv4-poort, val terug op de
-// socket, en laat IPv6 (meerdere dubbele punten) ongemoeid.
+// Behind Cloudflare/Caddy, req.ip can arrive as "1.2.3.4:11046" (IPv4 with
+// port). express-rate-limit v7 validates the IP and otherwise throws
+// ERR_ERL_INVALID_IP_ADDRESS — uncaught async → the process crashes (and pm2
+// enters a restart loop). Strip a trailing IPv4 port, fall back to the
+// socket address, and leave IPv6 (multiple colons) untouched.
 function clientKey(req) {
   let ip = req.ip || req.socket?.remoteAddress || '';
Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/middleware/render.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -27,7 +27,7 @@
 const VIEWS_DIR = path.join(__dirname, '..', 'views');
 
-// App-versie (uit package.json) + korte commit-hash (uit .klonkt-version, door de
-// deploy geschreven) — getoond in de footer naast "Klonkt Beta". De hash loopt
-// automatisch mee bij élke deploy, dus de versie is nooit meer stale.
+// App version (from package.json) + short commit hash (from .klonkt-version, written by
+// the deploy script) — shown in the footer next to "Klonkt Beta". The hash is updated
+// automatically on every deploy, so the displayed version is never stale.
 let APP_VERSION = '';
 try {
@@ -36,10 +36,9 @@
     const sha = fs.readFileSync(path.join(__dirname, '..', '..', '.klonkt-version'), 'utf8').trim().slice(0, 7);
     if (sha) APP_VERSION += ' · ' + sha;
-  } catch { /* geen .klonkt-version (lokale dev) */ }
-} catch { /* geen versie beschikbaar */ }
-
-// Site-tijdzone (Beheer → Instellingen). Leeg = server-default (UTC). Wordt
-// toegepast op álle server-side geformatteerde datums zodat ze in de zone van
-// de site staan i.p.v. UTC.
+  } catch { /* no .klonkt-version (local dev) */ }
+} catch { /* no version available */ }
+
+// Site timezone (Admin → Settings). Empty = server default (UTC). Applied to
+// all server-side formatted dates so they display in the site's timezone instead of UTC.
 const siteTimezone = () => getSetting('timezone') || undefined;
 
@@ -58,17 +57,17 @@
   const isPartial = req.headers['hx-request'] === 'true' || req.query.partial === '1';
 
-  // Voorkom dat de browser een htmx-PARTIAL (alleen #pcms-main, zónder <head>/CSS)
-  // onder dezelfde URL cachet en bij "terug" als volledige pagina serveert →
-  // ongestylede HTML. Vary: HX-Request scheidt partial- en full-responses in de
-  // cache; no-store op de partial zelf laat "terug" altijd de volledige pagina
-  // opnieuw ophalen. (Vary geldt ook voor tussenliggende caches/Cloudflare.)
+  // Prevent the browser from caching an htmx PARTIAL (only #pcms-main, without <head>/CSS)
+  // under the same URL and serving it as a full page on "back" → unstyled HTML.
+  // Vary: HX-Request separates partial and full responses in the cache;
+  // no-store on the partial forces "back" to always re-fetch the full page.
+  // (Vary also applies to intermediate caches / Cloudflare.)
   res.setHeader('Vary', 'HX-Request');
   if (isPartial) res.setHeader('Cache-Control', 'no-store');
 
-  // Bezit deze (niet-god) user een eigen site? Bepaalt of 'ie een "Beheer"-
-  // ingang ziet (artiest-zelfbeheer). god ziet beheer sowieso (op rol).
+  // Does this (non-god) user own a site? Determines whether they see an "Admin"
+  // entry (artist self-manage). god always sees admin (by role).
   let _u = req.session?.user || null;
-  // Ververs avatar + rol uit de DB zodat een stale sessie (bv. na een
-  // avatar-wijziging of rolwissel) zichzelf herstelt zonder opnieuw inloggen.
+  // Refresh avatar + role from the DB so a stale session (e.g. after an
+  // avatar change or role switch) heals itself without a new login.
   if (_u && _u.id) {
     const _fresh = db.prepare('SELECT avatar_url, role, lang FROM users WHERE id = ?').get(_u.id);
@@ -78,6 +77,6 @@
     db.prepare('SELECT 1 FROM sites WHERE owner_id = ? LIMIT 1').get(_u.id));
 
-  // De avatar van de SITE-EIGENAAR (niet de kijker!) — voor de Klonkt-site-kop,
-  // zodat de artiest z'n eigen account-foto als sitefoto kan gebruiken.
+  // The avatar of the SITE OWNER (not the viewer!) — for the Klonkt site header,
+  // so the artist can use their own account photo as the site photo.
   const _site = data.site || res.locals.site || null;
   const siteOwnerAvatar = (_site && _site.owner_id)
@@ -85,17 +84,17 @@
     : null;
 
-  // Kijker-modus: alles bekijken mag, niets wijzigen. Views gebruiken canMutate
-  // om schrijf-knoppen (posten, opslaan, verwijderen) te verbergen/uit te zetten.
+  // Viewer mode: may view everything, change nothing. Views use canMutate
+  // to hide/disable write buttons (post, save, delete).
   const _isViewer = isViewer(_u);
 
-  // Wie ziet de "Beheer"-link? god/admin, een site-eigenaar (artiest-zelfbeheer),
-  // én een kijker (mag het Beheer alleen-lezen inzien). Eén bron van waarheid,
-  // gespiegeld in topnav/hub-nav/profielsheet — anders raakt de link verborgen
-  // voor wie 'm wél mag zien (kijker zag 'm eerst nergens).
+  // Who sees the "Admin" link? god/admin, a site owner (artist self-manage),
+  // and a viewer (may view Admin read-only). One source of truth,
+  // mirrored in topnav/hub-nav/profile sheet — otherwise the link gets hidden
+  // for those who should see it (viewer didn't see it anywhere before).
   const _role = _u ? _u.role : null;
   const canSeeBeheer = !!(_u && (_role === 'god' || _role === 'admin' || _role === 'kijker' || userOwnsSite));
 
-  // Interface-taal: sessie-keuze (deze sessie) → eigen voorkeur van de ingelogde
-  // gebruiker (users.lang) → admin-ingestelde standaard (Beheer) → env → browser → nl.
+  // Interface language: session choice (this session) → logged-in user's own preference
+  // (users.lang) → admin-set default (Admin → Settings) → env → browser → nl.
   const _lang = resolveLang(req, {
     userLang: _u && _u.lang,
@@ -125,6 +124,6 @@
     tenancy: res.locals.tenancy || 'solo',
     hubTitle: getSetting('hub_title') || '',
-    footerNewsletter: getSetting('footer_newsletter') === '1', // nieuwsbrief-aanmelding in footer (premium)
-    agendaEnabled: getSetting('agenda_enabled') === '1', // Agenda/evenementen tonen in de pill (premium, opt-in)
+    footerNewsletter: getSetting('footer_newsletter') === '1', // newsletter sign-up in footer (premium)
+    agendaEnabled: getSetting('agenda_enabled') === '1', // show agenda/events in the pill (premium, opt-in)
     platforms_catalog: PLATFORMS_CATALOG,
     permissions: PermissionsService,
@@ -153,8 +152,8 @@
       // looks "unclickable". Escape any non-ASCII to \uXXXX: the header stays
       // ASCII-safe and remains valid JSON that htmx parses back unchanged.
-      // Per-site accent + palette zitten in de shell-<head> (style#pcms-site-accent
-      // + html[data-palette]) en worden NIET mee-geswapt bij htmx-nav. Stuur ze mee
-      // zodat de client ze bijwerkt — anders erft een artiest de kleuren van de
-      // vorige pagina (bv. hub-paars i.p.v. eigen groen). Zelfde afleiding als shell.ejs.
+      // Per-site accent + palette live in the shell <head> (style#pcms-site-accent
+      // + html[data-palette]) and are NOT swapped during htmx navigation. Send them along
+      // so the client updates them — otherwise an artist inherits the previous page's
+      // colours (e.g. hub-purple instead of their own green). Same derivation as shell.ejs.
       const _navAccent = (_site && _site.accent && /^#[0-9a-fA-F]{6}$/.test(_site.accent))
         ? _site.accent : '#e8b04b';
@@ -169,9 +168,9 @@
       }).replace(/[-￿]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
       res.setHeader('HX-Trigger-After-Settle', triggerJson);
-      // Site-chrome out-of-band mee-renderen, zodat de kop (topnav/profielkop/
-      // view-switcher) bij navigatie ALTIJD bij de nieuwe pagina/artiest hoort —
-      // terwijl de audioplayer (los in document.body) blijft leven (geen
-      // verspringen). htmx vervangt #pcms-chrome via hx-swap-oob. Niet kritisch:
-      // faalt 't, dan blijft de oude chrome staan (geen crash).
+      // Render the site chrome out-of-band so the header (topnav/profile header/
+      // view-switcher) ALWAYS matches the new page/artist on navigation —
+      // while the audio player (separate in document.body) keeps playing (no
+      // interruption). htmx replaces #pcms-chrome via hx-swap-oob. Non-critical:
+      // if it fails, the old chrome remains (no crash).
       let oobChrome = '';
       try {
@@ -181,5 +180,5 @@
           { async: false },
         );
-      } catch (e) { /* chrome-OOB overslaan */ }
+      } catch (e) { /* skip chrome OOB */ }
       return res.send(pageContent + oobChrome);
     }
Index: src/middleware/site.js
===================================================================
--- src/middleware/site.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/middleware/site.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -15,8 +15,8 @@
 
 /**
- * De primaire/hoofd-site — ÉÉN bron van waarheid (vervangt de "oudste site ="
- * hoofd"-aanname die voorheen los in resolveSite/hub/account/admin stond).
- * Leest de expliciete is_primary-vlag; valt terug op de oudste als die (nog)
- * nergens staat, zodat bestaand gedrag exact behouden blijft.
+ * The primary/main site — ONE source of truth (replaces the "oldest site ="
+ * main" assumption that was previously scattered across resolveSite/hub/account/admin).
+ * Reads the explicit is_primary flag; falls back to the oldest if it isn't set
+ * anywhere yet, so existing behaviour is preserved exactly.
  */
 export function getPrimarySite() {
@@ -28,12 +28,12 @@
 export function resolveSite(req, res, next) {
   const tenancy = getTenancy();
-  res.locals.tenancy = tenancy; // ook beschikbaar voor views
+  res.locals.tenancy = tenancy; // also available in views
 
-  // In HUB-mode mapt /user/:slug naar een specifieke site. In SOLO-mode bestaat
-  // er maar één site: we slaan die routing over en pinnen op de primaire site.
+  // In HUB mode /user/:slug maps to a specific site. In SOLO mode there is only
+  // one site: we skip that routing and pin to the primary site.
   if (tenancy === 'hub') {
-    // Een Klonkt-site is canoniek bereikbaar via /user/:slug. /sites/:slug is een
-    // legacy-alias → 301 naar de canonieke vorm zodat er één URL-schema overblijft
-    // (behoudt pad + querystring; raakt /admin/sites NIET, dat begint met /admin/).
+    // A Klonkt site is canonically reachable via /user/:slug. /sites/:slug is a
+    // legacy alias → 301 to the canonical form so one URL scheme remains
+    // (preserves path + query string; does NOT touch /admin/sites, which starts with /admin/).
     const m = req.path.match(/^\/(sites|user)\/([a-zA-Z0-9_-]+)(\/.*)?$/);
     if (m) {
@@ -49,10 +49,10 @@
       }
     }
-    // (Verwijderd: een dode "slug == hostname"-subdomein-hack. Slugs mogen geen
-    // punten bevatten, dus die kon nooit matchen. Echte subdomein-routing zou de
-    // subdomein-LABEL tegen de slug matchen — een aparte feature, niet dit.)
+    // (Removed: a dead "slug == hostname" subdomain hack. Slugs may not contain
+    // dots, so it could never match. Real subdomain routing would match the
+    // subdomain LABEL against the slug — a separate feature, not this.)
   }
 
-  // Solo (of hub zonder match): pin op de primaire/hoofd-site.
+  // Solo (or hub without a match): pin to the primary/main site.
   const defaultSite = getPrimarySite();
   if (defaultSite) {
@@ -79,7 +79,7 @@
 
   try {
-    // m.filename = de kale bestandsnaam; de speelbare URL is de gated stream-route
-    // (audioUrl). De media-tabel heeft GEEN url-kolom — de oude query selecteerde
-    // m.url en faalde dus altijd stil (lege speler). Nu bouwen we de URL uit filename.
+    // m.filename = the bare filename; the playable URL is the gated stream route
+    // (audioUrl). The media table has NO url column — the old query selected
+    // m.url and always failed silently (empty player). Now we build the URL from filename.
     const rows = db.prepare(`
       SELECT t.id, t.title, t.artist, t.duration, t.position, m.filename
Index: src/routes/account.js
===================================================================
--- src/routes/account.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/account.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -66,5 +66,5 @@
   const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth');
   const googleLinked = !!(account && account.google_sub);
-  if (account) { delete account.password_hash; delete account.google_sub; } // niet naar de view lekken
+  if (account) { delete account.password_hash; delete account.google_sub; } // don't leak to the view
 
   renderPage(req, res, 'pages/account', {
@@ -81,7 +81,7 @@
 });
 
-// ==================== PERSOONLIJKE INTERFACE-TAAL ====================
-// Slaat de taalkeuze op het account op (reist mee over apparaten/sessies) én
-// zet 'm meteen in de sessie zodat 't direct effect heeft.
+// ==================== PERSONAL INTERFACE LANGUAGE ====================
+// Saves the language choice on the account (persists across devices/sessions) and
+// also sets it in the session immediately so it takes effect right away.
 router.post('/lang', requireAuth, (req, res) => {
   const code = SUPPORTED.includes(req.body.lang) ? req.body.lang : null;
@@ -94,11 +94,11 @@
 });
 
-// De site die deze gebruiker mag bewerken vanuit z'n account: z'n eigen site
-// (owner_id), of voor een god de primaire site. Null als er niets is.
+// The site this user may edit from their account: their own site
+// (owner_id), or for a god the primary site. Null if nothing found.
 function ownedSite(user) {
   if (!user) return null;
   let site = db.prepare('SELECT id, title, tagline, slug, owner_id FROM sites WHERE owner_id = ? ORDER BY created_at LIMIT 1').get(user.id);
   if (!site && user.role === 'god') {
-    site = getPrimarySite(); // primaire/hoofd-site als fallback
+    site = getPrimarySite(); // primary/main site as fallback
   }
   return site || null;
@@ -124,6 +124,6 @@
   const bio = (req.body.bio || '').toString().slice(0, 500).trim();
 
-  // E-mail (optioneel mee te wijzigen). Validatie: geldig formaat + niet al door
-  // een ander account in gebruik. E-mail is het login-/reset-anker, dus uniek.
+  // Email (optionally also changed). Validation: valid format + not already in use
+  // by another account. Email is the login/reset anchor, so it must be unique.
   const email = (req.body.email || '').toString().trim();
   if (email) {
@@ -138,5 +138,5 @@
     db.prepare('UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
       .run(email, req.session.user.id);
-    req.session.user.email = email; // sessie bijwerken zodat de UI klopt
+    req.session.user.email = email; // update session so the UI reflects the change
   }
 
@@ -166,5 +166,5 @@
 
   const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
-  // Google-only accounts (luisteraars) hebben geen echt wachtwoord.
+  // Google-only accounts (listeners) have no real password.
   if (!row || !row.password_hash || row.password_hash === '!google-oauth') {
     return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)'));
@@ -181,6 +181,6 @@
 });
 
-// Google-account ontkoppelen. Alleen toegestaan als er nog een wachtwoord is,
-// anders zou je jezelf buitensluiten (geen login-methode meer over).
+// Unlink Google account. Only allowed if a password is set,
+// otherwise the user would lock themselves out (no login method left).
 router.post('/google/unlink', requireAuth, (req, res) => {
   const row = db.prepare('SELECT password_hash, google_sub FROM users WHERE id = ?').get(req.session.user.id);
Index: src/routes/admin-audio.js
===================================================================
--- src/routes/admin-audio.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin-audio.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -38,10 +38,10 @@
 const ALLOWED_AUDIO_EXT = new Set(['.mp3', '.m4a', '.mp4', '.aac', '.oga', '.ogg', '.opus', '.flac', '.wav', '.webm']);
 const ALLOWED_COVER_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
-const MAX_AUDIO_BYTES = 50 * 1024 * 1024;   // 50 MB — gecomprimeerde formaten (mp3/m4a/ogg/…)
-const MAX_WAV_BYTES   = 100 * 1024 * 1024;  // 100 MB — WAV is ongecomprimeerd, dus ruimer
+const MAX_AUDIO_BYTES = 50 * 1024 * 1024;   // 50 MB — compressed formats (mp3/m4a/ogg/…)
+const MAX_WAV_BYTES   = 100 * 1024 * 1024;  // 100 MB — WAV is uncompressed, so a higher limit
 const MAX_COVER_BYTES = 5 * 1024 * 1024;    // 5 MB
 
-// Per-bestand bovengrens op basis van extensie. multer's globale limiet is de
-// hoogste (WAV); de echte controle per type gebeurt in de upload-handler.
+// Per-file upper limit based on extension. multer's global limit is the
+// highest (WAV); the real per-type check happens in the upload handler.
 const audioByteLimitFor = (ext) => (ext.toLowerCase() === '.wav' ? MAX_WAV_BYTES : MAX_AUDIO_BYTES);
 
@@ -59,5 +59,5 @@
 const upload = multer({
   storage,
-  limits: { fileSize: MAX_WAV_BYTES }, // hoogste bovengrens (WAV) — per-type check in de handler
+  limits: { fileSize: MAX_WAV_BYTES }, // highest upper bound (WAV) — per-type check in the handler
   fileFilter: (req, file, cb) => {
     const ext = path.extname(file.originalname).toLowerCase();
@@ -73,6 +73,6 @@
 const router = express.Router();
 
-// "Open in"-platformlinks per track: alleen https + de juiste host accepteren
-// (href komt ongeescaped in de view → scheme/host-guard tegen misbruik).
+// "Open in" platform links per track: only https + the correct host accepted
+// (href arrives unescaped in the view → scheme/host guard against abuse).
 const LINK_DOMAINS = {
   spotify: ['spotify.com'],
@@ -86,5 +86,5 @@
     const h = new URL(u).hostname.toLowerCase();
     if (domains.some((d) => h === d || h.endsWith('.' + d))) return u;
-  } catch (e) { /* ongeldige URL */ }
+  } catch (e) { /* invalid URL */ }
   return null;
 }
@@ -148,6 +148,6 @@
     }
 
-    // Per-type audio size check. multer's globale limiet was de WAV-bovengrens
-    // (100MB); gecomprimeerde formaten blijven op 50MB.
+    // Per-type audio size check. multer's global limit was the WAV upper bound
+    // (100MB); compressed formats stay at 50MB.
     const audioExt = path.extname(audioFile.originalname).toLowerCase();
     const audioLimit = audioByteLimitFor(audioExt);
@@ -185,6 +185,6 @@
     const finalArtist = artist?.trim() || null;
     const finalAlbum  = album?.trim() || null;
-    // Eigenaarschap/licentie. credit valt terug op de artiest; deze gaan zowel de
-    // DB in als de ID3-tags van de mp3 (copyright + comment).
+    // Ownership/licence. credit falls back to the artist; these go both into the
+    // DB and into the ID3 tags of the mp3 (copyright + comment).
     const finalCredit  = (req.body.credit  || '').trim() || finalArtist || null;
     const finalLicense = (req.body.license || '').trim() || null;
@@ -231,7 +231,7 @@
       `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
 
-      // Duur automatisch: primair uit de transcode (ffmpeg codecData), anders een
-      // optionele client-side waarde (bulk-uploader leest <audio>.duration uit),
-      // anders NULL (UI toont dan '—:—', handmatig bij te werken in de editor).
+      // Duration automatically: primarily from the transcode (ffmpeg codecData), then
+      // an optional client-side value (bulk uploader reads <audio>.duration),
+      // otherwise NULL (UI then shows '—:—', editable manually in the editor).
       const clientDur = req.body.duration != null ? parseInt(req.body.duration, 10) : NaN;
       const finalDuration =
@@ -275,6 +275,6 @@
 });
 
-// Download-voor-email per track aan/uit (premium #2). Zonder-JS toggle vanaf de
-// audio-beheerlijst → flip + terug.
+// Download-for-email per track on/off (premium #2). No-JS toggle from the
+// audio admin list → flip + back.
 router.post('/:id/downloadable', requireGod, (req, res) => {
   const site = res.locals.site;
@@ -394,6 +394,6 @@
 
 /** GET /admin/audio/api/:id — single track with all metadata */
-// Maak een track ZONDER audiobestand (alleen titel + open-in links). Verschijnt
-// in albums/playlists in de lijst, met open-in-iconen maar zonder afspeelknop.
+// Create a track WITHOUT an audio file (title + open-in links only). Appears
+// in albums/playlists in the list, with open-in icons but no play button.
 router.post('/create-link', requireGod, express.json(), (req, res) => {
   const site = res.locals.site;
@@ -507,6 +507,6 @@
   }
 
-  // Verse rij + (als tag-velden wijzigden) de mp3 her-taggen, zodat de eigenaar/
-  // licentie ook IN het bestand staat (ID3) en meereist bij een download.
+  // Fresh row + (if tag fields changed) retag the mp3, so that the owner/
+  // licence is also IN the file (ID3) and travels with it on download.
   const fresh = db.prepare(`
     SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.credit, t.license, m.storage_path
@@ -527,5 +527,5 @@
       } });
     } catch (e) {
-      console.warn('[admin-audio] ID3 her-taggen mislukt (DB is wel bijgewerkt):', e.message);
+      console.warn('[admin-audio] ID3 retag failed (DB was still updated):', e.message);
     }
   }
Index: src/routes/admin-circle.js
===================================================================
--- src/routes/admin-circle.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin-circle.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,11 +1,11 @@
 /**
- * Admin: Cirkel-beheer (god-only).
- *   GET  /admin/circle           -> lijst van cirkel-links + status
- *   POST /admin/circle/add       -> Klonkt-URL toevoegen
+ * Admin: Circle management (god-only).
+ *   GET  /admin/circle           -> list of circle links + status
+ *   POST /admin/circle/add       -> add a Klonkt URL
  *   POST /admin/circle/:id/remove
- *   POST /admin/circle/:id/sync  -> nu verversen (pull + verifieer)
- *   POST /admin/circle/allow     -> toggle "mag in cirkels van anderen verschijnen"
+ *   POST /admin/circle/:id/sync  -> refresh now (pull + verify)
+ *   POST /admin/circle/allow     -> toggle "may appear in others' circles"
  *
- * Zie docs/cirkels-v1-spec.md §5d.
+ * See docs/cirkels-v1-spec.md §5d.
  */
 
@@ -51,7 +51,7 @@
   const site = primarySite();
   if (!site) return res.redirect('/admin/circle?error=' + encodeURIComponent('Geen site gevonden'));
-  // Schema automatisch aanvullen: een kale domeinnaam → https://, een getypte
-  // http:// → https:// (federatie is bewust https-only, getekende feeds). Zo hoeft
-  // de gebruiker nooit zelf http(s):// te typen.
+  // Auto-complete the scheme: a bare domain name → https://, a typed
+  // http:// → https:// (federation is intentionally https-only, signed feeds). So the
+  // user never has to type http(s):// themselves.
   let url = (req.body.remote_url || '').toString().trim().replace(/\/+$/, '');
   if (url && !/^[a-z]+:\/\//i.test(url)) url = 'https://' + url;
@@ -68,5 +68,5 @@
     return res.redirect('/admin/circle?error=' + encodeURIComponent('Deze site staat al in je cirkel'));
   }
-  // Meteen ophalen i.p.v. wachten op de 15-min-loop.
+  // Fetch immediately instead of waiting for the 15-minute loop.
   try {
     const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(id);
@@ -75,12 +75,12 @@
   } catch (e) {
     const msg = String((e && e.message) || e);
-    // 404 = geen cirkel-endpoint. Hubs federeren bewust NIET (hun /.klonkt/actor.json
-    // geeft 404), net als losse niet-Klonkt-sites. Niet toevoegen: rol de insert terug
-    // zodat er geen dode "fout"-rij in de cirkel blijft staan.
+    // 404 = no circle endpoint. Hubs intentionally do NOT federate (their /.klonkt/actor.json
+    // returns 404), same as standalone non-Klonkt sites. Don't add: roll back the insert
+    // so no dead "error" row is left in the circle.
     if (/\b404\b/.test(msg)) {
       db.prepare('DELETE FROM circle_links WHERE id = ?').run(id);
       return res.redirect('/admin/circle?error=' + encodeURIComponent('Niet toegevoegd: deze site doet niet mee aan cirkels. Een hub kan geen cirkel-partner zijn (en losse/niet-Klonkt-sites ook niet).'));
     }
-    // Andere (mogelijk tijdelijke) fout → link blijft staan; later "Verversen".
+    // Other (possibly temporary) error → link stays; use "Refresh" later to retry.
     return res.redirect('/admin/circle?success=' + encodeURIComponent('Toegevoegd — synchroniseren mislukte (klik "Verversen" om opnieuw te proberen)'));
   }
@@ -91,5 +91,5 @@
   if (link) {
     db.prepare('DELETE FROM circle_links WHERE id = ?').run(link.id);
-    // Gecachte content opruimen als geen andere link nog naar deze actor wijst.
+    // Clean up cached content if no other link still points to this actor.
     if (link.remote_actor_id) {
       const other = db.prepare('SELECT 1 FROM circle_links WHERE remote_actor_id = ? LIMIT 1').get(link.remote_actor_id);
@@ -117,5 +117,5 @@
 });
 
-// Alles in één keer verversen (handig "voor de zekerheid").
+// Refresh everything at once (handy "just to be sure").
 router.post('/sync-all', requireGod, async (req, res) => {
   try {
Index: src/routes/admin-comments.js
===================================================================
--- src/routes/admin-comments.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin-comments.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -61,5 +61,5 @@
   const site = res.locals.site;
   if (!site) return res.status(404).send('No site');
-  const base = res.locals.siteUrlBase || ''; // /user/<slug> in hub-artiestcontext, anders ''
+  const base = res.locals.siteUrlBase || ''; // /user/<slug> in hub artist context, otherwise ''
 
   const row = db.prepare(`
Index: src/routes/admin-epk.js
===================================================================
--- src/routes/admin-epk.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin-epk.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,10 +1,10 @@
 /**
- * Admin: Perskit (EPK) bewerken — per-site bio + pers-contact.
+ * Admin: Edit press kit (EPK) — per-site bio + press contact.
  *
- * GET  /admin/epk   -> formulier met huidige bio + contact
- * POST /admin/epk   -> opslaan (app_settings: epk_bio_<siteId> / epk_contact_<siteId>)
+ * GET  /admin/epk   -> form with current bio + contact
+ * POST /admin/epk   -> save (app_settings: epk_bio_<siteId> / epk_contact_<siteId>)
  *
- * De perskit-pagina zelf (/pers) leest deze waarden; tracks + recente posts komen
- * automatisch. Perskit is premium + solo (zie routes/epk.js).
+ * The press kit page itself (/pers) reads these values; tracks + recent posts come
+ * automatically. Press kit is premium + solo (see routes/epk.js).
  */
 
@@ -49,5 +49,5 @@
   setSetting('epk_bio_' + site.id, (req.body.epk_bio || '').toString().slice(0, 1000).trim());
   setSetting('epk_contact_' + site.id, (req.body.epk_contact || '').toString().slice(0, 300).trim());
-  // Gekozen nummers: alleen ids van DEZE site, max 5, in de aangeleverde volgorde.
+  // Chosen tracks: only ids belonging to THIS site, max 5, in the supplied order.
   let ids = req.body.epk_tracks;
   if (!Array.isArray(ids)) ids = ids ? [ids] : [];
Index: src/routes/admin-newsletter.js
===================================================================
--- src/routes/admin-newsletter.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin-newsletter.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,10 +1,10 @@
 /**
- * Nieuwsbrief — beheerkant (premium feature #1).
+ * Newsletter — admin side (premium feature #1).
  *
- *   GET  /admin/newsletter        -> opstellen + abonnee-aantallen + historie
- *   POST /admin/newsletter/send   -> verstuur naar alle BEVESTIGDE abonnees (SMTP)
+ *   GET  /admin/newsletter        -> compose + subscriber counts + history
+ *   POST /admin/newsletter/send   -> send to all CONFIRMED subscribers (SMTP)
  *
- * Premium-gated + site-beheerder. Versturen vereist ingestelde SMTP; zonder SMTP
- * worden aanmeldingen wél verzameld (single opt-in), alleen versturen kan dan niet.
+ * Premium-gated + site manager. Sending requires configured SMTP; without SMTP
+ * sign-ups are still collected (single opt-in), only sending is unavailable.
  */
 
@@ -86,5 +86,5 @@
       });
       sent++;
-    } catch (e) { /* sla deze ontvanger over, ga door */ }
+    } catch (e) { /* skip this recipient, continue */ }
   }
   db.prepare('INSERT INTO newsletters (id, site_id, subject, body, recipient_count) VALUES (?,?,?,?,?)')
Index: src/routes/admin-patreon.js
===================================================================
--- src/routes/admin-patreon.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin-patreon.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,13 +1,13 @@
 /**
- * Admin: Patreon koppelen voor de premium-laag (god-only).
+ * Admin: Link Patreon for the premium layer (god-only).
  *
- * GET /admin/patreon/connect    -> stuur de beheerder naar de license-server
- *                                  (oauth/start) met onze callback als return.
- * GET /admin/patreon/callback   -> license-server keert terug met ?klonkt_token
- *                                  (of ?klonkt_error). Verifieer + sla op.
- * GET /admin/patreon/disconnect -> entitlement wissen.
+ * GET /admin/patreon/connect    -> redirect the admin to the license server
+ *                                  (oauth/start) with our callback as return URL.
+ * GET /admin/patreon/callback   -> license server returns with ?klonkt_token
+ *                                  (or ?klonkt_error). Verify + store.
+ * GET /admin/patreon/disconnect -> clear entitlement.
  *
- * Het echte verdienmodel-slot zit in het ondertekende token (alleen de
- * license-server kan tekenen). Zie PatreonService.js.
+ * The real monetisation lock is in the signed token (only the
+ * license server can sign). See PatreonService.js.
  */
 
Index: src/routes/admin-seo.js
===================================================================
--- src/routes/admin-seo.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin-seo.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,15 +1,15 @@
 /**
- * Admin: geavanceerde SEO-instellingen van de primaire site.
+ * Admin: advanced SEO settings for the primary site.
  *
- * GET  /admin/seo   -> formulier met alle SEO/social-velden van de hoofdsite
- * POST /admin/seo   -> opslaan (god-only)
+ * GET  /admin/seo   -> form with all SEO/social fields for the main site
+ * POST /admin/seo   -> save (god-only)
  *
- * Deze velden worden al door de <head> (shell.ejs) en de JSON-LD/OpenGraph-
- * tags geconsumeerd, maar waren tot nu toe nergens te bewerken. De basis-
- * velden (titel/bio/robots) blijven in Uiterlijk; dit is de geavanceerde laag:
- * titel-sjabloon, canonical, social-share-afbeelding, verificatie-metas,
- * publisher/JSON-LD en OpenGraph-locale.
+ * These fields are already consumed by the <head> (shell.ejs) and the JSON-LD/
+ * OpenGraph tags, but were previously not editable anywhere. The basic
+ * fields (title/bio/robots) remain in Appearance; this is the advanced layer:
+ * title template, canonical, social share image, verification metas,
+ * publisher/JSON-LD and OpenGraph locale.
  *
- * Werkt op de PRIMAIRE site (solo = de enige site; hub = de bedrijfssite).
+ * Operates on the PRIMARY site (solo = the only site; hub = the company site).
  */
 
Index: src/routes/admin-settings.js
===================================================================
--- src/routes/admin-settings.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin-settings.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,14 +1,14 @@
 /**
- * Admin: globale instellingen.
- *  - tenancy-modus (Solo/Hub)
- *  - hub-branding (naam/tagline/intro/hero van de generieke hub-hoofdpagina)
+ * Admin: global settings.
+ *  - tenancy mode (Solo/Hub)
+ *  - hub branding (name/tagline/intro/hero of the generic hub home page)
  *
- * GET  /admin/settings   -> toon huidige instellingen
- * POST /admin/settings   -> sla op (god-only). Accepteert nu ook een geuploade
- *                           hero-afbeelding (multipart); een upload wint van het
- *                           URL-tekstveld. Zonder upload blijft het URL-veld leidend.
+ * GET  /admin/settings   -> show current settings
+ * POST /admin/settings   -> save (god-only). Also accepts an uploaded
+ *                           hero image (multipart); an upload wins over the
+ *                           URL text field. Without an upload the URL field is leading.
  *
- * De hub-pagina is generiek (van geen enkele user); deze branding leeft in
- * globale settings, niet in een site.
+ * The hub page is generic (belonging to no user); this branding lives in
+ * global settings, not in a site.
  */
 
@@ -30,6 +30,6 @@
 const router = express.Router();
 
-// Hero dark-overlay: percentage 0-100 (0 = geen overlay, 100 = volledig zwart).
-// Default 45 = de oude hardgecodeerde waarde, zodat bestaande hubs niet wijzigen.
+// Hero dark overlay: percentage 0-100 (0 = no overlay, 100 = fully black).
+// Default 45 = the old hard-coded value, so existing hubs don't change appearance.
 function clampOverlay(raw) {
   const v = parseInt(raw, 10);
@@ -38,6 +38,6 @@
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
-// Hero-uploads landen in storage/media/hero → bereikbaar als /media/hero/<file>
-// (de /media static handler serveert storage/media). Zelfde model als avatars.
+// Hero uploads land in storage/media/hero → accessible as /media/hero/<file>
+// (the /media static handler serves storage/media). Same model as avatars.
 const HERO_DIR = path.resolve(
   process.env.HERO_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'hero')
@@ -45,7 +45,7 @@
 fs.mkdirSync(HERO_DIR, { recursive: true });
 
-// Alleen raster-formaten voor de upload. SVG mag bewust NIET via upload (raw
-// SVG kan script bevatten → opgeslagen-XSS bij direct openen); een SVG-hero kan
-// nog steeds via het URL-veld (zoals de meegeleverde demo-placeholder).
+// Only raster formats for upload. SVG is intentionally NOT allowed via upload
+// (raw SVG can contain scripts → stored-XSS when opened directly); an SVG hero
+// can still be set via the URL field (like the bundled demo placeholder).
 const ALLOWED_HERO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
 const MAX_HERO_BYTES = 5 * 1024 * 1024;
@@ -95,6 +95,6 @@
 
 router.post('/', requireGod, (req, res) => {
-  // multer.single verwerkt multipart (hub-branding form). Bij een gewone
-  // urlencoded POST (tenancy-form) doet multer niets en blijft req.body intact.
+  // multer.single processes multipart (hub branding form). For a plain
+  // urlencoded POST (tenancy form) multer does nothing and req.body stays intact.
   heroUpload.single('hub_hero_file')(req, res, (err) => {
     if (err) {
@@ -103,7 +103,7 @@
 
     if (typeof req.body.tenancy !== 'undefined') {
-      // Hub-modus is een premium-feature: alleen naar hub schakelen als premium
-      // ontgrendeld is (premium-laag uit = vrij; aan = Patreon vereist). Al-hub
-      // blijven mag altijd, zodat een instance nooit vastloopt.
+      // Hub mode is a premium feature: only switch to hub if premium is
+      // unlocked (premium layer off = free; on = Patreon required). Staying on
+      // hub is always allowed, so an instance can never get stuck.
       if (req.body.tenancy === 'hub' && !premiumUnlocked() && getTenancy() !== 'hub') {
         return res.redirect('/admin/settings?error=' + encodeURIComponent('Hub-modus is een premium-functie — koppel Patreon in Beheer → Instellingen.'));
@@ -112,11 +112,11 @@
     }
     if (typeof req.body.default_lang !== 'undefined') {
-      // Standaardtaal voor bezoekers (leeg = volg env/browser). Valideert tegen NL/EN/DE.
+      // Default language for visitors (empty = follow env/browser). Validated against NL/EN/DE.
       const dl = (req.body.default_lang || '').toString().toLowerCase();
       setSetting('default_lang', SUPPORTED.includes(dl) ? dl : '');
     }
     if (typeof req.body.timezone !== 'undefined') {
-      // Site-tijdzone (IANA, bv. Europe/Amsterdam). Leeg = server-default (UTC).
-      // Valideer met Intl zodat een onzin-waarde nooit de datum-rendering breekt.
+      // Site timezone (IANA, e.g. Europe/Amsterdam). Empty = server default (UTC).
+      // Validate with Intl so a nonsense value never breaks date rendering.
       const tz = (req.body.timezone || '').toString().trim();
       let valid = '';
@@ -134,8 +134,8 @@
     }
 
-    // Hero: een geüploade afbeelding wint; anders het URL-tekstveld.
+    // Hero: an uploaded image wins; otherwise the URL text field.
     if (req.file) {
       const newUrl = `/media/hero/${toWebp(req.file)}`;
-      // Ruim een vorige geüploade hero op (alleen als die uit onze hero-map kwam).
+      // Clean up a previously uploaded hero (only if it came from our hero dir).
       const old = getSetting('hub_hero_image') || '';
       if (old.startsWith('/media/hero/')) {
@@ -155,5 +155,5 @@
 });
 
-// Google-login op een eigen Beheer-pagina (los van de algemene instellingen).
+// Google login on its own admin page (separate from the general settings).
 router.get('/google', requireGod, (req, res) => {
   renderPage(req, res, 'pages/admin-google', {
@@ -171,6 +171,6 @@
 });
 
-// Google-login (luisteraars) configureren — Client ID + Secret in app_settings.
-// De redirect-URI leiden we af van PUBLIC_BASE_URL (zie config/google.js).
+// Configure Google login (listeners) — Client ID + Secret in app_settings.
+// The redirect URI is derived from PUBLIC_BASE_URL (see config/google.js).
 router.post('/google', requireGod, (req, res) => {
   if (req.body.clear === '1') {
@@ -180,5 +180,5 @@
   }
   setSetting('google_client_id', (req.body.google_client_id || '').toString().trim());
-  // Secret alleen overschrijven als er een nieuwe waarde is ingevoerd (leeg = laat staan).
+  // Only overwrite the secret if a new value was entered (empty = leave as-is).
   const secret = (req.body.google_client_secret || '').toString().trim();
   if (secret) setSetting('google_client_secret', secret);
@@ -197,5 +197,5 @@
   setSetting('smtp_user', (b.smtp_user || '').toString().trim());
   setSetting('smtp_from', (b.smtp_from || '').toString().trim());
-  // Wachtwoord alleen overschrijven als er een nieuwe waarde is ingevoerd.
+  // Only overwrite the password if a new value was entered.
   const pass = (b.smtp_pass || '').toString();
   if (pass) setSetting('smtp_pass', pass);
@@ -203,5 +203,5 @@
 });
 
-// Nieuwsbrief-aanmelding in de footer aan/uit.
+// Newsletter sign-up in the footer on/off.
 router.post('/footer', requireGod, (req, res) => {
   setSetting('footer_newsletter', req.body.footer_newsletter ? '1' : '0');
@@ -209,5 +209,5 @@
 });
 
-// Testmail sturen naar een opgegeven adres (of de ingelogde gebruiker).
+// Send a test email to a specified address (or the logged-in user).
 router.post('/smtp/test', requireGod, async (req, res) => {
   const to = ((req.body && req.body.to) || (req.session.user && req.session.user.email) || '').toString().trim();
Index: src/routes/admin-shows.js
===================================================================
--- src/routes/admin-shows.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin-shows.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,11 +1,11 @@
 /**
- * Show-agenda (premium feature #8) — beheerkant.
+ * Show agenda (premium feature #8) — admin side.
  *
- *   GET  /admin/shows           -> lijst + toevoeg-formulier
- *   POST /admin/shows           -> show toevoegen (optioneel notify-mail naar abonnees)
+ *   GET  /admin/shows           -> list + add form
+ *   POST /admin/shows           -> add show (optional notify email to subscribers)
  *   POST /admin/shows/:id/delete
  *
- * Premium + site-beheerder. Notify-mail vereist SMTP; zonder SMTP wordt de show
- * gewoon opgeslagen (geen mail).
+ * Premium + site manager. Notify email requires SMTP; without SMTP the show is
+ * simply saved (no email sent).
  */
 
@@ -79,5 +79,5 @@
         });
         sent++;
-      } catch { /* sla over */ }
+      } catch { /* skip */ }
     }
   }
@@ -86,5 +86,5 @@
 
 router.post('/toggle', requireSiteManager, premiumGate, (req, res) => {
-  // Agenda tonen op de site (Agenda-knop in de pill + /shows-pagina).
+  // Show the agenda on the site (Agenda button in the pill + /shows page).
   setSetting('agenda_enabled', req.body.enabled ? '1' : '0');
   res.redirect((res.locals.siteUrlBase || '') + '/admin/shows');
Index: src/routes/admin-sites.js
===================================================================
--- src/routes/admin-sites.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin-sites.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -137,5 +137,5 @@
 }
 
-/** Geldige user-id voor owner-toewijzing, of null bij leeg/onbekend. */
+/** Valid user-id for owner assignment, or null if empty/unknown. */
 function validOwnerId(raw) {
   const id = (raw || '').toString().trim();
@@ -144,5 +144,5 @@
 }
 
-/** Geef een user admin-rechten op een site (idempotent upsert). */
+/** Grant a user admin rights on a site (idempotent upsert). */
 function grantSiteAdmin(siteId, userId) {
   db.prepare(`
@@ -152,5 +152,5 @@
 }
 
-/** Kandidaat-owners voor het owner-keuzeveld (god-only). */
+/** Candidate owners for the owner selector field (god-only). */
 function listOwnerCandidates() {
   return db.prepare('SELECT id, username, role FROM users ORDER BY username').all();
@@ -183,6 +183,6 @@
     bodyClass: 'on-admin',
     isNew: true,
-    // ?owner=<id> (vanaf de gebruikers-pagina: "geef deze user een Klonkt") wordt
-    // voorgeselecteerd; anders de aanmakende god.
+    // ?owner=<id> (from the users page: "give this user a Klonkt") is
+    // pre-selected; otherwise defaults to the creating god.
     site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() },
     users: listOwnerCandidates(),
@@ -211,7 +211,7 @@
   const f = { ...siteEditableFields(), ...req.body };
 
-  // Owner: god mag de site aan een ANDERE gebruiker toewijzen — dit is de kern
-  // van hub-modus (elke gebruiker z'n eigen, zelf te beheren Klonkt). Leeg of
-  // ongeldig → de aanmakende god zelf.
+  // Owner: god may assign the site to a DIFFERENT user — this is the core of
+  // hub mode (each user their own self-managed Klonkt). Empty or invalid → the
+  // creating god themselves.
   const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
 
@@ -242,6 +242,6 @@
   );
 
-  // De OWNER (niet per se de aanmaker) krijgt een site_members-admin-rij → zo komt
-  // 'ie door canAdminSite + de requireSiteManager-gates en beheert 'ie z'n site.
+  // The OWNER (not necessarily the creator) gets a site_members admin row → this
+  // lets them pass canAdminSite + requireSiteManager gates to manage their site.
   grantSiteAdmin(siteId, ownerId);
 
@@ -332,6 +332,6 @@
   );
 
-  // Owner (her)toewijzen — ALLEEN god. Een site-owner die z'n eigen site bewerkt
-  // kan de eigenaar niet wijzigen (het veld wordt voor niet-god ook niet getoond).
+  // (Re)assign owner — god ONLY. A site-owner editing their own site cannot
+  // change the owner (the field is not shown to non-god users either).
   if (req.session.user.role === 'god') {
     const newOwner = validOwnerId(req.body.owner_id);
@@ -345,7 +345,7 @@
 });
 
-// ==================== MAAK PRIMAIR ====================
-// God kiest welke site de primaire/hoofd-site is (de label-/bedrijfssite in hub;
-// in solo dé site). Precies één site is primair → eerst alles uit, dan deze aan.
+// ==================== MAKE PRIMARY ====================
+// God chooses which site is the primary/main site (the label/company site in hub;
+// in solo mode: the one site). Exactly one site is primary → clear all, then set this one.
 router.post('/:slug/make-primary', requireGod, (req, res) => {
   const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
Index: src/routes/admin-stats.js
===================================================================
--- src/routes/admin-stats.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin-stats.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,10 +1,10 @@
 /**
- * Admin: Statistieken (premium-module, god-only).
+ * Admin: Statistics (premium module, god-only).
  *
- * GET /admin/stats -> cookievrije statistieken: bezoekers/weergaven per dag,
- *                     plays, en de populairste posts/tracks.
+ * GET /admin/stats -> cookie-free statistics: visitors/views per day,
+ *                     plays, and the most popular posts/tracks.
  *
- * Premium-gated via premiumUnlocked() (premium-laag uit = gewoon beschikbaar;
- * aan = Patreon vereist). Tracking zit in StatsService (geen cookies).
+ * Premium-gated via premiumUnlocked() (premium layer off = freely available;
+ * on = Patreon required). Tracking is in StatsService (no cookies).
  */
 
@@ -22,5 +22,5 @@
     return res.status(403).send('Statistieken is een premium-functie — koppel Patreon in Beheer → Instellingen.');
   }
-  // Link-in-bio klikken (premium #6) voor de huidige site.
+  // Link-in-bio clicks (premium #6) for the current site.
   let linkClicks = [];
   if (res.locals.site) {
Index: src/routes/admin-updates.js
===================================================================
--- src/routes/admin-updates.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin-updates.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,12 +1,12 @@
 /**
  * Admin: Updates (god-only).
- * Git-gebaseerde v1 voor instances die via de bare-repo draaien.
- *   GET  /admin/updates      -> huidige vs. nieuwste versie + status
- *   POST /admin/updates/run  -> haal nieuwste main op + herstart (detached script)
+ * Git-based v1 for instances running from a bare repo.
+ *   GET  /admin/updates      -> current vs. latest version + status
+ *   POST /admin/updates/run  -> fetch latest main + restart (detached script)
  *
- * De instance kent z'n "huidige" commit uit .klonkt-version (door het script
- * geschreven) en de "nieuwste" uit de bare repo (KLONKT_GIT_DIR). Voor externe
- * self-hosters komt later een GESIGNEERDE release-feed (zie monetization-plan);
- * deze v1 is bewust simpel en alleen voor Robins eigen VPS-instances.
+ * The instance knows its "current" commit from .klonkt-version (written by the
+ * script) and the "latest" from the bare repo (KLONKT_GIT_DIR). For external
+ * self-hosters a SIGNED release feed will follow later (see monetization plan);
+ * this v1 is intentionally simple and only for Robin's own VPS instances.
  */
 
@@ -37,5 +37,5 @@
 }
 
-// Laatste 5 commits op main = de "laatste wijzigingen" die je bij bijwerken krijgt.
+// Last 5 commits on main = the "recent changes" you'll get when updating.
 function recentChanges() {
   const out = git(['log', '-5', '--format=%s%x1f%cd', '--date=short', 'main']);
@@ -72,5 +72,5 @@
   }
   try {
-    // Detached + losgekoppeld: overleeft de pm2-reload die deze app herstart.
+    // Detached + unlinked: survives the pm2-reload that restarts this app.
     const child = spawn('bash', [UPDATE_SCRIPT, process.cwd()], { detached: true, stdio: 'ignore' });
     child.unref();
Index: src/routes/admin-users.js
===================================================================
--- src/routes/admin-users.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin-users.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -20,7 +20,7 @@
 const router = express.Router();
 
-// 'kijker' = alleen-lezen demonstratie/audit-account: mag alles bekijken (incl.
-// Beheer), maar de globale guard blokkeert elke wijziging. Vervangt de oude
-// losse 'kijk-modus'-vlag (readonly), die nu door deze rol wordt afgedekt.
+// 'kijker' = read-only demo/audit account: may view everything (incl. admin panel),
+// but the global guard blocks all mutations. Replaces the old separate
+// 'kijk-modus' flag (readonly), which is now covered by this role.
 const VALID_ROLES = new Set(['kijker', 'member', 'admin', 'god']);
 
@@ -69,6 +69,6 @@
   }
 
-  // readonly=0: de alleen-lezen-status zit nu volledig in de 'kijker'-rol, dus
-  // bij elke rolwijziging ruimen we de legacy-vlag op (geen dubbele bron).
+  // readonly=0: read-only status now lives entirely in the 'kijker' role, so
+  // on every role change we clear the legacy flag (no dual source of truth).
   db.prepare('UPDATE users SET role = ?, readonly = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
     .run(newRole, userId);
@@ -89,7 +89,7 @@
   }
 
-  // Cascade-verwijderen: de sites van deze user (+ posts/playlists/audio/leden/
-  // comments daaronder), z'n eigen content elders, en daarna de user zelf.
-  // Atomisch in een transactie — faalt er een FK, dan rolt alles terug.
+  // Cascade delete: this user's sites (+ posts/playlists/audio/members/
+  // comments under them), their own content elsewhere, then the user themselves.
+  // Atomic in a transaction — if any FK fails, everything rolls back.
   const del = db.transaction(() => {
     const sites = db.prepare('SELECT id FROM sites WHERE owner_id = ?').all(userId).map((s) => s.id);
@@ -102,5 +102,5 @@
       db.prepare('DELETE FROM sites WHERE id = ?').run(sid);
     }
-    // Eigen content op andere sites + losse koppelingen.
+    // Own content on other sites + loose associations.
     db.prepare('DELETE FROM comments WHERE post_id IN (SELECT id FROM posts WHERE author_id = ?)').run(userId);
     db.prepare('DELETE FROM posts WHERE author_id = ?').run(userId);
Index: src/routes/admin.js
===================================================================
--- src/routes/admin.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/admin.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -14,7 +14,7 @@
 const router = express.Router();
 
-// Recente posts van één site, CONCEPTEN BOVENAAN, met mode-bewuste edit/view-URLs.
-// Lost op dat drafts (status != published) nergens terug te vinden waren: de
-// tijdlijn toont alleen gepubliceerde posts.
+// Recent posts from one site, DRAFTS ON TOP, with mode-aware edit/view URLs.
+// Solves the problem that drafts (status != published) were not findable anywhere:
+// the timeline shows only published posts.
 function sitePosts(siteId, siteSlug, tenancy, limit = 60) {
   const base = tenancy === 'hub' ? `/user/${siteSlug}` : '';
@@ -35,8 +35,7 @@
   const user = req.session.user;
 
-  // Een kijker mag het volledige (god-)Beheer alleen-lezen inzien — net als god
-  // dus, alleen schrijven is globaal geblokkeerd. Een gewone artiest die een
-  // eigen site bezit krijgt een "Mijn Klonkt Hub"-dashboard, gescopet op z'n
-  // eigen site. Bezit 'ie geen site -> geen beheer.
+  // A kijker may view the full (god) admin panel read-only — same as god,
+  // but writing is globally blocked. A regular artist who owns a site gets
+  // a "My Klonkt Hub" dashboard, scoped to their own site. No site -> no admin.
   if (user.role !== 'god' && user.role !== 'kijker') {
     const mySite = db.prepare(
@@ -60,6 +59,6 @@
   const tenancy = getTenancy();
 
-  // De primaire/hoofd-site — in solo dé site, in hub de hoofdsite. Geeft de
-  // "Uiterlijk"-tegel z'n edit-link + de posts/concepten-lijst.
+  // The primary/main site — in solo THE site, in hub the main site. Provides the
+  // "Appearance" tile with its edit link + the posts/drafts list.
   const primarySite = getPrimarySite();
 
@@ -73,5 +72,5 @@
   };
 
-  // Sites/users-tabellen zijn alleen in hub relevant; in solo besparen we de query.
+  // Sites/users tables are only relevant in hub mode; in solo we skip the query.
   const sites = tenancy === 'hub' ? db.prepare(`
     SELECT s.slug, s.title, s.created_at, u.username AS owner_username
@@ -89,6 +88,6 @@
   `).all() : [];
 
-  // Posts/concepten van de primaire site (in solo = de site; in hub = de
-  // hoofdsite van de admin). Concepten staan bovenaan zodat ze vindbaar zijn.
+  // Posts/drafts of the primary site (in solo = the site; in hub = the admin's
+  // main site). Drafts are listed first so they are easy to find.
   const posts = primarySite ? sitePosts(primarySite.id, primarySite.slug, tenancy) : [];
 
@@ -105,6 +104,6 @@
 });
 
-// Handleiding — doorzoekbare uitleg van alle Beheer-functies. Zichtbaar voor wie
-// het Beheer mag zien (ingelogd); puur statische hulptekst, niets gevoeligs.
+// Handleiding — searchable explanation of all admin features. Visible to anyone
+// who may view the admin panel (logged in); purely static help text, nothing sensitive.
 router.get('/handleiding', requireAuth, (req, res) => {
   renderPage(req, res, 'pages/admin-help', {
Index: src/routes/artists.js
===================================================================
--- src/routes/artists.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/artists.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,11 +1,11 @@
 /**
- * Artiesten-directory — alleen in hub-modus.
+ * Artists directory — hub mode only.
  *
- * GET /leden?q=&page=  -> doorzoekbare, gepagineerde lijst van ALLE
- * Klonkt-site's. De hub-home toont maar een beperkte selectie; deze pagina
- * schaalt naar honderden/duizenden artiesten via zoeken + paginering.
+ * GET /leden?q=&page=  -> searchable, paginated list of ALL
+ * Klonkt sites. The hub home shows only a limited selection; this page
+ * scales to hundreds/thousands of artists via search + pagination.
  *
- * In solo-modus bestaat er maar één site -> next() (valt door naar postsRoutes,
- * die 'artiesten' als onbekende slug afhandelt).
+ * In solo mode there is only one site -> next() (falls through to postsRoutes,
+ * which handles 'artiesten' as an unknown slug).
  */
 
@@ -26,12 +26,12 @@
   if (!Number.isFinite(page) || page < 1) page = 1;
 
-  // De hoofd-/labelsite (oudste) is geen artiest -> uit de directory weren,
-  // consistent met de hub-home die 'm apart toont.
+  // The main/label site (oldest) is not an artist -> exclude from the directory,
+  // consistent with the hub home which displays it separately.
   const mainRow = db.prepare('SELECT id FROM sites ORDER BY created_at ASC LIMIT 1').get();
   const mainId = mainRow ? mainRow.id : '';
 
-  // Zoekterm tegen titel/slug/tagline (case-insensitive via LIKE; SQLite LIKE is
-  // standaard ongevoelig voor ASCII-hoofdletters). De ESCAPE '\' maakt %, _ en \
-  // in de zoekterm letterlijk (anders zouden ze als wildcards werken).
+  // Search term against title/slug/tagline (case-insensitive via LIKE; SQLite LIKE is
+  // case-insensitive for ASCII by default). ESCAPE '\' makes %, _, and \
+  // in the search term literal (otherwise they would act as wildcards).
   const like = '%' + q.replace(/[\\%_]/g, (m) => '\\' + m) + '%';
   const conds = ['s.id != @mainId'];
Index: src/routes/audio.js
===================================================================
--- src/routes/audio.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/audio.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -92,7 +92,7 @@
   const range = req.headers.range;
 
-  // Statistieken: tel één play bij de initiële player-fetch (niet bij scrub/
-  // range-continuaties; replays binnen 24u komen uit de browsercache → geen
-  // dubbeltelling). Best-effort, mag nooit de stream breken.
+  // Statistics: count one play on the initial player fetch (not on scrub/
+  // range continuations; replays within 24h come from the browser cache → no
+  // double counting). Best-effort, must never break the stream.
   if (req.get('X-Audio-Player') === '1' && (!range || /^bytes=0-/.test(range))) {
     try {
@@ -141,6 +141,6 @@
 });
 
-// Welke post bevat deze track? (voor de mini-speler → "spring naar de post +
-// scroll naar de track".) Pakt de nieuwste gepubliceerde post met [[track:<id>]].
+// Which post contains this track? (for the mini-player → "jump to the post +
+// scroll to the track".) Fetches the newest published post with [[track:<id>]].
 router.get('/track/:id/post', (req, res) => {
   const id = String(req.params.id || '');
Index: src/routes/auth.js
===================================================================
--- src/routes/auth.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/auth.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -10,7 +10,7 @@
 import { premiumUnlocked } from '../services/PatreonService.js';
 
-// Fan-login (luisteraars inloggen met Google om te reageren) is een premium-
-// feature: beschikbaar als Google is ingesteld ÉN de premium-laag ontgrendeld is
-// (premium uit = vrij; aan = Patreon vereist).
+// Fan login (listeners signing in with Google to comment) is a premium feature:
+// available when Google is configured AND the premium layer is unlocked
+// (premium off = open to all; on = Patreon required).
 function fanLoginReady() {
   return googleConfigured() && premiumUnlocked();
@@ -22,14 +22,14 @@
 const router = express.Router();
 
-// Vaste dummy-hash: zo draait login altijd één bcrypt-vergelijking, ook als de
-// user niet bestaat of geen wachtwoord heeft — geen timing-oracle voor enumeratie.
+// Fixed dummy hash: ensures login always runs one bcrypt comparison, even when the
+// user doesn't exist or has no password — no timing oracle for enumeration.
 const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10);
 
-// Canonieke basis-URL voor links in e-mails (reset). Uit headers bouwen is
-// spoofbaar (X-Forwarded-Host); een vaste config sluit dat uit.
+// Canonical base URL for links in emails (reset). Building it from headers is
+// spoofable (X-Forwarded-Host); a fixed config eliminates that risk.
 function publicBaseUrl(req) {
   const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, '');
   if (cfg) return cfg;
-  // Fallback (dev): trust-proxy-gesaneerde protocol + Host-header (NIET de rauwe
+  // Fallback (dev): trust-proxy-sanitised protocol + Host header (NOT the raw
   // X-Forwarded-Host).
   return `${req.protocol}://${req.get('host')}`;
@@ -40,6 +40,6 @@
 }
 
-// Eerste-keer-setup? Pas zolang er nog geen enkele gebruiker is mag /register een
-// beheerder aanmaken. Daarna is registratie dicht (luisteraars komen via Google).
+// First-time setup? Only while there are no users yet may /register create an
+// admin account. Afterwards registration is closed (listeners come via Google).
 function isSetupMode() {
   return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0;
@@ -47,8 +47,7 @@
 
 // ==================== LOGIN ====================
-// Publieke loginpagina: voor BEZOEKERS alleen Google-login (luisteraars/fans).
-// De beheerders-login (wachtwoord) staat hier bewust NIET — die zit verborgen op
-// /auth/admin (zie hieronder), zodat de admin-login niet zichtbaar is op de plek
-// waar bezoekers heen worden gestuurd.
+// Public login page: for VISITORS only Google-login (listeners/fans).
+// The admin login (password) is intentionally NOT here — it lives hidden at
+// /auth/admin (see below), so the admin login is not visible where visitors land.
 router.get('/login', (req, res) => {
   const next = safeNext(req.query.next) || '';
@@ -68,6 +67,6 @@
 });
 
-// Verborgen beheerders-login (gebruikersnaam + wachtwoord). Nergens in de UI
-// gelinkt — de beheerder navigeert hier rechtstreeks naartoe (/auth/admin).
+// Hidden admin login (username + password). Not linked anywhere in the UI —
+// the admin navigates here directly (/auth/admin).
 router.get('/admin', (req, res) => {
   const next = safeNext(req.query.next) || '';
@@ -91,6 +90,6 @@
   const next = safeNext(req.body.next) || '';
 
-  // Foutweergave op de (verborgen) beheerders-loginpagina: toon het wachtwoord-
-  // formulier opnieuw (adminLogin:true), niet de Google-only publieke pagina.
+  // Error display on the (hidden) admin login page: re-show the password
+  // form (adminLogin:true), not the Google-only public page.
   const renderErr = (error, status = 400) => {
     res.status(status);
@@ -105,6 +104,6 @@
 
   const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username);
-  // Altijd één bcrypt-vergelijking (dummy als de user geen bruikbaar wachtwoord
-  // heeft) zodat de responstijd niets over het bestaan van een account verraadt.
+  // Always one bcrypt comparison (dummy if the user has no usable password)
+  // so response time reveals nothing about whether the account exists.
   const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth');
   const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH);
@@ -119,9 +118,9 @@
 });
 
-// ==================== EERSTE-KEER-SETUP (beheerder aanmaken) ====================
+// ==================== FIRST-TIME SETUP (create admin account) ====================
 router.get('/register', (req, res) => {
   const next = safeNext(req.query.next) || '';
   if (req.session.user) return res.redirect(next || '/');
-  // Geen publieke registratie: alleen de allereerste beheerder mag hier aangemaakt.
+  // No public registration: only the very first admin may be created here.
   if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
   renderPage(req, res, 'pages/auth-register', {
@@ -139,5 +138,5 @@
   });
 
-  // Hard gesloten zodra er een gebruiker is — voorkomt een tweede "admin" via deze route.
+  // Hard-closed once a user exists — prevents a second "admin" via this route.
   if (!isSetupMode()) return res.redirect('/auth/login');
 
@@ -150,5 +149,5 @@
   const userId = uuid();
   const hash = bcrypt.hashSync(password, 10);
-  // De allereerste gebruiker is de beheerder (god).
+  // The very first user is the administrator (god).
   db.prepare(`
     INSERT INTO users (id, username, email, password_hash, role, theme, palette)
@@ -156,7 +155,7 @@
   `).run(userId, username, email, hash);
 
-  // Persoonlijke site auto-aanmaken (single-tenant-ombouw volgt later).
-  // Setup-wizard: sitenaam + taal komen uit het formulier; taal = de taal waarin
-  // de bezoeker de wizard invulde (resolveLang) en wordt meteen de site-standaard.
+  // Auto-create a personal site (single-tenant restructure follows later).
+  // Setup wizard: site name + language come from the form; language = the language
+  // the visitor used to fill in the wizard (resolveLang) and becomes the site default.
   if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) {
     const siteId = uuid();
@@ -168,5 +167,5 @@
     `).run(siteId, username.toLowerCase(), title, '', userId, lang);
     db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId);
-    try { setSetting('default_lang', lang); } catch (e) { /* niet fataal */ }
+    try { setSetting('default_lang', lang); } catch (e) { /* non-fatal */ }
   }
 
@@ -175,5 +174,5 @@
 });
 
-// ==================== WACHTWOORD VERGETEN (aanvraag) ====================
+// ==================== FORGOT PASSWORD (request) ====================
 router.get('/reset-request', (req, res) => {
   if (req.session.user) return res.redirect('/');
@@ -191,7 +190,7 @@
     const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email);
     if (user) {
-      const token = crypto.randomBytes(32).toString('hex'); // ruw: gaat alleen de mail/link in
+      const token = crypto.randomBytes(32).toString('hex'); // raw: only goes into the mail/link
       const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min
-      // Alleen de HASH opslaan: DB-leestoegang levert zo geen bruikbaar token op.
+      // Store only the HASH: so DB read access yields no usable token.
       db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')
         .run(hashToken(token), expires, user.id);
@@ -211,9 +210,9 @@
         }
       } else if (process.env.NODE_ENV !== 'production') {
-        // Dev zonder SMTP: link in log + op de pagina tonen.
+        // Dev without SMTP: show the link in the log + on the page.
         console.log(`[password-reset] ${user.email} -> ${url}`);
         devResetUrl = url;
       } else {
-        // Productie zonder SMTP: NOOIT het token loggen. Verwijs naar de CLI break-glass.
+        // Production without SMTP: NEVER log the token. Refer to the CLI break-glass.
         console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`);
       }
@@ -221,5 +220,5 @@
   }
 
-  // Anti-enumeratie: zelfde antwoord ongeacht of het adres bestaat.
+  // Anti-enumeration: same response regardless of whether the address exists.
   renderPage(req, res, 'pages/auth-reset-request', {
     pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
@@ -228,5 +227,5 @@
 });
 
-// ==================== WACHTWOORD RESETTEN (toepassen) ====================
+// ==================== RESET PASSWORD (apply) ====================
 router.get('/reset/:token', (req, res) => {
   const row = db.prepare(`
@@ -266,6 +265,6 @@
 });
 
-// ==================== GOOGLE-LOGIN (luisteraars/reageerders) ====================
-// Per-instance, eigen Google-client. Geeft ALTIJD rol member — nooit beheer.
+// ==================== GOOGLE LOGIN (listeners/commenters) ====================
+// Per-instance, own Google client. ALWAYS grants role member — never admin.
 router.get('/google', (req, res) => {
   if (!fanLoginReady()) {
@@ -279,8 +278,8 @@
 });
 
-// Google KOPPELEN aan het huidige (ingelogde) account — bv. een beheerder die
-// voortaan óók met Google wil inloggen. Vereist dat je al ingelogd bent (met
-// wachtwoord); de koppeling slaat de google_sub op het eigen account op.
-// Alleen googleConfigured() nodig (geen premium-gate — dit is geen fan-login).
+// LINK Google to the current (logged-in) account — e.g. an admin who also wants
+// to log in with Google. Requires being already logged in (with password); the
+// link stores the google_sub on their own account.
+// Only googleConfigured() needed (no premium gate — this is not fan login).
 router.get('/google/link', requireAuth, (req, res) => {
   if (!googleConfigured()) {
@@ -289,5 +288,5 @@
   const state = crypto.randomBytes(16).toString('hex');
   req.session.oauthState = state;
-  req.session.oauthLink = true; // koppel-modus i.p.v. login-modus
+  req.session.oauthLink = true; // link mode instead of login mode
   res.redirect(authorizeUrl(state));
 });
@@ -320,5 +319,5 @@
     const email = (info.email || '').trim().toLowerCase();
 
-    // ── KOPPEL-MODUS: Google aan het huidige (ingelogde) account hangen ──
+    // ── LINK MODE: attach Google to the current (logged-in) account ──
     if (linking) {
       delete req.session.oauthState; delete req.session.oauthLink;
@@ -326,5 +325,5 @@
       if (!info.sub) return failLink('Google gaf geen account-id terug. Probeer opnieuw.');
       if (info.email && info.email_verified === false) return failLink('Je Google-adres is niet geverifieerd.');
-      // Dit Google-account mag niet al aan een ANDER account hangen.
+      // This Google account must not already be linked to a DIFFERENT account.
       const other = db.prepare('SELECT id FROM users WHERE google_sub = ? AND id != ?').get(info.sub, req.session.user.id);
       if (other) return failLink('Dit Google-account is al aan een andere gebruiker gekoppeld.');
@@ -336,5 +335,5 @@
     }
 
-    // ── LOGIN-MODUS (luisteraars/fans + gekoppelde beheerder) ──
+    // ── LOGIN MODE (listeners/fans + linked admin) ──
     if (!fanLoginReady()) return failLogin('unavailable');
     const next = safeNext(req.session.oauthNext) || '';
@@ -342,12 +341,12 @@
     if (!email || info.email_verified === false) return failLogin('email');
 
-    // Zoek EERST op de gekoppelde Google-account (google_sub). Een sub-match is het
-    // expliciete koppel-bewijs → log in met de eigen rol, OOK als het Google-
-    // mailadres afwijkt van het account-mailadres (bv. een beheerder die een ander
-    // Gmail koppelt). Daarna pas op e-mail.
+    // Look FIRST by linked Google account (google_sub). A sub-match is explicit
+    // proof of the link → log in with their own role, EVEN IF the Google email
+    // differs from the account email (e.g. an admin who linked a different Gmail).
+    // Only then fall back to email lookup.
     let user = info.sub ? db.prepare('SELECT * FROM users WHERE google_sub = ?').get(info.sub) : null;
 
     if (user) {
-      // Gekoppeld account gevonden → eigen rol behouden. Avatar bijwerken indien leeg.
+      // Linked account found → keep their own role. Update avatar if empty.
       db.prepare(`
         UPDATE users SET avatar_url = COALESCE(avatar_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?
@@ -356,9 +355,9 @@
       user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
       if (user && (user.role === 'god' || user.role === 'admin')) {
-        // Beheerder gevonden op e-mail maar ZONDER gekoppelde sub → Google geeft
-        // nooit beheer. Eerst koppelen via Account → Inloggen met Google.
+        // Admin found by email but WITHOUT a linked sub → Google never grants admin.
+        // Must first link via Account → Sign in with Google.
         return failLogin('admin');
       } else if (user) {
-        // Bestaande luisteraar: koppel google_sub/avatar als die ontbreken.
+        // Existing listener: link google_sub/avatar if missing.
         db.prepare(`
           UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?),
@@ -366,5 +365,5 @@
         `).run(info.sub || null, info.picture || null, user.id);
       } else {
-        // Nieuwe luisteraar — altijd member.
+        // New listener — always member.
         const userId = uuid();
         const username = uniqueUsername(info.name || email.split('@')[0]);
Index: src/routes/changelog.js
===================================================================
--- src/routes/changelog.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/changelog.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,11 +1,11 @@
 /**
- * Publieke wijzigingen-/release-pagina.
+ * Public changelog / release page.
  *
- * GET /changelog  -> rendert CHANGELOG.md (de bron van waarheid voor releases).
+ * GET /changelog  -> renders CHANGELOG.md (the source of truth for releases).
  *
- * De app-versie (footer, package.json) is bewust losgekoppeld van de
- * cirkel-federatie-proto (KLONKT_PROTO): een versie-bump is cosmetisch en raakt
- * de federatie niet. We tonen de proto hier expliciet zodat per release zichtbaar
- * is met welke federatie-versie deze instance praat (cirkels = lockstep per proto).
+ * The app version (footer, package.json) is intentionally decoupled from the
+ * circle federation proto (KLONKT_PROTO): a version bump is cosmetic and does not
+ * affect federation. We show the proto here explicitly so that each release
+ * makes visible which federation version this instance speaks (circles = lockstep per proto).
  */
 
Index: src/routes/circle.js
===================================================================
--- src/routes/circle.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/circle.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,8 +1,8 @@
 /**
- * Cirkel-feed + lokale lees-pagina.
- *   GET /cirkel        -> overzicht (zelfde timeline/grid-view als de home)
- *   GET /cirkel/:id    -> losse remote-post in eigen chrome (blijf op je site)
- * Alleen actief als tenancy === 'circle' (anders next() -> postsRoutes/404).
- * Zie docs/cirkels-v1-spec.md §5c.
+ * Circle feed + local reading page.
+ *   GET /cirkel        -> overview (same timeline/grid view as the home)
+ *   GET /cirkel/:id    -> individual remote post in own chrome (stay on your site)
+ * Only active when tenancy === 'circle' (otherwise next() -> postsRoutes/404).
+ * See docs/cirkels-v1-spec.md §5c.
  */
 
@@ -25,5 +25,5 @@
 }
 
-// ── Overzicht ────────────────────────────────────────────────
+// ── Overview ─────────────────────────────────────────────────
 router.get('/cirkel', (req, res, next) => {
   if (getTenancy() !== 'circle') return next();
@@ -42,6 +42,6 @@
     return {
       id: r.id,
-      // Lokale lees-pagina -> de kaart blijft op de eigen site (post-card linkt
-      // lokaal + htmx, GEEN external_url).
+      // Local reading page -> the card stays on the own site (post-card links
+      // locally + htmx, NO external_url).
       slug: 'cirkel/' + encodeURIComponent(r.id),
       title: r.title || '(zonder titel)',
@@ -58,6 +58,6 @@
   });
 
-  // Sites in de cirkel — alleen actieve links (outdated/error vallen weg, net als
-  // hun posts). Voor de grafische header met avatars.
+  // Sites in the circle — active links only (outdated/error ones are excluded, along
+  // with their posts). Used for the graphic header with avatars.
   const sites = db.prepare(`
     SELECT a.name, a.url, a.avatar
@@ -76,5 +76,5 @@
 });
 
-// ── Losse remote-post (lokaal lezen) ─────────────────────────
+// ── Individual remote post (local reading) ────────────────────
 router.get('/cirkel/:id', (req, res, next) => {
   if (getTenancy() !== 'circle') return next();
Index: src/routes/comments.js
===================================================================
--- src/routes/comments.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/comments.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -58,5 +58,5 @@
     if (!parent) return res.status(400).send('Invalid parent comment');
     resolvedParent = parent.parent_comment_id || parent.id;
-    parentAuthorId = parent.author_id; // ontvanger van de "antwoord"-melding
+    parentAuthorId = parent.author_id; // recipient of the "reply" notification
   }
 
@@ -77,7 +77,6 @@
   `).run(commentId, post.id, req.session.user.id, resolvedParent, rawContent, status);
 
-  // Melding (alleen bij een zichtbare reactie): antwoord → de auteur van de reactie
-  // waarop gereageerd is; top-level reactie → de auteur van de post. notify() slaat
-  // jezelf-notificeren over.
+  // Notification (only for visible comments): reply → author of the parent comment;
+  // top-level comment → author of the post. notify() skips self-notifications.
   if (status === 'approved') {
     const url = `${res.locals.siteUrlBase || ''}/${post.slug}#comment-${commentId}`;
Index: src/routes/download.js
===================================================================
--- src/routes/download.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/download.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,12 +1,12 @@
 /**
- * Download-voor-email (premium feature #2).
+ * Download-for-email (premium feature #2).
  *
- *   GET  /downloads                 -> lijst van downloadbare tracks (premium; anders 404)
- *   GET  /download/:id              -> e-mail-capture-pagina voor één track
- *   POST /download/:id              -> e-mail opslaan (-> mailinglijst) + download vrijgeven
- *   GET  /download/:id/bestand      -> serveert het bestand (sessie-gated na capture)
+ *   GET  /downloads                 -> list of downloadable tracks (premium; 404 otherwise)
+ *   GET  /download/:id              -> email capture page for a single track
+ *   POST /download/:id              -> save email (-> mailing list) + unlock download
+ *   GET  /download/:id/bestand      -> serves the file (session-gated after capture)
  *
- * De fan laat z'n e-mail achter en krijgt het bestand; het adres komt in de
- * subscribers-lijst (source 'download', single opt-in — geen confirm-drempel vóór de
+ * The fan leaves their email and receives the file; the address is added to the
+ * subscribers list (source 'download', single opt-in — no confirm step before the
  * download). Hub: via /user/:slug/... (resolveSite + siteUrlBase).
  */
@@ -24,7 +24,7 @@
 const router = express.Router();
 
-// Als er een echte (gepinde) post met slug 'downloads' bestaat, hangt de
-// downloads-lijst feitelijk aan die post. Dan tonen we óók de Newer/Older-postnav,
-// zodat de bezoeker net als bij een post verder kan bladeren.
+// If a real (pinned) post with slug 'downloads' exists, the downloads list is
+// effectively attached to that post. We then also show the Newer/Older post nav
+// so the visitor can browse just like on a regular post.
 function downloadsPostNav(req, res) {
   const site = res.locals.site;
@@ -40,5 +40,5 @@
 
 const MIME = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg' };
-const GRACE_MS = 15 * 60 * 1000; // download-venster na capture
+const GRACE_MS = 15 * 60 * 1000; // download window after capture
 
 function dlTrack(siteId, id) {
@@ -55,5 +55,5 @@
 }
 
-// Lijst van downloadbare tracks.
+// List of downloadable tracks.
 router.get('/downloads', (req, res, next) => {
   if (!premiumUnlocked()) return next();
@@ -67,6 +67,6 @@
   renderPage(req, res, 'pages/downloads', {
     pageTitle: 'Downloads — ' + (site.title || ''),
-    // on-special = compacte profielkop (zoals op een post); on-downloads = pill grijs
-    // + feature-route-gedrag. Samen → downloads ziet er net zo uit als een post.
+    // on-special = compact profile header (like on a post); on-downloads = grey pill
+    // + feature-route behaviour. Together → downloads looks just like a post.
     bodyClass: 'on-downloads on-special',
     dlTracks: tracks,
@@ -76,5 +76,5 @@
 });
 
-// Capture-pagina voor één track.
+// Capture page for a single track.
 router.get('/download/:id', (req, res, next) => {
   if (!premiumUnlocked()) return next();
@@ -93,5 +93,5 @@
 });
 
-// E-mail opslaan + download vrijgeven.
+// Save email + unlock download.
 router.post('/download/:id', (req, res, next) => {
   if (!premiumUnlocked()) return next();
@@ -109,5 +109,5 @@
     });
   }
-  // Download vrijgeven in de sessie (kort venster).
+  // Unlock download in the session (short window).
   if (!req.session.dl) req.session.dl = {};
   req.session.dl[track.id] = Date.now();
@@ -118,5 +118,5 @@
 });
 
-// Het bestand serveren — alleen als er net een e-mail is achtergelaten (sessie).
+// Serve the file — only if an email was just submitted (session-gated).
 router.get('/download/:id/bestand', (req, res, next) => {
   if (!premiumUnlocked()) return next();
@@ -129,6 +129,6 @@
     return res.status(403).send('Laat eerst je e-mailadres achter om te downloaden.');
   }
-  // De speelbare/te-downloaden file = de KALE filename (storage_path is een
-  // absoluut pad → faalt de slash-guard). Zelfde aanpak als /audio/stream.
+  // The playable/downloadable file = the BARE filename (storage_path is an
+  // absolute path → fails the slash-guard). Same approach as /audio/stream.
   const sp = track.filename;
   if (!sp || sp.includes('/') || sp.includes('\\') || sp.includes('..')) return res.status(400).send('Bad path');
Index: src/routes/embed.js
===================================================================
--- src/routes/embed.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/embed.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,12 +1,12 @@
 /**
- * Embedbare player (premium feature #7).
+ * Embeddable player (premium feature #7).
  *
- *   GET /embed   -> een zelfstandige, compacte audiospeler-pagina (geen shell),
- *                   bedoeld om op EXTERNE sites in een <iframe> te zetten.
+ *   GET /embed   -> a standalone, compact audio player page (no shell),
+ *                   intended to be placed in an <iframe> on EXTERNAL sites.
  *
- * De pagina wordt door ons (klonkt-origin) geserveerd, dus de audio-requests vanuit
- * het iframe blijven same-origin → de /audio/stream-gate laat ze door, ook al staat
- * het iframe op een vreemde site. We overrulen alleen Helmet's frameguard +
- * frame-ancestors zodat externe sites mógen inbedden. Hub: /user/:slug/embed.
+ * The page is served by us (klonkt-origin), so audio requests from within
+ * the iframe remain same-origin → the /audio/stream gate lets them through,
+ * even when the iframe is on a foreign site. We only override Helmet's frameguard
+ * + frame-ancestors so that external sites are allowed to embed us. Hub: /user/:slug/embed.
  */
 
@@ -21,5 +21,5 @@
   if (!site) return next();
 
-  // Inbedden op externe sites toestaan (overrule de globale frameguard/CSP).
+  // Allow embedding on external sites (override the global frameguard/CSP).
   res.removeHeader('X-Frame-Options');
   res.setHeader(
Index: src/routes/epk.js
===================================================================
--- src/routes/epk.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/epk.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,13 +1,13 @@
 /**
- * EPK / perskit (premium) — een deelbare perspagina per Klonkt-site.
+ * EPK / press kit (premium) — a shareable press page per Klonkt site.
  *
- * GET /pers  (solo) of /user/:slug/pers (hub, via resolveSite + siteUrlBase)
- *   -> nette, openbare perskit: hero (foto/titel/tagline), korte bio, topnummers,
- *      recente posts en een contact-knop. Bedoeld om naar boekers/pers te sturen.
+ * GET /pers  (solo) or /user/:slug/pers (hub, via resolveSite + siteUrlBase)
+ *   -> clean, public press kit: hero (photo/title/tagline), short bio, top tracks,
+ *      recent posts and a contact button. Intended to share with bookers/press.
  *
- * Premium-gated: niet-premium instances hebben GEEN /pers (next() -> 404 via de
- * catch-all). De PAGINA zelf is openbaar (geen login) zodat pers 'm kan bekijken;
- * alleen het BESTAAN ervan is premium. Geen login-e-mail lekken: contact loopt via
- * een expliciet ingesteld pers-adres (epk_contact, per site) of anders de site zelf.
+ * Premium-gated: non-premium instances have NO /pers (next() -> 404 via the
+ * catch-all). The PAGE itself is public (no login) so press can view it;
+ * only its EXISTENCE is premium. No login email leak: contact goes via an
+ * explicitly configured press address (epk_contact, per site) or the site itself.
  */
 
@@ -21,15 +21,15 @@
 
 router.get('/pers', (req, res, next) => {
-  if (!premiumUnlocked()) return next();      // geen premium -> geen perskit
+  if (!premiumUnlocked()) return next();      // no premium -> no press kit
   const site = res.locals.site;
   if (!site) return next();
 
-  // Nummers op de perskit: een door de admin GEKOZEN selectie (max 5, in eigen
-  // volgorde) als die is ingesteld; anders automatisch de top 5 meest beluisterde.
+  // Tracks on the press kit: an admin-CHOSEN selection (max 5, in custom order)
+  // if configured; otherwise automatically the top 5 most-listened.
   let chosenIds = [];
   try {
     const raw = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]');
     if (Array.isArray(raw)) chosenIds = raw.filter((x) => typeof x === 'string').slice(0, 5);
-  } catch (e) { /* ongeldige JSON → val terug op top */ }
+  } catch (e) { /* invalid JSON → fall back to top */ }
 
   let tracks;
@@ -41,5 +41,5 @@
     ).all(site.id, ...chosenIds);
     const byId = new Map(rows.map((r) => [r.id, r]));
-    tracks = chosenIds.map((id) => byId.get(id)).filter(Boolean);  // behoud gekozen volgorde
+    tracks = chosenIds.map((id) => byId.get(id)).filter(Boolean);  // preserve chosen order
   } else {
     tracks = db.prepare(
@@ -60,8 +60,8 @@
   ).all(site.id);
 
-  // Pers-contact: per-site instelling (epk_contact_<siteId>) als die er is, anders
-  // de globale epk_contact. NOOIT automatisch de login-mail tonen.
+  // Press contact: per-site setting (epk_contact_<siteId>) if present, otherwise
+  // the global epk_contact. NEVER auto-expose the login email.
   const contact = (getSetting('epk_contact_' + site.id, '') || getSetting('epk_contact', '') || '').trim();
-  // Korte pers-bio: per-site instelling, anders de tagline van de site.
+  // Short press bio: per-site setting, otherwise the site's tagline.
   const bio = (getSetting('epk_bio_' + site.id, '') || site.tagline || '').trim();
 
Index: src/routes/federation.js
===================================================================
--- src/routes/federation.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/federation.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,9 +1,9 @@
-// routes/federation.js — publieke Cirkels-endpoints (v1, publicatie-kant).
+// routes/federation.js — public Cirkels endpoints (v1, publication side).
 //
-//   GET /.klonkt/actor.json   — ActivityStreams-actor + Ed25519-pubkey
-//   GET /.klonkt/outbox.json  — publieke posts als AS Create-objecten,
-//                               getekend via de Klonkt-Signature-header
+//   GET /.klonkt/actor.json   — ActivityStreams actor + Ed25519 public key
+//   GET /.klonkt/outbox.json  — public posts as AS Create objects,
+//                               signed via the Klonkt-Signature header
 //
-// Site-agnostisch en zonder auth — alleen lezen. Zie docs/cirkels-v1-spec.md.
+// Site-agnostic and unauthenticated — read-only. See docs/cirkels-v1-spec.md.
 
 import express from 'express';
@@ -18,5 +18,5 @@
 }
 
-// De proto die de consument zegt te draaien (uit z'n request-header), of 0.
+// The proto the consumer claims to be running (from their request header), or 0.
 function consumerProto(req) {
   return parseInt(req.get('Klonkt-Proto') || '0', 10) || 0;
@@ -24,8 +24,8 @@
 
 router.get('/.klonkt/actor.json', (req, res) => {
-  // Cirkels = solo-naar-solo; hubs publiceren geen federatie-actor.
+  // Circles = solo-to-solo; hubs do not publish a federation actor.
   if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus');
-  // De actor serveren we ALTIJD (ook aan oudere consumenten) zodat zij onze proto
-  // kunnen lezen en een nette "update vereist"-melding kunnen tonen.
+  // We ALWAYS serve the actor (including to older consumers) so they can read our
+  // proto and show a clean "update required" message.
   const body = JSON.stringify(buildActor(baseUrl(req)), null, 2);
   res.type('application/activity+json; charset=utf-8');
@@ -38,7 +38,7 @@
   if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus');
   res.set('Klonkt-Proto', String(KLONKT_PROTO));
-  // Te-oude consument? Weiger met 426 Upgrade Required (de crypto-binding sluit 'm
-  // sowieso al uit; dit geeft een expliciet, leesbaar signaal). proto 0 = geen
-  // header (bv. een browser/curl) → toestaan, die verifieert toch niet.
+  // Consumer too old? Reject with 426 Upgrade Required (the crypto binding already
+  // excludes them; this gives an explicit, readable signal). proto 0 = no header
+  // (e.g. a browser/curl) → allow, they won't verify anyway.
   const cp = consumerProto(req);
   if (cp && cp < MIN_PROTO) {
Index: src/routes/hub.js
===================================================================
--- src/routes/hub.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/hub.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,8 +1,8 @@
 /**
- * Hub-hoofdpagina — alleen in hub-modus. In plaats van de primaire Klonkt-site te
- * tonen, rendert '/' hier een bedrijfs-overview: de laatste posts van ALLE
- * gebruikers samengevat + een lijst van de Klonkt-site's.
+ * Hub home page — hub mode only. Instead of rendering the primary Klonkt site,
+ * '/' renders a company overview here: the latest posts from ALL users combined
+ * + a list of the Klonkt sites.
  *
- * In solo-modus doet dit niets (next()) en rendert posts.js de enige site.
+ * In solo mode this does nothing (next()) and posts.js renders the single site.
  */
 
@@ -16,10 +16,10 @@
 router.get('/', (req, res, next) => {
   if (getTenancy() !== 'hub') return next();
-  // Als resolveSite een specifieke site adresseerde (/user/:slug of /sites/:slug),
-  // is req.url naar '/' herschreven — dan NIET de overview tonen maar de site zelf
-  // laten renderen door posts.js. siteUrlBase is dan gezet.
+  // If resolveSite addressed a specific site (/user/:slug or /sites/:slug),
+  // req.url was rewritten to '/' — do NOT show the overview but let posts.js
+  // render the site itself. siteUrlBase is set in that case.
   if (res.locals.siteUrlBase) return next();
 
-  // Laatste gepubliceerde posts over álle sites heen.
+  // Latest published posts across all sites.
   const posts = db.prepare(`
     SELECT p.title, p.slug, p.excerpt, p.published_at, p.created_at,
@@ -35,6 +35,6 @@
   `).all();
 
-  // De hoofd-/labelsite (de expliciet primaire = de bedrijfs-/hoofdaccount) is
-  // GEEN artiest; die tonen we apart bovenaan, niet in de Artiesten-roster.
+  // The main/label site (the explicitly primary = the company/main account) is
+  // NOT an artist; we display it separately at the top, not in the Artists roster.
   const mainSite = db.prepare(`
     SELECT s.id, s.slug, s.title, s.tagline, s.profile_photo, s.accent,
@@ -48,7 +48,7 @@
   const mainId = mainSite ? mainSite.id : '';
 
-  // Uitgelichte Klonkt-site's voor de home-roster: meest-actief eerst (aantal
-  // gepubliceerde posts), dan nieuwste. Excl. de hoofdsite. Beperkt tot
-  // HOME_ROSTER_LIMIT zodat de home schaalt — volledige lijst staat op /leden.
+  // Featured Klonkt sites for the home roster: most active first (number of
+  // published posts), then newest. Excl. the main site. Capped at
+  // HOME_ROSTER_LIMIT so the home scales — full list is at /leden.
   const HOME_ROSTER_LIMIT = 24;
   const artists = db.prepare(`
@@ -64,6 +64,6 @@
   const totalArtists = db.prepare('SELECT COUNT(*) AS c FROM sites WHERE id != ?').get(mainId).c;
 
-  // De hub-pagina is GENERIEK (van geen enkele user) — branding komt uit globale
-  // instellingen die de admin in Beheer beheert, niet uit een site.
+  // The hub page is GENERIC (not belonging to any user) — branding comes from global
+  // settings managed by the admin in the admin panel, not from a site.
   const hub = {
     title: getSetting('hub_title') || 'Overzicht',
Index: src/routes/lang.js
===================================================================
--- src/routes/lang.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/lang.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,4 +1,4 @@
-// Taalkeuze van de bezoeker: /lang/:code zet de interface-taal in de sessie en
-// stuurt terug naar waar je vandaan kwam. (Content blijft in de taal van de auteur.)
+// Visitor language choice: /lang/:code sets the interface language in the session
+// and redirects back to where you came from. (Content stays in the author's language.)
 import express from 'express';
 import { SUPPORTED } from '../services/i18n.js';
@@ -10,13 +10,13 @@
   const code = SUPPORTED.includes(req.params.code) ? req.params.code : 'nl';
   if (req.session) req.session.lang = code;
-  // Ingelogd? Bewaar de keuze ook op het account zodat 'ie meereist over
-  // apparaten/sessies (niet alleen deze sessie-cookie).
+  // Logged in? Also save the choice on the account so it follows the user
+  // across devices/sessions (not just this session cookie).
   if (req.session && req.session.user && req.session.user.id) {
     try {
       db.prepare('UPDATE users SET lang = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(code, req.session.user.id);
       req.session.user.lang = code;
-    } catch { /* lang-kolom ontbreekt op een oude DB → sessie-only, geen breuk */ }
+    } catch { /* lang column missing on an old DB → session-only, no breakage */ }
   }
-  // Veilige terug-URL: alleen een intern pad (geen open redirect).
+  // Safe back URL: internal path only (no open redirect).
   let back = (typeof req.query.r === 'string') ? req.query.r : '';
   if (!back.startsWith('/') || back.startsWith('//')) {
Index: src/routes/linkbio.js
===================================================================
--- src/routes/linkbio.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/linkbio.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,12 +1,12 @@
 /**
- * Link-in-bio + klikstats (premium feature #6).
+ * Link-in-bio + click stats (premium feature #6).
  *
- *   GET /links          -> Linktree-achtige pagina met de profile_links van de site
- *   GET /links/go/:i     -> telt de klik (per url) en stuurt door naar de externe URL
+ *   GET /links          -> Linktree-style page with the site's profile_links
+ *   GET /links/go/:i     -> counts the click (per url) and redirects to the external URL
  *
- * Hergebruikt de bestaande sites.profile_links (JSON [{platform,url}]) + de
- * PLATFORMS-iconen/labels. Klikken landen in link_clicks (zie /admin/stats).
- * Open-redirect-veilig: /links/go/:i stuurt ALLEEN door naar een url die in de
- * eigen profile_links staat. Hub: via /user/:slug/links.
+ * Reuses the existing sites.profile_links (JSON [{platform,url}]) + the
+ * PLATFORMS icons/labels. Clicks are stored in link_clicks (see /admin/stats).
+ * Open-redirect safe: /links/go/:i ONLY redirects to a url present in the
+ * site's own profile_links. Hub: via /user/:slug/links.
  */
 
@@ -48,5 +48,5 @@
   if (!link || !link.url) return next();
   const url = String(link.url);
-  // Alleen externe http(s)- of mailto-links (geen open redirect / javascript:).
+  // Only external http(s) or mailto links (no open redirect / javascript:).
   if (!/^https?:\/\//i.test(url) && !/^mailto:/i.test(url)) return res.status(400).send('Bad link');
   try {
@@ -55,5 +55,5 @@
        ON CONFLICT(site_id, url) DO UPDATE SET clicks = clicks + 1, updated_at = CURRENT_TIMESTAMP`
     ).run(site.id, url);
-  } catch { /* telling mag de redirect nooit breken */ }
+  } catch { /* counting must never break the redirect */ }
   res.redirect(302, url);
 });
Index: src/routes/newsletter.js
===================================================================
--- src/routes/newsletter.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/newsletter.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,12 +1,12 @@
 /**
- * Nieuwsbrief — publieke kant (premium feature #1).
+ * Newsletter — public side (premium feature #1).
  *
- *   GET  /nieuwsbrief                      -> aanmeldformulier (premium; anders 404)
- *   POST /nieuwsbrief                      -> aanmelden (double opt-in als SMTP er is)
- *   GET  /nieuwsbrief/bevestigen/:token    -> opt-in bevestigen
- *   GET  /nieuwsbrief/uitschrijven/:token  -> uitschrijven (ALTIJD toegestaan)
+ *   GET  /nieuwsbrief                      -> sign-up form (premium; 404 otherwise)
+ *   POST /nieuwsbrief                      -> subscribe (double opt-in if SMTP configured)
+ *   GET  /nieuwsbrief/bevestigen/:token    -> confirm opt-in
+ *   GET  /nieuwsbrief/uitschrijven/:token  -> unsubscribe (ALWAYS allowed)
  *
- * In hub-modus loopt dit via /user/:slug/nieuwsbrief (resolveSite zet siteUrlBase).
- * Confirm-/unsub-links in de mail zijn absoluut (PUBLIC_BASE_URL + siteUrlBase).
+ * In hub mode this runs via /user/:slug/nieuwsbrief (resolveSite sets siteUrlBase).
+ * Confirm/unsub links in the mail are absolute (PUBLIC_BASE_URL + siteUrlBase).
  */
 
@@ -53,5 +53,5 @@
 
   if (r.status === 'pending') {
-    // Double opt-in: stuur de bevestigingsmail.
+    // Double opt-in: send the confirmation email.
     const link = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/bevestigen/' + r.token);
     const unsub = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/uitschrijven/' + r.token);
@@ -81,6 +81,6 @@
 });
 
-// Uitschrijven mag altijd (ook als de premium-laag later uit zou gaan): een abonnee
-// moet zich altijd kunnen afmelden. Niet premium-gated.
+// Unsubscribe is always allowed (even if the premium layer is later disabled): a
+// subscriber must always be able to opt out. Not premium-gated.
 router.get('/nieuwsbrief/uitschrijven/:token', (req, res) => {
   const ok = unsubscribe(req.params.token);
Index: src/routes/notifications.js
===================================================================
--- src/routes/notifications.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/notifications.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,5 +1,5 @@
 /**
- * GET /notifications — meldingenpagina voor de ingelogde gebruiker.
- * Openen = alles als gelezen markeren (de teller in de header valt dan weg).
+ * GET /notifications — notifications page for the logged-in user.
+ * Opening it marks everything as read (the counter in the header disappears).
  */
 import express from 'express';
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/posts.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -49,8 +49,8 @@
 });
 
-// Maakt een unieke slug binnen de site: 'titel', 'titel-2', 'titel-3', …
-// Zo wordt een tweede post met dezelfde titel NIET geweigerd ("bestaat al"),
-// maar krijgt 'ie automatisch een vrij achtervoegsel. exceptId = de post die
-// we bijwerken (mag z'n eigen slug houden).
+// Generates a unique slug within the site: 'title', 'title-2', 'title-3', …
+// A second post with the same title is NOT rejected ("already exists"),
+// but automatically gets a free suffix. exceptId = the post being updated
+// (allowed to keep its own slug).
 function uniqueSlug(siteId, base, exceptId = null) {
   let candidate = base;
@@ -190,5 +190,5 @@
   if (RESERVED_SLUGS.has(finalSlug)) finalSlug = `${finalSlug}-post`;
 
-  // Dubbele titel/slug? Automatisch uniek maken (titel-2, titel-3, …) i.p.v. weigeren.
+  // Duplicate title/slug? Make it unique automatically (title-2, title-3, …) instead of rejecting.
   finalSlug = uniqueSlug(site.id, finalSlug);
 
@@ -199,6 +199,6 @@
   let finalStatus = status || 'draft';
   let publishedAt = finalStatus === 'published' ? now : null;
-  // Release-planning: gepubliceerd + een toekomstige publish_at -> 'scheduled'
-  // (de Scheduler zet 'm live op het moment zelf). Verleden/leeg -> meteen live.
+  // Release planning: published + a future publish_at -> 'scheduled'
+  // (the Scheduler makes it live at that moment). Past/empty -> live immediately.
   let publishAt = null;
   const pa = Date.parse(req.body.publish_at || '');
@@ -297,5 +297,5 @@
     const cleaned = newSlug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
     const safe = RESERVED_SLUGS.has(cleaned) ? `${cleaned}-post` : cleaned;
-    // Dubbele slug? Automatisch uniek maken i.p.v. weigeren (eigen post mag z'n slug houden).
+    // Duplicate slug? Make it unique automatically instead of rejecting (own post may keep its slug).
     finalSlug = uniqueSlug(site.id, safe, post.id);
   }
@@ -310,5 +310,5 @@
   }
 
-  // Release-planning: gepubliceerd + toekomstige publish_at -> 'scheduled'.
+  // Release planning: published + future publish_at -> 'scheduled'.
   let publishAt = null;
   const pa = Date.parse(req.body.publish_at || '');
@@ -412,11 +412,10 @@
 });
 
-// Pad naar de like-knop-partial (voor de htmx-toggle re-render).
+// Path to the like button partial (for the htmx toggle re-render).
 const LIKE_PARTIAL = path.join(__dirname, '..', 'views', 'partials', 'like-button.ejs');
 
-// ==================== LIKE / FAVORIET ====================
-// Een ingelogde gebruiker (geen kijker — de globale guard blokkeert non-GET voor
-// kijkers) togglet een like op een gepubliceerde post. Geeft de her-gerenderde
-// knop terug (htmx outerHTML-swap).
+// ==================== LIKE / FAVOURITE ====================
+// A logged-in user (not a viewer — the global guard blocks non-GET for viewers)
+// toggles a like on a published post. Returns the re-rendered button (htmx outerHTML swap).
 router.post('/posts/:id/like', requireAuth, (req, res) => {
   const userId = req.session.user.id;
@@ -430,5 +429,5 @@
   } else {
     db.prepare('INSERT OR IGNORE INTO post_likes (post_id, user_id) VALUES (?, ?)').run(post.id, userId);
-    // Melding voor de post-auteur (notify slaat jezelf-liken over).
+    // Notification for the post author (notify skips self-likes).
     notify({
       userId: post.author_id, actorId: userId, actorName: req.session.user.username, type: 'like',
@@ -444,6 +443,6 @@
 });
 
-// Favorieten = de posts die de ingelogde gebruiker likete. Solo: binnen de
-// huidige site. Hub: over alle sites (met juiste /user/<slug>-links).
+// Favourites = posts the logged-in user has liked. Solo: within the current
+// site. Hub: across all sites (with correct /user/<slug> links).
 router.get('/favorieten', requireAuth, (req, res) => {
   const userId = req.session.user.id;
@@ -469,7 +468,7 @@
 });
 
-// Newer/Older-buren over ALLE posts in feed-volgorde. Gedeeld door de volledige
-// post-render én de fan-gate (premium fan_only), zodat de navigatie overal gelijk
-// is. Solo: binnen de site (pinned eerst, dan datum). Hub: globaal op datum.
+// Newer/Older neighbours across ALL posts in feed order. Shared by the full
+// 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.
 function postNeighbors(site, post, isHub) {
   const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
@@ -507,5 +506,5 @@
   `).get(site.id, req.params.slug);
 
-  if (!post) return next(); // onbekende slug -> nette 404 catch-all
+  if (!post) return next(); // unknown slug -> clean 404 catch-all
 
   // Permission to view: published OR (logged in + can edit)
@@ -515,10 +514,10 @@
   }
 
-  // Fan-only preview (premium #3): volledige inhoud alleen voor ingelogde fans.
-  // Anonieme bezoekers krijgen een nette login-gate i.p.v. de inhoud (de titel/
-  // teaser mag elders wel als lokkertje verschijnen).
+  // 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)) {
-    // Zelfde Newer/Older-navigatie als op een gewone post, zodat de bezoeker op
-    // de fan-gate niet vastloopt maar verder kan bladeren.
+    // 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', {
@@ -532,5 +531,5 @@
   }
 
-  // Statistieken: tel de weergave (skipt beheerders + niet-gepubliceerd-eigen-preview).
+  // Statistics: count the view (skips admins + unpublished own-preview).
   if (post.status === 'published') recordPostView(post, req);
 
@@ -588,5 +587,5 @@
       const byAlbum = new Map();
       for (const r of albumRows) {
-        // Link-only tracks (geen bestand) blijven in het album-overzicht (url '').
+        // Link-only tracks (no file) remain in the album overview (url '').
         if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
         byAlbum.get(r.album).push({
@@ -625,7 +624,7 @@
   }
   } else {
-    // LITE-modus (KLONKT_AUDIO=off): geen eigen audio (geen ffmpeg/stream-route).
-    // Externe embeds (YouTube/SoundCloud/Spotify) blijven wel; de eigen-audio-
-    // shortcodes ([[track]]/[[album]]/[[playlist]]) verwijderen we netjes.
+    // LITE mode (KLONKT_AUDIO=off): no own audio (no ffmpeg/stream route).
+    // External embeds (YouTube/SoundCloud/Spotify) remain; the own-audio
+    // shortcodes ([[track]]/[[album]]/[[playlist]]) are cleanly stripped.
     html = AudioEmbedService.autoembed(html);
     html = AudioEmbedService.embedMediaShortcodes(html);
@@ -665,11 +664,11 @@
   // Prev / next chronological (kept for back-compat — "post-nav" feature
   // below the article still uses these as a simple linear navigation).
-  // Hub-modus: Gerelateerde posts + Newer/Older trekken uit ALLE users (alle
-  // sites), nieuwste->oudste. Solo-modus: binnen de huidige site (oud gedrag).
+  // Hub mode: Related posts + Newer/Older pull from ALL users (all sites),
+  // newest first. Solo mode: within the current site (old behaviour).
   const isHub = res.locals.tenancy === 'hub';
-  // Per-post URL-basis: in hub wijst een link naar /user/<site-slug>/<post-slug>.
+  // Per-post URL base: in hub a link points to /user/<site-slug>/<post-slug>.
   const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
 
-  // Newer/Older over ALLE posts (gedeelde helper — ook door de fan-gate gebruikt).
+  // Newer/Older across ALL posts (shared helper — also used by the fan gate).
   const { newerPost, olderPost } = postNeighbors(site, post, isHub);
 
@@ -727,5 +726,5 @@
   relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
 
-  // Likes / favorieten: aantal + of de ingelogde gebruiker deze post likete.
+  // Likes / favourites: count + whether the logged-in user liked this post.
   const likeCount = db.prepare('SELECT COUNT(*) AS c FROM post_likes WHERE post_id = ?').get(post.id).c;
   const likedByMe = !!(req.session?.user &&
Index: src/routes/search.js
===================================================================
--- src/routes/search.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/search.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,15 +1,15 @@
 /**
- * GET /search?q=...          -> volledige resultatenpagina
- * GET /search/suggest?q=...  -> compacte JSON voor live resultaten in de overlay
+ * GET /search?q=...          -> full results page
+ * GET /search/suggest?q=...  -> compact JSON for live results in the overlay
  *
- * Doorzoekt de huidige site op:
+ * Searches the current site across:
  *   1. Posts via posts_fts (FTS5, prefix-matching) — published only.
- *   2. Nummers (audio_tracks) op titel / artiest / album.
- *   3. Evenementen (shows) op plaats / locatie / land / notitie — als de agenda aan staat.
- *   4. Pagina's (Agenda / Downloads / Links / Perskit / Archief) op naam — alleen
- *      de beschikbare.
+ *   2. Tracks (audio_tracks) on title / artist / album.
+ *   3. Events (shows) on city / venue / country / notes — when the agenda is enabled.
+ *   4. Pages (Agenda / Downloads / Links / Press kit / Archive) by name — only
+ *      the available ones.
  *
- * FTS5: user-input wordt getokeniseerd op niet-letter/cijfer en elk token tussen
- * dubbele quotes + `*` gezet → prefix-match, geen operator-soup/syntax-errors.
+ * FTS5: user input is tokenised on non-letter/digit chars and each token is wrapped
+ * in double quotes + `*` → prefix-match, no operator-soup/syntax-errors.
  */
 
@@ -47,6 +47,6 @@
 }
 
-// ── De kern: alle bronnen doorzoeken voor één site. `lim` begrenst per groep
-//    (klein voor de live-suggesties, ruim voor de volle pagina). ──────────────
+// ── Core: search all sources for one site. `lim` caps results per group
+//    (small for live suggestions, large for the full page). ──────────────────
 function searchSite(req, res, rawQ, lim) {
   const site = res.locals.site;
@@ -79,5 +79,5 @@
   }
 
-  // 2. Nummers
+  // 2. Tracks
   try {
     const trackRows = db.prepare(`
@@ -108,5 +108,5 @@
   } catch (err) { if (!out.queryError) out.queryError = err.message; }
 
-  // 3. Evenementen (agenda) — alleen als de agenda publiek aan staat.
+  // 3. Events (agenda) — only when the agenda is publicly enabled.
   if (premiumUnlocked() && getSetting('agenda_enabled') === '1') {
     try {
@@ -125,5 +125,5 @@
   }
 
-  // 4. Pagina's — curated, alleen de beschikbare; match op de (vertaalde) naam.
+  // 4. Pages — curated, available ones only; matched against the (translated) name.
   const ql = rawQ.toLowerCase();
   const candidates = [
@@ -143,5 +143,5 @@
 }
 
-// ── Volledige resultatenpagina ───────────────────────────────────────────────
+// ── Full results page ────────────────────────────────────────────────────────
 router.get('/', (req, res) => {
   const site = res.locals.site;
@@ -165,5 +165,5 @@
 });
 
-// ── Live suggesties (JSON) ───────────────────────────────────────────────────
+// ── Live suggestions (JSON) ──────────────────────────────────────────────────
 router.get('/suggest', (req, res) => {
   const site = res.locals.site;
Index: src/routes/shows.js
===================================================================
--- src/routes/shows.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/routes/shows.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,10 +1,10 @@
 /**
- * Show-agenda + notify-me (premium feature #8) — publieke kant.
+ * Show agenda + notify-me (premium feature #8) — public side.
  *
- *   GET  /shows         -> komende optredens + "houd me op de hoogte"-formulier
- *   POST /shows/notify  -> aanmelden voor show-aankondigingen (subscribers, source
- *                          'notify'; double opt-in als SMTP er is)
+ *   GET  /shows         -> upcoming gigs + "keep me posted" form
+ *   POST /shows/notify  -> subscribe to show announcements (subscribers, source
+ *                          'notify'; double opt-in if SMTP configured)
  *
- * De notify-bevestiging/uitschrijving hergebruikt de generieke subscriber-links
+ * The notify confirm/unsubscribe reuses the generic subscriber links
  * (/nieuwsbrief/bevestigen|uitschrijven/:token). Hub: /user/:slug/shows.
  */
@@ -20,5 +20,5 @@
 const router = express.Router();
 
-// Agenda is opt-in: pas bereikbaar als de beheerder 'm heeft ingeschakeld.
+// Agenda is opt-in: only accessible once the admin has enabled it.
 function agendaOn() { return getSetting('agenda_enabled') === '1'; }
 
Index: src/server.js
===================================================================
--- src/server.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/server.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -2,5 +2,5 @@
  * Klonkt Beta — server bootstrap
  *
- * Persoonlijk multi-site platform — Node + SQLite + htmx.
+ * Personal multi-site platform — Node + SQLite + htmx.
  * Stack: Express + better-sqlite3 + EJS + htmx + ws.
  */
@@ -87,23 +87,23 @@
         "'self'",
         "'unsafe-inline'",
-        // Eigen custom-embeds (embed-player.js) laden de OFFICIELE player-API's
-        // van deze hosts. Zonder deze whitelist blokkeert de CSP ze stil (alleen
-        // een console-fout) en faalt de embed-speler.
+        // Our custom embeds (embed-player.js) load the OFFICIAL player APIs
+        // from these hosts. Without this whitelist the CSP silently blocks them
+        // (only a console error) and the embed player fails.
         "https://www.youtube.com",   // YouTube IFrame Player API (+ www-widgetapi.js)
-        "https://s.ytimg.com",       // YouTube player-assets
+        "https://s.ytimg.com",       // YouTube player assets
         "https://w.soundcloud.com",  // SoundCloud Widget API (api.js)
         "https://open.spotify.com",  // Spotify iFrame API (loader)
-        "https://*.spotifycdn.com",  // Spotify iFrame API (echte bundle: embed-cdn.spotifycdn.com)
+        "https://*.spotifycdn.com",  // Spotify iFrame API (real bundle: embed-cdn.spotifycdn.com)
       ],
-      // Helmet's default zet script-src-attr op 'none', wat ALLE inline event-
-      // handlers (onchange/onclick/onsubmit) blokkeert — daardoor deed o.a. de
-      // avatar-upload (<input onchange="this.form.submit()">) en de rol-dropdown
-      // niets. We staan inline handlers expliciet toe, consistent met de al
-      // toegestane inline <script> hierboven.
+      // Helmet's default sets script-src-attr to 'none', which blocks ALL inline
+      // event handlers (onchange/onclick/onsubmit) — causing e.g. the avatar
+      // upload (<input onchange="this.form.submit()">) and the role dropdown to
+      // silently do nothing. We explicitly allow inline handlers, consistent with
+      // the already-allowed inline <script> above.
       scriptSrcAttr: ["'unsafe-inline'"],
       styleSrc: ["'self'", "'unsafe-inline'"],
-      // blob: nodig voor de afbeeldings-editor (Cropper) — die toont de gekozen
-      // foto via een URL.createObjectURL(blob:…). Zonder blob: blokkeert de CSP
-      // de <img> stil → leeg bewerk-venster. (media-src heeft blob: al voor audio.)
+      // blob: required for the image editor (Cropper) — it displays the chosen
+      // photo via URL.createObjectURL(blob:…). Without blob: the CSP silently
+      // blocks the <img> → empty edit window. (media-src already has blob: for audio.)
       imgSrc: ["'self'", "data:", "https:", "blob:"],
       connectSrc: ["'self'", "wss:", "ws:", "https://*.spotifycdn.com", "https://*.scdn.co"],
@@ -121,5 +121,5 @@
         "https://embed.music.apple.com",
         "https://www.youtube-nocookie.com",
-        "https://www.youtube.com",   // YouTube IFrame API maakt soms een www.youtube.com-iframe
+        "https://www.youtube.com",   // YouTube IFrame API sometimes creates a www.youtube.com iframe
         "https://player.vimeo.com",
       ],
@@ -143,13 +143,12 @@
 if (!isDev) app.set('trust proxy', 1);
 
-// Schema aanmaken/bijwerken VÓÓR iets de DB aanraakt: de session-store doet
-// bij constructie al een query op de `sessions`-tabel, dus bij een verse
-// install moeten de tabellen eerst bestaan (anders: "no such table: sessions"
-// → crash-loop op de allereerste boot).
+// Create/migrate the schema BEFORE anything touches the DB: the session store
+// queries the `sessions` table on construction, so on a fresh install the tables
+// must exist first (otherwise: "no such table: sessions" → crash loop on first boot).
 initializeDatabase();
-startScheduler(); // release-planning: zet geplande posts live zodra publish_at bereikt is
-
-// Vangnet: garandeer dat er altijd een primaire site is (solo/hub/circle).
-// Idempotent — doet niets als er al een site is of nog geen beheerder.
+startScheduler(); // release planning: publish scheduled posts when publish_at is reached
+
+// Safety net: guarantee that there is always a primary site (solo/hub/circle).
+// Idempotent — does nothing if a site already exists or there is no admin yet.
 ensurePrimarySite();
 
@@ -173,15 +172,15 @@
 app.use('/assets', express.static(path.join(__dirname, 'assets'), { maxAge: isDev ? 0 : '1y' }));
 app.use('/media', express.static(process.env.MEDIA_PATH || './storage/media', {
-  // Publieke media (post-covers, avatars) moet door andere Klonkt-sites in hun
-  // CIRKEL cross-origin embedbaar zijn. Helmet zet standaard CORP=same-origin,
-  // wat die afbeeldingen in de browser blokkeert (bestand komt wél binnen, maar
-  // de browser weigert 'm te tonen). Voor /media dus expliciet cross-origin.
+  // Public media (post covers, avatars) must be cross-origin embeddable by other
+  // Klonkt sites in their CIRCLE. Helmet sets CORP=same-origin by default, which
+  // causes the browser to block those images (the file arrives, but the browser
+  // refuses to render it). Set cross-origin explicitly for /media.
   setHeaders: (res) => res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'),
 }));
 
-// (Verwijderd) TWA / digital-asset-links — alleen nodig voor de APK/TWA-variant.
-// Klonkt is PWA-only; geen assetlinks.json meer.
-
-// Cirkels: periodieke achtergrond-sync van remote instances (no-op tenzij tenancy='circle').
+// (Removed) TWA / digital-asset-links — only needed for the APK/TWA variant.
+// Klonkt is PWA-only; assetlinks.json is no longer served.
+
+// Circles: periodic background sync of remote instances (no-op unless tenancy='circle').
 startCircleSyncLoop();
 
@@ -203,6 +202,6 @@
 })();
 
-// Cirkels-federatie: publieke, site-agnostische endpoints (/.klonkt/*).
-// Vóór resolveSite/theme — ze hebben geen site-context nodig.
+// Circle federation: public, site-agnostic endpoints (/.klonkt/*).
+// Before resolveSite/theme — they don't need a site context.
 app.use(federationRoutes);
 
@@ -211,12 +210,12 @@
 app.use(loadTheme);
 
-// Lichtgewicht CSRF-defense: weiger cross-origin state-wijzigende requests.
-// Same-origin forms + HTMX sturen een matchende Origin; ontbreekt Origin dan
-// laten we door (non-browser clients). sameSite:'lax' op de sessiecookie is de
-// tweede laag. (Geldt niet voor GET/HEAD/OPTIONS.)
+// Lightweight CSRF defense: reject cross-origin state-mutating requests.
+// Same-origin forms + HTMX send a matching Origin; missing Origin is allowed
+// through (non-browser clients). sameSite:'lax' on the session cookie is the
+// second layer. (Does not apply to GET/HEAD/OPTIONS.)
 app.use((req, res, next) => {
   if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
   const origin = req.get('origin');
-  if (!origin) return next(); // geen Origin -> geen browser-CSRF-vector
+  if (!origin) return next(); // no Origin → no browser CSRF vector
   let originHost;
   try { originHost = new URL(origin).host; } catch { return res.status(403).send('Ongeldige origin'); }
@@ -225,14 +224,14 @@
 });
 
-// Kijker-accounts: alles bekijken mag (incl. Beheer), niets wijzigen. Dit is de
-// ENIGE schrijf-blokkade — fail-closed, vóór alle route-handlers. Elke state-
-// wijzigende methode wordt geweigerd (de login-POST zet de sessie pas ná deze
-// guard, dus die valt er niet onder). I.p.v. rauwe 403-tekst tonen we een nette
-// pagina (of, bij HTMX, een ingeswapte melding).
+// Viewer accounts: may view everything (including Admin), change nothing. This is
+// the ONLY write gate — fail-closed, before all route handlers. Every state-mutating
+// method is rejected (the login POST sets the session after this guard, so it is
+// not affected). Instead of raw 403 text we render a clean page (or, for HTMX,
+// a swapped-in message).
 app.use((req, res, next) => {
   const mutating = req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS';
   if (mutating && isViewer(req.session?.user)) {
     if (req.headers['hx-request'] === 'true') {
-      // htmx swapt niet op 4xx; stuur 200 + retarget zodat de melding in #pcms-main verschijnt.
+      // htmx doesn't swap on 4xx; send 200 + retarget so the message appears in #pcms-main.
       res.setHeader('HX-Retarget', '#pcms-main');
       res.setHeader('HX-Reswap', 'innerHTML');
@@ -277,5 +276,5 @@
 // Feed/sitemap routes are mounted at root because they're at well-known paths
 app.use('/', feedRoutes);
-app.use('/leden', artistsRoutes); // doorzoekbare leden-directory (alleen hub; solo: next())
+app.use('/leden', artistsRoutes); // searchable member directory (hub only; solo: next())
 app.get('/artiesten', (req, res) => res.redirect(301, req.originalUrl.replace(/^\/artiesten/, '/leden'))); // oude URL -> /leden
 app.use('/', hubRoutes); // hub-overview op '/' (solo: next() -> postsRoutes)
@@ -306,7 +305,7 @@
 
   // A stable identity per site so installs don't collide (Chromium uses `id`).
-  // NB: een id-wissel orphant bestaande PWA-installs (er is geen migratie die een
-  // install over een id-verandering heen tilt) — wie een site al als PWA had,
-  // moet 'm één keer opnieuw installeren. Data blijft server-side, dus niets kwijt.
+  // NB: changing the id orphans existing PWA installs (no migration carries an
+  // install across an id change) — anyone who already installed the site as a
+  // PWA will need to reinstall once. Data stays server-side, so nothing is lost.
   const idBase = site?.slug ? `klonkt-${site.slug}` : 'klonkt';
 
@@ -376,12 +375,12 @@
   self.clients.claim();
 });
-// ALLEEN navigaties (HTML-pagina's) onderscheppen, voor een offline-fallback.
-// Afbeeldingen, CSS, JS en /media NIET aanraken — laat de browser die native
-// afhandelen. Anders kon een mislukte netwerk-fetch terugvallen op een lege
-// cache-match (undefined) en zo een afbeelding "kapot" maken bij een gewone
-// refresh (hard reload omzeilt de SW en werkte daarom wél).
+// ONLY intercept navigations (HTML pages) for an offline fallback.
+// Do NOT touch images, CSS, JS or /media — let the browser handle those natively.
+// Otherwise a failed network fetch could fall back to an empty cache match
+// (undefined) and "break" an image on a normal refresh (hard reload bypasses
+// the SW, which is why that case worked fine).
 self.addEventListener('fetch', e => {
   if (e.request.method !== 'GET') return;
-  if (e.request.mode !== 'navigate') return; // alleen page-loads
+  if (e.request.mode !== 'navigate') return; // page loads only
   e.respondWith(
     fetch(e.request).catch(() => caches.match('/').then(r => r || Response.error()))
@@ -404,6 +403,6 @@
 app.use((req, res) => {
   res.status(404);
-  // Nette, mobielvriendelijke 404 via de shell (viewport + nav + huisstijl).
-  // Valt terug op kale HTML als het renderen onverhoopt faalt.
+  // Clean, mobile-friendly 404 via the shell (viewport + nav + site theme).
+  // Falls back to bare HTML if rendering unexpectedly fails.
   try {
     return renderPage(req, res, 'pages/404', {
Index: src/services/AudioEmbedService.js
===================================================================
--- src/services/AudioEmbedService.js	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/AudioEmbedService.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/AudioTranscoder.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/CircleFederation.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/CircleService.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/ImageWebpService.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/NotificationService.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/PatreonService.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/PermissionsService.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/PlaylistService.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/Scheduler.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/SettingsService.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/StatsService.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/SubscriberService.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/ThemeService.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/services/ensurePrimarySite.js	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -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 (
Index: src/views/pages/account.ejs
===================================================================
--- src/views/pages/account.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/account.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -90,5 +90,5 @@
   </section>
 
-  <%# ── PERSOONLIJKE TAAL ───────────────────────────────────── %>
+  <%# ── PERSONAL LANGUAGE ──────────────────────────────────── %>
   <% if (canMutate) { %>
   <section class="ax-card">
@@ -110,5 +110,5 @@
   <% } %>
 
-  <%# ── SITE (eigenaar mag de naam van z'n site aanpassen) ── %>
+  <%# ── SITE (owner may update their site name) ─────────── %>
   <% if (typeof editableSite !== 'undefined' && editableSite && canMutate) { %>
   <section class="ax-card">
@@ -130,6 +130,6 @@
   <% } %>
 
-  <%# ── WACHTWOORD ────────────────────────────────────────── %>
-  <%# In kijker-modus geen wachtwoord-sectie (niets te wijzigen). %>
+  <%# ── PASSWORD ──────────────────────────────────────────── %>
+  <%# In viewer mode there is no password section (nothing to change). %>
   <% if (canMutate) { %>
   <% if (hasPassword) { %>
Index: src/views/pages/admin-circle.ejs
===================================================================
--- src/views/pages/admin-circle.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/admin-circle.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -79,5 +79,5 @@
                   <span class="circ-badge">&#9208; <%= _cPaused %></span>
                 <% } else { %>
-                  <%# Onbekende status: toon 'm rauw + eventuele reden i.p.v. stil "gepauzeerd". %>
+                  <%# Unknown status: show it raw + any reason instead of silently showing "paused". %>
                   <span class="circ-badge"><%= l.status %></span>
                   <% if (l.last_error) { %><div class="circ-reason"><%= l.last_error %></div><% } %>
@@ -123,6 +123,6 @@
 .circ-status { display: flex; flex-wrap: wrap; align-items: center; gap: .35rem .5rem; margin-top: .25rem; font-size: .78rem; color: var(--ink-muted); }
 .circ-meta { color: var(--ink-muted); }
-/* Reden op een eigen regel onder de badge; normale woordafbreking (geen break-all
-   die hele zinnen midden in woorden knipt — dat zit op de URL-small, niet hier). */
+/* Reason on its own line below the badge; normal word breaking (no break-all
+   that cuts whole sentences mid-word — that is applied to the URL small, not here). */
 .circ-reason { flex-basis: 100%; line-height: 1.4; color: var(--ink-muted); word-break: normal; overflow-wrap: anywhere; }
 .btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
Index: src/views/pages/admin-google.ejs
===================================================================
--- src/views/pages/admin-google.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/admin-google.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -103,5 +103,5 @@
 .set-redirect-warn { margin: 0.85rem 0 0; font-size: 0.85rem; color: var(--ink-muted); }
 .set-redirect-warn code { background: var(--paper-2); padding: 0.05rem 0.35rem; border-radius: 4px; }
-/* Genummerde, altijd-zichtbare stap-voor-stap-uitleg (beginnervriendelijk) */
+/* Numbered, always-visible step-by-step instructions (beginner-friendly) */
 .gg-steps { list-style: none; margin: 0 0 1rem; padding: 0; display: flex; flex-direction: column; gap: 0.85rem; }
 .gg-steps > li { display: flex; gap: 0.7rem; align-items: flex-start; }
Index: src/views/pages/admin-seo.ejs
===================================================================
--- src/views/pages/admin-seo.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/admin-seo.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -110,6 +110,6 @@
 
 .site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
-/* <fieldset> krimpt door de UA min-inline-size:min-content niet mee → mobiel-overflow.
-   min-width:0 dwingt meekrimpen. */
+/* <fieldset> does not shrink because the UA sets min-inline-size:min-content → mobile overflow.
+   min-width:0 forces it to shrink. */
 .site-form fieldset { min-width: 0; }
 .site-form input, .site-form textarea, .site-form select { min-width: 0; }
Index: src/views/pages/admin-settings.ejs
===================================================================
--- src/views/pages/admin-settings.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/admin-settings.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -40,5 +40,5 @@
   </section>
 
-  <%# ── Standaardtaal voor bezoekers ── %>
+  <%# ── Default language for visitors ── %>
   <section class="set-card" style="margin-top:1rem">
     <h2><%= t('aset.default_lang') %></h2>
@@ -58,5 +58,5 @@
   </section>
 
-  <%# ── Tijdzone ── %>
+  <%# ── Timezone ── %>
   <section class="set-card" style="margin-top:1rem">
     <h2><%= t('aset.timezone') %></h2>
@@ -78,6 +78,6 @@
   </section>
 
-  <%# Modus-specifieke config — staat direct onder de Modus-kaart zodat 'ie bij
-      de gekozen modus hoort (Hub bij hub, Cirkel bij circle). %>
+  <%# Mode-specific config — placed directly below the Mode card so it visually
+      belongs to the selected mode (Hub under hub, Circle under circle). %>
   <% if (tenancy === 'hub') { %>
   <section class="set-card set-card--mode" style="margin-top:1rem">
@@ -141,6 +141,6 @@
   <% } %>
 
-  <%# Premium (Patreon) — alleen zichtbaar als de self-hoster de premium-laag aanzet
-      (env KLONKT_PREMIUM_ENABLED=on). Standaard onzichtbaar; niets gegate. %>
+  <%# Premium (Patreon) — only visible when the self-hoster enables the premium layer
+      (env KLONKT_PREMIUM_ENABLED=on). Hidden by default; nothing gated. %>
   <% if (premium && premium.enabled) { %>
   <section class="set-card" style="margin-top:1rem">
@@ -162,5 +162,5 @@
   <% } %>
 
-  <%# Google-login staat op een eigen Beheer-pagina (compacte link hier). %>
+  <%# Google login has its own admin page (compact link shown here). %>
   <section class="set-card" style="margin-top:1rem">
     <h2><%= t('aset.google_login') %></h2>
@@ -170,5 +170,5 @@
   </section>
 
-  <%# Nieuwsbrief-aanmelding in de footer (premium). %>
+  <%# Newsletter signup in the footer (premium). %>
   <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
   <section class="set-card" style="margin-top:1rem">
@@ -185,5 +185,5 @@
   <% } %>
 
-  <%# E-mail / SMTP — nodig voor nieuwsbrief versturen, notify-mails en wachtwoord-reset. %>
+  <%# E-mail / SMTP — required for sending newsletters, notify-mails and password resets. %>
   <section class="set-card" style="margin-top:1rem">
     <h2><%= t('aset.email_smtp') %></h2>
@@ -233,10 +233,10 @@
 .admin-tagline a { color: var(--accent); }
 .set-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem; }
-/* Modus-specifieke kaart (Hub/Cirkel): accent-rand links zodat 'ie zichtbaar bij
-   de gekozen modus hoort, direct onder de Modus-kaart. */
+/* Mode-specific card (Hub/Circle): accent border on the left so it visually
+   belongs to the selected mode, directly below the Mode card. */
 .set-card--mode { border-left: 3px solid var(--accent); }
 .set-card h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 0 0 0.5rem; }
 .set-help { color: var(--ink-muted); font-size: 0.9rem; margin: 0 0 1.25rem; }
-/* Google-setup-gids (uitklapbaar) */
+/* Google setup guide (collapsible) */
 .set-google-guide { margin: 0 0 1rem; border: 1px solid var(--rule); border-radius: 8px; background: var(--paper); }
 .set-google-guide > summary { cursor: pointer; padding: 0.6rem 0.85rem; font-size: 0.9rem; font-weight: 600; color: var(--accent); list-style: none; }
@@ -249,6 +249,6 @@
 .set-google-guide .set-help { padding: 0 0.85rem; }
 .set-form { display: flex; flex-direction: column; gap: 1rem; max-width: 460px; }
-/* Kale <label>'s in een set-form krijgen dezelfde nette stacked-stijl als .set-field:
-   label-tekst boven, input full-width. (.set-toggle = checkbox-rij, uitgezonderd.) */
+/* Bare <label>s inside a set-form inherit the same neat stacked style as .set-field:
+   label text on top, input full-width. (.set-toggle = checkbox row, excluded.) */
 .set-form > label:not(.set-toggle):not(.set-opt) { display: flex; flex-direction: column; gap: 0.3rem; }
 .set-form > label:not(.set-toggle):not(.set-opt) > span { font-size: 0.8rem; font-weight: 600; color: var(--ink-soft, var(--ink-muted)); }
@@ -277,6 +277,6 @@
 .set-field textarea { resize: vertical; }
 .set-field input:focus, .set-field textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
-/* Selects dezelfde nette stijl als de inputs i.p.v. de kale native dropdown:
-   eigen chevron, accent-focus, volle breedte. */
+/* Selects styled the same as inputs instead of the bare native dropdown:
+   custom chevron, accent focus ring, full width. */
 .set-form select {
   appearance: none; -webkit-appearance: none;
Index: src/views/pages/admin-shows.ejs
===================================================================
--- src/views/pages/admin-shows.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/admin-shows.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -63,6 +63,6 @@
   .ash-note { font-size: 13px; opacity: .8; margin: 0 0 18px; }
   .ash-form { border: 1px solid rgba(128,128,128,.2); border-radius: 14px; padding: 16px; margin-bottom: 24px; }
-  /* Mobiel-eerst: één kolom (twee 1fr-kolommen met niet-krimpbare date/text-inputs
-     liepen samen ~374px > telefoonbreedte). Twee kolommen pas vanaf 520px. */
+  /* Mobile-first: single column (two 1fr columns with non-shrinkable date/text inputs
+     combined to ~374px > phone width). Two columns only from 520px onwards. */
   .ash-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
   @media (min-width: 520px) { .ash-grid { grid-template-columns: 1fr 1fr; } }
Index: src/views/pages/admin-site-edit.ejs
===================================================================
--- src/views/pages/admin-site-edit.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/admin-site-edit.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -24,7 +24,7 @@
         <input type="text" name="title" value="<%= site.title || '' %>" required maxlength="200">
       </label>
-      <%# Eigenaar (her)toewijzen — ALLEEN god ÉN alleen in hub-modus. In hub geef
-          je zo elke gebruiker z'n eigen, zelf te beheren Klonkt. In solo (één site,
-          één eigenaar) is dit zinloos/verwarrend, dus verborgen. %>
+      <%# Assign (or reassign) owner — ONLY god AND only in hub mode. In hub this
+          gives each user their own self-managed Klonkt. In solo (one site,
+          one owner) this is pointless/confusing, so it's hidden. %>
       <% if (user && user.role === 'god' && (typeof tenancy !== 'undefined' && tenancy === 'hub')) { %>
       <label>
@@ -250,6 +250,6 @@
 <script>
 (function() {
-  // Slug-URL-veld: toon de echte host als gedimde prefix (de slug zelf is
-  // gekleurd via CSS). Hub → host/user/<slug>, anders host/<slug>.
+  // Slug URL field: show the real host as a dimmed prefix (the slug itself is
+  // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>.
   var sh = document.getElementById('slug-host');
   if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/');
@@ -354,8 +354,8 @@
 
 .site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
-/* Mobiel: een <fieldset> heeft een browser-default min-inline-size:min-content en
-   krimpt daardoor NIET mee → het hele formulier werd breder dan het scherm. min-width:0
-   forceert meekrimpen; de form-velden krijgen max-width:100% + border-box zodat ze
-   nooit buiten hun fieldset lopen. */
+/* Mobile: a <fieldset> has a browser-default min-inline-size:min-content and
+   therefore does NOT shrink → the entire form became wider than the screen. min-width:0
+   forces it to shrink; form fields get max-width:100% + border-box so they
+   never overflow their fieldset. */
 .site-form fieldset { min-width: 0; }
 .site-form input, .site-form select, .site-form textarea { min-width: 0; max-width: 100%; box-sizing: border-box; }
@@ -398,5 +398,5 @@
 .site-form input[readonly] { opacity: 0.6; cursor: not-allowed; background: var(--paper-2); }
 
-/* Slug als URL-veld: gedimde host-prefix + de slug zelf in de accentkleur. */
+/* Slug as URL field: dimmed host prefix + the slug itself in the accent colour. */
 .slug-url {
   display: flex; align-items: stretch;
@@ -425,6 +425,6 @@
 }
 
-/* Selects: zelfde look als de inputs, met een eigen chevron i.p.v. de kale
-   native pijl. De geopende optielijst blijft door de browser gerenderd. */
+/* Selects: same look as the inputs, with a custom chevron instead of the bare
+   native arrow. The open option list is still rendered by the browser. */
 .site-form select {
   appearance: none;
@@ -562,7 +562,7 @@
   .photo-controls { gap: .55rem; }
 }
-/* Mobiel-eerst: platform-keuze op een eigen regel (volle breedte), daaronder de
-   URL-input + verwijderknop. Voorkomt horizontale overflow op telefoons (de vaste
-   160px-kolom + niet-krimpbare input liepen samen ~447px > schermbreedte). */
+/* Mobile-first: platform selector on its own line (full width), URL input + remove
+   button below. Prevents horizontal overflow on phones (fixed 160px column +
+   non-shrinkable input together ~447px > screen width). */
 .profile-link-row {
   display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;
Index: src/views/pages/admin-users.ejs
===================================================================
--- src/views/pages/admin-users.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/admin-users.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -71,6 +71,6 @@
 
           <div class="ax-user-controls">
-            <%# Hub: geef deze gebruiker een eigen, zelf te beheren Klonkt — opent het
-                nieuw-site-formulier met deze gebruiker al als eigenaar voorgeselecteerd. %>
+            <%# Hub: give this user their own self-managed Klonkt — opens the
+                new-site form with this user already pre-selected as owner. %>
             <% if (typeof tenancy !== 'undefined' && tenancy === 'hub' && canMutate) { %>
               <a href="/admin/sites/new?owner=<%= u.id %>" class="ax-icon-btn"
@@ -98,5 +98,5 @@
               </form>
             <% } else { %>
-              <%# Lege plek zodat de rol-kolom op elke rij uitlijnt. %>
+              <%# Empty placeholder so the role column aligns on every row. %>
               <span class="ax-icon-btn-spacer" aria-hidden="true"></span>
             <% } %>
Index: src/views/pages/auth-login.ejs
===================================================================
--- src/views/pages/auth-login.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/auth-login.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,7 +1,7 @@
 <%
-  // adminLogin = de verborgen beheerders-loginpagina (/auth/admin): toont het
-  // gebruikersnaam/wachtwoord-formulier. De publieke /auth/login toont voor
-  // bezoekers ALLEEN Google-login (luisteraars/fans). Zo staat de admin-login
-  // niet zichtbaar op de plek waar bezoekers heen gestuurd worden.
+  // adminLogin = the hidden admin login page (/auth/admin): shows the
+  // username/password form. The public /auth/login shows visitors
+  // ONLY Google login (listeners/fans). This keeps the admin login
+  // off the page where visitors are directed.
   var _admin = (typeof adminLogin !== 'undefined' && adminLogin);
 %>
@@ -32,5 +32,5 @@
 
   <% if (_admin) { %>
-    <%# Verborgen beheerders-login (wachtwoord). %>
+    <%# Hidden admin login (password). %>
     <form method="post" action="/auth/login" class="auth-form">
       <% if (typeof next !== 'undefined' && next) { %>
@@ -49,5 +49,5 @@
     </form>
   <% } else if (googleReady) { %>
-    <%# Publieke login: alleen Google, voor luisteraars/fans. %>
+    <%# Public login: Google only, for listeners/fans. %>
     <p class="auth-sub"><%= t('auth.public_sub') %></p>
     <a class="btn btn-google" href="/auth/google<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">
@@ -65,5 +65,5 @@
 
   <% if (!_admin) { %>
-    <%# Klikbare box voor de beheerder → naar de verborgen wachtwoord-login. %>
+    <%# Clickable box for the admin → navigates to the hidden password login. %>
     <a class="auth-admin-box" href="/auth/admin<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">
       <span class="auth-admin-box-icon" aria-hidden="true">🔑</span>
@@ -85,6 +85,6 @@
   .auth-notice-list li { margin: 4px 0; }
   .auth-sub { color: var(--ink-muted); margin: 0 0 1rem; text-align: center; }
-  /* Google-knop is wit → tekst altijd donker houden, ook op hover (de globale
-     a.btn:hover-regel maakt 'm anders bijna-wit op wit = onleesbaar). */
+  /* Google button is white → keep text dark at all times, including hover (the global
+     a.btn:hover rule would otherwise make it near-white on white = unreadable). */
   a.btn-google, a.btn-google:hover { color: #1f1f1f; }
   .auth-admin-box {
Index: src/views/pages/circle-feed.ejs
===================================================================
--- src/views/pages/circle-feed.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/circle-feed.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -26,5 +26,5 @@
     </div>
     <% } else { %>
-      <%# Meer dan 5: geen chips (zou een muur worden) — alleen het aantal. %>
+      <%# More than 5: no chips (would become a wall) — show the count only. %>
       <span class="cirkel-count"><%= t('cfeed.count', { n: sites.length }) %></span>
     <% } %>
@@ -38,8 +38,8 @@
 <% } else { %>
 
-  <%# Dezelfde view als de hoofdpagina: timeline (post-card) + grid (post-tile),
-      gewisseld door de view-switcher via body[data-feed-view]. De remote-posts
-      zijn in circle.js naar het lokale post-shape gevormd (met external_url,
-      zodat de kaarten naar de bron-site linken). %>
+  <%# Same view as the home page: timeline (post-card) + grid (post-tile),
+      switched by the view-switcher via body[data-feed-view]. The remote posts
+      are shaped to the local post format in circle.js (with external_url,
+      so cards link to the source site). %>
 
   <!-- ===== TIMELINE (standaard) ===== -->
Index: src/views/pages/fan-gate.ejs
===================================================================
--- src/views/pages/fan-gate.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/fan-gate.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,6 +1,6 @@
 <div class="container fg-navwrap">
-  <%# Zelfde post-navigatie (Newer/Older) als op een gewone post, zodat een
-      bezoeker op de fan-gate niet vastloopt maar verder kan bladeren. Zelfde
-      maten als .post-page (720px / padding 0 1rem) zodat de nav pixel-gelijk is. %>
+  <%# Same post navigation (Newer/Older) as on a regular post, so a visitor
+      on the fan gate can still browse rather than getting stuck. Same
+      dimensions as .post-page (720px / padding 0 1rem) for pixel-perfect alignment. %>
   <%- include('../partials/post-nav', { newerPost: (typeof newerPost !== 'undefined' ? newerPost : null), olderPost: (typeof olderPost !== 'undefined' ? olderPost : null) }) %>
 </div>
@@ -17,6 +17,6 @@
 
 <style>
-  /* EXACT dezelfde class-combinatie (.container + override) én waarden als
-     .post-page in post.ejs, zodat de post-nav cascade-identiek uitlijnt. */
+  /* EXACT same class combination (.container + override) and values as
+     .post-page in post.ejs, so the post-nav aligns identically in the cascade. */
   .fg-navwrap { max-width: 720px; margin: 0.5rem auto 2rem; padding: 0 1rem; }
   .fg { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; }
Index: src/views/pages/hub-home.ejs
===================================================================
--- src/views/pages/hub-home.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/hub-home.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,6 +1,6 @@
 <div class="hub<%= hub.heroImage ? ' hub--img' : '' %>">
 
-  <%# ── HERO (label) — de mini topnav-balk staat erboven (chrome.ejs);
-        de hero zelf is de "grote" header van de hub-home ──────────────── %>
+  <%# ── HERO (label) — the mini topnav bar sits above it (chrome.ejs);
+        the hero itself is the "large" header of the hub home ──────────────── %>
   <header class="hub-hero<%= hub.heroImage ? ' has-img' : '' %>"<% if (hub.heroImage) { %> style="--hero-img:url('<%= hub.heroImage %>'); --hero-ov:<%= ((hub.heroOverlay == null ? 45 : hub.heroOverlay)/100).toFixed(2) %>"<% } %>>
     <div class="hub-hero-inner container">
@@ -17,5 +17,5 @@
   <div class="container hub-body">
 
-    <%# ── HOOFDPAGINA (label-/hoofdaccount — geen artiest) ──────────── %>
+    <%# ── MAIN PAGE (label-/primary account — not an artist) ──────────── %>
     <% if (mainSite) { %>
     <section class="hub-sec hub-sec-main">
@@ -37,5 +37,5 @@
     <% } %>
 
-    <%# ── ARTIESTEN-ROSTER ─────────────────────────────────────────── %>
+    <%# ── ARTIST ROSTER ─────────────────────────────────────────── %>
     <% if (artists && artists.length) { %>
     <% var _total = (typeof totalArtists !== 'undefined') ? totalArtists : artists.length; %>
@@ -69,5 +69,5 @@
     <% } %>
 
-    <%# ── LAATSTE POSTS ────────────────────────────────────────────── %>
+    <%# ── LATEST POSTS ────────────────────────────────────────────── %>
     <% if (posts && posts.length) { %>
     <section class="hub-sec">
@@ -99,6 +99,6 @@
 .hub { width: 100%; position: relative; }
 
-/* Hero sluit direct aan op de mini topnav-balk — geen tussenruimte boven de
-   hub-home (override van de globale main-padding-top). */
+/* Hero connects directly to the mini topnav bar — no gap above the
+   hub home (overrides the global main-padding-top). */
 .pcms-main { padding-top: 0; }
 .hub-hero { margin-top: 0; }
@@ -160,5 +160,5 @@
 .hub-more-link:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, var(--paper-2)); }
 
-/* ── Hoofdpagina-kaart (label-/hoofdaccount, apart van de artiesten) ── */
+/* ── Main page card (label-/primary account, separate from the artists) ── */
 .hub-sec-main { margin-top: 2.25rem; }
 .hub-main-card {
@@ -199,5 +199,5 @@
 }
 
-/* ── Roster (artiesten) ───────────────────────────────────────────── */
+/* ── Roster (artists) ───────────────────────────────────────────── */
 .roster {
   display: grid;
@@ -231,5 +231,5 @@
 }
 
-/* ── Feed (laatste posts) ─────────────────────────────────────────── */
+/* ── Feed (latest posts) ─────────────────────────────────────────── */
 .hub-feed { display: flex; flex-direction: column; gap: 0.6rem; }
 .feed-item {
Index: src/views/pages/post-edit.ejs
===================================================================
--- src/views/pages/post-edit.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/post-edit.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -9,7 +9,7 @@
 // and styling changed.
 const _hasCover = !!post.cover_image_url;
-// In hub-modus moeten save/create/cancel de /user/<slug>/-prefix dragen, anders
-// valt de request terug op de primaire site (siteUrlBase leeg) en rendert de
-// post na opslaan headerless (bareChrome). In solo is _base leeg.
+// In hub mode, save/create/cancel must carry the /user/<slug>/ prefix, otherwise
+// the request falls back to the primary site (siteUrlBase empty) and the post
+// renders headerless (bareChrome) after saving. In solo mode _base is empty.
 const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 %>
@@ -138,6 +138,6 @@
              data-placeholder="<%= t('pedit.editor_placeholder') %>"></div>
 
-        <%# Sticky "tik om te bewerken"-hint (alleen zichtbaar op touch + niet-fullscreen
-            via CSS). Puur visueel (pointer-events:none); de tik op het veld opent fullscreen. %>
+        <%# Sticky "tap to edit" hint (only visible on touch + non-fullscreen
+            via CSS). Purely visual (pointer-events:none); tapping the field opens fullscreen. %>
         <div class="pe-edit-hint" aria-hidden="true">✎ <%= t('pedit.tap_to_edit') %></div>
 
@@ -225,7 +225,7 @@
               if (!cb || !box) return;
               var inp = document.getElementById('pe-publish-at');
-              var SITE_TZ = '<%= timezone || '' %>'; // ingestelde site-tijdzone; leeg = browser-lokaal
+              var SITE_TZ = '<%= timezone || '' %>'; // configured site timezone; empty = browser local
               var pad = function (n) { return String(n).padStart(2, '0'); };
-              // Offset (ms) tussen een tijdzone en UTC op een bepaald moment.
+              // Offset (ms) between a timezone and UTC at a given moment.
               function tzOffset(date, tz) {
                 var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
@@ -233,5 +233,5 @@
                 return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime();
               }
-              // datetime-local "wandtijd" (in de site-zone) → UTC Date.
+              // datetime-local "wall time" (in the site zone) → UTC Date.
               function wallToUtc(wall) {
                 if (!SITE_TZ) return new Date(wall);
@@ -239,5 +239,5 @@
                 return new Date(guess - tzOffset(new Date(guess), SITE_TZ));
               }
-              // UTC-ISO → "YYYY-MM-DDTHH:MM" wandtijd in de site-zone.
+              // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone.
               function utcToWall(iso) {
                 var d = new Date(iso); if (isNaN(d)) return '';
@@ -247,7 +247,7 @@
                 return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
               }
-              // Prefill: opgeslagen UTC → wandtijd in de site-zone.
+              // Prefill: stored UTC → wall time in the site zone.
               if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso);
-              // "Ingepland voor" in leesbare tijd van de site-zone.
+              // "Scheduled for" in human-readable time in the site zone.
               var when = document.getElementById('pe-sched-when');
               if (when && when.dataset.iso) {
@@ -257,5 +257,5 @@
               function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; }
               cb.addEventListener('change', sync); sync();
-              // Bij opslaan: wandtijd in de site-zone → UTC-ISO via een hidden veld.
+              // On save: wall time in the site zone → UTC-ISO via a hidden field.
               var form = cb.closest('form');
               if (form) {
@@ -466,5 +466,5 @@
   width: 32px; height: 32px;
   padding: 0;
-  touch-action: manipulation; /* snappy taps op mobiel, geen dubbeltik-zoom */
+  touch-action: manipulation; /* snappy taps on mobile, no double-tap zoom */
   -webkit-user-select: none; user-select: none;
   background: transparent;
@@ -478,6 +478,6 @@
   -webkit-tap-highlight-color: transparent;
 }
-/* Hover alleen op echte hover-apparaten — op touch blijft :hover anders "plakken"
-   na een tik, waardoor de actief/inactief-staat onleesbaar wordt. */
+/* Hover only on real hover-capable devices — on touch :hover otherwise "sticks"
+   after a tap, making the active/inactive state unreadable. */
 @media (hover: hover) {
   .pe-toolbar button:hover {
@@ -487,6 +487,6 @@
 }
 .pe-toolbar button:active { transform: scale(.94); }
-/* Actieve opmaak = onmiskenbaar gevuld met de accentkleur. Zo is op mobiel
-   meteen duidelijk dat bv. vet AAN staat (tik nogmaals = uit). */
+/* Active formatting = unmistakably filled with the accent colour. On mobile
+   it's immediately clear when e.g. bold is ON (tap again = off). */
 .pe-toolbar button.is-active {
   background: var(--accent);
@@ -505,5 +505,5 @@
 .pe-toolbar-spacer { flex: 1; }
 
-/* ─── Volledig-scherm schrijfmodus ─── */
+/* ─── Full-screen writing mode ─── */
 .fs-icon-compress { display: none; }
 .pe-editor-frame.pe-fs .fs-icon-expand { display: none; }
@@ -514,20 +514,20 @@
   height: 100dvh;
   background: var(--paper);
-  overflow: hidden;   /* alléén het tekstveld scrollt, niet het frame zelf */
-}
-/* In fullscreen vult het schrijfveld de resterende ruimte en scrollt het zelf
-   (max-height: none overschrijft de mobiele box-begrenzing hieronder). */
+  overflow: hidden;   /* only the text field scrolls, not the frame itself */
+}
+/* In fullscreen the writing field fills the remaining space and scrolls itself
+   (max-height: none overrides the mobile box limit below). */
 .pe-editor-frame.pe-fs .pe-editor {
   flex: 1 1 auto; min-height: 0; height: auto; max-height: none;
 }
-/* NB: bewust GEEN overflow:hidden op html/body in fullscreen — dat kon blijven
-   hangen (bv. fullscreen verlaten via navigatie) en blokkeerde dan het scrollen van
-   de hele pagina. Het fullscreen-frame (position:fixed, inset:0) dekt de pagina al
-   af, en op touch zijn de scrollbalken verborgen → geen dubbele balk nodig. */
-
-/* "Klaar"-knop: alleen in fullscreen zichtbaar (links in de toolbar), duidelijke
-   accent-pill i.p.v. een vierkant icoontje. */
-/* Verberg-regel specifieker dan ".pe-toolbar button" (anders wint die en zie je
-   de Klaar-knop ook inline als kleine knop). Alleen in fullscreen tonen → groot. */
+/* NB: deliberately NO overflow:hidden on html/body in fullscreen — that could get
+   stuck (e.g. leaving fullscreen via navigation) and would block scrolling on the
+   whole page. The fullscreen frame (position:fixed, inset:0) already covers the page,
+   and on touch scrollbars are hidden → no double bar needed. */
+
+/* "Done" button: only visible in fullscreen (left side of toolbar), clear
+   accent-pill instead of a square icon. */
+/* Hide rule more specific than ".pe-toolbar button" (otherwise that wins and the
+   Done button also shows inline as a small button). Only shown in fullscreen → large. */
 .pe-toolbar button.pe-fs-done { display: none; }
 .pe-editor-frame.pe-fs .pe-fs-done {
@@ -556,13 +556,13 @@
 }
 .pe-editor:focus { outline: none; }
-/* Inline (niet-fullscreen) groeit het schrijfveld gewoon mee met de inhoud — geen
-   interne scroll-box (scroll-binnen-scroll is verwarrend). Op mobiel/tablet gaat
-   typen sowieso fullscreen (zie JS), waar het veld de pagina vult en als enige
-   scrollt. */
+/* Inline (non-fullscreen) the writing field simply grows with the content — no
+   internal scroll-box (scroll-within-scroll is confusing). On mobile/tablet typing
+   always goes fullscreen (see JS), where the field fills the page and is the sole
+   scroller. */
 .pe-editor-frame:not(.pe-fs) .pe-editor { overflow: visible; }
 
-/* Touch: het inline content-veld is geen tekstveld maar een tap-vlak → fullscreen
-   bewerken. Een "✎ Tik om te bewerken"-pill plakt sticky onderaan de container,
-   zodat de hint altijd in beeld is zonder midden in de tekst te staan. */
+/* Touch: the inline content field is not a text field but a tap target → fullscreen
+   editing. A "✎ Tap to edit" pill sticks to the bottom of the container,
+   so the hint is always visible without sitting in the middle of the text. */
 .pe-editor.pe-tap-to-edit { cursor: pointer; }
 .pe-edit-hint { display: none; }
@@ -571,5 +571,5 @@
     display: block;
     position: sticky;
-    bottom: 4.5rem;   /* boven de sticky Opslaan/Annuleren-balk */
+    bottom: 4.5rem;   /* above the sticky Save/Cancel bar */
     width: max-content;
     max-width: calc(100% - 2rem);
@@ -580,9 +580,9 @@
     pointer-events: none; text-align: center; white-space: nowrap;
   }
-  /* Niet relevant op touch (slepen/selecteren hoort bij inline-bewerken op desktop). */
+  /* Not relevant on touch (drag/select belongs to inline editing on desktop). */
   .pe-content-hint { display: none; }
-  /* Inline (niet-fullscreen) op touch: simpel houden — je bewerkt hier toch niet,
-     dus geen opmaak-toolbar en geen losse rand. Alleen de content-preview + de
-     "tik om te bewerken"-pill. De toolbar verschijnt pas in fullscreen. */
+  /* Inline (non-fullscreen) on touch: keep it simple — you don't edit here anyway,
+     so no formatting toolbar and no border. Just the content preview + the
+     "tap to edit" pill. The toolbar only appears in fullscreen. */
   .pe-editor-frame:not(.pe-fs) .pe-toolbar { display: none; }
   .pe-editor-frame:not(.pe-fs) { border-top: 0; }
@@ -955,5 +955,5 @@
 }
 
-/* Vastpinnen: checkbox + ▲▼-stepper met beschrijving (i.p.v. een ruw rang-getal). */
+/* Pin: checkbox + ▲▼-stepper with description (instead of a raw rank number). */
 .pe-pin { display: flex; flex-direction: column; gap: 0.45rem; }
 .pe-pin-pos { display: flex; align-items: center; gap: 0.55rem; padding-left: 1.65rem; }
@@ -978,5 +978,5 @@
   gap: 0.5rem;
   padding: 0.85rem 1rem;
-  margin: 0.5rem -1rem 0;   /* extend to viewport edges on tight container */
+  margin: 0.5rem -1rem 0;   /* extend to viewport edges on a narrow container */
   background: color-mix(in srgb, var(--paper) 94%, transparent);
   -webkit-backdrop-filter: blur(8px);
@@ -987,14 +987,14 @@
 .pe-actions-spacer { flex: 1; }
 
-/* De editor is een focus-scherm: verberg de mobiele site-tabbalk (Home/Zoek/…)
-   zodat er niet twee balken onderaan stapelen (Opslaan-balk + tabbalk). De
-   Opslaan/Annuleren-balk wordt dan de enige onderbalk → gewoon op bottom:0,
-   geen tab-offset meer nodig. Op desktop is de tabbalk toch al verborgen. */
+/* The editor is a focus screen: hide the mobile site tab bar (Home/Search/…)
+   so two bars don't stack at the bottom (Save bar + tab bar). The
+   Save/Cancel bar then becomes the only bottom bar → plain bottom:0,
+   no tab offset needed. On desktop the tab bar is already hidden. */
 body:has(.post-edit-page) .bottom-tab { display: none; }
-/* Audiospeler (indien aan het spelen) niet meer 56px optillen voor de nu-verborgen
-   tabbalk, anders zou die zweven boven de actiebalk. */
+/* Audio player (if playing) no longer lifted 56px for the now-hidden
+   tab bar, otherwise it would float above the action bar. */
 body:has(.post-edit-page) .audio-player { bottom: 0; }
 
-/* ── Afbeeldings-editor (draaien / bijsnijden / spiegelen) ── */
+/* ── Image editor (rotate / crop / mirror) ── */
 .imed-backdrop {
   position: fixed; inset: 0; z-index: 9999;
@@ -1015,6 +1015,6 @@
 }
 .imed-stage img { display: block; max-width: 100%; }
-/* Cropper z'n container moet de volle stage-hoogte vullen — anders klapt 'ie dicht
-   in een flex-kolom zonder expliciete hoogte → leeg bewerk-venster (geen afbeelding). */
+/* Cropper's container must fill the full stage height — otherwise it collapses
+   in a flex column without an explicit height → empty edit window (no image). */
 .imed-stage .cropper-container { width: 100% !important; height: 100% !important; }
 .imed-tools {
@@ -1052,6 +1052,6 @@
   }
 
-  // ── Afbeeldings-editor (draaien / bijsnijden / spiegelen) ──────────
-  // Lazy-load Cropper.js (lokaal gevendord) pas bij het eerste gebruik.
+  // ── Image editor (rotate / crop / mirror) ──────────
+  // Lazy-load Cropper.js (locally vendored) on first use.
   let _cropperReady = null;
   function ensureCropper() {
@@ -1073,11 +1073,11 @@
   }
 
-  // Opent de editor voor een gekozen bestand; resolved met een bewerkt File,
-  // of null als de gebruiker annuleert. Animatie-GIF's worden niet door de
-  // canvas-editor gehaald (zouden statisch worden) — die uploaden direct.
+  // Opens the editor for a chosen file; resolves with an edited File,
+  // or null if the user cancels. Animated GIFs are not sent through the
+  // canvas editor (they would become static) — those upload directly.
   async function openImageEditor(file) {
     if (!file || !file.type || !file.type.startsWith('image/')) return file;
-    if (file.type === 'image/gif') return file; // behoud animatie
-    try { await ensureCropper(); } catch (_) { return file; } // editor onbeschikbaar → direct uploaden
+    if (file.type === 'image/gif') return file; // preserve animation
+    try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly
 
     return new Promise((resolve) => {
@@ -1118,5 +1118,5 @@
         cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true });
       };
-      img.onerror = () => { cleanup(); resolve(file); }; // kon niet laden → upload het origineel
+      img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original
       img.src = url;
 
@@ -1179,5 +1179,5 @@
       const edited = await openImageEditor(coverField.files[0]);
       coverField.value = '';
-      if (!edited) return; // geannuleerd
+      if (!edited) return; // cancelled
       coverStatus.classList.remove('is-error');
       coverStatus.textContent = '<%= t('pedit.js_uploading') %>';
@@ -1224,6 +1224,6 @@
   if (!editor) return;
 
-  // Titel alleen automatisch focussen op desktop (muis/trackpad). Op touch zou
-  // dit meteen het toetsenbord openen bij het openen van de editor — niet gewenst.
+  // Auto-focus the title only on desktop (mouse/trackpad). On touch this would
+  // immediately open the keyboard when the editor opens — not desired.
   try {
     const titleInput = form && form.querySelector('input[name="title"]');
@@ -1329,9 +1329,9 @@
 
   // ── Toolbar wiring
-  // Houd de scrollpositie vast rond een edit-commando. execCommand/insert scrollt
-  // standaard de caret in beeld → het beeld "verspringt" bij het aanklikken van een
-  // opmaakknop. We leggen ALLE scrollbare voorouders (editor, frame, #pcms-main, …)
-  // + de pagina vast en zetten ze terug — sync én over een paar frames, want Chrome
-  // scrollt soms pas een frame later. De gebruiker scrollt zo zelf.
+  // Lock the scroll position around an edit command. execCommand/insert scrolls
+  // the caret into view by default → the view "jumps" when clicking a formatting
+  // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …)
+  // + the page and restore them — sync and over a few frames, because Chrome
+  // sometimes scrolls a frame later. The user scrolls themselves.
   function scrollableAncestors(el) {
     const list = [];
@@ -1345,6 +1345,6 @@
   }
   function keepScroll(fn) {
-    // In fullscreen staat de pagina vast (body overflow:hidden) en mag het veld
-    // gewoon naar de caret scrollen — geen pagina-sprong mogelijk, dus niets fixen.
+    // In fullscreen the page is locked (body overflow:hidden) and the field may
+    // scroll to the caret freely — no page jump possible, so nothing to fix.
     const frame = document.querySelector('.pe-editor-frame');
     if (frame && frame.classList.contains('pe-fs')) { fn(); return; }
@@ -1389,5 +1389,5 @@
     execCmd('createLink', url);
   }
-  // De huidige selectie zit in een <blockquote> binnen de editor? Geef 'm terug.
+  // Is the current selection inside a <blockquote> within the editor? Return it.
   function blockquoteAncestor() {
     const sel = window.getSelection();
@@ -1400,7 +1400,7 @@
     return null;
   }
-  // Echte toggle: execCommand('formatBlock','blockquote') zet 'm wél AAN maar
-  // krijgt 'm nooit meer UIT (browser-quirk). Staat de caret al in een quote →
-  // pak de wrapper uit; anders pas blockquote toe.
+  // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but
+  // can never turn it OFF (browser quirk). If the caret is already in a quote →
+  // unwrap it; otherwise apply blockquote.
   function toggleBlockquote() {
     keepScroll(function () {
@@ -1409,5 +1409,5 @@
       if (bq) {
         const parent = bq.parentNode;
-        // Inhoud uit de quote halen, op z'n plek, en de lege wrapper verwijderen.
+        // Extract content from the quote in place, then remove the empty wrapper.
         const ref = bq;
         let firstMoved = null;
@@ -1418,5 +1418,5 @@
         }
         parent.removeChild(bq);
-        // Caret terugzetten in de uitgepakte inhoud.
+        // Restore the caret inside the unwrapped content.
         if (firstMoved) {
           const sel = window.getSelection();
@@ -1436,9 +1436,9 @@
 
   if (toolbar) {
-    // CRUCIAAL (mobiel + desktop): voorkom dat een toolbar-knop de focus/selectie
-    // uit het editor-veld steelt. Zonder dit raakt de selectie kwijt bij het tikken
-    // → execCommand werkt op een lege selectie (vet kan niet meer UIT) én de browser
-    // scrollt de caret opnieuw in beeld (de "sprong naar beneden"). preventDefault op
-    // mousedown houdt de focus in de editor; de click blijft gewoon vuren.
+    // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection
+    // from the editor field. Without this the selection is lost on tap
+    // → execCommand operates on an empty selection (bold can no longer be toggled OFF)
+    // and the browser scrolls the caret back into view (the "jump down"). preventDefault
+    // on mousedown keeps focus in the editor; the click still fires normally.
     toolbar.addEventListener('mousedown', (e) => {
       if (e.target.closest('button')) e.preventDefault();
@@ -1457,13 +1457,13 @@
   }
 
-  // ── Volledig-scherm schrijfmodus: het schrijfveld vult de hele pagina.
+  // ── Full-screen writing mode: the writing field fills the whole page.
   const fsBtn = document.getElementById('pe-fullscreen-btn');
   const editorFrame = document.querySelector('.pe-editor-frame');
   const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
 
-  // Op mobiel duwt het toetsenbord de zichtbare (visual) viewport omhoog terwijl
-  // een position:fixed-frame aan de LAYOUT-viewport blijft hangen → de toolbar
-  // schuift uit beeld. Houd het fullscreen-frame daarom gelijk aan de visual
-  // viewport (top + hoogte), zodat de toolbar altijd bovenaan zichtbaar blijft.
+  // On mobile the keyboard pushes the visible (visual) viewport up while
+  // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar
+  // slides out of view. Keep the fullscreen frame aligned to the visual
+  // viewport (top + height) so the toolbar stays visible at the top.
   function syncFsViewport() {
     if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
@@ -1499,5 +1499,5 @@
       }
     }
-    // Op touch is het veld inline NIET bewerkbaar; alleen in fullscreen wel.
+    // On touch the field is NOT editable inline; only in fullscreen.
     if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false');
     if (on) {
@@ -1505,6 +1505,6 @@
     } else {
       if (isTouch) editor.blur();
-      // Bij sluiten: naar de TOP van de content scrollen i.p.v. ergens onderaan
-      // (footer) te blijven hangen.
+      // On close: scroll to the TOP of the content instead of staying
+      // somewhere at the bottom (footer).
       requestAnimationFrame(function () {
         try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {}
@@ -1512,7 +1512,7 @@
     }
   }
-  // De fullscreen schrijf-"pagina": openen pusht een history-state zodat de browser-
-  // back-knop (en de Klaar-knop) 'm sluit en je terug bent in het formulier — voelt
-  // als een aparte pagina, maar álle formuliervelden blijven intact (zelfde DOM).
+  // The fullscreen writing "page": opening pushes a history state so the browser
+  // back button (and the Done button) closes it and returns you to the form — feels
+  // like a separate page, but all form fields remain intact (same DOM).
   function openFs() {
     if (isFs()) return;
@@ -1522,5 +1522,5 @@
   function closeFs() {
     if (!isFs()) return;
-    if (history.state && history.state.peFs) history.back(); // → popstate sluit af
+    if (history.state && history.state.peFs) history.back(); // → popstate closes it
     else applyFs(false);
   }
@@ -1534,7 +1534,7 @@
   });
 
-  // Op mobiel/tablet (touch): het content-veld is INLINE NIET bewerkbaar — het is
-  // dan geen tekstveld. Eén tik → fullscreen, waar het wél bewerkbaar wordt
-  // (toggleFullscreen zet contenteditable aan/uit). Zo kun je nooit inline typen.
+  // On mobile/tablet (touch): the content field is NOT editable inline — it is
+  // not a text field there. One tap → fullscreen, where it becomes editable
+  // (toggleFullscreen toggles contenteditable). This prevents inline typing.
   if (isTouch) {
     editor.setAttribute('contenteditable', 'false');
@@ -1555,5 +1555,5 @@
       try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
     }
-    // Quote-knop: actief als de caret in een <blockquote> staat (toggle-feedback).
+    // Quote button: active when the caret is inside a <blockquote> (toggle feedback).
     const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
     if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
@@ -1588,5 +1588,5 @@
   async function uploadAndInsertImage(file) {
     const edited = await openImageEditor(file);
-    if (!edited) return; // geannuleerd
+    if (!edited) return; // cancelled
     contentStatus.classList.remove('is-error');
     contentStatus.textContent = '<%= t('pedit.js_uploading') %>';
@@ -1654,6 +1654,6 @@
   }
 
-  // ── Embed insert: plak een platform-URL -> [[embed:url]]-chip die server-side
-  //    een iframe wordt (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
+  // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes
+  //    an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
   const embedBtn = document.getElementById('insert-embed-btn');
   if (embedBtn) {
@@ -1674,5 +1674,5 @@
     const tpEmpty  = document.getElementById('tp-empty');
     const tpSearch = document.getElementById('tp-search');
-    let tpCache = null;       // cached tracks list (fetched once per page load)
+    let tpCache = null;       // cached track list (fetched once per page load)
     let tpLastFocus = null;   // element to restore focus to on close
 
@@ -1870,11 +1870,11 @@
 <script>
 (function () {
-  // Vastpinnen: checkbox toggelt het verborgen rang-veld (0 = niet gepind),
-  // ▲▼ verschuift de plek, met een leesbare beschrijving i.p.v. een ruw getal.
+  // Pin: checkbox toggles the hidden rank field (0 = not pinned),
+  // ▲▼ shifts the position, with a readable description instead of a raw number.
   var toggle = document.getElementById('pin-toggle');
   var rank   = document.getElementById('pin-rank');
   var pos    = document.getElementById('pin-pos');
   var label  = document.getElementById('pin-label');
-  var up     = document.getElementById('pin-up');    // hoger = lager getal (richting 1/bovenaan)
+  var up     = document.getElementById('pin-up');    // higher = lower number (towards 1/top)
   var down   = document.getElementById('pin-down');
   if (!toggle || !rank || !pos) return;
@@ -1900,11 +1900,11 @@
 
 (function () {
-  // De Opslaan/Annuleren-balk (position: sticky; bottom:0) net boven twee mogelijke
-  // obstakels houden door een dynamische bottom-offset te zetten = het grootste van:
-  //  1) de hoogte van het toetsenbord-deel dat de layout-viewport NIET dekt
-  //     (op iOS verschuift de visual viewport; op Android verkleint de layout-
-  //     viewport door interactive-widget=resizes-content → offset ≈ 0);
-  //  2) de hoogte van de spelende audiospeler (fixed, z-index 1000).
-  // We blijven bij sticky (geen fixed/top-gegoochel → geen balk midden in beeld).
+  // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible
+  // obstacles by setting a dynamic bottom offset = the greater of:
+  //  1) the height of the keyboard area NOT covered by the layout viewport
+  //     (on iOS the visual viewport shifts; on Android the layout viewport shrinks
+  //     due to interactive-widget=resizes-content → offset ≈ 0);
+  //  2) the height of the playing audio player (fixed, z-index 1000).
+  // We stick with sticky (no fixed/top tricks → no bar floating in the middle).
   var bar = document.querySelector('.pe-actions');
   if (!bar) return;
@@ -1922,5 +1922,5 @@
   window.addEventListener('resize', position);
   if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); }
-  // has-audio-player wisselt via een body-class → daarop reageren.
+  // has-audio-player is toggled via a body class → observe it.
   try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
 })();
Index: src/views/pages/post.ejs
===================================================================
--- src/views/pages/post.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/pages/post.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,11 +1,11 @@
 <%
-// In hub-mode is de site bereikbaar onder /user/<slug>; alle site-scoped links
-// (tag, edit, delete, comments) moeten met die prefix anders vallen ze terug op
-// de primaire site → 404 / verkeerde site. In solo is _base = ''.
+// In hub mode the site is accessible under /user/<slug>; all site-scoped links
+// (tag, edit, delete, comments) must carry that prefix otherwise they fall back
+// to the primary site → 404 / wrong site. In solo mode _base = ''.
 const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 %>
 <article class="container post-page">
 
-  <%# Navigatie over ALLE posts — BOVEN de post (gedeeld partial). %>
+  <%# Navigation across ALL posts — ABOVE the post (shared partial). %>
   <%- include('../partials/post-nav', { newerPost: newerPost, olderPost: olderPost }) %>
 
@@ -79,5 +79,5 @@
 
     <% if (typeof currentPath === 'string' && currentPath && currentPath.indexOf('/' + post.slug) === 0) { %>
-      <% /* If the URL has ?pending=1, the previous submit landed in the queue */ %>
+      <% /* If the URL has ?pending=1, the previous submit landed in the moderation queue */ %>
     <% } %>
     <script>
@@ -164,6 +164,6 @@
                         </div>
 
-                        <%# Reageren op een antwoord — landt (via resolvedParent op de
-                            server) in dezelfde thread, dus 1 niveau diep blijft behouden. %>
+                        <%# Replying to a reply — lands (via resolvedParent on the
+                            server) in the same thread, so the 1-level depth is preserved. %>
                         <% if (user && canMutate) { %>
                           <form method="post" action="<%= _base %>/comments" class="comment-reply-form" hidden data-reply-form-for="<%= r.id %>">
@@ -431,9 +431,9 @@
 .comment-form button { align-self: flex-start; }
 .comment-reply-form-actions { display: flex; gap: 0.5rem; }
-/* Reply-formulier pas tonen NA klik op REPLY. De display:flex hierboven overschreef
-   anders het [hidden]-attribuut → formulier stond altijd open. */
+/* Show the reply form only AFTER clicking REPLY. The display:flex above would
+   otherwise override the [hidden] attribute → form was always open. */
 .comment-reply-form[hidden] { display: none; }
-/* DELETE-knop zit in een <form>; display:contents laat 'm als directe flex-sibling
-   van REPLY meedoen → pixel-perfecte uitlijning + gelijke gap. */
+/* DELETE button is inside a <form>; display:contents makes it a direct flex-sibling
+   of REPLY → pixel-perfect alignment + equal gap. */
 .comment-actions form { display: contents; }
 
Index: src/views/partials/bottom-tab.ejs
===================================================================
--- src/views/partials/bottom-tab.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/partials/bottom-tab.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -15,6 +15,6 @@
 const _ownProfile  = user ? ('/users/' + user.username) : null;
 const _isHub       = (typeof tenancy !== 'undefined' && tenancy === 'hub');
-// Home gaat in hub-modus ALTIJD naar de hub-root (/), niet naar de sub-home van
-// een artiest. In solo/circle is _siteUrlBase leeg, dus dat blijft '/'.
+// Home in hub mode ALWAYS goes to the hub root (/), not to an artist's sub-home.
+// In solo/circle _siteUrlBase is empty, so it stays '/'.
 const _homeHref    = (_isHub ? '' : _siteUrlBase) + '/';
 
@@ -27,5 +27,5 @@
 
 let _active = null;
-// In hub is Home alleen actief op de échte hub-root, niet op een artiest-sub-home.
+// In hub, Home is only active on the real hub root, not on an artist sub-home.
 if (_isHub ? (_path === '/') : (_p === '/' || _p === '')) _active = 'home';
 else if (_p.indexOf('/search') === 0 || _p.indexOf('/tag/') === 0 || _p.indexOf('/type/') === 0) _active = 'search';
@@ -38,7 +38,7 @@
 
   <!-- Home -->
-  <%# htmx-navigatie (geen full reload): de persistente audioplayer in document.body
-      overleeft dan, dus de muziek verspringt niet bij het navigeren. Op mobiel is de
-      top-balk toch verborgen, dus een context-reset via full-load is hier niet nodig. %>
+  <%# htmx navigation (no full reload): the persistent audio player in document.body
+      survives, so music does not skip when navigating. On mobile the top bar is hidden
+      anyway, so a context reset via full-load is not needed here. %>
   <a class="bottom-tab-item<%= _active === 'home' ? ' is-active' : '' %>"
      href="<%= _homeHref %>"
@@ -308,8 +308,8 @@
 
 <script>
-// Bottom-tab zoekknop → opent #search-overlay. Via event-delegatie + de overlay
-// ELKE klik opnieuw opzoeken: de overlay zit in de chrome die bij htmx-navigatie
-// OOB vervangen wordt, dus een één-keer-opgevangen referentie verschaalt
-// (knop deed daarna niets). Guard tegen dubbel-wiren.
+// Bottom-tab search button → opens #search-overlay. Using event delegation + looking
+// up the overlay on EVERY click: the overlay lives in the chrome that is OOB-replaced
+// during htmx navigation, so a once-captured reference goes stale
+// (button did nothing afterwards). Guard against double-wiring.
 (function() {
   if (window.__btSearchWired) return;
Index: src/views/partials/footer.ejs
===================================================================
--- src/views/partials/footer.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/partials/footer.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -13,6 +13,6 @@
       <span>© <%= new Date().getFullYear() %> <%= (site && site.title) || 'Klonkt' %></span>
     </div>
-    <%# Install-app knop — in de footer-rij (midden), bespaart een hele extra rij.
-        JS detecteert platform & opent een modal met PWA-install-stappen. %>
+    <%# Install-app button — placed in the footer row (centre), saves a whole extra row.
+        JS detects the platform & opens a modal with PWA install steps. %>
     <button type="button" class="install-app-btn install-app-btn--footer" data-pcms-install-app>
       <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 20h14v-2H5v2zm7-18L5.33 9h3.84v6h5.66V9h3.84L12 2z" fill="currentColor"/></svg>
@@ -114,7 +114,7 @@
 }
 .footer-lang:hover { border-color: var(--accent); }
-/* Open dropdown-opties leesbaar (anders wit-op-wit op dark mode). */
+/* Make open dropdown options readable (otherwise white-on-white in dark mode). */
 .footer-lang option { background: var(--paper); color: var(--ink); }
-/* Op desktop staat de taalkeuze in de header-nav → footer-versie verbergen. */
+/* On desktop the language picker lives in the header nav → hide the footer version. */
 @media (min-width: 768px) { .footer-lang { display: none; } }
 .footer-version { color: inherit; text-decoration: none; }
@@ -144,9 +144,9 @@
 }
 
-/* Compacte install-knop ín de footer-rij (midden) i.p.v. een grote losse CTA
-   op een eigen regel — bespaart verticale ruimte. */
+/* Compact install button inside the footer row (centre) instead of a large standalone
+   CTA on its own line — saves vertical space. */
 .install-app-btn--footer {
   flex: 0 0 auto;
-  width: auto !important;          /* override de mobile .install-app-btn width:100% */
+  width: auto !important;          /* override the mobile .install-app-btn width:100% */
   padding: 0.42rem 0.95rem;
   font-size: 0.82rem;
@@ -154,5 +154,5 @@
 }
 .install-app-btn--footer svg { width: 15px; height: 15px; }
-/* Op smal scherm: laat de drie items netjes centreren als ze wrappen. */
+/* On narrow screens: let the three items centre neatly when they wrap. */
 @media (max-width: 600px) {
   .site-footer-inner { justify-content: center; text-align: center; }
Index: src/views/partials/like-button.ejs
===================================================================
--- src/views/partials/like-button.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/partials/like-button.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,5 +1,5 @@
-<%# Like-/favorietknop voor een post. Ingelogd → htmx-toggle (POST /posts/:id/like,
-    swap outerHTML met deze partial terug). Niet ingelogd → link naar login.
-    Verwacht: post (met .id), likedByMe, likeCount, loggedIn, loginNext. %>
+<%# Like/favourite button for a post. Logged in → htmx toggle (POST /posts/:id/like,
+    swaps outerHTML back with this partial). Not logged in → link to login.
+    Expects: post (with .id), likedByMe, likeCount, loggedIn, loginNext. %>
 <%
   var _liked = (typeof likedByMe !== 'undefined' && likedByMe);
Index: src/views/partials/post-nav.ejs
===================================================================
--- src/views/partials/post-nav.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/partials/post-nav.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -1,5 +1,5 @@
-<%# Post-navigatie (Newer links / Older rechts) — gedeeld door post.ejs én
-    fan-gate.ejs. Verwacht newerPost + olderPost in scope. Ontbreekt een buur,
-    dan een uitgegrijsde placeholder zodat elke post dezelfde structuur houdt. %>
+<%# Post navigation (Newer link left / Older right) — shared by post.ejs and
+    fan-gate.ejs. Expects newerPost + olderPost in scope. If a neighbour is missing,
+    a greyed-out placeholder keeps every post in the same two-card structure. %>
 <% if ((typeof newerPost !== 'undefined' && newerPost) || (typeof olderPost !== 'undefined' && olderPost)) { %>
   <nav class="post-nav post-nav--top">
@@ -35,9 +35,9 @@
 <style>
 .post-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--rule); }
-/* Boven de post: geen lijn/gap erboven, maar een scheidingslijn ERONDER (naar de content). */
+/* Above the post: no line/gap above it, but a dividing line BELOW (toward the content). */
 .post-nav--top { margin-top: 0; padding-top: 0; border-top: 0; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--rule); }
 .post-nav-prev, .post-nav-next { display: flex; flex-direction: column; padding: 1rem; background: var(--paper-2); border-radius: 6px; text-decoration: none; color: var(--ink); }
-/* Lege plek (geen nieuwere/oudere post): zelfde kaart, uitgegrijsd + niet klikbaar,
-   zodat elke post dezelfde twee-kaarten-structuur houdt. */
+/* Empty slot (no newer/older post): same card, greyed out + not clickable,
+   so every post keeps the same two-card structure. */
 .post-nav-prev.is-empty, .post-nav-next.is-empty { opacity: 0.4; cursor: default; }
 .post-nav-prev.is-empty .post-nav-title, .post-nav-next.is-empty .post-nav-title { font-weight: 400; font-style: italic; color: var(--ink-muted); }
Index: src/views/partials/shared-styles.ejs
===================================================================
--- src/views/partials/shared-styles.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/partials/shared-styles.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -6,14 +6,14 @@
   border-bottom: 1px solid var(--rule);
   background: var(--paper);
-  /* Geen extra margin-top: de pill wordt zo gecentreerd tussen de profielkop-
-     streep (boven) en de eigen border (onder). De gelijke lucht boven/onder de
-     pill komt uit de symmetrische container-padding hieronder. De margin-bottom
-     (1.5rem in style.css) blijft lucht naar de feed. */
+  /* No extra margin-top: this centres the pill between the profile-header
+     rule (above) and the own border (below). Equal space above/below the
+     pill comes from the symmetric container padding below. The margin-bottom
+     (1.5rem in style.css) keeps breathing room toward the feed. */
   margin-top: 0;
 }
 .view-switcher-wrap .container {
   display: flex; align-items: center; justify-content: center; gap: 0.75rem;
-  /* Symmetrische padding → even veel lucht boven als onder de pill (≈8px),
-     gecentreerd tussen de twee lijnen. */
+  /* Symmetric padding → equal space above and below the pill (≈8px),
+     centred between the two dividing lines. */
   padding: 0.5rem 1rem;
   max-width: 1200px; margin: 0 auto;
@@ -36,12 +36,12 @@
 }
 .view-switch-btn:hover { color: var(--ink); }
-/* Actief = wit (lichte achtergrond, donkere tekst). Via aria-selected (element-
-   attribuut, door syncAria gezet + op htmx:afterSettle) → htmx-robuust. Op niet-
-   feed-pagina's zet syncAria aria-selected=false → beide grijs. */
+/* Active = white (light background, dark text). Via aria-selected (element
+   attribute, set by syncAria + on htmx:afterSettle) → htmx-robust. On non-
+   feed pages syncAria sets aria-selected=false → both grey. */
 .view-switch-btn[aria-selected="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
 
-/* Agenda = een EIGEN pill (los van Tijdlijn/Grid, zodat de feed-toggle nooit
-   verstoord raakt). Zelfde look als de view-switcher-pill. Zichtbaar in alle modi. */
-.agenda-pill { display: inline-flex; align-items: center; }   /* geen box — losse icoon+tekst link */
+/* Agenda = its OWN pill (separate from Timeline/Grid, so the feed-toggle is never
+   disturbed). Same look as the view-switcher pill. Visible in all modes. */
+.agenda-pill { display: inline-flex; align-items: center; }   /* no box — standalone icon+text link */
 .vs-agenda {
   display: inline-flex; align-items: center; gap: 0.4rem;
@@ -53,11 +53,11 @@
 }
 .vs-agenda:hover { color: var(--ink); }
-/* Actief (op de agendapagina) = helder/wit. Element-class (in de OOB-gerenderde
-   chrome) i.p.v. body-class-CSS → werkt betrouwbaar ook na htmx-navigatie. */
+/* Active (on the agenda page) = bright/white. Element-class (in the OOB-rendered
+   chrome) rather than body-class CSS → stays reliable after htmx navigation. */
 .vs-agenda.is-active { color: var(--ink); font-weight: 600; }
 
-/* Mobiel: Agenda op een eigen rij bovenaan, en Solo/Cirkel + Tijdlijn/Grid samen
-   op de rij eronder (samen ~330px → past binnen een telefoon, i.p.v. de oude
-   onevenwichtige split Agenda+Solo / Tijdlijn). */
+/* Mobile: Agenda on its own row at the top, Solo/Circle + Timeline/Grid together
+   on the row below (together ~330px → fits within a phone, instead of the old
+   unbalanced split Agenda+Solo / Timeline). */
 @media (max-width: 520px) {
   .view-switcher-wrap .agenda-pill { flex: 0 0 100%; justify-content: center; }
@@ -92,10 +92,10 @@
 .on-admin .view-switcher-wrap { display: none; }
 
-/* Op een losse post is er geen feed om te schakelen, dus geen knop hoort "actief"
-   (gekleurd) te ogen. De actieve staat zit in style.css op body[data-feed-view]
-   / body[data-grid-cols] (background:var(--ink)); we overrulen die SPECIFIEKER
-   via body.on-post zodat de knoppen neutraal/grijs worden — maar wél klikbaar
-   blijven (de klik-handler navigeert dan naar de feed in die weergave). De
-   body-class wisselt client-side bij HTMX-nav, dus dit werkt ook zonder reload. */
+/* On a standalone post there is no feed to switch, so no button should look "active"
+   (coloured). The active state lives in style.css on body[data-feed-view]
+   / body[data-grid-cols] (background:var(--ink)); we override that MORE SPECIFICALLY
+   via body.on-post so the buttons become neutral/grey — but remain clickable
+   (the click-handler then navigates to the feed in that view). The
+   body-class switches client-side on HTMX-nav, so this works without a reload too. */
 body.on-post[data-feed-view="timeline"] .view-switch-btn[data-view="timeline"],
 body.on-post[data-feed-view="grid"] .view-switch-btn[data-view="grid"],
Index: src/views/partials/topnav.ejs
===================================================================
--- src/views/partials/topnav.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/partials/topnav.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -27,7 +27,7 @@
         <a href="<%= _siteUrlBase %>/" class="nav-link nav-link-admin"><%= t('nav.back_to_site') %></a>
       <% } else if (typeof tenancy !== 'undefined' && tenancy === 'hub') { %>
-        <%# Hub: de brand gaat naar de hub-home via HTMX. De chrome wordt in hub-modus
-            altijd gerenderd en via body.on-hub (CSS) verborgen op de landing, dus htmx
-            kan schakelen zonder full-reload → de audioplayer blijft leven. %>
+        <%# Hub: the brand navigates to the hub home via HTMX. The chrome is always
+            rendered in hub mode and hidden on the landing via body.on-hub (CSS), so htmx
+            can switch without a full reload → the audio player stays alive. %>
         <a class="nav-hub-home" href="/" aria-label="Home" title="Home"
            hx-get="/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/" hx-indicator="#pcms-loading">
@@ -207,9 +207,9 @@
 <script>
 (function() {
-  // EÉN keer bedraden. Deze chrome (topnav) wordt bij htmx-navigatie out-of-band
-  // opnieuw ingevoegd → zonder deze guard zou dit script bij elke navigatie EXTRA
-  // listeners stapelen, waardoor de thema-toggle 2× (of vaker) vuurde = geen netto
-  // wisseling ("toggle werkt niet meer"). Alles hieronder is event-delegatie op
-  // body/document, dus het werkt ook voor knoppen die ná deze run (OOB) verschijnen.
+  // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation
+  // → without this guard the script would stack EXTRA listeners on every navigation,
+  // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working").
+  // Everything below uses event delegation on body/document, so it also works for
+  // buttons that appear after this run (OOB).
   if (window.__pcmsChromeWired) return;
   window.__pcmsChromeWired = true;
@@ -229,5 +229,5 @@
     if (e.target.closest('#search-toggle')) { openSearch(); return; }
     if (e.target.closest('#search-close')) { closeSearch(); return; }
-    // Open dropdowns (gebruikersmenu + taalkeuze) sluiten bij klik erbuiten.
+    // Close open dropdowns (user menu + language picker) on click outside.
     document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) {
       if (!d.contains(e.target)) d.removeAttribute('open');
@@ -239,5 +239,5 @@
   });
 
-  // ── Live resultaten tijdens typen ───────────────────────────────
+  // ── Live results while typing ───────────────────────────────────
   var _ST = {
     posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>',
@@ -280,5 +280,5 @@
     }, 200);
   });
-  // Klik op een suggestie → overlay sluiten (de link/boost doet de navigatie).
+  // Click on a suggestion → close the overlay (the link/boost handles navigation).
   document.addEventListener('click', function(e) {
     if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; }
@@ -313,5 +313,5 @@
 }
 
-/* Hub-home brand is WÉL zichtbaar — de thuisknop op artiest-/hub-pagina's. */
+/* Hub-home brand IS visible — the home button on artist/hub pages. */
 .masthead-brand .nav-hub-home {
   display: inline-flex; align-items: center; justify-content: center;
@@ -436,5 +436,5 @@
   min-width: 220px;
   padding: .35rem;
-  /* Frosted glass: transparant + zware blur zodat het frosted effect duidelijk is. */
+  /* Frosted glass: transparent + heavy blur so the frosted effect is clearly visible. */
   background: color-mix(in srgb, var(--paper) 60%, transparent);
   backdrop-filter: blur(34px) saturate(190%);
@@ -565,5 +565,5 @@
 .ss-all { display: block; margin-top: .35rem; padding: .55rem .6rem; border-top: 1px solid var(--rule); color: var(--accent); font-weight: 600; text-decoration: none; font-size: .9rem; }
 .ss-all:hover { text-decoration: underline; }
-/* Meldingen-bel + ongelezen-badge */
+/* Notification bell + unread badge */
 .nav-notif { position: relative; }
 .notif-badge {
Index: src/views/partials/track-editor.ejs
===================================================================
--- src/views/partials/track-editor.ejs	(revision bb42dfb3c61f3495f4ebd21e0066a369b7d0a202)
+++ src/views/partials/track-editor.ejs	(revision 834bcc3c41c52321679550863b74ca035538659f)
@@ -195,5 +195,5 @@
 }
 
-/* ── Credit-veld met ©-invoegknop ──────────────────────────── */
+/* ── Credit field with © insert button ──────────────────────────── */
 .te-credit-row { display: flex; gap: 0.4rem; align-items: stretch; }
 .te-credit-row input { flex: 1; min-width: 0; }
@@ -638,5 +638,5 @@
     }
 
-    // ── ©-teken invoegen in het credit-veld ─────────────────
+    // ── Insert © symbol into the credit field ─────────────────
     const copyrBtn = $('#te-credit-copyr');
     if (copyrBtn) {
@@ -693,14 +693,14 @@
     });
 
-    // ── Auto-duur ───────────────────────────────────────────
-    // De server bepaalt de duur al automatisch bij upload. Dit is de vangnet/
-    // UX-laag: opent een admin een bestaande track zónder duur, dan lezen we 'm
-    // hier uit de audio-metadata en vullen het veld — zodat je nooit seconden
-    // hoeft te typen. Bestaande waarde wordt nooit overschreven. We halen de
-    // bytes via dezelfde header-gate als de speler (X-Audio-Player).
+    // ── Auto-duration ───────────────────────────────────────────
+    // The server already determines duration automatically on upload. This is the
+    // fallback/UX layer: if an admin opens an existing track without a duration,
+    // we read it from the audio metadata and fill the field — so you never need
+    // to type seconds manually. An existing value is never overwritten. We fetch
+    // the bytes via the same header gate as the player (X-Audio-Player).
     (async function autoDuration() {
       const durEl = $('#te-duration');
       if (!durEl || !track.stream_url) return;
-      if (durEl.value && Number(durEl.value) > 0) return;  // al ingevuld → met rust laten
+      if (durEl.value && Number(durEl.value) > 0) return;  // already filled → leave it alone
       let objUrl = null;
       try {
