Index: src/routes/account.js
===================================================================
--- src/routes/account.js	(revision fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/account.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin-audio.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin-circle.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin-comments.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin-epk.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin-newsletter.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin-patreon.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin-seo.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin-settings.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin-shows.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin-sites.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin-stats.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin-updates.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin-users.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/admin.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/artists.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/audio.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/auth.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/changelog.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/circle.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/comments.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/download.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/embed.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/epk.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/federation.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/hub.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/lang.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/linkbio.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/newsletter.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/notifications.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/posts.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/search.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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 fe6aac98b367d4205ad391eb328f83aeaea2483f)
+++ src/routes/shows.js	(revision 6d3e2c100bdd039fe8e36935dcbfc4a5ef8631d3)
@@ -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'; }
 
