Changeset 834bcc3 in Klonkt for src


Ignore:
Timestamp:
06/23/2026 06:14:27 PM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
d774679
Parents:
bb42dfb
Message:

i18n: translate Dutch code comments to English across src/

Comments in routes/services/views/config/middleware/assets translated to
English for the public repo. A few dev-facing throw/console message strings
were Englished too. No user-facing UI strings or i18n dictionary values changed
(src/services/i18n.js untouched). Logic unchanged.

Co-Authored-By: Claude <noreply@…>

Location:
src
Files:
81 edited

Legend:

Unmodified
Added
Removed
  • src/assets/js/audio-player.js

    rbb42dfb r834bcc3  
    230230    }
    231231    currentObjectUrl = objUrl;
    232     // Schone overgang: pause + load forceert reset van internal state na
    233     // meerdere src-changes (voorkomt state-corruption van het audio-element).
     232    // Clean transition: pause + load forces a reset of internal state after
     233    // multiple src changes (prevents state corruption of the audio element).
    234234    try { audio.pause(); } catch (e) {}
    235235    audio.src = objUrl;
     
    276276  // Used by the site pre-seed so opening a page doesn't auto-download audio;
    277277  // the blob is fetched lazily on the first play().
    278   // Markeer de huidige track persistent (blijvende highlight zolang 'ie actief is).
     278  // Persistently mark the current track (stays highlighted as long as it's active).
    279279  function markPlaying(trackId) {
    280280    document.querySelectorAll('.pat-playing').forEach((e) => e.classList.remove('pat-playing'));
     
    283283    if (el) el.classList.add('pat-playing');
    284284  }
    285   // Na een htmx-navigatie is de post-DOM vervangen → highlight opnieuw zetten.
     285  // After an htmx navigation the post DOM is replaced → reapply the highlight.
    286286  document.body.addEventListener('htmx:afterSettle', () => {
    287287    const t = queue[currentIndex];
     
    341341  // (@media max-width:719.98px). On wider/desktop widths the sheet is a centered
    342342  // panel, so we do NOT auto-open it there.
    343   // Drie weergaves (Robin 2026-06-15): telefoon (<768) = fullscreen sheet;
    344   // tablet/auto (768–1199) = grote landscape full-player (tablet + tablet-in-de-
    345   // auto); desktop (≥1200) = alleen mini-speler, GEEN full player. matchMedia
    346   // zodat dit exact de CSS-breekpunten volgt.
     343  // Three layouts (Robin 2026-06-15): phone (<768) = fullscreen sheet;
     344  // tablet/car (768–1199) = large landscape full-player (tablet + car-mode);
     345  // desktop (≥1200) = mini-player only, NO full player. matchMedia so this
     346  // exactly follows the CSS breakpoints.
    347347  function playerTier() {
    348348    if (window.matchMedia('(min-width: 1200px)').matches) return 'desktop';
     
    359359    loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
    360360    // Mobile: a track press from an album/playlist auto-opens the full
    361     // now-playing sheet (Spotify-style) i.p.v. alleen de dunne mini-strip.
    362     // Alleen hier (setQueue = een verse, door-de-gebruiker-gestarte queue) —
    363     // niet bij next/prev of de site-pre-seed — zodat een sheet die de gebruiker
    364     // bewust sloot niet vanzelf terugkomt.
     361    // now-playing sheet (Spotify-style) instead of just the thin mini-strip.
     362    // Only here (setQueue = a fresh, user-initiated queue) —
     363    // not on next/prev or the site pre-seed — so a sheet the user
     364    // deliberately closed doesn't reappear by itself.
    365365    if (hasFullPlayer()) openSheet();
    366366  }
     
    377377      p.catch((err) => {
    378378        console.warn('[pcms-audio] play() rejected:', err.name, err.message);
    379         // Browser-autoplay-policy heeft 't gestopt (typisch na 3-4
    380         // automatische plays op iOS Safari, of als tab tijdelijk inactive
    381         // was). Visuele hint dat user op play moet tappen.
     379        // Browser autoplay policy blocked it (typically after 3-4
     380        // auto-plays on iOS Safari, or when the tab was temporarily inactive).
     381        // Show a visual hint for the user to tap play.
    382382        if (err && err.name === 'NotAllowedError') {
    383383          root.classList.add('audio-needs-tap');
     
    440440
    441441  // ============================================================
    442   // 4b. Mutual exclusion — gedeelde media-registry (zie embed-player.js).
    443   // ============================================================
    444   // Alle spelers (deze site-speler + de YouTube/SoundCloud/Spotify-embeds)
    445   // registreren zich in window.pcmsMediaRegistry. Start er één, dan pauzeert de
    446   // vorige. Dit is de precieze vervanger van de oude focus/blur-heuristiek voor
    447   // de embeds met een echte JS-API. (De blur-fallback hieronder blijft staan
    448   // voor iframe-only embeds zonder API: Bandcamp/Apple Music/Vimeo.)
     442  // 4b. Mutual exclusion — shared media registry (see embed-player.js).
     443  // ============================================================
     444  // All players (this site player + YouTube/SoundCloud/Spotify embeds)
     445  // register themselves in window.pcmsMediaRegistry. Starting one pauses
     446  // the previous. This is the precise replacement for the old focus/blur
     447  // heuristic for embeds with a real JS API. (The blur fallback below stays
     448  // for iframe-only embeds without an API: Bandcamp/Apple Music/Vimeo.)
    449449  function mediaRegistry() {
    450450    if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
     
    467467  // 5. Audio element events → UI sync
    468468  // ============================================================
    469   // Error-counter voorkomt infinite-loop als ALLE tracks broken zijn.
     469  // Error counter prevents an infinite loop when ALL tracks are broken.
    470470  let consecutiveErrors = 0;
    471471
     
    473473    isPlaying = true;
    474474    root.classList.add('is-playing');
    475     root.classList.remove('audio-needs-tap');  // verstop tap-hint
    476     mediaRegistry().setActive(registrySelf);   // pauzeer eventueel spelende embeds
    477   });
    478   // Reset de error-teller pas bij ECHTE playback-start (`playing`), niet bij
    479   // het eager `play`-event. `play` vuurt vóór een eventuele netwerk-/decode-
    480   // fout, dus resetten daar zou de 3-strikes-stop nooit laten triggeren bij
    481   // een kapotte track → infinite "next"-loop. `playing` vuurt alleen als er
    482   // daadwerkelijk audio speelt.
     475    root.classList.remove('audio-needs-tap');  // hide tap hint
     476    mediaRegistry().setActive(registrySelf);   // pause any currently playing embeds
     477  });
     478  // Reset the error counter only on a REAL playback start (`playing`), not the
     479  // eager `play` event. `play` fires before any network/decode error, so resetting
     480  // there would prevent the 3-strikes stop from ever triggering on a broken
     481  // track → infinite "next" loop. `playing` only fires when audio is actually playing.
    483482  audio.addEventListener('playing', () => { consecutiveErrors = 0; preloadNext(); });
    484483  audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
     
    488487    console.error('[pcms-audio] playback error', code, audio.src, e);
    489488    consecutiveErrors++;
    490     // Bij netwerk/decode-fout: skip naar volgende track ipv stilstaan.
    491     // Max 3 fouten op rij voordat we opgeven (anders infinite loop).
     489    // On network/decode error: skip to next track instead of stalling.
     490    // Max 3 consecutive errors before giving up (otherwise infinite loop).
    492491    if (consecutiveErrors < 3 && queue.length > 1) {
    493       console.warn('[pcms-audio] auto-skip naar volgende na error', consecutiveErrors);
     492      console.warn('[pcms-audio] auto-skip to next after error', consecutiveErrors);
    494493      setTimeout(next, 400);
    495494    }
     
    545544  // 7. Sheet expand/close + drag-down-to-close
    546545  // ============================================================
    547   // Back-knop sluit de sheet op mobiel: bij openen pushen we een history-entry,
    548   // zodat de telefoon-terugknop (popstate) eerst de sheet sluit i.p.v. de pagina
    549   // te verlaten. We balanceren 'm bij een UI-sluiting via history.back().
     546  // Back button closes the sheet on mobile: on open we push a history entry
     547  // so the phone back button (popstate) closes the sheet first instead of
     548  // leaving the page. We balance it on a UI-initiated close via history.back().
    550549  let sheetHistoryPushed = false;
    551550
    552551  function openSheet() {
    553     if (!hasFullPlayer()) return; // desktop (≥1200): geen full player, alleen mini-speler
     552    if (!hasFullPlayer()) return; // desktop (≥1200): no full player, mini-player only
    554553    if (sheet.classList.contains('is-open')) return;
    555554    sheet.classList.add('is-open');
    556555    sheet.setAttribute('aria-hidden', 'false');
    557556    document.body.classList.add('audio-sheet-locked');
    558     // Telefoon + tablet: history-entry zodat de terug-knop eerst de sheet sluit.
     557    // Phone + tablet: push a history entry so the back button closes the sheet first.
    559558    try { history.pushState({ pcmsSheet: true }, ''); sheetHistoryPushed = true; } catch (e) {}
    560559  }
     
    566565    sheetPanel.style.removeProperty('--pcms-drag-y');
    567566    sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
    568     // UI-sluiting (X / swipe / backdrop / Esc): pop onze eigen history-entry zodat
    569     // de volgende terug-knop weer normaal navigeert. Bij een popstate-sluiting
    570     // (de terug-knop zelf) is de entry al gepopt.
     567    // UI close (X / swipe / backdrop / Esc): pop our own history entry so the
     568    // next back button navigates normally. On a popstate close (back button itself)
     569    // the entry is already popped.
    571570    const wasPushed = sheetHistoryPushed;
    572571    sheetHistoryPushed = false;
     
    576575    if (sheet.classList.contains('is-open')) closeSheet(true);
    577576  });
    578   // Mini-player-info aanklikken:
    579   //  - DESKTOP (≥768px): spring naar de post waar de track vandaan komt (als bekend),
    580   //    via htmx zodat de audio blijft spelen. Geen post bekend → val terug op de sheet.
    581   //  - MOBIEL: altijd de uitgebreide speler-sheet openen (huidig gedrag).
     577  // Clicking the mini-player track info:
     578  //  - DESKTOP (≥1200px): jump to the post the track came from (if known),
     579  //    via htmx so audio keeps playing. No post known → fall back to the sheet.
     580  //  - MOBILE/TABLET: always open the full now-playing sheet.
    582581  function scrollToTrack(trackId) {
    583582    if (!trackId) { window.scrollTo(0, 0); return; }
     
    594593        const p = window.htmx.ajax('GET', url, { target: '#pcms-main', swap: 'innerHTML' });
    595594        history.pushState({}, '', url + hash);
    596         // Na de swap naar de track scrollen (klein uitstel zodat de globale
    597         // afterSwap-top-scroll eerst is geweest); val terug op top als niet gevonden.
     595        // Scroll to the track after the swap (small delay so the global
     596        // afterSwap scroll-to-top runs first); fall back to top if not found.
    598597        const go = () => setTimeout(() => scrollToTrack(trackId), 60);
    599598        if (p && typeof p.then === 'function') p.then(go); else setTimeout(go, 150);
    600599        return;
    601       } catch (e) { /* val terug op volledige navigatie */ }
     600      } catch (e) { /* fall back to full navigation */ }
    602601    }
    603602    location.href = url + hash;
     
    605604  expandTrigger.addEventListener('click', () => {
    606605    const t = queue[currentIndex];
    607     // Alleen op échte desktop (≥1200, geen full-player) springen we naar de post.
    608     // Tablet + telefoon hebben een full-player → open die (zoals mobiel).
     606    // Only on true desktop (≥1200, no full-player) do we jump to the post.
     607    // Tablet + phone have a full-player → open it (same as mobile behaviour).
    609608    const jumpToPost = !hasFullPlayer();
    610609    if (jumpToPost && t) {
    611       // 1) Track speelde vanuit een post → die URL kennen we al.
     610      // 1) Track played from a post → we already know that URL.
    612611      if (t.postUrl) { goToPost(t.postUrl, t.id); return; }
    613       // 2) Site-brede track (geen postUrl) → zoek de post op via de track-id.
     612      // 2) Site-wide track (no postUrl) → look up the post via the track id.
    614613      if (t.id) {
    615614        fetch('/audio/track/' + encodeURIComponent(t.id) + '/post')
     
    627626    if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
    628627  });
    629   // Naar desktop-breedte (≥1200) gesleept terwijl de full player open is? Sluiten
    630   // op desktop bestaat de full player niet.
     628  // Resized to desktop width (≥1200) while the full player is open? Close it
     629  // the full player doesn't exist on desktop.
    631630  window.addEventListener('resize', () => {
    632631    if (!hasFullPlayer() && sheet.classList.contains('is-open')) closeSheet();
    633632  });
    634633
    635   // Vangnet voor mutual exclusion. Voor YouTube/SoundCloud/Spotify-embeds doet de
    636   // registry dit al precies (echte play-events). Maar voor iframe-only embeds
    637   // ZONDER JS-API (Bandcamp/Apple/Vimeo) én voor de iframe-FALLBACK (als een
    638   // ad-blocker de player-API blokkeert) is er geen play-event: daar vangen we het
    639   // af via focus. Klikt de gebruiker zo'n iframe aan → window 'blur' → pauzeer
    640   // onze speler. (Voor de API-embeds is dit hooguit een onschadelijke dubbele
    641   // pauze.)
     634  // Fallback for mutual exclusion. For YouTube/SoundCloud/Spotify embeds the
     635  // registry already handles this precisely (real play events). But for
     636  // iframe-only embeds WITHOUT a JS API (Bandcamp/Apple/Vimeo) and for the
     637  // iframe FALLBACK (when an ad-blocker blocks the player API) there is no
     638  // play event: we catch those via focus. User clicks such an iframe →
     639  // window 'blur' → pause our player. (For API embeds this is at worst a
     640  // harmless double-pause.)
    642641  window.addEventListener('blur', () => {
    643642    setTimeout(() => {
    644643      const el = document.activeElement;
    645       // Alleen embed-iframes (binnen .folio-embed) pauzeren de speler — niet een
    646       // willekeurig iframe (captcha/reclame/kaart) dat per ongeluk focus krijgt.
     644      // Only embed iframes (inside .folio-embed) pause the player — not a
     645      // random iframe (captcha/ad/map) that happens to receive focus.
    647646      if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) {
    648647        pause();
     
    710709  // wrapper. For the other three the data is on the button itself. The
    711710  // handler reads from button-first, falls back to wrapper.
    712   // Event-delegation op document.body i.p.v. per-knop listeners. Dit overleeft
    713   // HTMX history-restores: de mobiele terug-knop (popstate) laat HTMX #pcms-main
    714   // terugzetten uit z'n snapshot; een per-element `data-pcms-attached`-vlag zou
    715   // dan dode knoppen geven (vlag ingebakken in de snapshot, listener weg). Eén
    716   // gedelegeerde listener werkt ongeacht hoe vaak de DOM ge(her)swapt wordt.
     711  // Event delegation on document.body instead of per-button listeners. This
     712  // survives HTMX history-restores: the mobile back button (popstate) lets HTMX
     713  // restore #pcms-main from its snapshot; a per-element `data-pcms-attached` flag
     714  // would leave dead buttons (flag baked into the snapshot, listener gone). One
     715  // delegated listener works regardless of how many times the DOM is (re)swapped.
    717716  const PLAY_SELECTOR =
    718717    '.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall';
     
    722721    e.preventDefault();
    723722    e.stopPropagation();
    724     // De post waar je VANAF afspeelt = de pagina waar je nu bent (de embeds staan
    725     // in de post-content). Bewaar 'm op de track(s) zodat de mini-player er op
    726     // desktop naartoe kan springen — overleeft ook de sessionStorage-resume.
     723    // The post you're playing FROM = the current page (embeds live in post content).
     724    // Store it on the track(s) so the desktop mini-player can jump back to it —
     725    // survives the sessionStorage resume as well.
    727726    const postUrl = location.pathname + location.search;
    728727    // Resolve metadata: button-first, then closest .post-audio-track wrapper
     
    804803
    805804  // ============================================================
    806   // 10. Sessie-persistentie — speler "blijft" over page-navigaties heen
    807   // ============================================================
    808   // Een <audio> overleeft geen volledige page-load (en cross-context navigatie
    809   // — bv. naar de headerloze hub-overview — is bewust full-nav). We bewaren de
    810   // sessie in sessionStorage en herstellen + hervatten 'm op de volgende pagina:
    811   // de speler staat er weer met dezelfde track op dezelfde positie. In Chrome
    812   // (hoge media-engagement) speelt 'ie meteen door; staat de browser autoplay
    813   // niet toe, dan staat 'ie klaar op die plek (één tik = verder).
     805  // 10. Session persistence — player "survives" across page navigations
     806  // ============================================================
     807  // An <audio> element doesn't survive a full page load (and cross-context
     808  // navigation — e.g. to the headerless hub overview — is intentionally a
     809  // full-nav). We save the session to sessionStorage and restore + resume it
     810  // on the next page: the player comes back with the same track at the same
     811  // position. In Chrome (high media engagement) it resumes immediately;
     812  // if the browser blocks autoplay it waits at that position (one tap = play).
    814813  const PLAYER_STATE_KEY = 'pcms-player-state';
    815814  let pendingSeek = 0;
     
    830829  audio.addEventListener('ended', savePlayerState);
    831830  setInterval(() => { if (audio.src && !audio.paused) savePlayerState(); }, 5000);
    832   // Herstelde positie toepassen zodra de track-metadata binnen is.
     831  // Apply the restored position once track metadata is available.
    833832  audio.addEventListener('loadedmetadata', () => {
    834833    if (pendingSeek > 0 && isFinite(audio.duration) && audio.duration > 0) {
     
    845844    pendingSeek = s.time || 0;
    846845    const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
    847     // playing -> fetch + (poging tot) hervatten; gepauzeerd -> alleen metadata.
     846    // playing → fetch + (attempt to) resume; paused → meta-only.
    848847    loadTrack(idx, !!s.playing, !s.playing);
    849848    return true;
     
    853852  // 11. Site-level pre-seed (window.PCMS_SITE_TRACKS)
    854853  // ============================================================
    855   // Een actieve sessie (restore) wint van de pagina-seed, zodat lopende muziek
    856   // doorgaat i.p.v. vervangen te worden door de tracks van de nieuwe pagina.
     854  // An active session (restore) wins over the page seed, so music that is
     855  // already playing continues instead of being replaced by the new page's tracks.
    857856  if (!restorePlayerState()) {
    858857    if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
     
    864863        cover:  t.cover_url || t.cover || '',
    865864      }));
    866       // Queue alleen klaarzetten — de speler-balk verschijnt PAS bij de eerste
    867       // audio-klik (een .post-audio-track of de mini-speler-play roept setQueue/
    868       // loadTrack → die toont de balk). Geen pre-seed-balk meer op page-load.
     865      // Only prime the queue — the player bar appears only on the first audio click
     866      // (.post-audio-track or the mini-player play button calls setQueue/loadTrack,
     867      // which shows the bar). No more pre-seed bar on page load.
    869868      currentIndex = 0;
    870869    }
  • src/assets/js/embed-player.js

    rbb42dfb r834bcc3  
    11/**
    2  * Klonkt Embed Player — eigen, in-huisstijl media-embeds bovenop de ÉCHTE
    3  * player-API's (YouTube IFrame API, SoundCloud Widget API, Spotify iFrame API).
     2 * Klonkt Embed Player — custom, on-brand media embeds on top of the REAL
     3 * player APIs (YouTube IFrame API, SoundCloud Widget API, Spotify iFrame API).
    44 *
    5  * De server (AudioEmbedService) rendert per embed een placeholder:
     5 * The server (AudioEmbedService) renders a placeholder per embed:
    66 *   <div class="folio-embed folio-embed--<provider> pcms-embed-card"
    77 *        data-embed-provider data-embed-ref data-embed-type data-embed-url></div>
    8  * Dit script bouwt daar onze eigen kaart omheen (cover/poster + onze play-knop +
    9  * voortgangsbalk in huisstijl) en bestuurt de onderliggende speler via de
    10  * platform-API, zodat play/pause/voortgang in ÓNZE handen liggen.
     8 * This script wraps it in our own card (cover/poster + our play button +
     9 * progress bar in brand style) and controls the underlying player via the
     10 * platform API, so play/pause/progress are in OUR hands.
    1111 *
    12  * Capability-eerlijkheid:
    13  *  - youtube/soundcloud  → volledig eigen controls (native chrome verborgen).
    14  *  - spotify             → besturing + onze frame eromheen; Spotify's eigen UI
    15  *                          blijft binnenin (kan niet anders zonder Premium+OAuth).
     12 * Capability honesty:
     13 *  - youtube/soundcloud  → fully custom controls (native chrome hidden).
     14 *  - spotify             → controls + our frame around it; Spotify's own UI
     15 *                          stays inside (no alternative without Premium+OAuth).
    1616 *
    17  * Mutual exclusion: elke speler (incl. de site-audiospeler in audio-player.js)
    18  * registreert zich in window.pcmsMediaRegistry. Start er één → de vorige pauzeert.
    19  * Dit vervangt de oude focus/blur-heuristiek door echte play-events.
     17 * Mutual exclusion: every player (incl. the site audio player in audio-player.js)
     18 * registers itself in window.pcmsMediaRegistry. Starting one pauses the previous.
     19 * This replaces the old focus/blur heuristic with real play events.
    2020 *
    21  * Singleton — guard tegen dubbel-init (HTMX laadt scripts soms opnieuw).
     21 * Singleton — guard against double-init (HTMX can reload scripts).
    2222 */
    2323(function () {
     
    2525
    2626  // ============================================================
    27   // 0. Gedeelde playback-registry (ook door audio-player.js gebruikt)
     27  // 0. Shared playback registry (also used by audio-player.js)
    2828  // ============================================================
    2929  function registry() {
     
    3131    const r = {
    3232      _active: null,
    33       // Markeer `player` als de enige actieve; pauzeer de vorige.
     33      // Mark `player` as the sole active one; pause the previous.
    3434      setActive(player) {
    3535        if (this._active && this._active !== player && this._active.pause) {
     
    4545
    4646  // ============================================================
    47   // 1. Lazy script-loaders — 1 promise per platform, gedeeld over N embeds.
    48   //    We laden een platform-script PAS als er een embed van dat platform op de
    49   //    pagina daadwerkelijk wordt gestart.
     47  // 1. Lazy script loaders — 1 promise per platform, shared across N embeds.
     48  //    A platform script is only loaded when an embed from that platform
     49  //    is actually started on the page.
    5050  // ============================================================
    5151  const scripts = {};
     
    5555      s.src = src; s.async = true;
    5656      s.onload = () => resolve();
    57       s.onerror = () => reject(new Error('embed-script faalde: ' + src));
     57      s.onerror = () => reject(new Error('embed script failed: ' + src));
    5858      document.head.appendChild(s);
    5959    });
    6060  }
    61   // Belangrijk: alle loaders REJECTEN bij een script-fout (bv. een ad-blocker die
    62   // het API-script blokkeert) én na een time-out — zodat de aanroeper netjes kan
    63   // terugvallen op het kale platform-iframe i.p.v. eeuwig te hangen.
     61  // Important: all loaders REJECT on script errors (e.g. an ad-blocker blocking
     62  // the API script) and on timeout — so the caller can gracefully fall back to
     63  // the plain platform iframe instead of hanging indefinitely.
    6464  const API_TIMEOUT = 8000;
    6565
    66   // YouTube: globale callback onYouTubeIframeAPIReady (1×) → in promise wikkelen.
     66  // YouTube: global callback onYouTubeIframeAPIReady (once) → wrap in a promise.
    6767  function ytApi() {
    6868    if (scripts.yt) return scripts.yt;
     
    7979    return scripts.yt;
    8080  }
    81   // SoundCloud: geen globale ready-callback; resolve op script-onload, daarna
    82   // wacht elke widget op z'n eigen SC.Widget.Events.READY.
     81  // SoundCloud: no global ready callback; resolve on script onload, then
     82  // each widget waits for its own SC.Widget.Events.READY.
    8383  function scApi() {
    8484    if (scripts.sc) return scripts.sc;
     
    9191    return scripts.sc;
    9292  }
    93   // Spotify: globale callback onSpotifyIframeApiReady(IFrameAPI) (1×) → wrappen.
     93  // Spotify: global callback onSpotifyIframeApiReady(IFrameAPI) (once) → wrap in a promise.
    9494  function spotifyApi() {
    9595    if (scripts.sp) return scripts.sp;
     
    101101      };
    102102      loadScript('https://open.spotify.com/embed/iframe-api/v1').catch(reject);
    103       // Korter dan API_TIMEOUT: Spotify's bundle initialiseert snel óf helemaal
    104       // niet (CDN 503 / origin-gating). Niet 8s wachten vóór de iframe-fallback.
     103      // Shorter than API_TIMEOUT: Spotify's bundle initialises quickly or not at all
     104      // (CDN 503 / origin-gating). No need to wait 8 s before the iframe fallback.
    105105      setTimeout(() => reject(new Error('Spotify API timeout')), 4000);
    106106    });
     
    108108  }
    109109
    110   // Kaal platform-iframe als fallback wanneer de JS-API geblokkeerd/onbereikbaar
    111   // is. Ad-blockers laten de embed-iframes doorgaans wél door. Autoplay-param
    112   // omdat we hier altijd in een user-gesture-context zitten (klik op play).
     110  // Plain platform iframe as fallback when the JS API is blocked/unreachable.
     111  // Ad-blockers generally let embed iframes through. Autoplay parameter
     112  // is safe here because we're always in a user-gesture context (play click).
    113113  function fallbackIframe(provider, ref, url) {
    114114    if (provider === 'youtube') {
     
    138138    const m = (url || '').match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
    139139    if (m) return m[1];
    140     return null;  // geen blinde slice — een ongeldige ref geeft liever niets dan een kapotte id
    141   }
    142   // Alleen http(s) als href toelaten (defense-in-depth tegen javascript:/data:).
     140    return null;  // no blind slice — an invalid ref returns nothing rather than a broken id
     141  }
     142  // Only allow http(s) as href (defense-in-depth against javascript:/data: URIs).
    143143  function safeHref(u) {
    144144    try { const p = new URL(u, location.href); return (p.protocol === 'http:' || p.protocol === 'https:') ? u : '#'; }
     
    154154
    155155  // ============================================================
    156   // 3. Card controller — bouwt de huisstijl-kaart + delegeert naar een adapter
     156  // 3. Card controller — builds the on-brand card + delegates to an adapter
    157157  // ============================================================
    158158  function buildCard(el) {
     
    165165    el.classList.remove('pcms-embed-loading');
    166166
    167     // Onze speler-zijde (registry-peer). pause() wijst naar de adapter zodra die
    168     // gemount is; ervoor is 't een no-op.
     167    // Our player side (registry peer). pause() points to the adapter once
     168    // mounted; before that it's a no-op.
    169169    let adapter = null;
    170170    const self = { pause() { if (adapter && adapter.pause) { try { adapter.pause(); } catch (e) {} } } };
    171171
    172     // Teardown-hook: aangeroepen door de MutationObserver als deze kaart uit de
    173     // DOM verdwijnt (HTMX-swap) → adapter opruimen (timers/iframes) + registry
    174     // vrijgeven, zodat er geen poll-timers of spelers blijven lekken.
     172    // Teardown hook: called by the MutationObserver when this card leaves the
     173    // DOM (HTMX swap) → clean up the adapter (timers/iframes) + release the registry,
     174    // so no poll timers or players keep leaking.
    175175    el._pcmsDestroy = function () {
    176176      try { if (adapter && adapter.destroy) adapter.destroy(); } catch (e) {}
     
    182182    let dur = 0;
    183183
    184     // --- UI ophangen (verschilt per provider-type) ---
     184    // --- Mount the UI (differs per provider type) ---
    185185    const isVideo = provider === 'youtube';
    186186    const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls
     
    220220    const volEl = el.querySelector('.pcms-embed-vol');
    221221    const muteBtn = el.querySelector('.pcms-embed-mute');
    222     let vol = 100;       // huidige volume 0-100 (wordt op de adapter toegepast)
     222    let vol = 100;       // current volume 0-100 (applied to the adapter)
    223223    let preMuteVol = 100;
    224224
     
    251251    };
    252252
    253     // API geblokkeerd/onbereikbaar → kaal platform-iframe (graceful degradation).
    254     // De mutual-exclusion loopt voor deze fallback via de blur-heuristiek
    255     // (audio-player.js), want de kaart krijgt geen .is-mounted.
     253    // API blocked/unreachable → plain platform iframe (graceful degradation).
     254    // Mutual exclusion for this fallback runs via the blur heuristic
     255    // (audio-player.js), because the card never gets .is-mounted.
    256256    function renderFallback() {
    257257      const fb = fallbackIframe(provider, ref, url);
     
    271271      el.appendChild(iframe);
    272272
    273       // Mutual exclusion óók voor de fallback-iframe. Een cross-origin iframe
    274       // kunnen we niet via een API pauzeren, dus 'pauze' = herladen ZONDER
    275       // autoplay (= stopt het geluid, speler blijft zichtbaar/herstartbaar).
    276       // We registreren 'm als actief: nu (= gebruiker start de embed) pauzeert de
    277       // site-speler/andere embeds; en als de site-speler later start, pauzeert de
    278       // registry deze fallback.
     273      // Mutual exclusion also for the fallback iframe. A cross-origin iframe
     274      // cannot be paused via an API, so 'pause' = reload WITHOUT
     275      // autoplay (= stops the audio, player stays visible/restartable).
     276      // We register it as active: now (= user starts the embed) the
     277      // site player/other embeds pause; and when the site player starts later,
     278      // the registry pauses this fallback.
    279279      self.pause = function () {
    280280        try {
     
    294294    }
    295295
    296     // Eerste interactie → adapter mounten + spelen. Daarna toggelt de knop.
     296    // First interaction → mount the adapter + play. The button toggles after that.
    297297    async function ensureMountedAndPlay() {
    298298      if (mounted) { if (adapter) adapter.play(); return; }
    299299      mounted = true;
    300       // Spotify: hun speler is toch niet te skinnen (besturing-only) én de
    301       // iFrame-API-bundle initialiseert in de praktijk vaak niet (CDN-gating/503)
    302       // → niet op de API wachten, meteen het kale Spotify-iframe tonen. Instant.
     300      // Spotify: their player cannot be skinned (controls-only) and the
     301      // iFrame API bundle often fails to initialise in practice (CDN-gating/503)
     302      // → don't wait for the API, show the plain Spotify iframe immediately.
    303303      if (provider === 'spotify') { renderFallback(); return; }
    304304      el.classList.add('pcms-embed-busy');
     
    306306        adapter = await MOUNTERS[provider](mountEl, { ref, url }, hooks);
    307307        if (el._pcmsApplyVol) el._pcmsApplyVol();  // onthouden volume toepassen
    308         // is-mounted: CSS verbergt de poster (video) of de Spotify-facade en
    309         // toont de echte speler. Voor SoundCloud blijft onze kaart+balk staan en
    310         // blijft het (functionele) iframe off-screen verborgen.
     308        // is-mounted: CSS hides the poster (video) or Spotify facade and
     309        // shows the real player. For SoundCloud our card+bar stays visible and
     310        // the (functional) iframe remains hidden off-screen.
    311311        el.classList.add('is-mounted');
    312312        adapter.play();
    313313      } catch (err) {
    314         console.warn('[pcms-embed] API niet beschikbaar, val terug op kaal iframe', provider, err);
     314        console.warn('[pcms-embed] API unavailable, falling back to plain iframe', provider, err);
    315315        renderFallback();
    316316      }
     
    328328      adapter.seek(ratio * dur);
    329329    });
    330     // Volume: schuif zet vol (0-100) en past 'm toe op de adapter (YT/SC hebben
    331     // setVolume). De waarde wordt onthouden en na het mounten opnieuw toegepast.
     330    // Volume: slider sets vol (0-100) and applies it to the adapter (YT/SC support
     331    // setVolume). The value is remembered and reapplied after mounting.
    332332    function applyVol() {
    333333      if (adapter && adapter.setVolume) { try { adapter.setVolume(vol); } catch (e) {} }
     
    341341      applyVol();
    342342    });
    343     el._pcmsApplyVol = applyVol;  // door ensureMountedAndPlay aangeroepen na mount
     343    el._pcmsApplyVol = applyVol;  // called by ensureMountedAndPlay after mount
    344344  }
    345345
     
    349349
    350350  // ============================================================
    351   // 4. Adapters — normaliseren de 3 zeer verschillende API's naar 1 vorm.
    352   //    Elke mounter geeft { play, pause, seek } terug en roept hooks aan met
    353   //    seconden (units worden hier rechtgetrokken).
     351  // 4. Adapters — normalise the 3 very different APIs to one common shape.
     352  //    Each mounter returns { play, pause, seek } and calls hooks with
     353  //    seconds (units are normalised here).
    354354  // ============================================================
    355355  const MOUNTERS = {
     
    403403    },
    404404
    405     // ---- SoundCloud: Widget API, visual=false, eigen controls ----
     405    // ---- SoundCloud: Widget API, visual=false, custom controls ----
    406406    async soundcloud(mountEl, { ref, url }, hooks) {
    407407      const SC = await scApi();
    408       // Iframe zelf bouwen (kale balk) en daarna SC.Widget eraan hangen.
     408      // Build the iframe ourselves (bare bar) and then attach SC.Widget to it.
    409409      const iframe = document.createElement('iframe');
    410410      iframe.allow = 'autoplay';
     
    453453    },
    454454
    455     // ---- Spotify: iFrame API — besturing + onze frame; geen eigen skin ----
     455    // ---- Spotify: iFrame API — controls + our frame; no custom skin ----
    456456    async spotify(mountEl, { ref, url }, hooks) {
    457457      const IFrameAPI = await spotifyApi();
     
    473473            const d = e && e.data ? e.data : {};
    474474            hooks.onProgress((d.position || 0) / 1000, (d.duration || 0) / 1000);
    475             // Geen betrouwbare 'ended'-event bij Spotify; we behandelen elke
    476             // isPaused-overgang als play/pause. Einde = gewoon een pauze (de balk
    477             // blijft op de eindpositie i.p.v. misleidend naar 0 te springen).
     475            // No reliable 'ended' event from Spotify; we treat every
     476            // isPaused transition as play/pause. End = just a pause (the bar
     477            // stays at the end position rather than misleadingly jumping to 0).
    478478            if (d.isPaused === false && lastPaused) { lastPaused = false; hooks.onPlay(); }
    479479            else if (d.isPaused === true && !lastPaused) { lastPaused = true; hooks.onPause(); }
     
    486486
    487487  // ============================================================
    488   // 5. Scan + HTMX-/DOM-mutatie-aware (de site navigeert deels via HTMX-swaps)
     488  // 5. Scan + HTMX/DOM-mutation-aware (the site partially navigates via HTMX swaps)
    489489  // ============================================================
    490490  function scan(root) {
     
    492492      if (el.dataset.embedInit) return;
    493493      el.dataset.embedInit = '1';
    494       try { buildCard(el); } catch (e) { console.error('[pcms-embed] buildCard faalde', e); }
     494      try { buildCard(el); } catch (e) { console.error('[pcms-embed] buildCard failed', e); }
    495495    });
    496496  }
     
    501501    scan(document);
    502502  }
    503   // HTMX vervangt #pcms-main bij interne navigatie → opnieuw scannen.
     503  // HTMX replaces #pcms-main on internal navigation → rescan.
    504504  document.body.addEventListener('htmx:afterSwap', (e) => scan(e.target || document));
    505505  document.body.addEventListener('htmx:load', (e) => scan(e.target || document));
    506506
    507   // Teardown bij verwijdering uit de DOM (HTMX vervangt #pcms-main innerHTML, of
    508   // een SPA-achtige swap). Zonder dit blijven YouTube-poll-timers + adapters/
    509   // iframes hangen als je wegnavigeert terwijl een embed speelt → CPU/geheugenlek
    510   // dat per navigatie opstapelt. We roepen el._pcmsDestroy() aan voor elke kaart
    511   // die echt uit het document verdwijnt.
     507  // Teardown on DOM removal (HTMX replaces #pcms-main innerHTML, or an SPA-like
     508  // swap). Without this, YouTube poll timers + adapters/iframes linger when
     509  // navigating away while an embed is playing → CPU/memory leak that accumulates
     510  // per navigation. We call el._pcmsDestroy() for every card that truly leaves the document.
    512511  const teardownObserver = new MutationObserver((muts) => {
    513512    for (const m of muts) {
  • src/assets/js/smooth-scroll.js

    rbb42dfb r834bcc3  
    22(function () {
    33  if (typeof Lenis === "undefined") return;
    4   // Skip op touch/mobile — native momentum is beter dan JS lib
     4  // Skip on touch/mobile — native momentum scroll is better than a JS lib
    55  var isMobile = window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 1024;
    66  if (isMobile) return;
  • src/config/database.js

    rbb42dfb r834bcc3  
    3939  ensureColumn('users', 'reset_token', 'TEXT');
    4040  ensureColumn('users', 'reset_token_expires', 'DATETIME');
    41   // Google OAuth: koppel een Google-account aan een user (login via Google).
     41  // Google OAuth: link a Google account to a user (login via Google).
    4242  ensureColumn('users', 'google_sub', 'TEXT');
    43   // Read-only/kijk-account: kan alles bekijken maar geen wijzigingen doen.
     43  // Read-only/viewer account: can view everything but make no changes.
    4444  ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0');
    45   // Persoonlijke interface-taal (nl|en|de). Null = volg de standaard (site/env/browser).
     45  // Personal interface language (nl|en|de). Null = follow the default (site/env/browser).
    4646  ensureColumn('users', 'lang', 'TEXT');
    4747  // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
    4848  ensureColumn('sites', 'comments_moderation_mode', "TEXT DEFAULT 'moderate'");
    49   // Cirkels: mag deze site in cirkels van anderen verschijnen (surfacing opt-out).
     49  // Circles: whether this site may appear in other sites' circles (surfacing opt-out).
    5050  ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1');
    5151
    52   // Eén EXPLICIETE primaire/hoofd-site (= de bedrijfs-/labelsite in hub-modus,
    53   // de enige site in solo) i.p.v. de fragiele "oudste = hoofd"-conventie die op
    54   // 4 plekken gedupliceerd stond. Backfill: markeer de oudste als er nog geen
    55   // primaire site is, zodat bestaand gedrag exact behouden blijft.
     52  // One EXPLICIT primary/main site (= the company/label site in hub mode,
     53  // the only site in solo) instead of the fragile "oldest = main" convention
     54  // that was duplicated in 4 places. Backfill: mark the oldest if no primary
     55  // site exists yet, so existing behaviour is preserved exactly.
    5656  ensureColumn('sites', 'is_primary', 'INTEGER DEFAULT 0');
    5757  try {
     
    6161      if (oldest) db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(oldest.id);
    6262    }
    63   } catch (e) { /* sites-tabel nog leeg/afwezig bij verse init — ensurePrimarySite regelt 't */ }
     63  } catch (e) { /* sites table still empty/absent on fresh init — ensurePrimarySite handles it */ }
    6464
    6565  // v9 audit additions —————————————————————————————————————————
     
    8282  // Per-post noindex + type
    8383  ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
    84   ensureColumn('posts', 'publish_at', 'DATETIME');         // release-planning (premium #3): geplande go-live
     84  ensureColumn('posts', 'publish_at', 'DATETIME');         // release planning (premium #3): scheduled go-live
    8585  ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0');  // fan-only preview (premium #3)
    8686  ensureColumn('posts', 'type',    "TEXT DEFAULT 'post'");  // post | foto | video | audio
    8787
    88   // Statistieken (premium-module) — kale tellers, cookievrij.
    89   ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0');         // weergaven per post
     88  // Statistics (premium module) — bare counters, cookie-free.
     89  ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0');         // views per post
    9090  ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0');  // plays per track
    91   ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-voor-email (premium #2)
    92   ensureColumn('audio_tracks', 'credit', 'TEXT');   // eigenaar/credit (copyright-houder)
    93   ensureColumn('audio_tracks', 'license', 'TEXT');  // licentie (bv. "CC BY 4.0", "Alle rechten voorbehouden")
    94   ensureColumn('audio_tracks', 'link_spotify',    'TEXT');  // "open in"-links per track
     91  ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-for-email (premium #2)
     92  ensureColumn('audio_tracks', 'credit', 'TEXT');   // owner/credit (copyright holder)
     93  ensureColumn('audio_tracks', 'license', 'TEXT');  // license (e.g. "CC BY 4.0", "All rights reserved")
     94  ensureColumn('audio_tracks', 'link_spotify',    'TEXT');  // "open in" links per track
    9595  ensureColumn('audio_tracks', 'link_youtube',    'TEXT');
    9696  ensureColumn('audio_tracks', 'link_soundcloud', 'TEXT');
     
    123123  `);
    124124
    125   // Globale app-instellingen (key/value singleton). O.a. de tenancy-modus
    126   // (solo = één site, hub = bedrijfssite + /user/). Default = solo.
     125  // Global app settings (key/value singleton). Includes the tenancy mode
     126  // (solo = one site, hub = company site + /user/). Default = solo.
    127127  db.exec(`
    128128    CREATE TABLE IF NOT EXISTS app_settings (
     
    134134  db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES ('tenancy', 'solo')").run();
    135135
    136   // ── Statistieken (premium) — cookievrij ─────────────────────
    137   // stat_daily: per dag per site het aantal pageviews (kale teller).
    138   // stat_visitor_day: per dag per site een rij per UNIEKE bezoeker-hash
    139   //   (sha256 van IP+UA+dag-salt; de salt roteert dagelijks en wordt nooit
    140   //   bewaard → geen persistente identifier, geen cookie, geen toestemming nodig).
     136  // ── Statistics (premium) — cookie-free ──────────────────────
     137  // stat_daily: pageview count per day per site (bare counter).
     138  // stat_visitor_day: one row per UNIQUE visitor hash per day per site
     139  //   (sha256 of IP+UA+day-salt; the salt rotates daily and is never stored
     140  //   → no persistent identifier, no cookie, no consent required).
    141141  db.exec(`
    142142    CREATE TABLE IF NOT EXISTS stat_daily (
     
    161161  `);
    162162
    163   // ── Cirkels (federatie) ─────────────────────────────────────
    164   // Decentrale, asymmetrische verbindingen tussen solo-instances.
     163  // ── Circles (federation) ────────────────────────────────────
     164  // Decentralised, asymmetric connections between solo instances.
    165165  db.exec(`
    166166    CREATE TABLE IF NOT EXISTS circle_links (
     
    200200  `);
    201201
    202   // Tags van de originele post — getoond in de cirkel (comma-separated string).
     202  // Tags from the original post — shown in the circle feed (comma-separated string).
    203203  ensureColumn('remote_posts', 'tags', 'TEXT');
    204204
    205   // Nieuwsbrief / mailinglijst (premium). Abonnees per site; double opt-in als SMTP
    206   // er is (status 'pending' tot bevestigd), anders single opt-in ('confirmed').
    207   // 'unsub' = uitgeschreven. token = confirm/unsubscribe-sleutel (in de e-maillinks).
     205  // Newsletter / mailing list (premium). Subscribers per site; double opt-in when SMTP
     206  // is configured (status 'pending' until confirmed), otherwise single opt-in ('confirmed').
     207  // 'unsub' = unsubscribed. token = confirm/unsubscribe key (used in email links).
    208208  db.exec(`
    209209    CREATE TABLE IF NOT EXISTS subscribers (
     
    221221  `);
    222222
    223   // Verstuurde nieuwsbrieven (historie + aantallen).
     223  // Sent newsletters (history + counts).
    224224  db.exec(`
    225225    CREATE TABLE IF NOT EXISTS newsletters (
     
    233233  `);
    234234
    235   // Show-agenda (premium #8): tourdata/optredens per site.
     235  // Show agenda (premium #8): tour dates / gigs per site.
    236236  db.exec(`
    237237    CREATE TABLE IF NOT EXISTS shows (
     
    250250  `);
    251251
    252   // Meldingen: iemand reageert op je reactie / post, of liket je post. Snapshots
    253   // van naam/titel zodat de lijst goedkoop te tonen is zonder joins.
    254   // NB: bewust 'user_notifications' — sommige oudere DBs hebben nog een stale,
    255   // ongebruikte 'notifications'-tabel met een ander schema (geen read-kolom).
     252  // Notifications: someone replies to your comment / post, or likes your post. Snapshots
     253  // of name/title so the list can be shown cheaply without joins.
     254  // NB: deliberately named 'user_notifications' — some older DBs still have a stale,
     255  // unused 'notifications' table with a different schema (no read column).
    256256  db.exec(`
    257257    CREATE TABLE IF NOT EXISTS user_notifications (
     
    270270  `);
    271271
    272   // Link-in-bio klikstatistiek (premium #6). Per (site, url) een teller; de
    273   // link-in-bio-pagina linkt via /links/go/:i dat de klik telt en doorstuurt.
     272  // Link-in-bio click statistics (premium #6). One counter per (site, url); the
     273  // link-in-bio page links via /links/go/:i which counts the click and redirects.
    274274  db.exec(`
    275275    CREATE TABLE IF NOT EXISTS link_clicks (
     
    282282  `);
    283283
    284   // Likes / favorieten: een ingelogde gebruiker kan een post liken. De set van
    285   // posts die een gebruiker likte = z'n favorieten (/favorieten-pagina). Eén rij
    286   // per (post, user); uniek zodat liken idempotent is.
     284  // Likes / favourites: a logged-in user can like a post. The set of
     285  // posts a user liked = their favourites (/favorieten page). One row
     286  // per (post, user); unique so that liking is idempotent.
    287287  db.exec(`
    288288    CREATE TABLE IF NOT EXISTS post_likes (
  • src/config/features.js

    rbb42dfb r834bcc3  
    1 // Feature-flags (boot-tijd, via env).
     1// Feature flags (boot-time, via env).
    22//
    3 // Lite-modus: zet KLONKT_AUDIO=off in .env om de HELE audio-feature uit te
    4 // schakelen — geen audio-/playlist-/download-/embed-routes, geen ffmpeg-aanroep,
    5 // geen speler en geen [[track]]/[[playlist]]-shortcodes. Zo draait Klonkt als
    6 // lichte blog/foto/EPK-site op een omgeving zónder ffmpeg/exec. Hub én Cirkels
    7 // blijven gewoon werken (die hangen niet van audio af).
     3// Lite mode: set KLONKT_AUDIO=off in .env to disable the ENTIRE audio feature —
     4// no audio/playlist/download/embed routes, no ffmpeg calls, no player, and no
     5// [[track]]/[[playlist]] shortcodes. This lets Klonkt run as a lightweight
     6// blog/photo/EPK site on environments without ffmpeg/exec. Hub and Circles
     7// keep working (they have no audio dependency).
    88//
    9 // Default = aan (volledige versie). Alleen de letterlijke waarde 'off' schakelt uit.
     9// Default = on (full version). Only the literal value 'off' disables it.
    1010export function audioEnabled() {
    1111  return String(process.env.KLONKT_AUDIO ?? 'on').toLowerCase() !== 'off';
  • src/config/google.js

    rbb42dfb r834bcc3  
    1 // Google OAuth2 voor LUISTERAARS (reageren). Per-instance: de self-hoster zet
    2 // z'n EIGEN Google-client. Zo hangt elke site aan z'n eigen Google Cloud project
    3 // — geen centrale afhankelijkheid, geen gedeelde aansprakelijkheid.
     1// Google OAuth2 for LISTENERS (commenting). Per-instance: each self-hoster sets
     2// their OWN Google client. This way every site is tied to its own Google Cloud
     3// project — no central dependency, no shared liability.
    44//
    5 // Config-bron (in deze volgorde): app_settings (ingesteld via Beheer → Instellingen),
    6 // anders de env-vars. Niet ingesteld → geen "Login met Google"-knop; de rest van
    7 // de site werkt door. Google-login geeft NOOIT beheerrechten.
     5// Config source (in this order): app_settings (set via Admin → Settings),
     6// otherwise env vars. Not configured → no "Login with Google" button; the rest
     7// of the site keeps working. Google login NEVER grants admin rights.
    88//
    9 // De redirect-URI wordt afgeleid van PUBLIC_BASE_URL (<base>/auth/google/callback),
    10 // of expliciet via GOOGLE_REDIRECT_URI. Die exacte URL moet in Google Cloud staan.
     9// The redirect URI is derived from PUBLIC_BASE_URL (<base>/auth/google/callback),
     10// or explicitly via GOOGLE_REDIRECT_URI. That exact URL must be listed in Google Cloud.
    1111
    1212import { getSetting } from '../services/SettingsService.js';
     
    1616const USERINFO_URL = 'https://openidconnect.googleapis.com/v1/userinfo';
    1717
    18 // Dynamisch lezen (UI-wijziging werkt zonder herstart). app_settings wint, env = fallback.
     18// Read dynamically (UI changes take effect without a restart). app_settings wins, env = fallback.
    1919function clientId() {
    2020  return getSetting('google_client_id', '') || process.env.GOOGLE_CLIENT_ID || '';
     
    2929}
    3030
    31 export function currentClientId() { return clientId(); } // niet-geheim, voor het formulier
     31export function currentClientId() { return clientId(); } // not secret, used for the settings form
    3232export function clientSecretSet() { return !!clientSecret(); }
    3333export function googleConfigured() {
     
    6161    body,
    6262  });
    63   if (!r.ok) throw new Error(`Google token-exchange faalde: ${r.status}`);
     63  if (!r.ok) throw new Error(`Google token exchange failed: ${r.status}`);
    6464  return r.json(); // { access_token, id_token, ... }
    6565}
     
    6868export async function fetchUserinfo(accessToken) {
    6969  const r = await fetch(USERINFO_URL, { headers: { Authorization: `Bearer ${accessToken}` } });
    70   if (!r.ok) throw new Error(`Google userinfo faalde: ${r.status}`);
     70  if (!r.ok) throw new Error(`Google userinfo failed: ${r.status}`);
    7171  return r.json();
    7272}
  • src/config/mailer.js

    rbb42dfb r834bcc3  
    1 // E-mail versturen (wachtwoord-reset, nieuwsbrief, notify). Optioneel: alleen actief
    2 // als SMTP is ingesteld — via Beheer → Instellingen (app_settings) OF env-vars.
     1// Send email (password reset, newsletter, notify). Optional: only active
     2// when SMTP is configured — via Admin → Settings (app_settings) OR env vars.
    33//
    4 // Config-bron (in deze volgorde): app_settings (ingesteld in de UI), anders env:
     4// Config source (in this order): app_settings (set via the UI), otherwise env:
    55//   SMTP_HOST, SMTP_PORT (default 587), SMTP_USER, SMTP_PASS, SMTP_FROM (default = USER)
    6 // Niet ingesteld → versturen valt terug op CLI (reset-admin) / wordt overgeslagen.
     6// Not configured → sending falls back to CLI (reset-admin) / is skipped.
    77
    88import nodemailer from 'nodemailer';
     
    2323}
    2424
    25 // Status voor de UI (zonder het wachtwoord te lekken).
     25// Status for the UI (without leaking the password).
    2626export function mailerStatus() {
    2727  const c = cfg();
     
    3333    from: c.from,
    3434    passSet: !!c.pass,
    35     // bron: handig om te tonen dat env nog actief is
     35    // source: useful to show that env vars are still active
    3636    fromEnv: !getSetting('smtp_host', '') && !!process.env.SMTP_HOST,
    3737  };
    3838}
    3939
    40 // Transport cachen, maar herbouwen zodra de config wijzigt (UI-edit zonder herstart).
     40// Cache the transport, but rebuild it whenever the config changes (UI edit without restart).
    4141let _transport = null, _key = null;
    4242function transport() {
     
    4747      host: c.host,
    4848      port: c.port,
    49       secure: c.port === 465, // 465 = impliciete TLS; 587 = STARTTLS
     49      secure: c.port === 465, // 465 = implicit TLS; 587 = STARTTLS
    5050      auth: { user: c.user, pass: c.pass },
    5151    });
     
    5656
    5757export async function sendMail({ to, subject, text, html }) {
    58   if (!mailerConfigured()) throw new Error('SMTP niet geconfigureerd');
     58  if (!mailerConfigured()) throw new Error('SMTP not configured');
    5959  const c = cfg();
    6060  return transport().sendMail({ from: c.from, to, subject, text, html });
  • src/middleware/auth.js

    rbb42dfb r834bcc3  
    3838}
    3939
    40 // Een 'kijker' mag ALLES bekijken (incl. Beheer) maar NIETS wijzigen. De
    41 // schrijf-blokkade zit in de globale guard in server.js; deze helper bepaalt
    42 // alleen "is dit een alleen-lezen account?". `readonly` is de legacy-vlag die
    43 // we nog meenemen zodat niet-gemigreerde demo-accounts geblokkeerd blijven.
     40// A 'kijker' (viewer) may VIEW everything (incl. Admin) but CHANGE nothing. The
     41// write block lives in the global guard in server.js; this helper only determines
     42// "is this a read-only account?". `readonly` is the legacy flag we still include
     43// so unmigrated demo accounts remain blocked.
    4444export function isViewer(user) {
    4545  return !!user && (user.role === 'kijker' || !!user.readonly);
     
    4949  if (!req.session?.user) return loginRedirect(req, res);
    5050  const role = req.session.user.role;
    51   // god beheert; een kijker mág het Beheer-paneel zien (alleen-lezen) — de
    52   // globale guard 403't elke write, dus dit geeft enkel kijk-toegang.
     51  // god manages; a viewer MAY see the Admin panel (read-only) — the
     52  // global guard 403s every write, so this only grants view access.
    5353  if (role !== 'god' && role !== 'kijker') {
    5454    return res.status(403).send('God role required');
     
    5757}
    5858
    59 // Mag de ingelogde user de HUIDIGE site (res.locals.site) beheren? god altijd;
    60 // anders alleen de owner van die site. Gebruikt voor site-gescopete beheerroutes
    61 // die een artiest via /user/<eigen-slug>/admin/... bereikt (res.locals.site is dan
    62 // z'n eigen site; een vreemde slug levert een andere site -> 403).
     59// Can the logged-in user manage the CURRENT site (res.locals.site)? god always;
     60// otherwise only the owner of that site. Used for site-scoped admin routes
     61// that an artist reaches via /user/<own-slug>/admin/... (res.locals.site is then
     62// their own site; a foreign slug yields a different site -> 403).
    6363export function requireSiteManager(req, res, next) {
    6464  if (!req.session?.user) return loginRedirect(req, res);
    6565  const u = req.session.user;
    66   if (u.role === 'god' || u.role === 'kijker') return next(); // kijker = alleen-lezen kijk-toegang
     66  if (u.role === 'god' || u.role === 'kijker') return next(); // viewer = read-only view access
    6767  const site = res.locals.site;
    68   // owner OF toegewezen mede-beheerder (site_members) — canAdminSite dekt beide.
     68  // owner OR assigned co-admin (site_members) — canAdminSite covers both.
    6969  if (site && PermissionsService.canAdminSite(u, site)) return next();
    7070  return res.status(403).send('Geen toegang tot deze site.');
    7171}
    7272
    73 // Idem, maar de site wordt bepaald door de :slug-parameter (bv. site-edit).
     73// Same, but the site is determined by the :slug parameter (e.g. site-edit).
    7474export function requireSiteManagerBySlug(req, res, next) {
    7575  if (!req.session?.user) return loginRedirect(req, res);
    7676  const u = req.session.user;
    77   if (u.role === 'god' || u.role === 'kijker') return next(); // kijker = alleen-lezen kijk-toegang
     77  if (u.role === 'god' || u.role === 'kijker') return next(); // viewer = read-only view access
    7878  const site = db.prepare('SELECT id, owner_id FROM sites WHERE slug = ?').get(req.params.slug);
    7979  if (site && PermissionsService.canAdminSite(u, site)) return next();
  • src/middleware/rate-limit.js

    rbb42dfb r834bcc3  
    1414import { renderPage } from './render.js';
    1515
    16 // Achter Cloudflare/Caddy kan req.ip binnenkomen als "1.2.3.4:11046" (IPv4 met
    17 // poort). express-rate-limit v7 valideert het IP en gooit anders
    18 // ERR_ERL_INVALID_IP_ADDRESS — onafgevangen async → het proces crasht (en pm2
    19 // loopt in een restart-loop). Strip een trailing IPv4-poort, val terug op de
    20 // socket, en laat IPv6 (meerdere dubbele punten) ongemoeid.
     16// Behind Cloudflare/Caddy, req.ip can arrive as "1.2.3.4:11046" (IPv4 with
     17// port). express-rate-limit v7 validates the IP and otherwise throws
     18// ERR_ERL_INVALID_IP_ADDRESS — uncaught async → the process crashes (and pm2
     19// enters a restart loop). Strip a trailing IPv4 port, fall back to the
     20// socket address, and leave IPv6 (multiple colons) untouched.
    2121function clientKey(req) {
    2222  let ip = req.ip || req.socket?.remoteAddress || '';
  • src/middleware/render.js

    rbb42dfb r834bcc3  
    2727const VIEWS_DIR = path.join(__dirname, '..', 'views');
    2828
    29 // App-versie (uit package.json) + korte commit-hash (uit .klonkt-version, door de
    30 // deploy geschreven) — getoond in de footer naast "Klonkt Beta". De hash loopt
    31 // automatisch mee bij élke deploy, dus de versie is nooit meer stale.
     29// App version (from package.json) + short commit hash (from .klonkt-version, written by
     30// the deploy script) — shown in the footer next to "Klonkt Beta". The hash is updated
     31// automatically on every deploy, so the displayed version is never stale.
    3232let APP_VERSION = '';
    3333try {
     
    3636    const sha = fs.readFileSync(path.join(__dirname, '..', '..', '.klonkt-version'), 'utf8').trim().slice(0, 7);
    3737    if (sha) APP_VERSION += ' · ' + sha;
    38   } catch { /* geen .klonkt-version (lokale dev) */ }
    39 } catch { /* geen versie beschikbaar */ }
    40 
    41 // Site-tijdzone (Beheer → Instellingen). Leeg = server-default (UTC). Wordt
    42 // toegepast op álle server-side geformatteerde datums zodat ze in de zone van
    43 // de site staan i.p.v. UTC.
     38  } catch { /* no .klonkt-version (local dev) */ }
     39} catch { /* no version available */ }
     40
     41// Site timezone (Admin → Settings). Empty = server default (UTC). Applied to
     42// all server-side formatted dates so they display in the site's timezone instead of UTC.
    4443const siteTimezone = () => getSetting('timezone') || undefined;
    4544
     
    5857  const isPartial = req.headers['hx-request'] === 'true' || req.query.partial === '1';
    5958
    60   // Voorkom dat de browser een htmx-PARTIAL (alleen #pcms-main, zónder <head>/CSS)
    61   // onder dezelfde URL cachet en bij "terug" als volledige pagina serveert →
    62   // ongestylede HTML. Vary: HX-Request scheidt partial- en full-responses in de
    63   // cache; no-store op de partial zelf laat "terug" altijd de volledige pagina
    64   // opnieuw ophalen. (Vary geldt ook voor tussenliggende caches/Cloudflare.)
     59  // Prevent the browser from caching an htmx PARTIAL (only #pcms-main, without <head>/CSS)
     60  // under the same URL and serving it as a full page on "back" → unstyled HTML.
     61  // Vary: HX-Request separates partial and full responses in the cache;
     62  // no-store on the partial forces "back" to always re-fetch the full page.
     63  // (Vary also applies to intermediate caches / Cloudflare.)
    6564  res.setHeader('Vary', 'HX-Request');
    6665  if (isPartial) res.setHeader('Cache-Control', 'no-store');
    6766
    68   // Bezit deze (niet-god) user een eigen site? Bepaalt of 'ie een "Beheer"-
    69   // ingang ziet (artiest-zelfbeheer). god ziet beheer sowieso (op rol).
     67  // Does this (non-god) user own a site? Determines whether they see an "Admin"
     68  // entry (artist self-manage). god always sees admin (by role).
    7069  let _u = req.session?.user || null;
    71   // Ververs avatar + rol uit de DB zodat een stale sessie (bv. na een
    72   // avatar-wijziging of rolwissel) zichzelf herstelt zonder opnieuw inloggen.
     70  // Refresh avatar + role from the DB so a stale session (e.g. after an
     71  // avatar change or role switch) heals itself without a new login.
    7372  if (_u && _u.id) {
    7473    const _fresh = db.prepare('SELECT avatar_url, role, lang FROM users WHERE id = ?').get(_u.id);
     
    7877    db.prepare('SELECT 1 FROM sites WHERE owner_id = ? LIMIT 1').get(_u.id));
    7978
    80   // De avatar van de SITE-EIGENAAR (niet de kijker!) — voor de Klonkt-site-kop,
    81   // zodat de artiest z'n eigen account-foto als sitefoto kan gebruiken.
     79  // The avatar of the SITE OWNER (not the viewer!) — for the Klonkt site header,
     80  // so the artist can use their own account photo as the site photo.
    8281  const _site = data.site || res.locals.site || null;
    8382  const siteOwnerAvatar = (_site && _site.owner_id)
     
    8584    : null;
    8685
    87   // Kijker-modus: alles bekijken mag, niets wijzigen. Views gebruiken canMutate
    88   // om schrijf-knoppen (posten, opslaan, verwijderen) te verbergen/uit te zetten.
     86  // Viewer mode: may view everything, change nothing. Views use canMutate
     87  // to hide/disable write buttons (post, save, delete).
    8988  const _isViewer = isViewer(_u);
    9089
    91   // Wie ziet de "Beheer"-link? god/admin, een site-eigenaar (artiest-zelfbeheer),
    92   // én een kijker (mag het Beheer alleen-lezen inzien). Eén bron van waarheid,
    93   // gespiegeld in topnav/hub-nav/profielsheet — anders raakt de link verborgen
    94   // voor wie 'm wél mag zien (kijker zag 'm eerst nergens).
     90  // Who sees the "Admin" link? god/admin, a site owner (artist self-manage),
     91  // and a viewer (may view Admin read-only). One source of truth,
     92  // mirrored in topnav/hub-nav/profile sheet — otherwise the link gets hidden
     93  // for those who should see it (viewer didn't see it anywhere before).
    9594  const _role = _u ? _u.role : null;
    9695  const canSeeBeheer = !!(_u && (_role === 'god' || _role === 'admin' || _role === 'kijker' || userOwnsSite));
    9796
    98   // Interface-taal: sessie-keuze (deze sessie) → eigen voorkeur van de ingelogde
    99   // gebruiker (users.lang) → admin-ingestelde standaard (Beheer) → env → browser → nl.
     97  // Interface language: session choice (this session) → logged-in user's own preference
     98  // (users.lang) → admin-set default (Admin → Settings) → env → browser → nl.
    10099  const _lang = resolveLang(req, {
    101100    userLang: _u && _u.lang,
     
    125124    tenancy: res.locals.tenancy || 'solo',
    126125    hubTitle: getSetting('hub_title') || '',
    127     footerNewsletter: getSetting('footer_newsletter') === '1', // nieuwsbrief-aanmelding in footer (premium)
    128     agendaEnabled: getSetting('agenda_enabled') === '1', // Agenda/evenementen tonen in de pill (premium, opt-in)
     126    footerNewsletter: getSetting('footer_newsletter') === '1', // newsletter sign-up in footer (premium)
     127    agendaEnabled: getSetting('agenda_enabled') === '1', // show agenda/events in the pill (premium, opt-in)
    129128    platforms_catalog: PLATFORMS_CATALOG,
    130129    permissions: PermissionsService,
     
    153152      // looks "unclickable". Escape any non-ASCII to \uXXXX: the header stays
    154153      // ASCII-safe and remains valid JSON that htmx parses back unchanged.
    155       // Per-site accent + palette zitten in de shell-<head> (style#pcms-site-accent
    156       // + html[data-palette]) en worden NIET mee-geswapt bij htmx-nav. Stuur ze mee
    157       // zodat de client ze bijwerkt — anders erft een artiest de kleuren van de
    158       // vorige pagina (bv. hub-paars i.p.v. eigen groen). Zelfde afleiding als shell.ejs.
     154      // Per-site accent + palette live in the shell <head> (style#pcms-site-accent
     155      // + html[data-palette]) and are NOT swapped during htmx navigation. Send them along
     156      // so the client updates them — otherwise an artist inherits the previous page's
     157      // colours (e.g. hub-purple instead of their own green). Same derivation as shell.ejs.
    159158      const _navAccent = (_site && _site.accent && /^#[0-9a-fA-F]{6}$/.test(_site.accent))
    160159        ? _site.accent : '#e8b04b';
     
    169168      }).replace(/[€-￿]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
    170169      res.setHeader('HX-Trigger-After-Settle', triggerJson);
    171       // Site-chrome out-of-band mee-renderen, zodat de kop (topnav/profielkop/
    172       // view-switcher) bij navigatie ALTIJD bij de nieuwe pagina/artiest hoort
    173       // terwijl de audioplayer (los in document.body) blijft leven (geen
    174       // verspringen). htmx vervangt #pcms-chrome via hx-swap-oob. Niet kritisch:
    175       // faalt 't, dan blijft de oude chrome staan (geen crash).
     170      // Render the site chrome out-of-band so the header (topnav/profile header/
     171      // view-switcher) ALWAYS matches the new page/artist on navigation
     172      // while the audio player (separate in document.body) keeps playing (no
     173      // interruption). htmx replaces #pcms-chrome via hx-swap-oob. Non-critical:
     174      // if it fails, the old chrome remains (no crash).
    176175      let oobChrome = '';
    177176      try {
     
    181180          { async: false },
    182181        );
    183       } catch (e) { /* chrome-OOB overslaan */ }
     182      } catch (e) { /* skip chrome OOB */ }
    184183      return res.send(pageContent + oobChrome);
    185184    }
  • src/middleware/site.js

    rbb42dfb r834bcc3  
    1515
    1616/**
    17  * De primaire/hoofd-site — ÉÉN bron van waarheid (vervangt de "oudste site ="
    18  * hoofd"-aanname die voorheen los in resolveSite/hub/account/admin stond).
    19  * Leest de expliciete is_primary-vlag; valt terug op de oudste als die (nog)
    20  * nergens staat, zodat bestaand gedrag exact behouden blijft.
     17 * The primary/main site — ONE source of truth (replaces the "oldest site ="
     18 * main" assumption that was previously scattered across resolveSite/hub/account/admin).
     19 * Reads the explicit is_primary flag; falls back to the oldest if it isn't set
     20 * anywhere yet, so existing behaviour is preserved exactly.
    2121 */
    2222export function getPrimarySite() {
     
    2828export function resolveSite(req, res, next) {
    2929  const tenancy = getTenancy();
    30   res.locals.tenancy = tenancy; // ook beschikbaar voor views
     30  res.locals.tenancy = tenancy; // also available in views
    3131
    32   // In HUB-mode mapt /user/:slug naar een specifieke site. In SOLO-mode bestaat
    33   // er maar één site: we slaan die routing over en pinnen op de primaire site.
     32  // In HUB mode /user/:slug maps to a specific site. In SOLO mode there is only
     33  // one site: we skip that routing and pin to the primary site.
    3434  if (tenancy === 'hub') {
    35     // Een Klonkt-site is canoniek bereikbaar via /user/:slug. /sites/:slug is een
    36     // legacy-alias → 301 naar de canonieke vorm zodat er één URL-schema overblijft
    37     // (behoudt pad + querystring; raakt /admin/sites NIET, dat begint met /admin/).
     35    // A Klonkt site is canonically reachable via /user/:slug. /sites/:slug is a
     36    // legacy alias → 301 to the canonical form so one URL scheme remains
     37    // (preserves path + query string; does NOT touch /admin/sites, which starts with /admin/).
    3838    const m = req.path.match(/^\/(sites|user)\/([a-zA-Z0-9_-]+)(\/.*)?$/);
    3939    if (m) {
     
    4949      }
    5050    }
    51     // (Verwijderd: een dode "slug == hostname"-subdomein-hack. Slugs mogen geen
    52     // punten bevatten, dus die kon nooit matchen. Echte subdomein-routing zou de
    53     // subdomein-LABEL tegen de slug matchen — een aparte feature, niet dit.)
     51    // (Removed: a dead "slug == hostname" subdomain hack. Slugs may not contain
     52    // dots, so it could never match. Real subdomain routing would match the
     53    // subdomain LABEL against the slug — a separate feature, not this.)
    5454  }
    5555
    56   // Solo (of hub zonder match): pin op de primaire/hoofd-site.
     56  // Solo (or hub without a match): pin to the primary/main site.
    5757  const defaultSite = getPrimarySite();
    5858  if (defaultSite) {
     
    7979
    8080  try {
    81     // m.filename = de kale bestandsnaam; de speelbare URL is de gated stream-route
    82     // (audioUrl). De media-tabel heeft GEEN url-kolom — de oude query selecteerde
    83     // m.url en faalde dus altijd stil (lege speler). Nu bouwen we de URL uit filename.
     81    // m.filename = the bare filename; the playable URL is the gated stream route
     82    // (audioUrl). The media table has NO url column — the old query selected
     83    // m.url and always failed silently (empty player). Now we build the URL from filename.
    8484    const rows = db.prepare(`
    8585      SELECT t.id, t.title, t.artist, t.duration, t.position, m.filename
  • src/routes/account.js

    rbb42dfb r834bcc3  
    6666  const hasPassword = !!(account && account.password_hash && account.password_hash !== '!google-oauth');
    6767  const googleLinked = !!(account && account.google_sub);
    68   if (account) { delete account.password_hash; delete account.google_sub; } // niet naar de view lekken
     68  if (account) { delete account.password_hash; delete account.google_sub; } // don't leak to the view
    6969
    7070  renderPage(req, res, 'pages/account', {
     
    8181});
    8282
    83 // ==================== PERSOONLIJKE INTERFACE-TAAL ====================
    84 // Slaat de taalkeuze op het account op (reist mee over apparaten/sessies) én
    85 // zet 'm meteen in de sessie zodat 't direct effect heeft.
     83// ==================== PERSONAL INTERFACE LANGUAGE ====================
     84// Saves the language choice on the account (persists across devices/sessions) and
     85// also sets it in the session immediately so it takes effect right away.
    8686router.post('/lang', requireAuth, (req, res) => {
    8787  const code = SUPPORTED.includes(req.body.lang) ? req.body.lang : null;
     
    9494});
    9595
    96 // De site die deze gebruiker mag bewerken vanuit z'n account: z'n eigen site
    97 // (owner_id), of voor een god de primaire site. Null als er niets is.
     96// The site this user may edit from their account: their own site
     97// (owner_id), or for a god the primary site. Null if nothing found.
    9898function ownedSite(user) {
    9999  if (!user) return null;
    100100  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);
    101101  if (!site && user.role === 'god') {
    102     site = getPrimarySite(); // primaire/hoofd-site als fallback
     102    site = getPrimarySite(); // primary/main site as fallback
    103103  }
    104104  return site || null;
     
    124124  const bio = (req.body.bio || '').toString().slice(0, 500).trim();
    125125
    126   // E-mail (optioneel mee te wijzigen). Validatie: geldig formaat + niet al door
    127   // een ander account in gebruik. E-mail is het login-/reset-anker, dus uniek.
     126  // Email (optionally also changed). Validation: valid format + not already in use
     127  // by another account. Email is the login/reset anchor, so it must be unique.
    128128  const email = (req.body.email || '').toString().trim();
    129129  if (email) {
     
    138138    db.prepare('UPDATE users SET email = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
    139139      .run(email, req.session.user.id);
    140     req.session.user.email = email; // sessie bijwerken zodat de UI klopt
     140    req.session.user.email = email; // update session so the UI reflects the change
    141141  }
    142142
     
    166166
    167167  const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.user.id);
    168   // Google-only accounts (luisteraars) hebben geen echt wachtwoord.
     168  // Google-only accounts (listeners) have no real password.
    169169  if (!row || !row.password_hash || row.password_hash === '!google-oauth') {
    170170    return res.redirect('/account?error=' + encodeURIComponent('Dit account heeft geen wachtwoord (Google-login)'));
     
    181181});
    182182
    183 // Google-account ontkoppelen. Alleen toegestaan als er nog een wachtwoord is,
    184 // anders zou je jezelf buitensluiten (geen login-methode meer over).
     183// Unlink Google account. Only allowed if a password is set,
     184// otherwise the user would lock themselves out (no login method left).
    185185router.post('/google/unlink', requireAuth, (req, res) => {
    186186  const row = db.prepare('SELECT password_hash, google_sub FROM users WHERE id = ?').get(req.session.user.id);
  • src/routes/admin-audio.js

    rbb42dfb r834bcc3  
    3838const ALLOWED_AUDIO_EXT = new Set(['.mp3', '.m4a', '.mp4', '.aac', '.oga', '.ogg', '.opus', '.flac', '.wav', '.webm']);
    3939const ALLOWED_COVER_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
    40 const MAX_AUDIO_BYTES = 50 * 1024 * 1024;   // 50 MB — gecomprimeerde formaten (mp3/m4a/ogg/…)
    41 const MAX_WAV_BYTES   = 100 * 1024 * 1024;  // 100 MB — WAV is ongecomprimeerd, dus ruimer
     40const MAX_AUDIO_BYTES = 50 * 1024 * 1024;   // 50 MB — compressed formats (mp3/m4a/ogg/…)
     41const MAX_WAV_BYTES   = 100 * 1024 * 1024;  // 100 MB — WAV is uncompressed, so a higher limit
    4242const MAX_COVER_BYTES = 5 * 1024 * 1024;    // 5 MB
    4343
    44 // Per-bestand bovengrens op basis van extensie. multer's globale limiet is de
    45 // hoogste (WAV); de echte controle per type gebeurt in de upload-handler.
     44// Per-file upper limit based on extension. multer's global limit is the
     45// highest (WAV); the real per-type check happens in the upload handler.
    4646const audioByteLimitFor = (ext) => (ext.toLowerCase() === '.wav' ? MAX_WAV_BYTES : MAX_AUDIO_BYTES);
    4747
     
    5959const upload = multer({
    6060  storage,
    61   limits: { fileSize: MAX_WAV_BYTES }, // hoogste bovengrens (WAV) — per-type check in de handler
     61  limits: { fileSize: MAX_WAV_BYTES }, // highest upper bound (WAV) — per-type check in the handler
    6262  fileFilter: (req, file, cb) => {
    6363    const ext = path.extname(file.originalname).toLowerCase();
     
    7373const router = express.Router();
    7474
    75 // "Open in"-platformlinks per track: alleen https + de juiste host accepteren
    76 // (href komt ongeescaped in de view → scheme/host-guard tegen misbruik).
     75// "Open in" platform links per track: only https + the correct host accepted
     76// (href arrives unescaped in the view → scheme/host guard against abuse).
    7777const LINK_DOMAINS = {
    7878  spotify: ['spotify.com'],
     
    8686    const h = new URL(u).hostname.toLowerCase();
    8787    if (domains.some((d) => h === d || h.endsWith('.' + d))) return u;
    88   } catch (e) { /* ongeldige URL */ }
     88  } catch (e) { /* invalid URL */ }
    8989  return null;
    9090}
     
    148148    }
    149149
    150     // Per-type audio size check. multer's globale limiet was de WAV-bovengrens
    151     // (100MB); gecomprimeerde formaten blijven op 50MB.
     150    // Per-type audio size check. multer's global limit was the WAV upper bound
     151    // (100MB); compressed formats stay at 50MB.
    152152    const audioExt = path.extname(audioFile.originalname).toLowerCase();
    153153    const audioLimit = audioByteLimitFor(audioExt);
     
    185185    const finalArtist = artist?.trim() || null;
    186186    const finalAlbum  = album?.trim() || null;
    187     // Eigenaarschap/licentie. credit valt terug op de artiest; deze gaan zowel de
    188     // DB in als de ID3-tags van de mp3 (copyright + comment).
     187    // Ownership/licence. credit falls back to the artist; these go both into the
     188    // DB and into the ID3 tags of the mp3 (copyright + comment).
    189189    const finalCredit  = (req.body.credit  || '').trim() || finalArtist || null;
    190190    const finalLicense = (req.body.license || '').trim() || null;
     
    231231      `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
    232232
    233       // Duur automatisch: primair uit de transcode (ffmpeg codecData), anders een
    234       // optionele client-side waarde (bulk-uploader leest <audio>.duration uit),
    235       // anders NULL (UI toont dan '—:—', handmatig bij te werken in de editor).
     233      // Duration automatically: primarily from the transcode (ffmpeg codecData), then
     234      // an optional client-side value (bulk uploader reads <audio>.duration),
     235      // otherwise NULL (UI then shows '—:—', editable manually in the editor).
    236236      const clientDur = req.body.duration != null ? parseInt(req.body.duration, 10) : NaN;
    237237      const finalDuration =
     
    275275});
    276276
    277 // Download-voor-email per track aan/uit (premium #2). Zonder-JS toggle vanaf de
    278 // audio-beheerlijst → flip + terug.
     277// Download-for-email per track on/off (premium #2). No-JS toggle from the
     278// audio admin list → flip + back.
    279279router.post('/:id/downloadable', requireGod, (req, res) => {
    280280  const site = res.locals.site;
     
    394394
    395395/** GET /admin/audio/api/:id — single track with all metadata */
    396 // Maak een track ZONDER audiobestand (alleen titel + open-in links). Verschijnt
    397 // in albums/playlists in de lijst, met open-in-iconen maar zonder afspeelknop.
     396// Create a track WITHOUT an audio file (title + open-in links only). Appears
     397// in albums/playlists in the list, with open-in icons but no play button.
    398398router.post('/create-link', requireGod, express.json(), (req, res) => {
    399399  const site = res.locals.site;
     
    507507  }
    508508
    509   // Verse rij + (als tag-velden wijzigden) de mp3 her-taggen, zodat de eigenaar/
    510   // licentie ook IN het bestand staat (ID3) en meereist bij een download.
     509  // Fresh row + (if tag fields changed) retag the mp3, so that the owner/
     510  // licence is also IN the file (ID3) and travels with it on download.
    511511  const fresh = db.prepare(`
    512512    SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.credit, t.license, m.storage_path
     
    527527      } });
    528528    } catch (e) {
    529       console.warn('[admin-audio] ID3 her-taggen mislukt (DB is wel bijgewerkt):', e.message);
     529      console.warn('[admin-audio] ID3 retag failed (DB was still updated):', e.message);
    530530    }
    531531  }
  • src/routes/admin-circle.js

    rbb42dfb r834bcc3  
    11/**
    2  * Admin: Cirkel-beheer (god-only).
    3  *   GET  /admin/circle           -> lijst van cirkel-links + status
    4  *   POST /admin/circle/add       -> Klonkt-URL toevoegen
     2 * Admin: Circle management (god-only).
     3 *   GET  /admin/circle           -> list of circle links + status
     4 *   POST /admin/circle/add       -> add a Klonkt URL
    55 *   POST /admin/circle/:id/remove
    6  *   POST /admin/circle/:id/sync  -> nu verversen (pull + verifieer)
    7  *   POST /admin/circle/allow     -> toggle "mag in cirkels van anderen verschijnen"
     6 *   POST /admin/circle/:id/sync  -> refresh now (pull + verify)
     7 *   POST /admin/circle/allow     -> toggle "may appear in others' circles"
    88 *
    9  * Zie docs/cirkels-v1-spec.md §5d.
     9 * See docs/cirkels-v1-spec.md §5d.
    1010 */
    1111
     
    5151  const site = primarySite();
    5252  if (!site) return res.redirect('/admin/circle?error=' + encodeURIComponent('Geen site gevonden'));
    53   // Schema automatisch aanvullen: een kale domeinnaam → https://, een getypte
    54   // http:// → https:// (federatie is bewust https-only, getekende feeds). Zo hoeft
    55   // de gebruiker nooit zelf http(s):// te typen.
     53  // Auto-complete the scheme: a bare domain name → https://, a typed
     54  // http:// → https:// (federation is intentionally https-only, signed feeds). So the
     55  // user never has to type http(s):// themselves.
    5656  let url = (req.body.remote_url || '').toString().trim().replace(/\/+$/, '');
    5757  if (url && !/^[a-z]+:\/\//i.test(url)) url = 'https://' + url;
     
    6868    return res.redirect('/admin/circle?error=' + encodeURIComponent('Deze site staat al in je cirkel'));
    6969  }
    70   // Meteen ophalen i.p.v. wachten op de 15-min-loop.
     70  // Fetch immediately instead of waiting for the 15-minute loop.
    7171  try {
    7272    const link = db.prepare('SELECT * FROM circle_links WHERE id = ?').get(id);
     
    7575  } catch (e) {
    7676    const msg = String((e && e.message) || e);
    77     // 404 = geen cirkel-endpoint. Hubs federeren bewust NIET (hun /.klonkt/actor.json
    78     // geeft 404), net als losse niet-Klonkt-sites. Niet toevoegen: rol de insert terug
    79     // zodat er geen dode "fout"-rij in de cirkel blijft staan.
     77    // 404 = no circle endpoint. Hubs intentionally do NOT federate (their /.klonkt/actor.json
     78    // returns 404), same as standalone non-Klonkt sites. Don't add: roll back the insert
     79    // so no dead "error" row is left in the circle.
    8080    if (/\b404\b/.test(msg)) {
    8181      db.prepare('DELETE FROM circle_links WHERE id = ?').run(id);
    8282      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).'));
    8383    }
    84     // Andere (mogelijk tijdelijke) fout → link blijft staan; later "Verversen".
     84    // Other (possibly temporary) error → link stays; use "Refresh" later to retry.
    8585    return res.redirect('/admin/circle?success=' + encodeURIComponent('Toegevoegd — synchroniseren mislukte (klik "Verversen" om opnieuw te proberen)'));
    8686  }
     
    9191  if (link) {
    9292    db.prepare('DELETE FROM circle_links WHERE id = ?').run(link.id);
    93     // Gecachte content opruimen als geen andere link nog naar deze actor wijst.
     93    // Clean up cached content if no other link still points to this actor.
    9494    if (link.remote_actor_id) {
    9595      const other = db.prepare('SELECT 1 FROM circle_links WHERE remote_actor_id = ? LIMIT 1').get(link.remote_actor_id);
     
    117117});
    118118
    119 // Alles in één keer verversen (handig "voor de zekerheid").
     119// Refresh everything at once (handy "just to be sure").
    120120router.post('/sync-all', requireGod, async (req, res) => {
    121121  try {
  • src/routes/admin-comments.js

    rbb42dfb r834bcc3  
    6161  const site = res.locals.site;
    6262  if (!site) return res.status(404).send('No site');
    63   const base = res.locals.siteUrlBase || ''; // /user/<slug> in hub-artiestcontext, anders ''
     63  const base = res.locals.siteUrlBase || ''; // /user/<slug> in hub artist context, otherwise ''
    6464
    6565  const row = db.prepare(`
  • src/routes/admin-epk.js

    rbb42dfb r834bcc3  
    11/**
    2  * Admin: Perskit (EPK) bewerken — per-site bio + pers-contact.
     2 * Admin: Edit press kit (EPK) — per-site bio + press contact.
    33 *
    4  * GET  /admin/epk   -> formulier met huidige bio + contact
    5  * POST /admin/epk   -> opslaan (app_settings: epk_bio_<siteId> / epk_contact_<siteId>)
     4 * GET  /admin/epk   -> form with current bio + contact
     5 * POST /admin/epk   -> save (app_settings: epk_bio_<siteId> / epk_contact_<siteId>)
    66 *
    7  * De perskit-pagina zelf (/pers) leest deze waarden; tracks + recente posts komen
    8  * automatisch. Perskit is premium + solo (zie routes/epk.js).
     7 * The press kit page itself (/pers) reads these values; tracks + recent posts come
     8 * automatically. Press kit is premium + solo (see routes/epk.js).
    99 */
    1010
     
    4949  setSetting('epk_bio_' + site.id, (req.body.epk_bio || '').toString().slice(0, 1000).trim());
    5050  setSetting('epk_contact_' + site.id, (req.body.epk_contact || '').toString().slice(0, 300).trim());
    51   // Gekozen nummers: alleen ids van DEZE site, max 5, in de aangeleverde volgorde.
     51  // Chosen tracks: only ids belonging to THIS site, max 5, in the supplied order.
    5252  let ids = req.body.epk_tracks;
    5353  if (!Array.isArray(ids)) ids = ids ? [ids] : [];
  • src/routes/admin-newsletter.js

    rbb42dfb r834bcc3  
    11/**
    2  * Nieuwsbrief — beheerkant (premium feature #1).
     2 * Newsletter — admin side (premium feature #1).
    33 *
    4  *   GET  /admin/newsletter        -> opstellen + abonnee-aantallen + historie
    5  *   POST /admin/newsletter/send   -> verstuur naar alle BEVESTIGDE abonnees (SMTP)
     4 *   GET  /admin/newsletter        -> compose + subscriber counts + history
     5 *   POST /admin/newsletter/send   -> send to all CONFIRMED subscribers (SMTP)
    66 *
    7  * Premium-gated + site-beheerder. Versturen vereist ingestelde SMTP; zonder SMTP
    8  * worden aanmeldingen wél verzameld (single opt-in), alleen versturen kan dan niet.
     7 * Premium-gated + site manager. Sending requires configured SMTP; without SMTP
     8 * sign-ups are still collected (single opt-in), only sending is unavailable.
    99 */
    1010
     
    8686      });
    8787      sent++;
    88     } catch (e) { /* sla deze ontvanger over, ga door */ }
     88    } catch (e) { /* skip this recipient, continue */ }
    8989  }
    9090  db.prepare('INSERT INTO newsletters (id, site_id, subject, body, recipient_count) VALUES (?,?,?,?,?)')
  • src/routes/admin-patreon.js

    rbb42dfb r834bcc3  
    11/**
    2  * Admin: Patreon koppelen voor de premium-laag (god-only).
     2 * Admin: Link Patreon for the premium layer (god-only).
    33 *
    4  * GET /admin/patreon/connect    -> stuur de beheerder naar de license-server
    5  *                                  (oauth/start) met onze callback als return.
    6  * GET /admin/patreon/callback   -> license-server keert terug met ?klonkt_token
    7  *                                  (of ?klonkt_error). Verifieer + sla op.
    8  * GET /admin/patreon/disconnect -> entitlement wissen.
     4 * GET /admin/patreon/connect    -> redirect the admin to the license server
     5 *                                  (oauth/start) with our callback as return URL.
     6 * GET /admin/patreon/callback   -> license server returns with ?klonkt_token
     7 *                                  (or ?klonkt_error). Verify + store.
     8 * GET /admin/patreon/disconnect -> clear entitlement.
    99 *
    10  * Het echte verdienmodel-slot zit in het ondertekende token (alleen de
    11  * license-server kan tekenen). Zie PatreonService.js.
     10 * The real monetisation lock is in the signed token (only the
     11 * license server can sign). See PatreonService.js.
    1212 */
    1313
  • src/routes/admin-seo.js

    rbb42dfb r834bcc3  
    11/**
    2  * Admin: geavanceerde SEO-instellingen van de primaire site.
     2 * Admin: advanced SEO settings for the primary site.
    33 *
    4  * GET  /admin/seo   -> formulier met alle SEO/social-velden van de hoofdsite
    5  * POST /admin/seo   -> opslaan (god-only)
     4 * GET  /admin/seo   -> form with all SEO/social fields for the main site
     5 * POST /admin/seo   -> save (god-only)
    66 *
    7  * Deze velden worden al door de <head> (shell.ejs) en de JSON-LD/OpenGraph-
    8  * tags geconsumeerd, maar waren tot nu toe nergens te bewerken. De basis-
    9  * velden (titel/bio/robots) blijven in Uiterlijk; dit is de geavanceerde laag:
    10  * titel-sjabloon, canonical, social-share-afbeelding, verificatie-metas,
    11  * publisher/JSON-LD en OpenGraph-locale.
     7 * These fields are already consumed by the <head> (shell.ejs) and the JSON-LD/
     8 * OpenGraph tags, but were previously not editable anywhere. The basic
     9 * fields (title/bio/robots) remain in Appearance; this is the advanced layer:
     10 * title template, canonical, social share image, verification metas,
     11 * publisher/JSON-LD and OpenGraph locale.
    1212 *
    13  * Werkt op de PRIMAIRE site (solo = de enige site; hub = de bedrijfssite).
     13 * Operates on the PRIMARY site (solo = the only site; hub = the company site).
    1414 */
    1515
  • src/routes/admin-settings.js

    rbb42dfb r834bcc3  
    11/**
    2  * Admin: globale instellingen.
    3  *  - tenancy-modus (Solo/Hub)
    4  *  - hub-branding (naam/tagline/intro/hero van de generieke hub-hoofdpagina)
     2 * Admin: global settings.
     3 *  - tenancy mode (Solo/Hub)
     4 *  - hub branding (name/tagline/intro/hero of the generic hub home page)
    55 *
    6  * GET  /admin/settings   -> toon huidige instellingen
    7  * POST /admin/settings   -> sla op (god-only). Accepteert nu ook een geuploade
    8  *                           hero-afbeelding (multipart); een upload wint van het
    9  *                           URL-tekstveld. Zonder upload blijft het URL-veld leidend.
     6 * GET  /admin/settings   -> show current settings
     7 * POST /admin/settings   -> save (god-only). Also accepts an uploaded
     8 *                           hero image (multipart); an upload wins over the
     9 *                           URL text field. Without an upload the URL field is leading.
    1010 *
    11  * De hub-pagina is generiek (van geen enkele user); deze branding leeft in
    12  * globale settings, niet in een site.
     11 * The hub page is generic (belonging to no user); this branding lives in
     12 * global settings, not in a site.
    1313 */
    1414
     
    3030const router = express.Router();
    3131
    32 // Hero dark-overlay: percentage 0-100 (0 = geen overlay, 100 = volledig zwart).
    33 // Default 45 = de oude hardgecodeerde waarde, zodat bestaande hubs niet wijzigen.
     32// Hero dark overlay: percentage 0-100 (0 = no overlay, 100 = fully black).
     33// Default 45 = the old hard-coded value, so existing hubs don't change appearance.
    3434function clampOverlay(raw) {
    3535  const v = parseInt(raw, 10);
     
    3838
    3939const __dirname = path.dirname(fileURLToPath(import.meta.url));
    40 // Hero-uploads landen in storage/media/hero → bereikbaar als /media/hero/<file>
    41 // (de /media static handler serveert storage/media). Zelfde model als avatars.
     40// Hero uploads land in storage/media/hero → accessible as /media/hero/<file>
     41// (the /media static handler serves storage/media). Same model as avatars.
    4242const HERO_DIR = path.resolve(
    4343  process.env.HERO_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'hero')
     
    4545fs.mkdirSync(HERO_DIR, { recursive: true });
    4646
    47 // Alleen raster-formaten voor de upload. SVG mag bewust NIET via upload (raw
    48 // SVG kan script bevatten → opgeslagen-XSS bij direct openen); een SVG-hero kan
    49 // nog steeds via het URL-veld (zoals de meegeleverde demo-placeholder).
     47// Only raster formats for upload. SVG is intentionally NOT allowed via upload
     48// (raw SVG can contain scripts → stored-XSS when opened directly); an SVG hero
     49// can still be set via the URL field (like the bundled demo placeholder).
    5050const ALLOWED_HERO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
    5151const MAX_HERO_BYTES = 5 * 1024 * 1024;
     
    9595
    9696router.post('/', requireGod, (req, res) => {
    97   // multer.single verwerkt multipart (hub-branding form). Bij een gewone
    98   // urlencoded POST (tenancy-form) doet multer niets en blijft req.body intact.
     97  // multer.single processes multipart (hub branding form). For a plain
     98  // urlencoded POST (tenancy form) multer does nothing and req.body stays intact.
    9999  heroUpload.single('hub_hero_file')(req, res, (err) => {
    100100    if (err) {
     
    103103
    104104    if (typeof req.body.tenancy !== 'undefined') {
    105       // Hub-modus is een premium-feature: alleen naar hub schakelen als premium
    106       // ontgrendeld is (premium-laag uit = vrij; aan = Patreon vereist). Al-hub
    107       // blijven mag altijd, zodat een instance nooit vastloopt.
     105      // Hub mode is a premium feature: only switch to hub if premium is
     106      // unlocked (premium layer off = free; on = Patreon required). Staying on
     107      // hub is always allowed, so an instance can never get stuck.
    108108      if (req.body.tenancy === 'hub' && !premiumUnlocked() && getTenancy() !== 'hub') {
    109109        return res.redirect('/admin/settings?error=' + encodeURIComponent('Hub-modus is een premium-functie — koppel Patreon in Beheer → Instellingen.'));
     
    112112    }
    113113    if (typeof req.body.default_lang !== 'undefined') {
    114       // Standaardtaal voor bezoekers (leeg = volg env/browser). Valideert tegen NL/EN/DE.
     114      // Default language for visitors (empty = follow env/browser). Validated against NL/EN/DE.
    115115      const dl = (req.body.default_lang || '').toString().toLowerCase();
    116116      setSetting('default_lang', SUPPORTED.includes(dl) ? dl : '');
    117117    }
    118118    if (typeof req.body.timezone !== 'undefined') {
    119       // Site-tijdzone (IANA, bv. Europe/Amsterdam). Leeg = server-default (UTC).
    120       // Valideer met Intl zodat een onzin-waarde nooit de datum-rendering breekt.
     119      // Site timezone (IANA, e.g. Europe/Amsterdam). Empty = server default (UTC).
     120      // Validate with Intl so a nonsense value never breaks date rendering.
    121121      const tz = (req.body.timezone || '').toString().trim();
    122122      let valid = '';
     
    134134    }
    135135
    136     // Hero: een geüploade afbeelding wint; anders het URL-tekstveld.
     136    // Hero: an uploaded image wins; otherwise the URL text field.
    137137    if (req.file) {
    138138      const newUrl = `/media/hero/${toWebp(req.file)}`;
    139       // Ruim een vorige geüploade hero op (alleen als die uit onze hero-map kwam).
     139      // Clean up a previously uploaded hero (only if it came from our hero dir).
    140140      const old = getSetting('hub_hero_image') || '';
    141141      if (old.startsWith('/media/hero/')) {
     
    155155});
    156156
    157 // Google-login op een eigen Beheer-pagina (los van de algemene instellingen).
     157// Google login on its own admin page (separate from the general settings).
    158158router.get('/google', requireGod, (req, res) => {
    159159  renderPage(req, res, 'pages/admin-google', {
     
    171171});
    172172
    173 // Google-login (luisteraars) configureren — Client ID + Secret in app_settings.
    174 // De redirect-URI leiden we af van PUBLIC_BASE_URL (zie config/google.js).
     173// Configure Google login (listeners) — Client ID + Secret in app_settings.
     174// The redirect URI is derived from PUBLIC_BASE_URL (see config/google.js).
    175175router.post('/google', requireGod, (req, res) => {
    176176  if (req.body.clear === '1') {
     
    180180  }
    181181  setSetting('google_client_id', (req.body.google_client_id || '').toString().trim());
    182   // Secret alleen overschrijven als er een nieuwe waarde is ingevoerd (leeg = laat staan).
     182  // Only overwrite the secret if a new value was entered (empty = leave as-is).
    183183  const secret = (req.body.google_client_secret || '').toString().trim();
    184184  if (secret) setSetting('google_client_secret', secret);
     
    197197  setSetting('smtp_user', (b.smtp_user || '').toString().trim());
    198198  setSetting('smtp_from', (b.smtp_from || '').toString().trim());
    199   // Wachtwoord alleen overschrijven als er een nieuwe waarde is ingevoerd.
     199  // Only overwrite the password if a new value was entered.
    200200  const pass = (b.smtp_pass || '').toString();
    201201  if (pass) setSetting('smtp_pass', pass);
     
    203203});
    204204
    205 // Nieuwsbrief-aanmelding in de footer aan/uit.
     205// Newsletter sign-up in the footer on/off.
    206206router.post('/footer', requireGod, (req, res) => {
    207207  setSetting('footer_newsletter', req.body.footer_newsletter ? '1' : '0');
     
    209209});
    210210
    211 // Testmail sturen naar een opgegeven adres (of de ingelogde gebruiker).
     211// Send a test email to a specified address (or the logged-in user).
    212212router.post('/smtp/test', requireGod, async (req, res) => {
    213213  const to = ((req.body && req.body.to) || (req.session.user && req.session.user.email) || '').toString().trim();
  • src/routes/admin-shows.js

    rbb42dfb r834bcc3  
    11/**
    2  * Show-agenda (premium feature #8) — beheerkant.
     2 * Show agenda (premium feature #8) — admin side.
    33 *
    4  *   GET  /admin/shows           -> lijst + toevoeg-formulier
    5  *   POST /admin/shows           -> show toevoegen (optioneel notify-mail naar abonnees)
     4 *   GET  /admin/shows           -> list + add form
     5 *   POST /admin/shows           -> add show (optional notify email to subscribers)
    66 *   POST /admin/shows/:id/delete
    77 *
    8  * Premium + site-beheerder. Notify-mail vereist SMTP; zonder SMTP wordt de show
    9  * gewoon opgeslagen (geen mail).
     8 * Premium + site manager. Notify email requires SMTP; without SMTP the show is
     9 * simply saved (no email sent).
    1010 */
    1111
     
    7979        });
    8080        sent++;
    81       } catch { /* sla over */ }
     81      } catch { /* skip */ }
    8282    }
    8383  }
     
    8686
    8787router.post('/toggle', requireSiteManager, premiumGate, (req, res) => {
    88   // Agenda tonen op de site (Agenda-knop in de pill + /shows-pagina).
     88  // Show the agenda on the site (Agenda button in the pill + /shows page).
    8989  setSetting('agenda_enabled', req.body.enabled ? '1' : '0');
    9090  res.redirect((res.locals.siteUrlBase || '') + '/admin/shows');
  • src/routes/admin-sites.js

    rbb42dfb r834bcc3  
    137137}
    138138
    139 /** Geldige user-id voor owner-toewijzing, of null bij leeg/onbekend. */
     139/** Valid user-id for owner assignment, or null if empty/unknown. */
    140140function validOwnerId(raw) {
    141141  const id = (raw || '').toString().trim();
     
    144144}
    145145
    146 /** Geef een user admin-rechten op een site (idempotent upsert). */
     146/** Grant a user admin rights on a site (idempotent upsert). */
    147147function grantSiteAdmin(siteId, userId) {
    148148  db.prepare(`
     
    152152}
    153153
    154 /** Kandidaat-owners voor het owner-keuzeveld (god-only). */
     154/** Candidate owners for the owner selector field (god-only). */
    155155function listOwnerCandidates() {
    156156  return db.prepare('SELECT id, username, role FROM users ORDER BY username').all();
     
    183183    bodyClass: 'on-admin',
    184184    isNew: true,
    185     // ?owner=<id> (vanaf de gebruikers-pagina: "geef deze user een Klonkt") wordt
    186     // voorgeselecteerd; anders de aanmakende god.
     185    // ?owner=<id> (from the users page: "give this user a Klonkt") is
     186    // pre-selected; otherwise defaults to the creating god.
    187187    site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() },
    188188    users: listOwnerCandidates(),
     
    211211  const f = { ...siteEditableFields(), ...req.body };
    212212
    213   // Owner: god mag de site aan een ANDERE gebruiker toewijzen — dit is de kern
    214   // van hub-modus (elke gebruiker z'n eigen, zelf te beheren Klonkt). Leeg of
    215   // ongeldig → de aanmakende god zelf.
     213  // Owner: god may assign the site to a DIFFERENT user — this is the core of
     214  // hub mode (each user their own self-managed Klonkt). Empty or invalid → the
     215  // creating god themselves.
    216216  const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
    217217
     
    242242  );
    243243
    244   // De OWNER (niet per se de aanmaker) krijgt een site_members-admin-rij → zo komt
    245   // 'ie door canAdminSite + de requireSiteManager-gates en beheert 'ie z'n site.
     244  // The OWNER (not necessarily the creator) gets a site_members admin row → this
     245  // lets them pass canAdminSite + requireSiteManager gates to manage their site.
    246246  grantSiteAdmin(siteId, ownerId);
    247247
     
    332332  );
    333333
    334   // Owner (her)toewijzen — ALLEEN god. Een site-owner die z'n eigen site bewerkt
    335   // kan de eigenaar niet wijzigen (het veld wordt voor niet-god ook niet getoond).
     334  // (Re)assign owner — god ONLY. A site-owner editing their own site cannot
     335  // change the owner (the field is not shown to non-god users either).
    336336  if (req.session.user.role === 'god') {
    337337    const newOwner = validOwnerId(req.body.owner_id);
     
    345345});
    346346
    347 // ==================== MAAK PRIMAIR ====================
    348 // God kiest welke site de primaire/hoofd-site is (de label-/bedrijfssite in hub;
    349 // in solo dé site). Precies één site is primair → eerst alles uit, dan deze aan.
     347// ==================== MAKE PRIMARY ====================
     348// God chooses which site is the primary/main site (the label/company site in hub;
     349// in solo mode: the one site). Exactly one site is primary → clear all, then set this one.
    350350router.post('/:slug/make-primary', requireGod, (req, res) => {
    351351  const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
  • src/routes/admin-stats.js

    rbb42dfb r834bcc3  
    11/**
    2  * Admin: Statistieken (premium-module, god-only).
     2 * Admin: Statistics (premium module, god-only).
    33 *
    4  * GET /admin/stats -> cookievrije statistieken: bezoekers/weergaven per dag,
    5  *                     plays, en de populairste posts/tracks.
     4 * GET /admin/stats -> cookie-free statistics: visitors/views per day,
     5 *                     plays, and the most popular posts/tracks.
    66 *
    7  * Premium-gated via premiumUnlocked() (premium-laag uit = gewoon beschikbaar;
    8  * aan = Patreon vereist). Tracking zit in StatsService (geen cookies).
     7 * Premium-gated via premiumUnlocked() (premium layer off = freely available;
     8 * on = Patreon required). Tracking is in StatsService (no cookies).
    99 */
    1010
     
    2222    return res.status(403).send('Statistieken is een premium-functie — koppel Patreon in Beheer → Instellingen.');
    2323  }
    24   // Link-in-bio klikken (premium #6) voor de huidige site.
     24  // Link-in-bio clicks (premium #6) for the current site.
    2525  let linkClicks = [];
    2626  if (res.locals.site) {
  • src/routes/admin-updates.js

    rbb42dfb r834bcc3  
    11/**
    22 * Admin: Updates (god-only).
    3  * Git-gebaseerde v1 voor instances die via de bare-repo draaien.
    4  *   GET  /admin/updates      -> huidige vs. nieuwste versie + status
    5  *   POST /admin/updates/run  -> haal nieuwste main op + herstart (detached script)
     3 * Git-based v1 for instances running from a bare repo.
     4 *   GET  /admin/updates      -> current vs. latest version + status
     5 *   POST /admin/updates/run  -> fetch latest main + restart (detached script)
    66 *
    7  * De instance kent z'n "huidige" commit uit .klonkt-version (door het script
    8  * geschreven) en de "nieuwste" uit de bare repo (KLONKT_GIT_DIR). Voor externe
    9  * self-hosters komt later een GESIGNEERDE release-feed (zie monetization-plan);
    10  * deze v1 is bewust simpel en alleen voor Robins eigen VPS-instances.
     7 * The instance knows its "current" commit from .klonkt-version (written by the
     8 * script) and the "latest" from the bare repo (KLONKT_GIT_DIR). For external
     9 * self-hosters a SIGNED release feed will follow later (see monetization plan);
     10 * this v1 is intentionally simple and only for Robin's own VPS instances.
    1111 */
    1212
     
    3737}
    3838
    39 // Laatste 5 commits op main = de "laatste wijzigingen" die je bij bijwerken krijgt.
     39// Last 5 commits on main = the "recent changes" you'll get when updating.
    4040function recentChanges() {
    4141  const out = git(['log', '-5', '--format=%s%x1f%cd', '--date=short', 'main']);
     
    7272  }
    7373  try {
    74     // Detached + losgekoppeld: overleeft de pm2-reload die deze app herstart.
     74    // Detached + unlinked: survives the pm2-reload that restarts this app.
    7575    const child = spawn('bash', [UPDATE_SCRIPT, process.cwd()], { detached: true, stdio: 'ignore' });
    7676    child.unref();
  • src/routes/admin-users.js

    rbb42dfb r834bcc3  
    2020const router = express.Router();
    2121
    22 // 'kijker' = alleen-lezen demonstratie/audit-account: mag alles bekijken (incl.
    23 // Beheer), maar de globale guard blokkeert elke wijziging. Vervangt de oude
    24 // losse 'kijk-modus'-vlag (readonly), die nu door deze rol wordt afgedekt.
     22// 'kijker' = read-only demo/audit account: may view everything (incl. admin panel),
     23// but the global guard blocks all mutations. Replaces the old separate
     24// 'kijk-modus' flag (readonly), which is now covered by this role.
    2525const VALID_ROLES = new Set(['kijker', 'member', 'admin', 'god']);
    2626
     
    6969  }
    7070
    71   // readonly=0: de alleen-lezen-status zit nu volledig in de 'kijker'-rol, dus
    72   // bij elke rolwijziging ruimen we de legacy-vlag op (geen dubbele bron).
     71  // readonly=0: read-only status now lives entirely in the 'kijker' role, so
     72  // on every role change we clear the legacy flag (no dual source of truth).
    7373  db.prepare('UPDATE users SET role = ?, readonly = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
    7474    .run(newRole, userId);
     
    8989  }
    9090
    91   // Cascade-verwijderen: de sites van deze user (+ posts/playlists/audio/leden/
    92   // comments daaronder), z'n eigen content elders, en daarna de user zelf.
    93   // Atomisch in een transactie — faalt er een FK, dan rolt alles terug.
     91  // Cascade delete: this user's sites (+ posts/playlists/audio/members/
     92  // comments under them), their own content elsewhere, then the user themselves.
     93  // Atomic in a transaction — if any FK fails, everything rolls back.
    9494  const del = db.transaction(() => {
    9595    const sites = db.prepare('SELECT id FROM sites WHERE owner_id = ?').all(userId).map((s) => s.id);
     
    102102      db.prepare('DELETE FROM sites WHERE id = ?').run(sid);
    103103    }
    104     // Eigen content op andere sites + losse koppelingen.
     104    // Own content on other sites + loose associations.
    105105    db.prepare('DELETE FROM comments WHERE post_id IN (SELECT id FROM posts WHERE author_id = ?)').run(userId);
    106106    db.prepare('DELETE FROM posts WHERE author_id = ?').run(userId);
  • src/routes/admin.js

    rbb42dfb r834bcc3  
    1414const router = express.Router();
    1515
    16 // Recente posts van één site, CONCEPTEN BOVENAAN, met mode-bewuste edit/view-URLs.
    17 // Lost op dat drafts (status != published) nergens terug te vinden waren: de
    18 // tijdlijn toont alleen gepubliceerde posts.
     16// Recent posts from one site, DRAFTS ON TOP, with mode-aware edit/view URLs.
     17// Solves the problem that drafts (status != published) were not findable anywhere:
     18// the timeline shows only published posts.
    1919function sitePosts(siteId, siteSlug, tenancy, limit = 60) {
    2020  const base = tenancy === 'hub' ? `/user/${siteSlug}` : '';
     
    3535  const user = req.session.user;
    3636
    37   // Een kijker mag het volledige (god-)Beheer alleen-lezen inzien — net als god
    38   // dus, alleen schrijven is globaal geblokkeerd. Een gewone artiest die een
    39   // eigen site bezit krijgt een "Mijn Klonkt Hub"-dashboard, gescopet op z'n
    40   // eigen site. Bezit 'ie geen site -> geen beheer.
     37  // A kijker may view the full (god) admin panel read-only — same as god,
     38  // but writing is globally blocked. A regular artist who owns a site gets
     39  // a "My Klonkt Hub" dashboard, scoped to their own site. No site -> no admin.
    4140  if (user.role !== 'god' && user.role !== 'kijker') {
    4241    const mySite = db.prepare(
     
    6059  const tenancy = getTenancy();
    6160
    62   // De primaire/hoofd-site — in solo dé site, in hub de hoofdsite. Geeft de
    63   // "Uiterlijk"-tegel z'n edit-link + de posts/concepten-lijst.
     61  // The primary/main site — in solo THE site, in hub the main site. Provides the
     62  // "Appearance" tile with its edit link + the posts/drafts list.
    6463  const primarySite = getPrimarySite();
    6564
     
    7372  };
    7473
    75   // Sites/users-tabellen zijn alleen in hub relevant; in solo besparen we de query.
     74  // Sites/users tables are only relevant in hub mode; in solo we skip the query.
    7675  const sites = tenancy === 'hub' ? db.prepare(`
    7776    SELECT s.slug, s.title, s.created_at, u.username AS owner_username
     
    8988  `).all() : [];
    9089
    91   // Posts/concepten van de primaire site (in solo = de site; in hub = de
    92   // hoofdsite van de admin). Concepten staan bovenaan zodat ze vindbaar zijn.
     90  // Posts/drafts of the primary site (in solo = the site; in hub = the admin's
     91  // main site). Drafts are listed first so they are easy to find.
    9392  const posts = primarySite ? sitePosts(primarySite.id, primarySite.slug, tenancy) : [];
    9493
     
    105104});
    106105
    107 // Handleiding — doorzoekbare uitleg van alle Beheer-functies. Zichtbaar voor wie
    108 // het Beheer mag zien (ingelogd); puur statische hulptekst, niets gevoeligs.
     106// Handleiding — searchable explanation of all admin features. Visible to anyone
     107// who may view the admin panel (logged in); purely static help text, nothing sensitive.
    109108router.get('/handleiding', requireAuth, (req, res) => {
    110109  renderPage(req, res, 'pages/admin-help', {
  • src/routes/artists.js

    rbb42dfb r834bcc3  
    11/**
    2  * Artiesten-directory — alleen in hub-modus.
     2 * Artists directory — hub mode only.
    33 *
    4  * GET /leden?q=&page=  -> doorzoekbare, gepagineerde lijst van ALLE
    5  * Klonkt-site's. De hub-home toont maar een beperkte selectie; deze pagina
    6  * schaalt naar honderden/duizenden artiesten via zoeken + paginering.
     4 * GET /leden?q=&page=  -> searchable, paginated list of ALL
     5 * Klonkt sites. The hub home shows only a limited selection; this page
     6 * scales to hundreds/thousands of artists via search + pagination.
    77 *
    8  * In solo-modus bestaat er maar één site -> next() (valt door naar postsRoutes,
    9  * die 'artiesten' als onbekende slug afhandelt).
     8 * In solo mode there is only one site -> next() (falls through to postsRoutes,
     9 * which handles 'artiesten' as an unknown slug).
    1010 */
    1111
     
    2626  if (!Number.isFinite(page) || page < 1) page = 1;
    2727
    28   // De hoofd-/labelsite (oudste) is geen artiest -> uit de directory weren,
    29   // consistent met de hub-home die 'm apart toont.
     28  // The main/label site (oldest) is not an artist -> exclude from the directory,
     29  // consistent with the hub home which displays it separately.
    3030  const mainRow = db.prepare('SELECT id FROM sites ORDER BY created_at ASC LIMIT 1').get();
    3131  const mainId = mainRow ? mainRow.id : '';
    3232
    33   // Zoekterm tegen titel/slug/tagline (case-insensitive via LIKE; SQLite LIKE is
    34   // standaard ongevoelig voor ASCII-hoofdletters). De ESCAPE '\' maakt %, _ en \
    35   // in de zoekterm letterlijk (anders zouden ze als wildcards werken).
     33  // Search term against title/slug/tagline (case-insensitive via LIKE; SQLite LIKE is
     34  // case-insensitive for ASCII by default). ESCAPE '\' makes %, _, and \
     35  // in the search term literal (otherwise they would act as wildcards).
    3636  const like = '%' + q.replace(/[\\%_]/g, (m) => '\\' + m) + '%';
    3737  const conds = ['s.id != @mainId'];
  • src/routes/audio.js

    rbb42dfb r834bcc3  
    9292  const range = req.headers.range;
    9393
    94   // Statistieken: tel één play bij de initiële player-fetch (niet bij scrub/
    95   // range-continuaties; replays binnen 24u komen uit de browsercache → geen
    96   // dubbeltelling). Best-effort, mag nooit de stream breken.
     94  // Statistics: count one play on the initial player fetch (not on scrub/
     95  // range continuations; replays within 24h come from the browser cache → no
     96  // double counting). Best-effort, must never break the stream.
    9797  if (req.get('X-Audio-Player') === '1' && (!range || /^bytes=0-/.test(range))) {
    9898    try {
     
    141141});
    142142
    143 // Welke post bevat deze track? (voor de mini-speler → "spring naar de post +
    144 // scroll naar de track".) Pakt de nieuwste gepubliceerde post met [[track:<id>]].
     143// Which post contains this track? (for the mini-player → "jump to the post +
     144// scroll to the track".) Fetches the newest published post with [[track:<id>]].
    145145router.get('/track/:id/post', (req, res) => {
    146146  const id = String(req.params.id || '');
  • src/routes/auth.js

    rbb42dfb r834bcc3  
    1010import { premiumUnlocked } from '../services/PatreonService.js';
    1111
    12 // Fan-login (luisteraars inloggen met Google om te reageren) is een premium-
    13 // feature: beschikbaar als Google is ingesteld ÉN de premium-laag ontgrendeld is
    14 // (premium uit = vrij; aan = Patreon vereist).
     12// Fan login (listeners signing in with Google to comment) is a premium feature:
     13// available when Google is configured AND the premium layer is unlocked
     14// (premium off = open to all; on = Patreon required).
    1515function fanLoginReady() {
    1616  return googleConfigured() && premiumUnlocked();
     
    2222const router = express.Router();
    2323
    24 // Vaste dummy-hash: zo draait login altijd één bcrypt-vergelijking, ook als de
    25 // user niet bestaat of geen wachtwoord heeft — geen timing-oracle voor enumeratie.
     24// Fixed dummy hash: ensures login always runs one bcrypt comparison, even when the
     25// user doesn't exist or has no password — no timing oracle for enumeration.
    2626const DUMMY_HASH = bcrypt.hashSync('constant-time-login-guard', 10);
    2727
    28 // Canonieke basis-URL voor links in e-mails (reset). Uit headers bouwen is
    29 // spoofbaar (X-Forwarded-Host); een vaste config sluit dat uit.
     28// Canonical base URL for links in emails (reset). Building it from headers is
     29// spoofable (X-Forwarded-Host); a fixed config eliminates that risk.
    3030function publicBaseUrl(req) {
    3131  const cfg = (process.env.PUBLIC_BASE_URL || '').replace(/\/$/, '');
    3232  if (cfg) return cfg;
    33   // Fallback (dev): trust-proxy-gesaneerde protocol + Host-header (NIET de rauwe
     33  // Fallback (dev): trust-proxy-sanitised protocol + Host header (NOT the raw
    3434  // X-Forwarded-Host).
    3535  return `${req.protocol}://${req.get('host')}`;
     
    4040}
    4141
    42 // Eerste-keer-setup? Pas zolang er nog geen enkele gebruiker is mag /register een
    43 // beheerder aanmaken. Daarna is registratie dicht (luisteraars komen via Google).
     42// First-time setup? Only while there are no users yet may /register create an
     43// admin account. Afterwards registration is closed (listeners come via Google).
    4444function isSetupMode() {
    4545  return db.prepare('SELECT COUNT(*) AS c FROM users').get().c === 0;
     
    4747
    4848// ==================== LOGIN ====================
    49 // Publieke loginpagina: voor BEZOEKERS alleen Google-login (luisteraars/fans).
    50 // De beheerders-login (wachtwoord) staat hier bewust NIET — die zit verborgen op
    51 // /auth/admin (zie hieronder), zodat de admin-login niet zichtbaar is op de plek
    52 // waar bezoekers heen worden gestuurd.
     49// Public login page: for VISITORS only Google-login (listeners/fans).
     50// The admin login (password) is intentionally NOT here — it lives hidden at
     51// /auth/admin (see below), so the admin login is not visible where visitors land.
    5352router.get('/login', (req, res) => {
    5453  const next = safeNext(req.query.next) || '';
     
    6867});
    6968
    70 // Verborgen beheerders-login (gebruikersnaam + wachtwoord). Nergens in de UI
    71 // gelinkt — de beheerder navigeert hier rechtstreeks naartoe (/auth/admin).
     69// Hidden admin login (username + password). Not linked anywhere in the UI —
     70// the admin navigates here directly (/auth/admin).
    7271router.get('/admin', (req, res) => {
    7372  const next = safeNext(req.query.next) || '';
     
    9190  const next = safeNext(req.body.next) || '';
    9291
    93   // Foutweergave op de (verborgen) beheerders-loginpagina: toon het wachtwoord-
    94   // formulier opnieuw (adminLogin:true), niet de Google-only publieke pagina.
     92  // Error display on the (hidden) admin login page: re-show the password
     93  // form (adminLogin:true), not the Google-only public page.
    9594  const renderErr = (error, status = 400) => {
    9695    res.status(status);
     
    105104
    106105  const user = db.prepare('SELECT * FROM users WHERE username = ? OR email = ?').get(username, username);
    107   // Altijd één bcrypt-vergelijking (dummy als de user geen bruikbaar wachtwoord
    108   // heeft) zodat de responstijd niets over het bestaan van een account verraadt.
     106  // Always one bcrypt comparison (dummy if the user has no usable password)
     107  // so response time reveals nothing about whether the account exists.
    109108  const usable = !!(user && user.password_hash && user.password_hash !== '!google-oauth');
    110109  const ok = bcrypt.compareSync(password, usable ? user.password_hash : DUMMY_HASH);
     
    119118});
    120119
    121 // ==================== EERSTE-KEER-SETUP (beheerder aanmaken) ====================
     120// ==================== FIRST-TIME SETUP (create admin account) ====================
    122121router.get('/register', (req, res) => {
    123122  const next = safeNext(req.query.next) || '';
    124123  if (req.session.user) return res.redirect(next || '/');
    125   // Geen publieke registratie: alleen de allereerste beheerder mag hier aangemaakt.
     124  // No public registration: only the very first admin may be created here.
    126125  if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
    127126  renderPage(req, res, 'pages/auth-register', {
     
    139138  });
    140139
    141   // Hard gesloten zodra er een gebruiker is — voorkomt een tweede "admin" via deze route.
     140  // Hard-closed once a user exists — prevents a second "admin" via this route.
    142141  if (!isSetupMode()) return res.redirect('/auth/login');
    143142
     
    150149  const userId = uuid();
    151150  const hash = bcrypt.hashSync(password, 10);
    152   // De allereerste gebruiker is de beheerder (god).
     151  // The very first user is the administrator (god).
    153152  db.prepare(`
    154153    INSERT INTO users (id, username, email, password_hash, role, theme, palette)
     
    156155  `).run(userId, username, email, hash);
    157156
    158   // Persoonlijke site auto-aanmaken (single-tenant-ombouw volgt later).
    159   // Setup-wizard: sitenaam + taal komen uit het formulier; taal = de taal waarin
    160   // de bezoeker de wizard invulde (resolveLang) en wordt meteen de site-standaard.
     157  // Auto-create a personal site (single-tenant restructure follows later).
     158  // Setup wizard: site name + language come from the form; language = the language
     159  // the visitor used to fill in the wizard (resolveLang) and becomes the site default.
    161160  if (!db.prepare('SELECT 1 FROM sites LIMIT 1').get()) {
    162161    const siteId = uuid();
     
    168167    `).run(siteId, username.toLowerCase(), title, '', userId, lang);
    169168    db.prepare(`INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')`).run(siteId, userId);
    170     try { setSetting('default_lang', lang); } catch (e) { /* niet fataal */ }
     169    try { setSetting('default_lang', lang); } catch (e) { /* non-fatal */ }
    171170  }
    172171
     
    175174});
    176175
    177 // ==================== WACHTWOORD VERGETEN (aanvraag) ====================
     176// ==================== FORGOT PASSWORD (request) ====================
    178177router.get('/reset-request', (req, res) => {
    179178  if (req.session.user) return res.redirect('/');
     
    191190    const user = db.prepare('SELECT id, email FROM users WHERE LOWER(email) = ?').get(email);
    192191    if (user) {
    193       const token = crypto.randomBytes(32).toString('hex'); // ruw: gaat alleen de mail/link in
     192      const token = crypto.randomBytes(32).toString('hex'); // raw: only goes into the mail/link
    194193      const expires = new Date(Date.now() + 30 * 60 * 1000).toISOString(); // 30 min
    195       // Alleen de HASH opslaan: DB-leestoegang levert zo geen bruikbaar token op.
     194      // Store only the HASH: so DB read access yields no usable token.
    196195      db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?')
    197196        .run(hashToken(token), expires, user.id);
     
    211210        }
    212211      } else if (process.env.NODE_ENV !== 'production') {
    213         // Dev zonder SMTP: link in log + op de pagina tonen.
     212        // Dev without SMTP: show the link in the log + on the page.
    214213        console.log(`[password-reset] ${user.email} -> ${url}`);
    215214        devResetUrl = url;
    216215      } else {
    217         // Productie zonder SMTP: NOOIT het token loggen. Verwijs naar de CLI break-glass.
     216        // Production without SMTP: NEVER log the token. Refer to the CLI break-glass.
    218217        console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`);
    219218      }
     
    221220  }
    222221
    223   // Anti-enumeratie: zelfde antwoord ongeacht of het adres bestaat.
     222  // Anti-enumeration: same response regardless of whether the address exists.
    224223  renderPage(req, res, 'pages/auth-reset-request', {
    225224    pageTitle: 'Wachtwoord resetten', bodyClass: 'on-special',
     
    228227});
    229228
    230 // ==================== WACHTWOORD RESETTEN (toepassen) ====================
     229// ==================== RESET PASSWORD (apply) ====================
    231230router.get('/reset/:token', (req, res) => {
    232231  const row = db.prepare(`
     
    266265});
    267266
    268 // ==================== GOOGLE-LOGIN (luisteraars/reageerders) ====================
    269 // Per-instance, eigen Google-client. Geeft ALTIJD rol member — nooit beheer.
     267// ==================== GOOGLE LOGIN (listeners/commenters) ====================
     268// Per-instance, own Google client. ALWAYS grants role member — never admin.
    270269router.get('/google', (req, res) => {
    271270  if (!fanLoginReady()) {
     
    279278});
    280279
    281 // Google KOPPELEN aan het huidige (ingelogde) account — bv. een beheerder die
    282 // voortaan óók met Google wil inloggen. Vereist dat je al ingelogd bent (met
    283 // wachtwoord); de koppeling slaat de google_sub op het eigen account op.
    284 // Alleen googleConfigured() nodig (geen premium-gate — dit is geen fan-login).
     280// LINK Google to the current (logged-in) account — e.g. an admin who also wants
     281// to log in with Google. Requires being already logged in (with password); the
     282// link stores the google_sub on their own account.
     283// Only googleConfigured() needed (no premium gate — this is not fan login).
    285284router.get('/google/link', requireAuth, (req, res) => {
    286285  if (!googleConfigured()) {
     
    289288  const state = crypto.randomBytes(16).toString('hex');
    290289  req.session.oauthState = state;
    291   req.session.oauthLink = true; // koppel-modus i.p.v. login-modus
     290  req.session.oauthLink = true; // link mode instead of login mode
    292291  res.redirect(authorizeUrl(state));
    293292});
     
    320319    const email = (info.email || '').trim().toLowerCase();
    321320
    322     // ── KOPPEL-MODUS: Google aan het huidige (ingelogde) account hangen ──
     321    // ── LINK MODE: attach Google to the current (logged-in) account ──
    323322    if (linking) {
    324323      delete req.session.oauthState; delete req.session.oauthLink;
     
    326325      if (!info.sub) return failLink('Google gaf geen account-id terug. Probeer opnieuw.');
    327326      if (info.email && info.email_verified === false) return failLink('Je Google-adres is niet geverifieerd.');
    328       // Dit Google-account mag niet al aan een ANDER account hangen.
     327      // This Google account must not already be linked to a DIFFERENT account.
    329328      const other = db.prepare('SELECT id FROM users WHERE google_sub = ? AND id != ?').get(info.sub, req.session.user.id);
    330329      if (other) return failLink('Dit Google-account is al aan een andere gebruiker gekoppeld.');
     
    336335    }
    337336
    338     // ── LOGIN-MODUS (luisteraars/fans + gekoppelde beheerder) ──
     337    // ── LOGIN MODE (listeners/fans + linked admin) ──
    339338    if (!fanLoginReady()) return failLogin('unavailable');
    340339    const next = safeNext(req.session.oauthNext) || '';
     
    342341    if (!email || info.email_verified === false) return failLogin('email');
    343342
    344     // Zoek EERST op de gekoppelde Google-account (google_sub). Een sub-match is het
    345     // expliciete koppel-bewijs → log in met de eigen rol, OOK als het Google-
    346     // mailadres afwijkt van het account-mailadres (bv. een beheerder die een ander
    347     // Gmail koppelt). Daarna pas op e-mail.
     343    // Look FIRST by linked Google account (google_sub). A sub-match is explicit
     344    // proof of the link → log in with their own role, EVEN IF the Google email
     345    // differs from the account email (e.g. an admin who linked a different Gmail).
     346    // Only then fall back to email lookup.
    348347    let user = info.sub ? db.prepare('SELECT * FROM users WHERE google_sub = ?').get(info.sub) : null;
    349348
    350349    if (user) {
    351       // Gekoppeld account gevonden → eigen rol behouden. Avatar bijwerken indien leeg.
     350      // Linked account found → keep their own role. Update avatar if empty.
    352351      db.prepare(`
    353352        UPDATE users SET avatar_url = COALESCE(avatar_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ?
     
    356355      user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
    357356      if (user && (user.role === 'god' || user.role === 'admin')) {
    358         // Beheerder gevonden op e-mail maar ZONDER gekoppelde sub → Google geeft
    359         // nooit beheer. Eerst koppelen via Account → Inloggen met Google.
     357        // Admin found by email but WITHOUT a linked sub → Google never grants admin.
     358        // Must first link via Account → Sign in with Google.
    360359        return failLogin('admin');
    361360      } else if (user) {
    362         // Bestaande luisteraar: koppel google_sub/avatar als die ontbreken.
     361        // Existing listener: link google_sub/avatar if missing.
    363362        db.prepare(`
    364363          UPDATE users SET google_sub = COALESCE(google_sub, ?), avatar_url = COALESCE(avatar_url, ?),
     
    366365        `).run(info.sub || null, info.picture || null, user.id);
    367366      } else {
    368         // Nieuwe luisteraar — altijd member.
     367        // New listener — always member.
    369368        const userId = uuid();
    370369        const username = uniqueUsername(info.name || email.split('@')[0]);
  • src/routes/changelog.js

    rbb42dfb r834bcc3  
    11/**
    2  * Publieke wijzigingen-/release-pagina.
     2 * Public changelog / release page.
    33 *
    4  * GET /changelog  -> rendert CHANGELOG.md (de bron van waarheid voor releases).
     4 * GET /changelog  -> renders CHANGELOG.md (the source of truth for releases).
    55 *
    6  * De app-versie (footer, package.json) is bewust losgekoppeld van de
    7  * cirkel-federatie-proto (KLONKT_PROTO): een versie-bump is cosmetisch en raakt
    8  * de federatie niet. We tonen de proto hier expliciet zodat per release zichtbaar
    9  * is met welke federatie-versie deze instance praat (cirkels = lockstep per proto).
     6 * The app version (footer, package.json) is intentionally decoupled from the
     7 * circle federation proto (KLONKT_PROTO): a version bump is cosmetic and does not
     8 * affect federation. We show the proto here explicitly so that each release
     9 * makes visible which federation version this instance speaks (circles = lockstep per proto).
    1010 */
    1111
  • src/routes/circle.js

    rbb42dfb r834bcc3  
    11/**
    2  * Cirkel-feed + lokale lees-pagina.
    3  *   GET /cirkel        -> overzicht (zelfde timeline/grid-view als de home)
    4  *   GET /cirkel/:id    -> losse remote-post in eigen chrome (blijf op je site)
    5  * Alleen actief als tenancy === 'circle' (anders next() -> postsRoutes/404).
    6  * Zie docs/cirkels-v1-spec.md §5c.
     2 * Circle feed + local reading page.
     3 *   GET /cirkel        -> overview (same timeline/grid view as the home)
     4 *   GET /cirkel/:id    -> individual remote post in own chrome (stay on your site)
     5 * Only active when tenancy === 'circle' (otherwise next() -> postsRoutes/404).
     6 * See docs/cirkels-v1-spec.md §5c.
    77 */
    88
     
    2525}
    2626
    27 // ── Overzicht ────────────────────────────────────────────────
     27// ── Overview ─────────────────────────────────────────────────
    2828router.get('/cirkel', (req, res, next) => {
    2929  if (getTenancy() !== 'circle') return next();
     
    4242    return {
    4343      id: r.id,
    44       // Lokale lees-pagina -> de kaart blijft op de eigen site (post-card linkt
    45       // lokaal + htmx, GEEN external_url).
     44      // Local reading page -> the card stays on the own site (post-card links
     45      // locally + htmx, NO external_url).
    4646      slug: 'cirkel/' + encodeURIComponent(r.id),
    4747      title: r.title || '(zonder titel)',
     
    5858  });
    5959
    60   // Sites in de cirkel — alleen actieve links (outdated/error vallen weg, net als
    61   // hun posts). Voor de grafische header met avatars.
     60  // Sites in the circle — active links only (outdated/error ones are excluded, along
     61  // with their posts). Used for the graphic header with avatars.
    6262  const sites = db.prepare(`
    6363    SELECT a.name, a.url, a.avatar
     
    7676});
    7777
    78 // ── Losse remote-post (lokaal lezen) ─────────────────────────
     78// ── Individual remote post (local reading) ────────────────────
    7979router.get('/cirkel/:id', (req, res, next) => {
    8080  if (getTenancy() !== 'circle') return next();
  • src/routes/comments.js

    rbb42dfb r834bcc3  
    5858    if (!parent) return res.status(400).send('Invalid parent comment');
    5959    resolvedParent = parent.parent_comment_id || parent.id;
    60     parentAuthorId = parent.author_id; // ontvanger van de "antwoord"-melding
     60    parentAuthorId = parent.author_id; // recipient of the "reply" notification
    6161  }
    6262
     
    7777  `).run(commentId, post.id, req.session.user.id, resolvedParent, rawContent, status);
    7878
    79   // Melding (alleen bij een zichtbare reactie): antwoord → de auteur van de reactie
    80   // waarop gereageerd is; top-level reactie → de auteur van de post. notify() slaat
    81   // jezelf-notificeren over.
     79  // Notification (only for visible comments): reply → author of the parent comment;
     80  // top-level comment → author of the post. notify() skips self-notifications.
    8281  if (status === 'approved') {
    8382    const url = `${res.locals.siteUrlBase || ''}/${post.slug}#comment-${commentId}`;
  • src/routes/download.js

    rbb42dfb r834bcc3  
    11/**
    2  * Download-voor-email (premium feature #2).
     2 * Download-for-email (premium feature #2).
    33 *
    4  *   GET  /downloads                 -> lijst van downloadbare tracks (premium; anders 404)
    5  *   GET  /download/:id              -> e-mail-capture-pagina voor één track
    6  *   POST /download/:id              -> e-mail opslaan (-> mailinglijst) + download vrijgeven
    7  *   GET  /download/:id/bestand      -> serveert het bestand (sessie-gated na capture)
     4 *   GET  /downloads                 -> list of downloadable tracks (premium; 404 otherwise)
     5 *   GET  /download/:id              -> email capture page for a single track
     6 *   POST /download/:id              -> save email (-> mailing list) + unlock download
     7 *   GET  /download/:id/bestand      -> serves the file (session-gated after capture)
    88 *
    9  * De fan laat z'n e-mail achter en krijgt het bestand; het adres komt in de
    10  * subscribers-lijst (source 'download', single opt-in — geen confirm-drempel vóór de
     9 * The fan leaves their email and receives the file; the address is added to the
     10 * subscribers list (source 'download', single opt-in — no confirm step before the
    1111 * download). Hub: via /user/:slug/... (resolveSite + siteUrlBase).
    1212 */
     
    2424const router = express.Router();
    2525
    26 // Als er een echte (gepinde) post met slug 'downloads' bestaat, hangt de
    27 // downloads-lijst feitelijk aan die post. Dan tonen we óók de Newer/Older-postnav,
    28 // zodat de bezoeker net als bij een post verder kan bladeren.
     26// If a real (pinned) post with slug 'downloads' exists, the downloads list is
     27// effectively attached to that post. We then also show the Newer/Older post nav
     28// so the visitor can browse just like on a regular post.
    2929function downloadsPostNav(req, res) {
    3030  const site = res.locals.site;
     
    4040
    4141const MIME = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg' };
    42 const GRACE_MS = 15 * 60 * 1000; // download-venster na capture
     42const GRACE_MS = 15 * 60 * 1000; // download window after capture
    4343
    4444function dlTrack(siteId, id) {
     
    5555}
    5656
    57 // Lijst van downloadbare tracks.
     57// List of downloadable tracks.
    5858router.get('/downloads', (req, res, next) => {
    5959  if (!premiumUnlocked()) return next();
     
    6767  renderPage(req, res, 'pages/downloads', {
    6868    pageTitle: 'Downloads — ' + (site.title || ''),
    69     // on-special = compacte profielkop (zoals op een post); on-downloads = pill grijs
    70     // + feature-route-gedrag. Samen → downloads ziet er net zo uit als een post.
     69    // on-special = compact profile header (like on a post); on-downloads = grey pill
     70    // + feature-route behaviour. Together → downloads looks just like a post.
    7171    bodyClass: 'on-downloads on-special',
    7272    dlTracks: tracks,
     
    7676});
    7777
    78 // Capture-pagina voor één track.
     78// Capture page for a single track.
    7979router.get('/download/:id', (req, res, next) => {
    8080  if (!premiumUnlocked()) return next();
     
    9393});
    9494
    95 // E-mail opslaan + download vrijgeven.
     95// Save email + unlock download.
    9696router.post('/download/:id', (req, res, next) => {
    9797  if (!premiumUnlocked()) return next();
     
    109109    });
    110110  }
    111   // Download vrijgeven in de sessie (kort venster).
     111  // Unlock download in the session (short window).
    112112  if (!req.session.dl) req.session.dl = {};
    113113  req.session.dl[track.id] = Date.now();
     
    118118});
    119119
    120 // Het bestand serveren — alleen als er net een e-mail is achtergelaten (sessie).
     120// Serve the file — only if an email was just submitted (session-gated).
    121121router.get('/download/:id/bestand', (req, res, next) => {
    122122  if (!premiumUnlocked()) return next();
     
    129129    return res.status(403).send('Laat eerst je e-mailadres achter om te downloaden.');
    130130  }
    131   // De speelbare/te-downloaden file = de KALE filename (storage_path is een
    132   // absoluut pad → faalt de slash-guard). Zelfde aanpak als /audio/stream.
     131  // The playable/downloadable file = the BARE filename (storage_path is an
     132  // absolute path → fails the slash-guard). Same approach as /audio/stream.
    133133  const sp = track.filename;
    134134  if (!sp || sp.includes('/') || sp.includes('\\') || sp.includes('..')) return res.status(400).send('Bad path');
  • src/routes/embed.js

    rbb42dfb r834bcc3  
    11/**
    2  * Embedbare player (premium feature #7).
     2 * Embeddable player (premium feature #7).
    33 *
    4  *   GET /embed   -> een zelfstandige, compacte audiospeler-pagina (geen shell),
    5  *                   bedoeld om op EXTERNE sites in een <iframe> te zetten.
     4 *   GET /embed   -> a standalone, compact audio player page (no shell),
     5 *                   intended to be placed in an <iframe> on EXTERNAL sites.
    66 *
    7  * De pagina wordt door ons (klonkt-origin) geserveerd, dus de audio-requests vanuit
    8  * het iframe blijven same-origin → de /audio/stream-gate laat ze door, ook al staat
    9  * het iframe op een vreemde site. We overrulen alleen Helmet's frameguard +
    10  * frame-ancestors zodat externe sites mógen inbedden. Hub: /user/:slug/embed.
     7 * The page is served by us (klonkt-origin), so audio requests from within
     8 * the iframe remain same-origin → the /audio/stream gate lets them through,
     9 * even when the iframe is on a foreign site. We only override Helmet's frameguard
     10 * + frame-ancestors so that external sites are allowed to embed us. Hub: /user/:slug/embed.
    1111 */
    1212
     
    2121  if (!site) return next();
    2222
    23   // Inbedden op externe sites toestaan (overrule de globale frameguard/CSP).
     23  // Allow embedding on external sites (override the global frameguard/CSP).
    2424  res.removeHeader('X-Frame-Options');
    2525  res.setHeader(
  • src/routes/epk.js

    rbb42dfb r834bcc3  
    11/**
    2  * EPK / perskit (premium) — een deelbare perspagina per Klonkt-site.
     2 * EPK / press kit (premium) — a shareable press page per Klonkt site.
    33 *
    4  * GET /pers  (solo) of /user/:slug/pers (hub, via resolveSite + siteUrlBase)
    5  *   -> nette, openbare perskit: hero (foto/titel/tagline), korte bio, topnummers,
    6  *      recente posts en een contact-knop. Bedoeld om naar boekers/pers te sturen.
     4 * GET /pers  (solo) or /user/:slug/pers (hub, via resolveSite + siteUrlBase)
     5 *   -> clean, public press kit: hero (photo/title/tagline), short bio, top tracks,
     6 *      recent posts and a contact button. Intended to share with bookers/press.
    77 *
    8  * Premium-gated: niet-premium instances hebben GEEN /pers (next() -> 404 via de
    9  * catch-all). De PAGINA zelf is openbaar (geen login) zodat pers 'm kan bekijken;
    10  * alleen het BESTAAN ervan is premium. Geen login-e-mail lekken: contact loopt via
    11  * een expliciet ingesteld pers-adres (epk_contact, per site) of anders de site zelf.
     8 * Premium-gated: non-premium instances have NO /pers (next() -> 404 via the
     9 * catch-all). The PAGE itself is public (no login) so press can view it;
     10 * only its EXISTENCE is premium. No login email leak: contact goes via an
     11 * explicitly configured press address (epk_contact, per site) or the site itself.
    1212 */
    1313
     
    2121
    2222router.get('/pers', (req, res, next) => {
    23   if (!premiumUnlocked()) return next();      // geen premium -> geen perskit
     23  if (!premiumUnlocked()) return next();      // no premium -> no press kit
    2424  const site = res.locals.site;
    2525  if (!site) return next();
    2626
    27   // Nummers op de perskit: een door de admin GEKOZEN selectie (max 5, in eigen
    28   // volgorde) als die is ingesteld; anders automatisch de top 5 meest beluisterde.
     27  // Tracks on the press kit: an admin-CHOSEN selection (max 5, in custom order)
     28  // if configured; otherwise automatically the top 5 most-listened.
    2929  let chosenIds = [];
    3030  try {
    3131    const raw = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]');
    3232    if (Array.isArray(raw)) chosenIds = raw.filter((x) => typeof x === 'string').slice(0, 5);
    33   } catch (e) { /* ongeldige JSON → val terug op top */ }
     33  } catch (e) { /* invalid JSON → fall back to top */ }
    3434
    3535  let tracks;
     
    4141    ).all(site.id, ...chosenIds);
    4242    const byId = new Map(rows.map((r) => [r.id, r]));
    43     tracks = chosenIds.map((id) => byId.get(id)).filter(Boolean);  // behoud gekozen volgorde
     43    tracks = chosenIds.map((id) => byId.get(id)).filter(Boolean);  // preserve chosen order
    4444  } else {
    4545    tracks = db.prepare(
     
    6060  ).all(site.id);
    6161
    62   // Pers-contact: per-site instelling (epk_contact_<siteId>) als die er is, anders
    63   // de globale epk_contact. NOOIT automatisch de login-mail tonen.
     62  // Press contact: per-site setting (epk_contact_<siteId>) if present, otherwise
     63  // the global epk_contact. NEVER auto-expose the login email.
    6464  const contact = (getSetting('epk_contact_' + site.id, '') || getSetting('epk_contact', '') || '').trim();
    65   // Korte pers-bio: per-site instelling, anders de tagline van de site.
     65  // Short press bio: per-site setting, otherwise the site's tagline.
    6666  const bio = (getSetting('epk_bio_' + site.id, '') || site.tagline || '').trim();
    6767
  • src/routes/federation.js

    rbb42dfb r834bcc3  
    1 // routes/federation.js — publieke Cirkels-endpoints (v1, publicatie-kant).
     1// routes/federation.js — public Cirkels endpoints (v1, publication side).
    22//
    3 //   GET /.klonkt/actor.json   — ActivityStreams-actor + Ed25519-pubkey
    4 //   GET /.klonkt/outbox.json  — publieke posts als AS Create-objecten,
    5 //                               getekend via de Klonkt-Signature-header
     3//   GET /.klonkt/actor.json   — ActivityStreams actor + Ed25519 public key
     4//   GET /.klonkt/outbox.json  — public posts as AS Create objects,
     5//                               signed via the Klonkt-Signature header
    66//
    7 // Site-agnostisch en zonder auth — alleen lezen. Zie docs/cirkels-v1-spec.md.
     7// Site-agnostic and unauthenticated — read-only. See docs/cirkels-v1-spec.md.
    88
    99import express from 'express';
     
    1818}
    1919
    20 // De proto die de consument zegt te draaien (uit z'n request-header), of 0.
     20// The proto the consumer claims to be running (from their request header), or 0.
    2121function consumerProto(req) {
    2222  return parseInt(req.get('Klonkt-Proto') || '0', 10) || 0;
     
    2424
    2525router.get('/.klonkt/actor.json', (req, res) => {
    26   // Cirkels = solo-naar-solo; hubs publiceren geen federatie-actor.
     26  // Circles = solo-to-solo; hubs do not publish a federation actor.
    2727  if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus');
    28   // De actor serveren we ALTIJD (ook aan oudere consumenten) zodat zij onze proto
    29   // kunnen lezen en een nette "update vereist"-melding kunnen tonen.
     28  // We ALWAYS serve the actor (including to older consumers) so they can read our
     29  // proto and show a clean "update required" message.
    3030  const body = JSON.stringify(buildActor(baseUrl(req)), null, 2);
    3131  res.type('application/activity+json; charset=utf-8');
     
    3838  if (getTenancy() === 'hub') return res.status(404).type('text/plain').send('Niet beschikbaar in hub-modus');
    3939  res.set('Klonkt-Proto', String(KLONKT_PROTO));
    40   // Te-oude consument? Weiger met 426 Upgrade Required (de crypto-binding sluit 'm
    41   // sowieso al uit; dit geeft een expliciet, leesbaar signaal). proto 0 = geen
    42   // header (bv. een browser/curl) → toestaan, die verifieert toch niet.
     40  // Consumer too old? Reject with 426 Upgrade Required (the crypto binding already
     41  // excludes them; this gives an explicit, readable signal). proto 0 = no header
     42  // (e.g. a browser/curl) → allow, they won't verify anyway.
    4343  const cp = consumerProto(req);
    4444  if (cp && cp < MIN_PROTO) {
  • src/routes/hub.js

    rbb42dfb r834bcc3  
    11/**
    2  * Hub-hoofdpagina — alleen in hub-modus. In plaats van de primaire Klonkt-site te
    3  * tonen, rendert '/' hier een bedrijfs-overview: de laatste posts van ALLE
    4  * gebruikers samengevat + een lijst van de Klonkt-site's.
     2 * Hub home page — hub mode only. Instead of rendering the primary Klonkt site,
     3 * '/' renders a company overview here: the latest posts from ALL users combined
     4 * + a list of the Klonkt sites.
    55 *
    6  * In solo-modus doet dit niets (next()) en rendert posts.js de enige site.
     6 * In solo mode this does nothing (next()) and posts.js renders the single site.
    77 */
    88
     
    1616router.get('/', (req, res, next) => {
    1717  if (getTenancy() !== 'hub') return next();
    18   // Als resolveSite een specifieke site adresseerde (/user/:slug of /sites/:slug),
    19   // is req.url naar '/' herschreven — dan NIET de overview tonen maar de site zelf
    20   // laten renderen door posts.js. siteUrlBase is dan gezet.
     18  // If resolveSite addressed a specific site (/user/:slug or /sites/:slug),
     19  // req.url was rewritten to '/' — do NOT show the overview but let posts.js
     20  // render the site itself. siteUrlBase is set in that case.
    2121  if (res.locals.siteUrlBase) return next();
    2222
    23   // Laatste gepubliceerde posts over álle sites heen.
     23  // Latest published posts across all sites.
    2424  const posts = db.prepare(`
    2525    SELECT p.title, p.slug, p.excerpt, p.published_at, p.created_at,
     
    3535  `).all();
    3636
    37   // De hoofd-/labelsite (de expliciet primaire = de bedrijfs-/hoofdaccount) is
    38   // GEEN artiest; die tonen we apart bovenaan, niet in de Artiesten-roster.
     37  // The main/label site (the explicitly primary = the company/main account) is
     38  // NOT an artist; we display it separately at the top, not in the Artists roster.
    3939  const mainSite = db.prepare(`
    4040    SELECT s.id, s.slug, s.title, s.tagline, s.profile_photo, s.accent,
     
    4848  const mainId = mainSite ? mainSite.id : '';
    4949
    50   // Uitgelichte Klonkt-site's voor de home-roster: meest-actief eerst (aantal
    51   // gepubliceerde posts), dan nieuwste. Excl. de hoofdsite. Beperkt tot
    52   // HOME_ROSTER_LIMIT zodat de home schaalt — volledige lijst staat op /leden.
     50  // Featured Klonkt sites for the home roster: most active first (number of
     51  // published posts), then newest. Excl. the main site. Capped at
     52  // HOME_ROSTER_LIMIT so the home scales — full list is at /leden.
    5353  const HOME_ROSTER_LIMIT = 24;
    5454  const artists = db.prepare(`
     
    6464  const totalArtists = db.prepare('SELECT COUNT(*) AS c FROM sites WHERE id != ?').get(mainId).c;
    6565
    66   // De hub-pagina is GENERIEK (van geen enkele user) — branding komt uit globale
    67   // instellingen die de admin in Beheer beheert, niet uit een site.
     66  // The hub page is GENERIC (not belonging to any user) — branding comes from global
     67  // settings managed by the admin in the admin panel, not from a site.
    6868  const hub = {
    6969    title: getSetting('hub_title') || 'Overzicht',
  • src/routes/lang.js

    rbb42dfb r834bcc3  
    1 // Taalkeuze van de bezoeker: /lang/:code zet de interface-taal in de sessie en
    2 // stuurt terug naar waar je vandaan kwam. (Content blijft in de taal van de auteur.)
     1// Visitor language choice: /lang/:code sets the interface language in the session
     2// and redirects back to where you came from. (Content stays in the author's language.)
    33import express from 'express';
    44import { SUPPORTED } from '../services/i18n.js';
     
    1010  const code = SUPPORTED.includes(req.params.code) ? req.params.code : 'nl';
    1111  if (req.session) req.session.lang = code;
    12   // Ingelogd? Bewaar de keuze ook op het account zodat 'ie meereist over
    13   // apparaten/sessies (niet alleen deze sessie-cookie).
     12  // Logged in? Also save the choice on the account so it follows the user
     13  // across devices/sessions (not just this session cookie).
    1414  if (req.session && req.session.user && req.session.user.id) {
    1515    try {
    1616      db.prepare('UPDATE users SET lang = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(code, req.session.user.id);
    1717      req.session.user.lang = code;
    18     } catch { /* lang-kolom ontbreekt op een oude DB → sessie-only, geen breuk */ }
     18    } catch { /* lang column missing on an old DB → session-only, no breakage */ }
    1919  }
    20   // Veilige terug-URL: alleen een intern pad (geen open redirect).
     20  // Safe back URL: internal path only (no open redirect).
    2121  let back = (typeof req.query.r === 'string') ? req.query.r : '';
    2222  if (!back.startsWith('/') || back.startsWith('//')) {
  • src/routes/linkbio.js

    rbb42dfb r834bcc3  
    11/**
    2  * Link-in-bio + klikstats (premium feature #6).
     2 * Link-in-bio + click stats (premium feature #6).
    33 *
    4  *   GET /links          -> Linktree-achtige pagina met de profile_links van de site
    5  *   GET /links/go/:i     -> telt de klik (per url) en stuurt door naar de externe URL
     4 *   GET /links          -> Linktree-style page with the site's profile_links
     5 *   GET /links/go/:i     -> counts the click (per url) and redirects to the external URL
    66 *
    7  * Hergebruikt de bestaande sites.profile_links (JSON [{platform,url}]) + de
    8  * PLATFORMS-iconen/labels. Klikken landen in link_clicks (zie /admin/stats).
    9  * Open-redirect-veilig: /links/go/:i stuurt ALLEEN door naar een url die in de
    10  * eigen profile_links staat. Hub: via /user/:slug/links.
     7 * Reuses the existing sites.profile_links (JSON [{platform,url}]) + the
     8 * PLATFORMS icons/labels. Clicks are stored in link_clicks (see /admin/stats).
     9 * Open-redirect safe: /links/go/:i ONLY redirects to a url present in the
     10 * site's own profile_links. Hub: via /user/:slug/links.
    1111 */
    1212
     
    4848  if (!link || !link.url) return next();
    4949  const url = String(link.url);
    50   // Alleen externe http(s)- of mailto-links (geen open redirect / javascript:).
     50  // Only external http(s) or mailto links (no open redirect / javascript:).
    5151  if (!/^https?:\/\//i.test(url) && !/^mailto:/i.test(url)) return res.status(400).send('Bad link');
    5252  try {
     
    5555       ON CONFLICT(site_id, url) DO UPDATE SET clicks = clicks + 1, updated_at = CURRENT_TIMESTAMP`
    5656    ).run(site.id, url);
    57   } catch { /* telling mag de redirect nooit breken */ }
     57  } catch { /* counting must never break the redirect */ }
    5858  res.redirect(302, url);
    5959});
  • src/routes/newsletter.js

    rbb42dfb r834bcc3  
    11/**
    2  * Nieuwsbrief — publieke kant (premium feature #1).
     2 * Newsletter — public side (premium feature #1).
    33 *
    4  *   GET  /nieuwsbrief                      -> aanmeldformulier (premium; anders 404)
    5  *   POST /nieuwsbrief                      -> aanmelden (double opt-in als SMTP er is)
    6  *   GET  /nieuwsbrief/bevestigen/:token    -> opt-in bevestigen
    7  *   GET  /nieuwsbrief/uitschrijven/:token  -> uitschrijven (ALTIJD toegestaan)
     4 *   GET  /nieuwsbrief                      -> sign-up form (premium; 404 otherwise)
     5 *   POST /nieuwsbrief                      -> subscribe (double opt-in if SMTP configured)
     6 *   GET  /nieuwsbrief/bevestigen/:token    -> confirm opt-in
     7 *   GET  /nieuwsbrief/uitschrijven/:token  -> unsubscribe (ALWAYS allowed)
    88 *
    9  * In hub-modus loopt dit via /user/:slug/nieuwsbrief (resolveSite zet siteUrlBase).
    10  * Confirm-/unsub-links in de mail zijn absoluut (PUBLIC_BASE_URL + siteUrlBase).
     9 * In hub mode this runs via /user/:slug/nieuwsbrief (resolveSite sets siteUrlBase).
     10 * Confirm/unsub links in the mail are absolute (PUBLIC_BASE_URL + siteUrlBase).
    1111 */
    1212
     
    5353
    5454  if (r.status === 'pending') {
    55     // Double opt-in: stuur de bevestigingsmail.
     55    // Double opt-in: send the confirmation email.
    5656    const link = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/bevestigen/' + r.token);
    5757    const unsub = fullUrl(req, res.locals.siteUrlBase, '/nieuwsbrief/uitschrijven/' + r.token);
     
    8181});
    8282
    83 // Uitschrijven mag altijd (ook als de premium-laag later uit zou gaan): een abonnee
    84 // moet zich altijd kunnen afmelden. Niet premium-gated.
     83// Unsubscribe is always allowed (even if the premium layer is later disabled): a
     84// subscriber must always be able to opt out. Not premium-gated.
    8585router.get('/nieuwsbrief/uitschrijven/:token', (req, res) => {
    8686  const ok = unsubscribe(req.params.token);
  • src/routes/notifications.js

    rbb42dfb r834bcc3  
    11/**
    2  * GET /notifications — meldingenpagina voor de ingelogde gebruiker.
    3  * Openen = alles als gelezen markeren (de teller in de header valt dan weg).
     2 * GET /notifications — notifications page for the logged-in user.
     3 * Opening it marks everything as read (the counter in the header disappears).
    44 */
    55import express from 'express';
  • src/routes/posts.js

    rbb42dfb r834bcc3  
    4949});
    5050
    51 // Maakt een unieke slug binnen de site: 'titel', 'titel-2', 'titel-3', …
    52 // Zo wordt een tweede post met dezelfde titel NIET geweigerd ("bestaat al"),
    53 // maar krijgt 'ie automatisch een vrij achtervoegsel. exceptId = de post die
    54 // we bijwerken (mag z'n eigen slug houden).
     51// Generates a unique slug within the site: 'title', 'title-2', 'title-3', …
     52// A second post with the same title is NOT rejected ("already exists"),
     53// but automatically gets a free suffix. exceptId = the post being updated
     54// (allowed to keep its own slug).
    5555function uniqueSlug(siteId, base, exceptId = null) {
    5656  let candidate = base;
     
    190190  if (RESERVED_SLUGS.has(finalSlug)) finalSlug = `${finalSlug}-post`;
    191191
    192   // Dubbele titel/slug? Automatisch uniek maken (titel-2, titel-3, …) i.p.v. weigeren.
     192  // Duplicate title/slug? Make it unique automatically (title-2, title-3, …) instead of rejecting.
    193193  finalSlug = uniqueSlug(site.id, finalSlug);
    194194
     
    199199  let finalStatus = status || 'draft';
    200200  let publishedAt = finalStatus === 'published' ? now : null;
    201   // Release-planning: gepubliceerd + een toekomstige publish_at -> 'scheduled'
    202   // (de Scheduler zet 'm live op het moment zelf). Verleden/leeg -> meteen live.
     201  // Release planning: published + a future publish_at -> 'scheduled'
     202  // (the Scheduler makes it live at that moment). Past/empty -> live immediately.
    203203  let publishAt = null;
    204204  const pa = Date.parse(req.body.publish_at || '');
     
    297297    const cleaned = newSlug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
    298298    const safe = RESERVED_SLUGS.has(cleaned) ? `${cleaned}-post` : cleaned;
    299     // Dubbele slug? Automatisch uniek maken i.p.v. weigeren (eigen post mag z'n slug houden).
     299    // Duplicate slug? Make it unique automatically instead of rejecting (own post may keep its slug).
    300300    finalSlug = uniqueSlug(site.id, safe, post.id);
    301301  }
     
    310310  }
    311311
    312   // Release-planning: gepubliceerd + toekomstige publish_at -> 'scheduled'.
     312  // Release planning: published + future publish_at -> 'scheduled'.
    313313  let publishAt = null;
    314314  const pa = Date.parse(req.body.publish_at || '');
     
    412412});
    413413
    414 // Pad naar de like-knop-partial (voor de htmx-toggle re-render).
     414// Path to the like button partial (for the htmx toggle re-render).
    415415const LIKE_PARTIAL = path.join(__dirname, '..', 'views', 'partials', 'like-button.ejs');
    416416
    417 // ==================== LIKE / FAVORIET ====================
    418 // Een ingelogde gebruiker (geen kijker — de globale guard blokkeert non-GET voor
    419 // kijkers) togglet een like op een gepubliceerde post. Geeft de her-gerenderde
    420 // knop terug (htmx outerHTML-swap).
     417// ==================== LIKE / FAVOURITE ====================
     418// A logged-in user (not a viewer — the global guard blocks non-GET for viewers)
     419// toggles a like on a published post. Returns the re-rendered button (htmx outerHTML swap).
    421420router.post('/posts/:id/like', requireAuth, (req, res) => {
    422421  const userId = req.session.user.id;
     
    430429  } else {
    431430    db.prepare('INSERT OR IGNORE INTO post_likes (post_id, user_id) VALUES (?, ?)').run(post.id, userId);
    432     // Melding voor de post-auteur (notify slaat jezelf-liken over).
     431    // Notification for the post author (notify skips self-likes).
    433432    notify({
    434433      userId: post.author_id, actorId: userId, actorName: req.session.user.username, type: 'like',
     
    444443});
    445444
    446 // Favorieten = de posts die de ingelogde gebruiker likete. Solo: binnen de
    447 // huidige site. Hub: over alle sites (met juiste /user/<slug>-links).
     445// Favourites = posts the logged-in user has liked. Solo: within the current
     446// site. Hub: across all sites (with correct /user/<slug> links).
    448447router.get('/favorieten', requireAuth, (req, res) => {
    449448  const userId = req.session.user.id;
     
    469468});
    470469
    471 // Newer/Older-buren over ALLE posts in feed-volgorde. Gedeeld door de volledige
    472 // post-render én de fan-gate (premium fan_only), zodat de navigatie overal gelijk
    473 // is. Solo: binnen de site (pinned eerst, dan datum). Hub: globaal op datum.
     470// Newer/Older neighbours across ALL posts in feed order. Shared by the full
     471// post render and the fan gate (premium fan_only) so navigation is consistent
     472// everywhere. Solo: within the site (pinned first, then date). Hub: globally by date.
    474473function postNeighbors(site, post, isHub) {
    475474  const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
     
    507506  `).get(site.id, req.params.slug);
    508507
    509   if (!post) return next(); // onbekende slug -> nette 404 catch-all
     508  if (!post) return next(); // unknown slug -> clean 404 catch-all
    510509
    511510  // Permission to view: published OR (logged in + can edit)
     
    515514  }
    516515
    517   // Fan-only preview (premium #3): volledige inhoud alleen voor ingelogde fans.
    518   // Anonieme bezoekers krijgen een nette login-gate i.p.v. de inhoud (de titel/
    519   // teaser mag elders wel als lokkertje verschijnen).
     516  // Fan-only preview (premium #3): full content only for logged-in fans.
     517  // Anonymous visitors get a clean login gate instead of the content (the title/
     518  // teaser may still appear elsewhere as a teaser).
    520519  if (post.fan_only && !(req.session && req.session.user)) {
    521     // Zelfde Newer/Older-navigatie als op een gewone post, zodat de bezoeker op
    522     // de fan-gate niet vastloopt maar verder kan bladeren.
     520    // Same Newer/Older navigation as on a normal post, so the visitor doesn't get
     521    // stuck on the fan gate but can keep browsing.
    523522    const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
    524523    return renderPage(req, res, 'pages/fan-gate', {
     
    532531  }
    533532
    534   // Statistieken: tel de weergave (skipt beheerders + niet-gepubliceerd-eigen-preview).
     533  // Statistics: count the view (skips admins + unpublished own-preview).
    535534  if (post.status === 'published') recordPostView(post, req);
    536535
     
    588587      const byAlbum = new Map();
    589588      for (const r of albumRows) {
    590         // Link-only tracks (geen bestand) blijven in het album-overzicht (url '').
     589        // Link-only tracks (no file) remain in the album overview (url '').
    591590        if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
    592591        byAlbum.get(r.album).push({
     
    625624  }
    626625  } else {
    627     // LITE-modus (KLONKT_AUDIO=off): geen eigen audio (geen ffmpeg/stream-route).
    628     // Externe embeds (YouTube/SoundCloud/Spotify) blijven wel; de eigen-audio-
    629     // shortcodes ([[track]]/[[album]]/[[playlist]]) verwijderen we netjes.
     626    // LITE mode (KLONKT_AUDIO=off): no own audio (no ffmpeg/stream route).
     627    // External embeds (YouTube/SoundCloud/Spotify) remain; the own-audio
     628    // shortcodes ([[track]]/[[album]]/[[playlist]]) are cleanly stripped.
    630629    html = AudioEmbedService.autoembed(html);
    631630    html = AudioEmbedService.embedMediaShortcodes(html);
     
    665664  // Prev / next chronological (kept for back-compat — "post-nav" feature
    666665  // below the article still uses these as a simple linear navigation).
    667   // Hub-modus: Gerelateerde posts + Newer/Older trekken uit ALLE users (alle
    668   // sites), nieuwste->oudste. Solo-modus: binnen de huidige site (oud gedrag).
     666  // Hub mode: Related posts + Newer/Older pull from ALL users (all sites),
     667  // newest first. Solo mode: within the current site (old behaviour).
    669668  const isHub = res.locals.tenancy === 'hub';
    670   // Per-post URL-basis: in hub wijst een link naar /user/<site-slug>/<post-slug>.
     669  // Per-post URL base: in hub a link points to /user/<site-slug>/<post-slug>.
    671670  const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
    672671
    673   // Newer/Older over ALLE posts (gedeelde helper — ook door de fan-gate gebruikt).
     672  // Newer/Older across ALL posts (shared helper — also used by the fan gate).
    674673  const { newerPost, olderPost } = postNeighbors(site, post, isHub);
    675674
     
    727726  relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
    728727
    729   // Likes / favorieten: aantal + of de ingelogde gebruiker deze post likete.
     728  // Likes / favourites: count + whether the logged-in user liked this post.
    730729  const likeCount = db.prepare('SELECT COUNT(*) AS c FROM post_likes WHERE post_id = ?').get(post.id).c;
    731730  const likedByMe = !!(req.session?.user &&
  • src/routes/search.js

    rbb42dfb r834bcc3  
    11/**
    2  * GET /search?q=...          -> volledige resultatenpagina
    3  * GET /search/suggest?q=...  -> compacte JSON voor live resultaten in de overlay
     2 * GET /search?q=...          -> full results page
     3 * GET /search/suggest?q=...  -> compact JSON for live results in the overlay
    44 *
    5  * Doorzoekt de huidige site op:
     5 * Searches the current site across:
    66 *   1. Posts via posts_fts (FTS5, prefix-matching) — published only.
    7  *   2. Nummers (audio_tracks) op titel / artiest / album.
    8  *   3. Evenementen (shows) op plaats / locatie / land / notitie — als de agenda aan staat.
    9  *   4. Pagina's (Agenda / Downloads / Links / Perskit / Archief) op naam — alleen
    10  *      de beschikbare.
     7 *   2. Tracks (audio_tracks) on title / artist / album.
     8 *   3. Events (shows) on city / venue / country / notes — when the agenda is enabled.
     9 *   4. Pages (Agenda / Downloads / Links / Press kit / Archive) by name — only
     10 *      the available ones.
    1111 *
    12  * FTS5: user-input wordt getokeniseerd op niet-letter/cijfer en elk token tussen
    13  * dubbele quotes + `*` gezet → prefix-match, geen operator-soup/syntax-errors.
     12 * FTS5: user input is tokenised on non-letter/digit chars and each token is wrapped
     13 * in double quotes + `*` → prefix-match, no operator-soup/syntax-errors.
    1414 */
    1515
     
    4747}
    4848
    49 // ── De kern: alle bronnen doorzoeken voor één site. `lim` begrenst per groep
    50 //    (klein voor de live-suggesties, ruim voor de volle pagina). ──────────────
     49// ── Core: search all sources for one site. `lim` caps results per group
     50//    (small for live suggestions, large for the full page). ──────────────────
    5151function searchSite(req, res, rawQ, lim) {
    5252  const site = res.locals.site;
     
    7979  }
    8080
    81   // 2. Nummers
     81  // 2. Tracks
    8282  try {
    8383    const trackRows = db.prepare(`
     
    108108  } catch (err) { if (!out.queryError) out.queryError = err.message; }
    109109
    110   // 3. Evenementen (agenda) — alleen als de agenda publiek aan staat.
     110  // 3. Events (agenda) — only when the agenda is publicly enabled.
    111111  if (premiumUnlocked() && getSetting('agenda_enabled') === '1') {
    112112    try {
     
    125125  }
    126126
    127   // 4. Pagina's — curated, alleen de beschikbare; match op de (vertaalde) naam.
     127  // 4. Pages — curated, available ones only; matched against the (translated) name.
    128128  const ql = rawQ.toLowerCase();
    129129  const candidates = [
     
    143143}
    144144
    145 // ── Volledige resultatenpagina ───────────────────────────────────────────────
     145// ── Full results page ────────────────────────────────────────────────────────
    146146router.get('/', (req, res) => {
    147147  const site = res.locals.site;
     
    165165});
    166166
    167 // ── Live suggesties (JSON) ───────────────────────────────────────────────────
     167// ── Live suggestions (JSON) ──────────────────────────────────────────────────
    168168router.get('/suggest', (req, res) => {
    169169  const site = res.locals.site;
  • src/routes/shows.js

    rbb42dfb r834bcc3  
    11/**
    2  * Show-agenda + notify-me (premium feature #8) — publieke kant.
     2 * Show agenda + notify-me (premium feature #8) — public side.
    33 *
    4  *   GET  /shows         -> komende optredens + "houd me op de hoogte"-formulier
    5  *   POST /shows/notify  -> aanmelden voor show-aankondigingen (subscribers, source
    6  *                          'notify'; double opt-in als SMTP er is)
     4 *   GET  /shows         -> upcoming gigs + "keep me posted" form
     5 *   POST /shows/notify  -> subscribe to show announcements (subscribers, source
     6 *                          'notify'; double opt-in if SMTP configured)
    77 *
    8  * De notify-bevestiging/uitschrijving hergebruikt de generieke subscriber-links
     8 * The notify confirm/unsubscribe reuses the generic subscriber links
    99 * (/nieuwsbrief/bevestigen|uitschrijven/:token). Hub: /user/:slug/shows.
    1010 */
     
    2020const router = express.Router();
    2121
    22 // Agenda is opt-in: pas bereikbaar als de beheerder 'm heeft ingeschakeld.
     22// Agenda is opt-in: only accessible once the admin has enabled it.
    2323function agendaOn() { return getSetting('agenda_enabled') === '1'; }
    2424
  • src/server.js

    rbb42dfb r834bcc3  
    22 * Klonkt Beta — server bootstrap
    33 *
    4  * Persoonlijk multi-site platform — Node + SQLite + htmx.
     4 * Personal multi-site platform — Node + SQLite + htmx.
    55 * Stack: Express + better-sqlite3 + EJS + htmx + ws.
    66 */
     
    8787        "'self'",
    8888        "'unsafe-inline'",
    89         // Eigen custom-embeds (embed-player.js) laden de OFFICIELE player-API's
    90         // van deze hosts. Zonder deze whitelist blokkeert de CSP ze stil (alleen
    91         // een console-fout) en faalt de embed-speler.
     89        // Our custom embeds (embed-player.js) load the OFFICIAL player APIs
     90        // from these hosts. Without this whitelist the CSP silently blocks them
     91        // (only a console error) and the embed player fails.
    9292        "https://www.youtube.com",   // YouTube IFrame Player API (+ www-widgetapi.js)
    93         "https://s.ytimg.com",       // YouTube player-assets
     93        "https://s.ytimg.com",       // YouTube player assets
    9494        "https://w.soundcloud.com",  // SoundCloud Widget API (api.js)
    9595        "https://open.spotify.com",  // Spotify iFrame API (loader)
    96         "https://*.spotifycdn.com",  // Spotify iFrame API (echte bundle: embed-cdn.spotifycdn.com)
     96        "https://*.spotifycdn.com",  // Spotify iFrame API (real bundle: embed-cdn.spotifycdn.com)
    9797      ],
    98       // Helmet's default zet script-src-attr op 'none', wat ALLE inline event-
    99       // handlers (onchange/onclick/onsubmit) blokkeert — daardoor deed o.a. de
    100       // avatar-upload (<input onchange="this.form.submit()">) en de rol-dropdown
    101       // niets. We staan inline handlers expliciet toe, consistent met de al
    102       // toegestane inline <script> hierboven.
     98      // Helmet's default sets script-src-attr to 'none', which blocks ALL inline
     99      // event handlers (onchange/onclick/onsubmit) — causing e.g. the avatar
     100      // upload (<input onchange="this.form.submit()">) and the role dropdown to
     101      // silently do nothing. We explicitly allow inline handlers, consistent with
     102      // the already-allowed inline <script> above.
    103103      scriptSrcAttr: ["'unsafe-inline'"],
    104104      styleSrc: ["'self'", "'unsafe-inline'"],
    105       // blob: nodig voor de afbeeldings-editor (Cropper) — die toont de gekozen
    106       // foto via een URL.createObjectURL(blob:…). Zonder blob: blokkeert de CSP
    107       // de <img> stil → leeg bewerk-venster. (media-src heeft blob: al voor audio.)
     105      // blob: required for the image editor (Cropper) — it displays the chosen
     106      // photo via URL.createObjectURL(blob:…). Without blob: the CSP silently
     107      // blocks the <img> → empty edit window. (media-src already has blob: for audio.)
    108108      imgSrc: ["'self'", "data:", "https:", "blob:"],
    109109      connectSrc: ["'self'", "wss:", "ws:", "https://*.spotifycdn.com", "https://*.scdn.co"],
     
    121121        "https://embed.music.apple.com",
    122122        "https://www.youtube-nocookie.com",
    123         "https://www.youtube.com",   // YouTube IFrame API maakt soms een www.youtube.com-iframe
     123        "https://www.youtube.com",   // YouTube IFrame API sometimes creates a www.youtube.com iframe
    124124        "https://player.vimeo.com",
    125125      ],
     
    143143if (!isDev) app.set('trust proxy', 1);
    144144
    145 // Schema aanmaken/bijwerken VÓÓR iets de DB aanraakt: de session-store doet
    146 // bij constructie al een query op de `sessions`-tabel, dus bij een verse
    147 // install moeten de tabellen eerst bestaan (anders: "no such table: sessions"
    148 // → crash-loop op de allereerste boot).
     145// Create/migrate the schema BEFORE anything touches the DB: the session store
     146// queries the `sessions` table on construction, so on a fresh install the tables
     147// must exist first (otherwise: "no such table: sessions" → crash loop on first boot).
    149148initializeDatabase();
    150 startScheduler(); // release-planning: zet geplande posts live zodra publish_at bereikt is
    151 
    152 // Vangnet: garandeer dat er altijd een primaire site is (solo/hub/circle).
    153 // Idempotent — doet niets als er al een site is of nog geen beheerder.
     149startScheduler(); // release planning: publish scheduled posts when publish_at is reached
     150
     151// Safety net: guarantee that there is always a primary site (solo/hub/circle).
     152// Idempotent — does nothing if a site already exists or there is no admin yet.
    154153ensurePrimarySite();
    155154
     
    173172app.use('/assets', express.static(path.join(__dirname, 'assets'), { maxAge: isDev ? 0 : '1y' }));
    174173app.use('/media', express.static(process.env.MEDIA_PATH || './storage/media', {
    175   // Publieke media (post-covers, avatars) moet door andere Klonkt-sites in hun
    176   // CIRKEL cross-origin embedbaar zijn. Helmet zet standaard CORP=same-origin,
    177   // wat die afbeeldingen in de browser blokkeert (bestand komt wél binnen, maar
    178   // de browser weigert 'm te tonen). Voor /media dus expliciet cross-origin.
     174  // Public media (post covers, avatars) must be cross-origin embeddable by other
     175  // Klonkt sites in their CIRCLE. Helmet sets CORP=same-origin by default, which
     176  // causes the browser to block those images (the file arrives, but the browser
     177  // refuses to render it). Set cross-origin explicitly for /media.
    179178  setHeaders: (res) => res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'),
    180179}));
    181180
    182 // (Verwijderd) TWA / digital-asset-links — alleen nodig voor de APK/TWA-variant.
    183 // Klonkt is PWA-only; geen assetlinks.json meer.
    184 
    185 // Cirkels: periodieke achtergrond-sync van remote instances (no-op tenzij tenancy='circle').
     181// (Removed) TWA / digital-asset-links — only needed for the APK/TWA variant.
     182// Klonkt is PWA-only; assetlinks.json is no longer served.
     183
     184// Circles: periodic background sync of remote instances (no-op unless tenancy='circle').
    186185startCircleSyncLoop();
    187186
     
    203202})();
    204203
    205 // Cirkels-federatie: publieke, site-agnostische endpoints (/.klonkt/*).
    206 // Vóór resolveSite/theme — ze hebben geen site-context nodig.
     204// Circle federation: public, site-agnostic endpoints (/.klonkt/*).
     205// Before resolveSite/theme — they don't need a site context.
    207206app.use(federationRoutes);
    208207
     
    211210app.use(loadTheme);
    212211
    213 // Lichtgewicht CSRF-defense: weiger cross-origin state-wijzigende requests.
    214 // Same-origin forms + HTMX sturen een matchende Origin; ontbreekt Origin dan
    215 // laten we door (non-browser clients). sameSite:'lax' op de sessiecookie is de
    216 // tweede laag. (Geldt niet voor GET/HEAD/OPTIONS.)
     212// Lightweight CSRF defense: reject cross-origin state-mutating requests.
     213// Same-origin forms + HTMX send a matching Origin; missing Origin is allowed
     214// through (non-browser clients). sameSite:'lax' on the session cookie is the
     215// second layer. (Does not apply to GET/HEAD/OPTIONS.)
    217216app.use((req, res, next) => {
    218217  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
    219218  const origin = req.get('origin');
    220   if (!origin) return next(); // geen Origin -> geen browser-CSRF-vector
     219  if (!origin) return next(); // no Origin → no browser CSRF vector
    221220  let originHost;
    222221  try { originHost = new URL(origin).host; } catch { return res.status(403).send('Ongeldige origin'); }
     
    225224});
    226225
    227 // Kijker-accounts: alles bekijken mag (incl. Beheer), niets wijzigen. Dit is de
    228 // ENIGE schrijf-blokkade — fail-closed, vóór alle route-handlers. Elke state-
    229 // wijzigende methode wordt geweigerd (de login-POST zet de sessie pas ná deze
    230 // guard, dus die valt er niet onder). I.p.v. rauwe 403-tekst tonen we een nette
    231 // pagina (of, bij HTMX, een ingeswapte melding).
     226// Viewer accounts: may view everything (including Admin), change nothing. This is
     227// the ONLY write gate — fail-closed, before all route handlers. Every state-mutating
     228// method is rejected (the login POST sets the session after this guard, so it is
     229// not affected). Instead of raw 403 text we render a clean page (or, for HTMX,
     230// a swapped-in message).
    232231app.use((req, res, next) => {
    233232  const mutating = req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS';
    234233  if (mutating && isViewer(req.session?.user)) {
    235234    if (req.headers['hx-request'] === 'true') {
    236       // htmx swapt niet op 4xx; stuur 200 + retarget zodat de melding in #pcms-main verschijnt.
     235      // htmx doesn't swap on 4xx; send 200 + retarget so the message appears in #pcms-main.
    237236      res.setHeader('HX-Retarget', '#pcms-main');
    238237      res.setHeader('HX-Reswap', 'innerHTML');
     
    277276// Feed/sitemap routes are mounted at root because they're at well-known paths
    278277app.use('/', feedRoutes);
    279 app.use('/leden', artistsRoutes); // doorzoekbare leden-directory (alleen hub; solo: next())
     278app.use('/leden', artistsRoutes); // searchable member directory (hub only; solo: next())
    280279app.get('/artiesten', (req, res) => res.redirect(301, req.originalUrl.replace(/^\/artiesten/, '/leden'))); // oude URL -> /leden
    281280app.use('/', hubRoutes); // hub-overview op '/' (solo: next() -> postsRoutes)
     
    306305
    307306  // A stable identity per site so installs don't collide (Chromium uses `id`).
    308   // NB: een id-wissel orphant bestaande PWA-installs (er is geen migratie die een
    309   // install over een id-verandering heen tilt) — wie een site al als PWA had,
    310   // moet 'm één keer opnieuw installeren. Data blijft server-side, dus niets kwijt.
     307  // NB: changing the id orphans existing PWA installs (no migration carries an
     308  // install across an id change) — anyone who already installed the site as a
     309  // PWA will need to reinstall once. Data stays server-side, so nothing is lost.
    311310  const idBase = site?.slug ? `klonkt-${site.slug}` : 'klonkt';
    312311
     
    376375  self.clients.claim();
    377376});
    378 // ALLEEN navigaties (HTML-pagina's) onderscheppen, voor een offline-fallback.
    379 // Afbeeldingen, CSS, JS en /media NIET aanraken — laat de browser die native
    380 // afhandelen. Anders kon een mislukte netwerk-fetch terugvallen op een lege
    381 // cache-match (undefined) en zo een afbeelding "kapot" maken bij een gewone
    382 // refresh (hard reload omzeilt de SW en werkte daarom wél).
     377// ONLY intercept navigations (HTML pages) for an offline fallback.
     378// Do NOT touch images, CSS, JS or /media — let the browser handle those natively.
     379// Otherwise a failed network fetch could fall back to an empty cache match
     380// (undefined) and "break" an image on a normal refresh (hard reload bypasses
     381// the SW, which is why that case worked fine).
    383382self.addEventListener('fetch', e => {
    384383  if (e.request.method !== 'GET') return;
    385   if (e.request.mode !== 'navigate') return; // alleen page-loads
     384  if (e.request.mode !== 'navigate') return; // page loads only
    386385  e.respondWith(
    387386    fetch(e.request).catch(() => caches.match('/').then(r => r || Response.error()))
     
    404403app.use((req, res) => {
    405404  res.status(404);
    406   // Nette, mobielvriendelijke 404 via de shell (viewport + nav + huisstijl).
    407   // Valt terug op kale HTML als het renderen onverhoopt faalt.
     405  // Clean, mobile-friendly 404 via the shell (viewport + nav + site theme).
     406  // Falls back to bare HTML if rendering unexpectedly fails.
    408407  try {
    409408    return renderPage(req, res, 'pages/404', {
  • src/services/AudioEmbedService.js

    rbb42dfb r834bcc3  
    1111 */
    1212
    13 // "Open in"-iconen (brand-gekleurd via CSS .pat-link--*).
     13// "Open in" icons (brand-colored via CSS .pat-link--).
    1414const OPEN_IN_SVG = {
    1515  spotify: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 100 20 10 10 0 000-20zm4.6 14.42a.62.62 0 01-.86.21c-2.35-1.44-5.3-1.76-8.79-.96a.62.62 0 11-.28-1.21c3.8-.87 7.07-.5 9.71 1.11.3.18.39.57.22.85zm1.23-2.73a.78.78 0 01-1.07.26c-2.69-1.66-6.79-2.14-9.97-1.17a.78.78 0 11-.45-1.49c3.63-1.1 8.15-.56 11.24 1.33.36.22.48.7.25 1.07zm.1-2.85C14.66 8.95 9.4 8.78 6.3 9.72a.93.93 0 11-.54-1.79c3.56-1.08 9.37-.87 13.07 1.33a.94.94 0 01-.96 1.61z"/></svg>',
     
    1919
    2020class AudioEmbedService {
    21   // Kleine "open in"-links voor een track (Spotify/YouTube/SoundCloud). De hrefs
    22   // zijn server-side al gevalideerd (alleen https + juiste host). Geeft '' als er
    23   // geen links zijn. Wordt naast de play-knop gezet (buiten de knop → geen
    24   // conflict met afspelen).
     21  // Small "open in" links for a track (Spotify/YouTube/SoundCloud). The hrefs
     22  // are already validated server-side (https + correct host only). Returns ''
     23  // when no links exist. Placed next to the play button (outside the button →
     24  // no conflict with playback).
    2525  static openInLinks(t) {
    2626    if (!t) return '';
     
    4040    url = url.trim();
    4141
    42     // Alleen http(s)-URL's embedden. De provider-regexes hieronder zijn NIET
    43     // verankerd, dus zonder deze check zou bv. `javascript:alert(1)//youtu.be/x`
    44     // matchen en als embed-URL belanden (stored XSS via een [[embed:...]]-
    45     // shortcode — die tekst gaat niet langs de HTML-sanitizer omdat 'ie in een
    46     // text-node zit). De scheme-guard sluit javascript:/data:/vbscript: enz. uit.
     42    // Only embed http(s) URLs. The provider regexes below are NOT anchored,
     43    // so without this check e.g. `javascript:alert(1)//youtu.be/x` would match
     44    // and land as an embed URL (stored XSS via an [[embed:...]] shortcode —
     45    // that text never passes through the HTML sanitizer because it lives in a
     46    // text node). The scheme guard excludes javascript:/data:/vbscript: etc.
    4747    if (!/^https?:\/\//i.test(url)) return null;
    4848
     
    6868    }
    6969
    70     // YouTube — video-id is altijd exact 11 tekens (lijnt uit met de client-side
    71     // ytId() in embed-player.js, die ook {11} verwacht).
     70    // YouTube — video id is always exactly 11 characters (aligns with the client-side
     71    // ytId() in embed-player.js, which also expects {11}).
    7272    if (/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([A-Za-z0-9_-]{11})/i.test(url)) {
    7373      const match = url.match(/(?:v=|youtu\.be\/|embed\/|shorts\/|live\/)([A-Za-z0-9_-]{11})/i);
     
    8686  static generateIframe(provider, config) {
    8787    switch (provider) {
    88       // Eigen custom-spelers (client-side via embed-player.js + de echte
    89       // platform-API's). We renderen een placeholder met data-attributen i.p.v.
    90       // het kale platform-iframe, zodat de embed in ÓNZE huisstijl verschijnt.
     88      // Custom players (client-side via embed-player.js + the real platform APIs).
     89      // We render a placeholder with data attributes instead of the bare platform
     90      // iframe, so the embed appears in OUR brand style.
    9191      case 'youtube':
    9292        return this.embedPlaceholder('youtube', config.id, 'video',
     
    9797        return this.embedPlaceholder('spotify', `spotify:${config.type}:${config.id}`,
    9898          config.type, config.url || `https://open.spotify.com/${config.type}/${config.id}`);
    99       // Geen JS-API (Bandcamp/Apple) of niet-prioritair (Vimeo): blijven een
    100       // iframe; mutual-exclusion loopt voor deze via de blur-fallback.
     99      // No JS API (Bandcamp/Apple) or low priority (Vimeo): remain as iframes;
     100      // mutual exclusion for these runs via the blur fallback.
    101101      case 'bandcamp':
    102102        return this.bandcampIframe(config);
     
    111111
    112112  /**
    113    * Placeholder voor een eigen custom-speler. embed-player.js pikt
    114    * .folio-embed[data-embed-provider] op en bouwt de kaart + speler client-side.
    115    * ALLE waarden via escape() — post.content_html wordt ongeescaped uitgevoerd.
     113   * Placeholder for a custom player. embed-player.js picks up
     114   * .folio-embed[data-embed-provider] and builds the card + player client-side.
     115   * ALL values go through escape() — post.content_html is executed unescaped.
    116116   */
    117117  static embedPlaceholder(provider, ref, type, url) {
     
    244244
    245245  /**
    246    * Replace [[embed:<url>]] shortcodes met de platform-iframe (YouTube, Spotify,
    247    * SoundCloud, Apple Music, Bandcamp, Vimeo). De editor-knop voegt deze
    248    * shortcode in; losse URL-regels embedden ook automatisch via autoembed().
    249    * Niet-ondersteunde/ongeldige URLs krijgen een nette inline-melding.
     246   * Replace [[embed:<url>]] shortcodes with the platform iframe (YouTube, Spotify,
     247   * SoundCloud, Apple Music, Bandcamp, Vimeo). The editor button inserts this
     248   * shortcode; bare URL lines also embed automatically via autoembed().
     249   * Unsupported/invalid URLs get a clean inline notice.
    250250   */
    251251  static embedMediaShortcodes(html) {
     
    274274      const artistH0 = this.escape(t.artist || '');
    275275      const creditBits0 = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
    276       // Link-only track (geen audiobestand): geen afspeelknop, wel info + open-in.
     276      // Link-only track (no audio file): no play button, but info + open-in links.
    277277      if (!t.url) {
    278278        const coverH0 = this.escape(t.cover || '');
     
    302302      const artistH = this.escape(t.artist || '');
    303303      const urlH = this.escape(t.url);
    304       // Zichtbare eigenaar/licentie-regel onder de track.
     304      // Visible owner/license line below the track.
    305305      const creditBits = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
    306306      const dataAttr = trackJson
    307307        .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
    308       // id="track-<id>" = anker zodat de mini-speler hierheen kan scrollen.
     308      // id="track-<id>" = anchor so the mini-player can scroll to this element.
    309309      return `<div class="post-audio-track" id="track-${id}" data-pcms-track-id="${id}" data-pcms-track-url="${urlH}" data-pcms-track='${dataAttr}'>
    310310  <button type="button" class="pat-play" aria-label="Play ${titleH}">
     
    337337      // Stable DOM id for this rendering — used as data-pcms-album-id on tracks
    338338      const albumDomId = 'album-' + Math.random().toString(36).slice(2, 10);
    339       // Alleen afspeelbare tracks (met url) in de queue; link-only tracks staan
    340       // wel in de lijst maar niet in de afspeel-JSON.
     339      // Only playable tracks (with url) in the queue; link-only tracks appear
     340      // in the list but not in the playback JSON.
    341341      const albumJson = JSON.stringify(album.tracks.filter((t) => t.url))
    342342        .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
     
    348348        const tTitle = this.escape(t.title || ('Track ' + (i + 1)));
    349349        const tArtist = this.escape(t.artist || '');
    350         // Link-only track: geen afspeelknop, wel nummer + info + open-in.
     350        // Link-only track: no play button, but track number + info + open-in links.
    351351        if (!t.url) {
    352352          return `    <li class="post-audio-track post-audio-track--static"${t.id ? ` id="track-${t.id}"` : ''}>
     
    436436      // Audio-player.js reads data-pcms-album for queue. Same shape as
    437437      // embedAlbumShortcodes — keep both in sync.
    438       // Alleen afspeelbare tracks in de queue; link-only tracks staan wel in de
    439       // lijst maar niet in de afspeel-JSON.
     438      // Only playable tracks in the queue; link-only tracks appear in the list
     439      // but not in the playback JSON.
    440440      const tracksData = pl.tracks.filter(t => t.url).map(t => ({
    441441        id:     t.id,
     
    479479          : `<span class="pat-num">${i + 1}</span>`;
    480480
    481         // Link-only track: geen klikbare afspeel-rij (statische div), wel open-in.
     481        // Link-only track: no clickable play row (static div), but open-in links.
    482482        if (!t.url) {
    483483          return `    <li class="post-album-track-compact post-album-track-compact--static"${t.id ? ` id="track-${t.id}"` : ''}>
     
    594594   * Replace [[link:url]] or [[link:url|Custom Label]] shortcodes with a
    595595   * branded "Open in <Platform>" anchor (no iframe). Opens in new tab.
    596    * Per Robin's v9: "Externe link, klik = open platform (target _blank)".
     596   * Per Robin's v9: "External link, click = open platform (target _blank)".
    597597   */
    598598  static embedExternalLinkShortcodes(html) {
  • src/services/AudioTranscoder.js

    rbb42dfb r834bcc3  
    126126      size: outStat.size,
    127127      mimeType: 'audio/mpeg',
    128       durationSec,   // hele seconden uit ffmpeg's codecData (null als onbekend)
     128      durationSec,   // whole seconds from ffmpeg's codecData (null if unknown)
    129129    };
    130130
     
    138138
    139139/**
    140  * Herschrijf de ID3-tags van een BESTAANDE mp3 zonder her-encoden (`-c copy`).
    141  * Gebruikt bij het bewerken van track-metadata (titel/artiest/album/credit/licentie)
    142  * zodat de eigendomsinfo in het bestand zelf meereist bij een download.
    143  * ffmpeg kan niet in-place editen → schrijf naar tmp en hernoem atomisch terug.
     140 * Rewrite the ID3 tags of an EXISTING mp3 without re-encoding (`-c copy`).
     141 * Used when editing track metadata (title/artist/album/credit/license) so that
     142 * ownership info travels with the file on download.
     143 * ffmpeg cannot edit in-place → write to tmp and atomically rename back.
    144144 */
    145145export async function retagMp3({ filePath, tags = {} }) {
    146146  if (!filePath) throw new Error('retagMp3: filePath required');
    147   await stat(filePath); // throws als 't bestand mist
     147  await stat(filePath); // throws if file is missing
    148148  const dir = path.dirname(filePath);
    149149  const base = path.basename(filePath, path.extname(filePath));
     
    152152    await new Promise((resolve, reject) => {
    153153      const cmd = ffmpeg(filePath)
    154         .audioCodec('copy')        // geen her-encode → snel, geen kwaliteitsverlies
     154        .audioCodec('copy')        // no re-encode → fast, no quality loss
    155155        .format('mp3')
    156156        .outputOptions('-id3v2_version', '3')
     
    167167    });
    168168    const s = await stat(tmpPath);
    169     if (s.size === 0) throw new Error('retag output is leeg');
     169    if (s.size === 0) throw new Error('retag output is empty');
    170170    await rename(tmpPath, filePath);
    171171    return { filePath, size: s.size };
    172172  } catch (err) {
    173     try { await unlink(tmpPath); } catch { /* tmp bestaat mogelijk niet */ }
     173    try { await unlink(tmpPath); } catch { /* tmp may not exist */ }
    174174    throw err;
    175175  }
     
    209209    if (tags.artist)    cmd.outputOptions('-metadata', `artist=${tags.artist}`);
    210210    if (tags.album)     cmd.outputOptions('-metadata', `album=${tags.album}`);
    211     if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`); // ID3 TCOP — eigenaar/credit
    212     if (tags.comment)   cmd.outputOptions('-metadata', `comment=${tags.comment}`);     // ID3 COMM — licentie
     211    if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`); // ID3 TCOP — owner/credit
     212    if (tags.comment)   cmd.outputOptions('-metadata', `comment=${tags.comment}`);     // ID3 COMM — license
    213213
    214214    cmd
    215       // codecData geeft de duur van de INPUT als "HH:MM:SS.xx" — zo bepalen we
    216       // de tracklengte automatisch zonder aparte ffprobe-binary.
     215      // codecData gives the INPUT duration as "HH:MM:SS.xx" — this lets us
     216      // determine the track length automatically without a separate ffprobe binary.
    217217      .on('codecData', (data) => { durationSec = parseHmsToSeconds(data && data.duration); })
    218218      .on('error', (err, stdout, stderr) => {
     
    230230
    231231/**
    232  * Parse een ffmpeg-duurstring "HH:MM:SS.xx" naar hele seconden. Geeft null bij
    233  * "N/A" of een onverwacht formaat.
     232 * Parse an ffmpeg duration string "HH:MM:SS.xx" to whole seconds. Returns null
     233 * for "N/A" or an unexpected format.
    234234 */
    235235function parseHmsToSeconds(hms) {
     
    242242
    243243/**
    244  * Lees de duur (hele seconden) van een audiobestand ZONDER te transcoderen.
    245  * Start een ffmpeg-pass en leest enkel het codecData-event (duur), waarna we het
    246  * proces direct stoppen — snel en zonder aparte ffprobe-binary (ffmpeg-static
    247  * levert alleen ffmpeg). Bedoeld voor het backfill-script.
     244 * Read the duration (whole seconds) of an audio file WITHOUT transcoding.
     245 * Starts an ffmpeg pass and reads only the codecData event (duration), then
     246 * kills the process immediately — fast and without a separate ffprobe binary
     247 * (ffmpeg-static ships only ffmpeg). Intended for the backfill script.
    248248 * @returns {Promise<number|null>}
    249249 */
     
    255255      .on('codecData', (data) => {
    256256        durationSec = parseHmsToSeconds(data && data.duration);
    257         try { cmd.kill('SIGKILL'); } catch { /* al klaar */ }
     257        try { cmd.kill('SIGKILL'); } catch { /* already done */ }
    258258        finish();
    259259      })
  • src/services/CircleFederation.js

    rbb42dfb r834bcc3  
    1 // CircleFederation.js — eigen publicatie-kant van "Cirkels" (v1).
     1// CircleFederation.js — publication side of "Circles" (v1).
    22//
    3 // Publiceert deze instance als een ActivityStreams-actor met een Ed25519-
    4 // sleutel, plus een outbox van publieke posts. De outbox wordt getekend zodat
    5 // consumenten (andere Klonkt-instances) de herkomst kunnen verifiëren.
     3// Publishes this instance as an ActivityStreams actor with an Ed25519 key,
     4// plus an outbox of public posts. The outbox is signed so that consumers
     5// (other Klonkt instances) can verify the origin.
    66//
    7 // v1 = alleen PUBLICEREN + tekenen. Het pullen/verifiëren van remote cirkels
    8 // (CircleService.sync) komt in een volgende stap. Zie docs/cirkels-v1-spec.md.
     7// v1 = PUBLISH + sign only. Pulling/verifying remote circles
     8// (CircleService.sync) comes in a later step. See docs/cirkels-v1-spec.md.
    99//
    10 // (Het idee om je netjes aan de bestaande standaarden te houden fluisterde een
    11 //  zekere Bart ons in. Wie hij is, waar hij vandaan komt — niemand die het zeker
    12 //  weet. Hij verscheen, sprak van ActivityStreams, en was weer weg.)
     10// (The idea of sticking neatly to existing standards was whispered to us by
     11//  a certain Bart. Who he is, where he came from — nobody knows for sure.
     12//  He appeared, spoke of ActivityStreams, and was gone.)
    1313
    1414import crypto from 'crypto';
     
    1616import { getSetting, setSetting } from './SettingsService.js';
    1717
    18 // ── Protocol-versie (federatie) ───────────────────────────────
    19 // KLONKT_PROTO zit IN de ondertekende grondslag (zie signingInput): een instance
    20 // die niet op deze proto draait kan onze getekende outbox NIET verifiëren, en wij
    21 // de hare niet. Bijblijven is dus geen beleefde check die je wegpatcht, maar
    22 // cryptografisch afgedwongen — de enige manier om mee te doen is dezelfde proto
    23 // draaien (= de update). Bump KLONKT_PROTO bij elke release die federatie/security
    24 // raakt, en koppel een securityfix aan elke bump → outdated = buiten + onveilig.
    25 // MIN_PROTO = de laagste proto waarmee we nog federeren.
     18// ── Protocol version (federation) ────────────────────────────
     19// KLONKT_PROTO is embedded IN the signed input (see signingInput): an instance
     20// not running this proto CANNOT verify our signed outbox, and we cannot verify
     21// theirs. Staying current is therefore not a polite check you can patch away,
     22// but cryptographically enforced — the only way to participate is to run the
     23// same proto (= apply the update). Bump KLONKT_PROTO for every release that
     24// touches federation/security, and attach a security fix to each bump →
     25// outdated = excluded + insecure.
     26// MIN_PROTO = the lowest proto we still federate with.
    2627export const KLONKT_PROTO = 2;
    2728export const MIN_PROTO = 2;
     
    3132}
    3233
    33 // ── Sleutelbeheer ─────────────────────────────────────────────
    34 // Per-instance Ed25519-keypair, eenmalig gegenereerd en in app_settings
    35 // bewaard. Privé = PKCS8-PEM (nooit serveren). Publiek = SPKI-DER base64
    36 // (gepubliceerd in de actor; round-trip via createPublicKey).
     34// ── Key management ────────────────────────────────────────────
     35// Per-instance Ed25519 keypair, generated once and stored in app_settings.
     36// Private = PKCS8 PEM (never served). Public = SPKI DER base64
     37// (published in the actor; round-tripped via createPublicKey).
    3738function getKeys() {
    3839  let priv = getSetting('circle_privkey_pem', null);
     
    5253}
    5354
    54 /** Tekent een body-string, gebonden aan de protocol-versie (Ed25519). */
     55/** Signs a body string, bound to the protocol version (Ed25519). */
    5556export function signBody(rawString, proto = KLONKT_PROTO) {
    5657  const key = crypto.createPrivateKey(getKeys().priv);
     
    5859}
    5960
    60 /** Verifieert een body tegen een SPKI-DER-base64 publieke sleutel, voor de gegeven
    61  *  proto. Een mismatch in proto = mismatch in grondslag = ongeldige handtekening. */
     61/** Verifies a body against an SPKI-DER-base64 public key for the given proto.
     62 *  A proto mismatch = a signing-input mismatch = invalid signature. */
    6263export function verifyBody(rawString, sigB64, pubDerB64, proto = KLONKT_PROTO) {
    6364  try {
     
    7374// ── Helpers ───────────────────────────────────────────────────
    7475function primarySite() {
    75   // Solo: de primaire/owner-site (eerst aangemaakt) — zelfde keuze als resolveSite.
     76  // Solo: the primary/owner site (oldest) — same choice as resolveSite.
    7677  return db.prepare('SELECT * FROM sites ORDER BY created_at ASC LIMIT 1').get();
    7778}
     
    8081  return String(s || '')
    8182    .replace(/<[^>]+>/g, ' ')
    82     .replace(/\[\[[^\]]*\]\]/g, ' ')   // [[playlist:..]]/[[track:..]]/[[album:..]]-shortcodes weg
     83    .replace(/\[\[[^\]]*\]\]/g, ' ')   // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes
    8384    .replace(/\s+/g, ' ')
    8485    .trim();
    8586}
    8687
    87 // Tags-kolom (JSON-array of comma-separated) -> nette string-array.
     88// Tags column (JSON array or comma-separated) -> clean string array.
    8889function parseTags(raw) {
    8990  if (!raw) return [];
    9091  if (Array.isArray(raw)) return raw.map((t) => String(t).trim()).filter(Boolean);
    91   try { const j = JSON.parse(raw); if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean); } catch { /* geen JSON */ }
     92  try { const j = JSON.parse(raw); if (Array.isArray(j)) return j.map((t) => String(t).trim()).filter(Boolean); } catch { /* not JSON */ }
    9293  return String(raw).split(',').map((t) => t.trim()).filter(Boolean);
    9394}
     
    103104}
    104105
    105 // allow_circle: een site mag in cirkels van anderen verschijnen. v1 koppelt dit
    106 // aan is_public (aparte expliciete flag volgt in de Beheer-UX-stap).
     106// allow_circle: a site may appear in other instances' circles. v1 ties this
     107// to is_public (a separate explicit flag follows in the admin UX step).
    107108function allowsCircle(site) {
    108109  return !!site && site.is_public !== 0 && site.allow_circle !== 0;
     
    170171        published,
    171172        ...(p.cover_image_url ? { image: { type: 'Image', url: abs(base, p.cover_image_url) } } : {}),
    172         // ActivityStreams: tags als Hashtag-objecten (href naar de bron-tagpagina).
     173        // ActivityStreams: tags as Hashtag objects (href points to the source tag page).
    173174        ...(tags.length ? { tag: tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/^#/, ''), href: `${base}/tag/${encodeURIComponent(t)}` })) } : {}),
    174175      },
  • src/services/CircleService.js

    rbb42dfb r834bcc3  
    1 // CircleService.js — pull-kant van Cirkels (v1).
     1// CircleService.js — pull side of Circles (v1).
    22//
    3 // Haalt per circle_link de remote actor + outbox op, verifieert de Ed25519-
    4 // handtekening, sanitiseert en cachet publieke posts in remote_actors/remote_posts.
    5 // Alleen LEZEN van remote; nooit schrijven. Zie docs/cirkels-v1-spec.md §5b.
     3// Per circle_link: fetches the remote actor + outbox, verifies the Ed25519
     4// signature, sanitizes, and caches public posts in remote_actors/remote_posts.
     5// READ ONLY from remote; never write. See docs/cirkels-v1-spec.md §5b.
    66
    77import db from '../config/database.js';
     
    1616  return String(s || '')
    1717    .replace(/<[^>]+>/g, ' ')
    18     .replace(/\[\[[^\]]*\]\]/g, ' ')   // [[playlist:..]]/[[track:..]]/[[album:..]]-shortcodes weg
     18    .replace(/\[\[[^\]]*\]\]/g, ' ')   // strip [[playlist:..]] / [[track:..]] / [[album:..]] shortcodes
    1919    .replace(/\s+/g, ' ')
    2020    .trim();
     
    3131}
    3232
    33 // AS Hashtag-array -> comma-separated tagnamen (zonder #), gesanitized.
     33// AS Hashtag array -> comma-separated tag names (without #), sanitized.
    3434function extractTags(tag) {
    3535  if (!Array.isArray(tag)) return null;
     
    4141}
    4242
    43 // Bron buiten de cirkel zetten met een leesbare reden (geen stille mislukking).
    44 // Aparte status 'outdated' zodat de Beheer-UI er een nette "update vereist"-
    45 // melding van kan maken i.p.v. een generieke fout.
     43// Mark a source as outside the circle with a readable reason (no silent failure).
     44// Separate 'outdated' status so the admin UI can show a clean "update required"
     45// notice instead of a generic error.
    4646function markOutdated(link, msg) {
    47   // Gecachte posts van deze bron weghalen: we kunnen ze niet meer verifiëren of
    48   // verversen (proto-mismatch), dus ze horen niet meer in de cirkel-feed.
     47  // Remove cached posts from this source: we can no longer verify or refresh
     48  // them (proto mismatch), so they no longer belong in the circle feed.
    4949  if (link.remote_actor_id) {
    5050    try { db.prepare('DELETE FROM remote_posts WHERE actor_id = ?').run(link.remote_actor_id); } catch {}
     
    5555}
    5656
    57 // Robuuste, defensieve fetch: alleen https, timeout, body-cap, redirect-follow.
     57// Robust, defensive fetch: https only, timeout, body cap, redirect follow.
    5858async function fetchText(url) {
    5959  if (!/^https:\/\//i.test(url)) throw new Error('alleen https toegestaan');
     
    6666      headers: {
    6767        Accept: 'application/activity+json, application/json',
    68         // Vertel de publisher onze proto → die kan ons met 426 weren als we te oud zijn.
     68        // Tell the publisher our proto → they can reject us with 426 if we are too old.
    6969        'Klonkt-Proto': String(KLONKT_PROTO),
    7070      },
     
    7272    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    7373    const buf = Buffer.from(await res.arrayBuffer());
    74     if (buf.length > MAX_BODY_BYTES) throw new Error('body te groot');
     74    if (buf.length > MAX_BODY_BYTES) throw new Error('body too large');
    7575    return { text: buf.toString('utf8'), headers: res.headers, finalUrl: res.url };
    7676  } finally {
     
    7979}
    8080
    81 // Lazy prepares — de tabellen bestaan pas ná initializeDatabase(); dit module
    82 // wordt geïmporteerd vóór die call, dus niet op module-niveau prepare'n.
     81// Lazy prepares — tables only exist after initializeDatabase(); this module is
     82// imported before that call, so do not prepare at module level.
    8383let _stmts = null;
    8484function stmts() {
     
    107107  const base = baseOf(link.remote_url);
    108108
    109   // 1. Actor ophalen + valideren
     109  // 1. Fetch + validate actor
    110110  const actorUrl = `${base}/.klonkt/actor.json`;
    111111  const a = await fetchText(actorUrl);
     
    117117  if (originOf(actorId) !== originOf(actorUrl)) throw new Error('actor.id heeft andere origin dan de actor-URL');
    118118
    119   // Protocol-versie-gate. De proto zit óók in de outbox-handtekening-grondslag,
    120   // dus liegen in de (ongetekende) actor helpt niet: bij een echte mismatch faalt
    121   // de verificatie verderop alsnog. Hier vooral voor een DUIDELIJKE melding +
    122   // buitensluiten zonder stille mislukking.
     119  // Protocol version gate. The proto is also embedded in the outbox signing
     120  // input, so lying in the (unsigned) actor does not help: a real mismatch
     121  // will still fail verification later. This check is mainly for a CLEAR
     122  // message + exclusion without silent failure.
    123123  const remoteProto = Number(actor.klonkt && actor.klonkt.proto) || 1;
    124124  if (remoteProto > KLONKT_PROTO) {
     
    131131  }
    132132
    133   // TOFU: een sleutelwissel vereist expliciete herbevestiging (anti-hijack)
     133  // TOFU: a key change requires explicit re-confirmation (anti-hijack)
    134134  const existing = db.prepare('SELECT public_key FROM remote_actors WHERE id = ?').get(actorId);
    135135  if (existing && existing.public_key !== pubKey) {
     
    146146  });
    147147
    148   // 2. Outbox ophalen + handtekening verifiëren
     148  // 2. Fetch outbox + verify signature
    149149  const outboxUrl = actor.outbox || `${base}/.klonkt/outbox.json`;
    150150  const o = await fetchText(outboxUrl);
     
    158158  const items = Array.isArray(outbox.orderedItems) ? outbox.orderedItems.slice(0, MAX_ITEMS) : [];
    159159
    160   // 3. Objecten sanitizen + cachen (same-origin als de actor = anti-impersonatie)
     160  // 3. Sanitize + cache objects (same origin as actor = anti-impersonation)
    161161  const actorOrigin = originOf(actorId);
    162162  const seen = new Set();
     
    186186  }
    187187
    188   // 4. Pruning: posts die niet meer in de outbox staan opruimen
     188  // 4. Pruning: remove posts that are no longer in the outbox
    189189  const known = db.prepare('SELECT id FROM remote_posts WHERE actor_id = ?').all(actorId).map((r) => r.id);
    190190  const stale = known.filter((id) => !seen.has(id));
     
    194194  }
    195195
    196   // Naam automatisch overnemen van de remote actor (geen handmatige invoer nodig).
    197   // COALESCE: heeft de actor geen naam, dan blijft een evt. bestaand label staan.
     196  // Automatically adopt the name from the remote actor (no manual entry needed).
     197  // COALESCE: if the actor has no name, any existing label is preserved.
    198198  db.prepare(
    199199    "UPDATE circle_links SET remote_actor_id=?, label=COALESCE(?, label), last_synced=CURRENT_TIMESTAMP, status='active', last_error=NULL WHERE id=?"
     
    221221
    222222let _timer = null;
    223 /** Periodieke achtergrond-sync (gated op tenancy='circle' binnen sync()). */
     223/** Periodic background sync (gated on tenancy='circle' inside sync()). */
    224224export function startCircleSyncLoop(intervalMs = 15 * 60 * 1000) {
    225225  if (_timer) return;
    226226  const run = () => { sync().catch((e) => console.error('[cirkels] sync-fout:', e.message)); };
    227   setTimeout(run, 30 * 1000); // korte delay na boot
     227  setTimeout(run, 30 * 1000); // short delay after boot
    228228  _timer = setInterval(run, intervalMs);
    229229  if (_timer.unref) _timer.unref();
  • src/services/ImageWebpService.js

    rbb42dfb r834bcc3  
    11/**
    2  * Zet een zojuist-geüploade afbeelding om naar WebP (kleiner, modern).
     2 * Convert a freshly uploaded image to WebP (smaller, modern format).
    33 *
    4  * Gebruikt het systeem-`cwebp` (libwebp). Aanwezig → converteer + verwijder het
    5  * origineel, geef de nieuwe .webp-bestandsnaam terug. Niet aanwezig of fout →
    6  * geef de originele bestandsnaam terug (graceful fallback, niks breekt).
     4 * Uses the system `cwebp` (libwebp). Present → convert + delete the original,
     5 * return the new .webp filename. Not present or error → return the original
     6 * filename (graceful fallback, nothing breaks).
    77 *
    8  * GIF blijft GIF (cwebp maakt geen geanimeerde webp van een gif); reeds-webp
    9  * wordt overgeslagen.
     8 * GIF stays GIF (cwebp cannot produce animated WebP from a GIF); already-WebP
     9 * files are skipped.
    1010 */
    1111import { execFileSync } from 'child_process';
     
    1717/**
    1818 * @param {{path:string, filename:string, destination?:string}} file  multer file
    19  * @returns {string} de definitieve bestandsnaam (basename) — .webp of het origineel
     19 * @returns {string} the final filename (basename) — .webp or the original
    2020 */
    2121export function toWebp(file) {
     
    2828  try {
    2929    execFileSync('cwebp', ['-quiet', '-q', QUALITY, file.path, '-o', outPath], { stdio: 'ignore' });
    30     if (!fs.existsSync(outPath) || fs.statSync(outPath).size === 0) throw new Error('lege output');
    31     try { fs.unlinkSync(file.path); } catch { /* origineel weg, niet kritisch */ }
     30    if (!fs.existsSync(outPath) || fs.statSync(outPath).size === 0) throw new Error('empty output');
     31    try { fs.unlinkSync(file.path); } catch { /* original gone, not critical */ }
    3232    return outName;
    3333  } catch (e) {
    34     console.warn('[webp] conversie overgeslagen (cwebp niet beschikbaar/fout):', e.message);
    35     try { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); } catch {} // ruim halve output op
    36     return file.filename; // behoud origineel
     34    console.warn('[webp] conversion skipped (cwebp not available/error):', e.message);
     35    try { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); } catch {} // clean up partial output
     36    return file.filename; // keep original
    3737  }
    3838}
  • src/services/NotificationService.js

    rbb42dfb r834bcc3  
    11/**
    2  * Meldingen — antwoord op je reactie, reactie op je post, like op je post.
    3  * Voor élke ingelogde gebruiker (Google-bezoekers/fans én admin). Snapshots van
    4  * actor-naam + post-titel zodat de lijst zonder joins te tonen is.
     2 * Notifications — reply to your comment, comment on your post, like on your post.
     3 * For every logged-in user (Google visitors/fans and admins). Snapshots of
     4 * actor name + post title so the list can be rendered without joins.
    55 */
    66import { randomUUID } from 'crypto';
    77import db from '../config/database.js';
    88
    9 // Maakt een melding aan. Doet niets als er geen ontvanger is of als je jezelf
    10 // zou notificeren (eigen reactie/like op eigen post/reactie).
     9// Creates a notification. Does nothing if there is no recipient or if you
     10// would notify yourself (your own comment/like on your own post/comment).
    1111export function notify({ userId, actorId, actorName, type, postSlug, postTitle, url }) {
    1212  if (!userId || userId === actorId) return;
     
    1616      VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
    1717    `).run(randomUUID(), userId, type, actorId || null, actorName || null, postSlug || null, postTitle || null, url || null);
    18   } catch { /* meldingen zijn niet-fataal */ }
     18  } catch { /* notifications are non-fatal */ }
    1919}
    2020
     
    3333export function markAllRead(userId) {
    3434  if (!userId) return;
    35   try { db.prepare('UPDATE user_notifications SET read = 1 WHERE user_id = ? AND read = 0').run(userId); } catch { /* noop */ }
     35  try { db.prepare('UPDATE user_notifications SET read = 1 WHERE user_id = ? AND read = 0').run(userId); } catch { /* no-op */ }
    3636}
    3737
  • src/services/PatreonService.js

    rbb42dfb r834bcc3  
    1 // Patreon-entitlement (premium-laag).
     1// Patreon entitlement (premium layer).
    22//
    3 // Model (Klonkt, 2026-06): de app + alle updates zijn gratis. Een paar premium-
    4 // modules (Hub-modus, Statistieken, Fan-login) zitten achter een $10-lifetime
    5 // Patreon-supporter-status. De centrale license-server (license.klonkt.com)
    6 // checkt Patreon en tekent een Ed25519-JWT "entitlement-token". DEZE instance
    7 // verifieert dat token OFFLINE met de publieke sleutel van de server — een
    8 // gekraakte/geforkte self-host kan dus geen geldig token verzinnen (alleen de
    9 // license-server kan tekenen). Dat is het echte slot; de feature-flags zelf zijn
    10 // op self-host wel te patchen (bewust geaccepteerd: $10 < moeite om te kraken).
     3// Model (Klonkt, 2026-06): the app + all updates are free. A few premium
     4// modules (Hub mode, Statistics, Fan login) are gated behind a $10 lifetime
     5// Patreon supporter status. The central license server (license.klonkt.com)
     6// checks Patreon and signs an Ed25519 JWT "entitlement token". THIS instance
     7// verifies that token OFFLINE using the server's public key — a cracked/forked
     8// self-host cannot forge a valid token (only the license server can sign).
     9// That is the real lock; feature flags themselves can be patched on self-host
     10// (deliberately accepted: $10 < effort to crack).
    1111//
    12 // Premium staat STANDAARD UIT (KLONKT_PREMIUM_ENABLED != 'on'): dan is er geen
    13 // premium-UI en wordt er niets gegate. De self-hoster zet 'm aan zodra Patreon
    14 // geregeld is.
     12// Premium is OFF by default (KLONKT_PREMIUM_ENABLED != 'on'): no premium UI
     13// is shown and nothing is gated. Self-hosters enable it once Patreon is set up.
    1514
    1615import crypto from 'node:crypto';
     
    2524export function licenseBase() { return LICENSE_URL; }
    2625
    27 // --- Publieke sleutel van de license-server cachen (voor offline verificatie) ---
     26// --- Cache the license-server public key (for offline verification) ---
    2827let _pubKey = null;
    2928async function licensePublicKey() {
    3029  if (_pubKey) return _pubKey;
    3130  const res = await fetch(`${LICENSE_URL}/pubkey`);
    32   if (!res.ok) throw new Error('pubkey fetch faalde: ' + res.status);
     31  if (!res.ok) throw new Error('pubkey fetch failed: ' + res.status);
    3332  const pem = await res.text();
    3433  _pubKey = crypto.createPublicKey(pem); // SPKI-PEM -> Ed25519 public key
     
    4039}
    4140
    42 // Verifieer een entitlement-token (EdDSA-JWT van de license-server). Gooit bij
    43 // ongeldige handtekening/issuer/verlooptijd. Geeft de claims terug.
     41// Verify an entitlement token (EdDSA JWT from the license server). Throws on
     42// invalid signature, issuer, or expiry. Returns the claims on success.
    4443export async function verifyEntitlementToken(token) {
    4544  const parts = String(token || '').split('.');
     
    4746  const [h, p, s] = parts;
    4847  const header = JSON.parse(b64urlToBuf(h).toString('utf8'));
    49   if (header.alg !== 'EdDSA') throw new Error('onverwacht alg');
     48  if (header.alg !== 'EdDSA') throw new Error('unexpected alg');
    5049  const key = await licensePublicKey();
    5150  const ok = crypto.verify(null, Buffer.from(`${h}.${p}`), key, b64urlToBuf(s));
    52   if (!ok) throw new Error('ongeldige handtekening');
     51  if (!ok) throw new Error('invalid signature');
    5352  const payload = JSON.parse(b64urlToBuf(p).toString('utf8'));
    54   if (payload.iss !== ISSUER) throw new Error('onverwachte issuer');
    55   if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('verlopen token');
     53  if (payload.iss !== ISSUER) throw new Error('unexpected issuer');
     54  if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('expired token');
    5655  return payload; // { sub, entitled, plan, lifetime_support_cents, exp, ... }
    5756}
     
    7170}
    7271
    73 // Is deze instance premium? Premium-laag aan + een geldig, niet-verlopen,
    74 // entitled opgeslagen token. Patreon-lifetime daalt nooit, dus opnieuw koppelen
    75 // na verloop slaagt altijd.
     72// Is this instance premium? Premium layer enabled + a valid, non-expired,
     73// entitled stored token. Patreon lifetime never decreases, so re-linking
     74// after expiry always succeeds.
    7675export function isPremium() {
    7776  if (!premiumEnabled()) return false;
     
    8281}
    8382
    84 // Is een premium-feature beschikbaar? True als de premium-laag UIT staat (dan is
    85 // niets gegate — huidige gedrag), of AAN én deze instance is entitled. False alleen
    86 // als premium aan staat maar er geen geldige Patreon-koppeling is (= betaalmuur).
     83// Is a premium feature available? True if the premium layer is OFF (nothing is
     84// gated — current behavior), or ON and this instance is entitled. False only
     85// if premium is on but there is no valid Patreon connection (= paywall).
    8786export function premiumUnlocked() {
    8887  return !premiumEnabled() || isPremium();
  • src/services/PermissionsService.js

    rbb42dfb r834bcc3  
    4646    if (!user) return false;
    4747    if (user.role === 'god') return true;
    48     if (!site) return false; // geen site-context (bv. hub-landing) -> niets te posten
     48    if (!site) return false; // no site context (e.g. hub landing) -> nothing to post to
    4949    if (user.id === site.owner_id) return true; // Site owner
    5050    if (this.canAdminSite(user, site)) return true;
     
    5959    if (user.role === 'god') return true;
    6060    if (user.id === site.owner_id) return true;
    61     // Toegewezen mede-beheerder (collaborator) via site_members. Dit werd
    62     // voorheen via een nooit-gevulde user.siteRoles gelezen → dode code; nu
    63     // direct op de tabel (paar checks per pagina, indexed = goedkoop).
     61    // Assigned co-admin (collaborator) via site_members. Previously read from
     62    // a never-populated user.siteRoles → dead code; now queried directly on
     63    // the table (a few checks per page, indexed = cheap).
    6464    return !!db.prepare(
    6565      "SELECT 1 FROM site_members WHERE site_id = ? AND user_id = ? AND role = 'admin' LIMIT 1"
  • src/services/PlaylistService.js

    rbb42dfb r834bcc3  
    104104
    105105    const mappedTracks = tracks
    106       // Link-only tracks (geen media-bestand) blijven in de lijst staan met url ''.
     106      // Link-only tracks (no media file) remain in the list with url ''.
    107107      .map(t => ({
    108108        id: t.id,
     
    116116        url: (t.filename && urlFor) ? urlFor(t.filename) : '',
    117117      }));
    118     // Geen eigen cover? Val terug op de eerste track-cover, zodat de kaart niet leeg is.
     118    // No playlist cover? Fall back to the first track cover so the card isn't empty.
    119119    const fallbackCover = (mappedTracks.find(t => t.cover) || {}).cover || '';
    120120    return {
     
    220220   * Delete a playlist. Track references in playlist_tracks are removed
    221221   * automatically via ON DELETE CASCADE. Posts that embed this playlist
    222    * will render a "playlist niet gevonden" placeholder.
     222   * will render a "playlist not found" placeholder.
    223223   */
    224224  static delete(siteId, id) {
  • src/services/Scheduler.js

    rbb42dfb r834bcc3  
    11/**
    2  * Scheduler — release-planning (premium #3).
     2 * Scheduler — release planning (premium #3).
    33 *
    4  * Geplande posts hebben status 'scheduled' + publish_at (toekomst). Een lichte
    5  * timer zet ze op 'published' zodra publish_at bereikt is. Zo hoeven de publieke
    6  * queries (status='published') NIET aangepast te worden — een geplande post is
    7  * gewoon nog niet 'published' en dus nergens publiek zichtbaar tot het moment.
     4 * Scheduled posts have status 'scheduled' + publish_at (future). A lightweight
     5 * timer flips them to 'published' once publish_at is reached. This means public
     6 * queries (status='published') need NO changes — a scheduled post simply isn't
     7 * 'published' yet and therefore invisible until that moment.
    88 */
    99
     
    2525    for (const p of due) {
    2626      upd.run(p.id);
    27       try { fts.run(HtmlSanitizerService.toPlainText(p.content || ''), p.title || '', p.username || '', p.id); } catch { /* FTS niet-fataal */ }
     27      try { fts.run(HtmlSanitizerService.toPlainText(p.content || ''), p.title || '', p.username || '', p.id); } catch { /* FTS failure is non-fatal */ }
    2828    }
    2929    return due.length;
     
    3333let _timer = null;
    3434export function startScheduler() {
    35   flipScheduledPosts();                 // direct bij boot
     35  flipScheduledPosts();                 // run immediately on boot
    3636  if (_timer) return;
    37   _timer = setInterval(flipScheduledPosts, 60 * 1000); // elke minuut
     37  _timer = setInterval(flipScheduledPosts, 60 * 1000); // every minute
    3838  if (_timer.unref) _timer.unref();
    3939}
  • src/services/SettingsService.js

    rbb42dfb r834bcc3  
    1 // Globale app-instellingen (key/value, gecached). Nu vooral de tenancy-modus.
     1// Global app settings (key/value, cached). Primarily used for the tenancy mode.
    22//
    3 //   tenancy = 'solo'  -> precies één site (de primaire/owner-site)
    4 //   tenancy = 'hub'   -> hoofdsite (bedrijf) + /user/, admin wijst Klonkt-site's toe
     3//   tenancy = 'solo'  -> exactly one site (the primary/owner site)
     4//   tenancy = 'hub'   -> main site (company) + /user/, admin assigns Klonkt sites
    55//
    6 // De cache wordt bij setSetting meteen ververst, dus een toggle in Beheer werkt
    7 // live zonder herstart.
     6// The cache is updated immediately on setSetting, so a toggle in admin
     7// takes effect live without a restart.
    88
    99import db from '../config/database.js';
  • src/services/StatsService.js

    rbb42dfb r834bcc3  
    1 // StatsService — cookievrije statistieken (premium-module).
     1// StatsService — cookie-free statistics (premium module).
    22//
    3 // Tellers: posts.view_count, audio_tracks.play_count, en per dag/site het aantal
    4 // pageviews (stat_daily) + unieke bezoekers (stat_visitor_day).
     3// Counters: posts.view_count, audio_tracks.play_count, and per day/site the number
     4// of pageviews (stat_daily) + unique visitors (stat_visitor_day).
    55//
    6 // Unieke bezoekers ZONDER cookie: een sha256 van IP+UA+dag-salt. De salt roteert
    7 // elke dag en wordt nooit langer bewaard → je kunt iemand niet over dagen heen
    8 // volgen, het ruwe IP wordt niet opgeslagen. Geen persistente identifier, geen
    9 // toestemmingsbanner nodig (Plausible/Fathom-aanpak).
     6// Unique visitors WITHOUT cookies: a sha256 of IP+UA+daily-salt. The salt rotates
     7// every day and is never stored longer → you cannot track someone across days,
     8// and the raw IP is never persisted. No persistent identifier, no consent
     9// banner required (Plausible/Fathom approach).
    1010
    1111import crypto from 'node:crypto';
     
    1717}
    1818
    19 // Dagelijks roterende salt (gecachet in proces, persistent in app_settings zodat
    20 // een herstart binnen dezelfde dag dezelfde salt houdt).
     19// Daily rotating salt (cached in process, persisted in app_settings so that
     20// a restart within the same day reuses the same salt).
    2121let _salt = null, _saltDay = null;
    2222function dailySalt() {
     
    3939}
    4040
    41 // De eigenaar/beheerder niet meetellen — anders inflate je je eigen cijfers.
     41// Don't count the owner/admin — otherwise you inflate your own numbers.
    4242function isOperator(req) {
    4343  const u = req && req.session && req.session.user;
     
    4545}
    4646
    47 // Bekende bots/crawlers + link-preview-fetchers + scripts overslaan, zodat ze de
    48 // weergaven/bezoeker-dagen niet opblazen. Geen UA = vrijwel altijd geautomatiseerd.
     47// Skip known bots/crawlers + link-preview fetchers + scripts so they don't inflate
     48// view/visitor-day counts. Empty UA = almost always automated.
    4949const BOT_RE = /bot|crawl|spider|slurp|mediapartners|bingpreview|facebookexternalhit|whatsapp|telegram|discord|twitter|linkedin|embedly|pinterest|redditbot|applebot|petalbot|yandex|baidu|duckduckbot|semrush|ahrefs|mj12|dotbot|uptimerobot|pingdom|statuscake|headless|lighthouse|gptbot|claude|ccbot|perplexity|bytespider|amazonbot|googleother|google-read-aloud|python-requests|scrapy|curl|wget|axios|node-fetch|go-http|java\/|okhttp|libwww|httpclient/i;
    5050function isBot(req) {
    5151  const ua = (req && req.headers && req.headers['user-agent']) || '';
    52   if (!ua) return true;          // lege UA = script/bot
     52  if (!ua) return true;          // empty UA = script/bot
    5353  return BOT_RE.test(ua);
    5454}
    5555
    56 // Lazy prepares — tabellen bestaan pas ná initializeDatabase(); dit module wordt
    57 // geïmporteerd vóór die call.
     56// Lazy prepares — tables only exist after initializeDatabase(); this module is
     57// imported before that call.
    5858let _s = null;
    5959function stmts() {
     
    7575}
    7676
    77 // Externe referrer-host uit de Referer-header (pro-stats #5). Lege/eigen-site/
    78 // ongeldige referrers worden overgeslagen → alleen echte externe bronnen tellen.
     77// External referrer host from the Referer header (pro stats #5). Empty/own-site/
     78// invalid referrers are skipped → only genuine external sources are counted.
    7979function recordReferrer(siteId, req) {
    8080  try {
     
    8484    if (!host) return;
    8585    const own = ((req.headers && req.headers.host) || '').replace(/^www\./, '').toLowerCase();
    86     if (host === own) return; // interne navigatie telt niet als bron
     86    if (host === own) return; // internal navigation does not count as a source
    8787    stmts().bumpReferrer.run(siteId, host.slice(0, 120));
    88   } catch { /* geen geldige referrer-URL → overslaan */ }
     88  } catch { /* not a valid referrer URL → skip */ }
    8989}
    9090
     
    9696    stmts().addVisitor.run(siteId, d, visitorHash(req));
    9797    recordReferrer(siteId, req);
    98   } catch { /* statistieken mogen nooit een request breken */ }
     98  } catch { /* stats must never break a request */ }
    9999}
    100100
     
    112112}
    113113
    114 // Instance-brede statistieken (solo = de site, hub = alle sites samen).
     114// Instance-wide statistics (solo = the site, hub = all sites combined).
    115115export function getStats(days = 14) {
    116116  days = [7, 14, 30, 90].includes(Number(days)) ? Number(days) : 14;
     
    129129  }
    130130  const totals = {
    131     pageviews: series.reduce((s, r) => s + r.pageviews, 0), // laatste N dagen
    132     visitors: series.reduce((s, r) => s + r.visitors, 0),   // som van dag-uniques (cookieless kan niet anders)
     131    pageviews: series.reduce((s, r) => s + r.pageviews, 0), // last N days
     132    visitors: series.reduce((s, r) => s + r.visitors, 0),   // sum of daily uniques (cookieless has no alternative)
    133133    plays: db.prepare('SELECT COALESCE(SUM(play_count), 0) AS n FROM audio_tracks').get().n,
    134134    postViews: db.prepare('SELECT COALESCE(SUM(view_count), 0) AS n FROM posts').get().n,
     
    142142    ORDER BY play_count DESC LIMIT 5
    143143  `).all();
    144   // Top externe bronnen (pro #5) — instance-breed geaggregeerd per host.
     144  // Top external sources (pro #5) — aggregated instance-wide per host.
    145145  let referrers = [];
    146146  try {
     
    149149    ).all();
    150150  } catch { referrers = []; }
    151   // All-time totalen (cookieloze unieke bezoekers = som van dag-uniques).
     151  // All-time totals (cookieless unique visitors = sum of daily uniques).
    152152  const allTime = {
    153153    pageviews: db.prepare('SELECT COALESCE(SUM(pageviews),0) AS n FROM stat_daily').get().n,
  • src/services/SubscriberService.js

    rbb42dfb r834bcc3  
    11/**
    2  * SubscriberService — nieuwsbrief-abonnees per site (premium feature #1).
     2 * SubscriberService — newsletter subscribers per site (premium feature #1).
    33 *
    4  * Double opt-in als SMTP er is (status 'pending' → 'confirmed' via confirm-link),
    5  * anders single opt-in ('confirmed' meteen). Elke abonnee heeft een token dat zowel
    6  * de confirm- als de unsubscribe-link draagt. Hergebruikt door #2 (download-voor-
    7  * email) en #8 (notify-me) als gedeelde abonnee-opslag.
     4 * Double opt-in when SMTP is configured (status 'pending' → 'confirmed' via
     5 * confirm link), otherwise single opt-in ('confirmed' immediately). Each
     6 * subscriber has a token used for both the confirm and unsubscribe links.
     7 * Reused by #2 (download-for-email) and #8 (notify-me) as shared subscriber storage.
    88 */
    99
     
    2323
    2424/**
    25  * Voeg een abonnee toe (of heractiveer een uitgeschreven/bestaande).
     25 * Add a subscriber (or reactivate an unsubscribed/existing one).
    2626 * @returns {{ok:boolean, status?:string, token?:string, created?:boolean, error?:string}}
    27  *   status 'pending'  → er moet nog bevestigd worden (stuur confirm-mail)
    28  *   status 'confirmed'→ direct actief (single opt-in)
     27 *   status 'pending'  → confirmation still required (send confirm email)
     28 *   status 'confirmed'→ immediately active (single opt-in)
    2929 */
    3030export function addSubscriber(siteId, email, source = 'widget', { doubleOptin = false } = {}) {
     
    3737
    3838  if (existing) {
    39     // Al actief → niets te doen (idempotent, geen dubbele mail).
     39    // Already confirmed → nothing to do (idempotent, no duplicate email).
    4040    if (existing.status === 'confirmed') return { ok: true, status: 'confirmed', token: existing.token, created: false };
    41     // Pending of uitgeschreven → opnieuw uitnodigen/activeren met een verse token.
     41    // Pending or unsubscribed → re-invite/reactivate with a fresh token.
    4242    const token = newToken();
    4343    db.prepare("UPDATE subscribers SET status = ?, token = ?, source = ?, confirmed_at = CASE WHEN ? = 'confirmed' THEN CURRENT_TIMESTAMP ELSE NULL END WHERE id = ?")
     
    6969}
    7070
    71 /** Bevestigde abonnees (email + token) voor een site — voor het versturen.
    72  * Optioneel filteren op bron (bv. 'notify' voor show-aankondigingen). */
     71/** Confirmed subscribers (email + token) for a site — for sending newsletters.
     72 * Optionally filter by source (e.g. 'notify' for show announcements). */
    7373export function confirmedFor(siteId, source) {
    7474  if (source) {
  • src/services/ThemeService.js

    rbb42dfb r834bcc3  
    1616
    1717class ThemeService {
    18   // Paper/ink komen 1-op-1 uit de [data-palette]-CSS in style.css (= wat ECHT
    19   // wordt toegepast); de accent-stip is een representatieve kleur per palet.
     18  // Paper/ink values map 1-to-1 from the [data-palette] CSS in style.css (= what
     19  // is ACTUALLY applied); the accent dot is a representative color per palette.
    2020  static PALETTES = {
    21     // Merk-standaard — komt overeen met klonkt.com (donkerblauw + geel).
     21    // Brand default — matches klonkt.com (dark blue + gold).
    2222    klonkt: {
    2323      name: 'Klonkt',
     
    6565      dark: { paper: '#1f0a0f', ink: '#fce4ea', accent: '#f06b9a' }
    6666    },
    67     // key blijft 'mint' (DB-veilig), maar omgekleurd naar warm Terracotta — minder groen.
     67    // key stays 'mint' (DB-safe), but recolored to warm Terracotta — less green.
    6868    mint: {
    6969      name: 'Terracotta',
     
    8383   * Each color works against both light and dark themes.
    8484   */
    85   // Evenwichtig over het kleurenwiel — minder groen/blauw (4 van de 12),
    86   // meer warme + paars/roze variatie. Allemaal leesbaar op licht én donker.
     85  // Balanced across the color wheel — fewer greens/blues (4 of 12),
     86  // more warm + purple/pink variation. All readable on both light and dark.
    8787  static ACCENTS = [
    8888    { key: 'klonkt',  name: 'Klonkt-geel', color: '#e8b04b' },
  • src/services/ensurePrimarySite.js

    rbb42dfb r834bcc3  
    22import db from '../config/database.js';
    33
    4 // Een Klonkt-instance hoort ALTIJD een primaire site te hebben — die draagt de
    5 // identiteit (titel, thema, profiel) en is het ankerpunt in solo/hub/circle.
    6 // De register-flow maakt er al één aan, maar een via een script aangemaakte
    7 // beheerder (of een om wat voor reden dan ook lege sites-tabel) liet de
    8 // instance zonder site achter: geen instellingen, dashboard liep dood.
     4// A Klonkt instance should ALWAYS have a primary site — it carries the identity
     5// (title, theme, profile) and is the anchor point in solo/hub/circle mode.
     6// The register flow already creates one, but an admin created via a script
     7// (or an empty sites table for any reason) left the instance without a site:
     8// no settings, dashboard would crash.
    99//
    10 // Deze helper draait bij boot (en is idempotent): zodra er een beheerder is
    11 // maar nog geen enkele site, maakt 'ie een standaard-site aan, eigendom van de
    12 // eerste god/admin. Tenancy-onafhankelijk — geldt voor solo, hub én circle.
     10// This helper runs at boot (and is idempotent): as soon as there is an admin
     11// but no site yet, it creates a default site owned by the first god/admin.
     12// Tenancy-agnostic — applies to solo, hub, and circle.
    1313
    1414function defaultTitle() {
     
    2020      if (label) return label.charAt(0).toUpperCase() + label.slice(1);
    2121    }
    22   } catch { /* val terug op generiek */ }
     22  } catch { /* fall back to generic */ }
    2323  return 'Mijn site';
    2424}
     
    2626export function ensurePrimarySite() {
    2727  const count = db.prepare('SELECT COUNT(*) AS c FROM sites').get().c;
    28   if (count > 0) return null; // er is al een site — niets te doen
     28  if (count > 0) return null; // a site already exists — nothing to do
    2929
    3030  const owner = db.prepare(
    3131    "SELECT id FROM users WHERE role IN ('god','admin') ORDER BY created_at LIMIT 1"
    3232  ).get();
    33   if (!owner) return null; // nog geen beheerder -> geen eigenaar, niets aanmaken
     33  if (!owner) return null; // no admin yet -> no owner, nothing to create
    3434
    3535  const siteId = uuid();
    36   const slug = 'main'; // niet gereserveerd; in solo wordt de primaire site sowieso gepind
     36  const slug = 'main'; // not reserved; in solo mode the primary site is always pinned anyway
    3737  db.prepare(`
    3838    INSERT INTO sites (
  • src/views/pages/account.ejs

    rbb42dfb r834bcc3  
    9090  </section>
    9191
    92   <%# ── PERSOONLIJKE TAAL ───────────────────────────────────── %>
     92  <%# ── PERSONAL LANGUAGE ──────────────────────────────────── %>
    9393  <% if (canMutate) { %>
    9494  <section class="ax-card">
     
    110110  <% } %>
    111111
    112   <%# ── SITE (eigenaar mag de naam van z'n site aanpassen) ── %>
     112  <%# ── SITE (owner may update their site name) ─────────── %>
    113113  <% if (typeof editableSite !== 'undefined' && editableSite && canMutate) { %>
    114114  <section class="ax-card">
     
    130130  <% } %>
    131131
    132   <%# ── WACHTWOORD ────────────────────────────────────────── %>
    133   <%# In kijker-modus geen wachtwoord-sectie (niets te wijzigen). %>
     132  <%# ── PASSWORD ──────────────────────────────────────────── %>
     133  <%# In viewer mode there is no password section (nothing to change). %>
    134134  <% if (canMutate) { %>
    135135  <% if (hasPassword) { %>
  • src/views/pages/admin-circle.ejs

    rbb42dfb r834bcc3  
    7979                  <span class="circ-badge">&#9208; <%= _cPaused %></span>
    8080                <% } else { %>
    81                   <%# Onbekende status: toon 'm rauw + eventuele reden i.p.v. stil "gepauzeerd". %>
     81                  <%# Unknown status: show it raw + any reason instead of silently showing "paused". %>
    8282                  <span class="circ-badge"><%= l.status %></span>
    8383                  <% if (l.last_error) { %><div class="circ-reason"><%= l.last_error %></div><% } %>
     
    123123.circ-status { display: flex; flex-wrap: wrap; align-items: center; gap: .35rem .5rem; margin-top: .25rem; font-size: .78rem; color: var(--ink-muted); }
    124124.circ-meta { color: var(--ink-muted); }
    125 /* Reden op een eigen regel onder de badge; normale woordafbreking (geen break-all
    126    die hele zinnen midden in woorden knipt — dat zit op de URL-small, niet hier). */
     125/* Reason on its own line below the badge; normal word breaking (no break-all
     126   that cuts whole sentences mid-word — that is applied to the URL small, not here). */
    127127.circ-reason { flex-basis: 100%; line-height: 1.4; color: var(--ink-muted); word-break: normal; overflow-wrap: anywhere; }
    128128.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
  • src/views/pages/admin-google.ejs

    rbb42dfb r834bcc3  
    103103.set-redirect-warn { margin: 0.85rem 0 0; font-size: 0.85rem; color: var(--ink-muted); }
    104104.set-redirect-warn code { background: var(--paper-2); padding: 0.05rem 0.35rem; border-radius: 4px; }
    105 /* Genummerde, altijd-zichtbare stap-voor-stap-uitleg (beginnervriendelijk) */
     105/* Numbered, always-visible step-by-step instructions (beginner-friendly) */
    106106.gg-steps { list-style: none; margin: 0 0 1rem; padding: 0; display: flex; flex-direction: column; gap: 0.85rem; }
    107107.gg-steps > li { display: flex; gap: 0.7rem; align-items: flex-start; }
  • src/views/pages/admin-seo.ejs

    rbb42dfb r834bcc3  
    110110
    111111.site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
    112 /* <fieldset> krimpt door de UA min-inline-size:min-content niet mee → mobiel-overflow.
    113    min-width:0 dwingt meekrimpen. */
     112/* <fieldset> does not shrink because the UA sets min-inline-size:min-content → mobile overflow.
     113   min-width:0 forces it to shrink. */
    114114.site-form fieldset { min-width: 0; }
    115115.site-form input, .site-form textarea, .site-form select { min-width: 0; }
  • src/views/pages/admin-settings.ejs

    rbb42dfb r834bcc3  
    4040  </section>
    4141
    42   <%# ── Standaardtaal voor bezoekers ── %>
     42  <%# ── Default language for visitors ── %>
    4343  <section class="set-card" style="margin-top:1rem">
    4444    <h2><%= t('aset.default_lang') %></h2>
     
    5858  </section>
    5959
    60   <%# ── Tijdzone ── %>
     60  <%# ── Timezone ── %>
    6161  <section class="set-card" style="margin-top:1rem">
    6262    <h2><%= t('aset.timezone') %></h2>
     
    7878  </section>
    7979
    80   <%# Modus-specifieke config — staat direct onder de Modus-kaart zodat 'ie bij
    81       de gekozen modus hoort (Hub bij hub, Cirkel bij circle). %>
     80  <%# Mode-specific config — placed directly below the Mode card so it visually
     81      belongs to the selected mode (Hub under hub, Circle under circle). %>
    8282  <% if (tenancy === 'hub') { %>
    8383  <section class="set-card set-card--mode" style="margin-top:1rem">
     
    141141  <% } %>
    142142
    143   <%# Premium (Patreon) — alleen zichtbaar als de self-hoster de premium-laag aanzet
    144       (env KLONKT_PREMIUM_ENABLED=on). Standaard onzichtbaar; niets gegate. %>
     143  <%# Premium (Patreon) — only visible when the self-hoster enables the premium layer
     144      (env KLONKT_PREMIUM_ENABLED=on). Hidden by default; nothing gated. %>
    145145  <% if (premium && premium.enabled) { %>
    146146  <section class="set-card" style="margin-top:1rem">
     
    162162  <% } %>
    163163
    164   <%# Google-login staat op een eigen Beheer-pagina (compacte link hier). %>
     164  <%# Google login has its own admin page (compact link shown here). %>
    165165  <section class="set-card" style="margin-top:1rem">
    166166    <h2><%= t('aset.google_login') %></h2>
     
    170170  </section>
    171171
    172   <%# Nieuwsbrief-aanmelding in de footer (premium). %>
     172  <%# Newsletter signup in the footer (premium). %>
    173173  <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
    174174  <section class="set-card" style="margin-top:1rem">
     
    185185  <% } %>
    186186
    187   <%# E-mail / SMTP — nodig voor nieuwsbrief versturen, notify-mails en wachtwoord-reset. %>
     187  <%# E-mail / SMTP — required for sending newsletters, notify-mails and password resets. %>
    188188  <section class="set-card" style="margin-top:1rem">
    189189    <h2><%= t('aset.email_smtp') %></h2>
     
    233233.admin-tagline a { color: var(--accent); }
    234234.set-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem; }
    235 /* Modus-specifieke kaart (Hub/Cirkel): accent-rand links zodat 'ie zichtbaar bij
    236    de gekozen modus hoort, direct onder de Modus-kaart. */
     235/* Mode-specific card (Hub/Circle): accent border on the left so it visually
     236   belongs to the selected mode, directly below the Mode card. */
    237237.set-card--mode { border-left: 3px solid var(--accent); }
    238238.set-card h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 0 0 0.5rem; }
    239239.set-help { color: var(--ink-muted); font-size: 0.9rem; margin: 0 0 1.25rem; }
    240 /* Google-setup-gids (uitklapbaar) */
     240/* Google setup guide (collapsible) */
    241241.set-google-guide { margin: 0 0 1rem; border: 1px solid var(--rule); border-radius: 8px; background: var(--paper); }
    242242.set-google-guide > summary { cursor: pointer; padding: 0.6rem 0.85rem; font-size: 0.9rem; font-weight: 600; color: var(--accent); list-style: none; }
     
    249249.set-google-guide .set-help { padding: 0 0.85rem; }
    250250.set-form { display: flex; flex-direction: column; gap: 1rem; max-width: 460px; }
    251 /* Kale <label>'s in een set-form krijgen dezelfde nette stacked-stijl als .set-field:
    252    label-tekst boven, input full-width. (.set-toggle = checkbox-rij, uitgezonderd.) */
     251/* Bare <label>s inside a set-form inherit the same neat stacked style as .set-field:
     252   label text on top, input full-width. (.set-toggle = checkbox row, excluded.) */
    253253.set-form > label:not(.set-toggle):not(.set-opt) { display: flex; flex-direction: column; gap: 0.3rem; }
    254254.set-form > label:not(.set-toggle):not(.set-opt) > span { font-size: 0.8rem; font-weight: 600; color: var(--ink-soft, var(--ink-muted)); }
     
    277277.set-field textarea { resize: vertical; }
    278278.set-field input:focus, .set-field textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
    279 /* Selects dezelfde nette stijl als de inputs i.p.v. de kale native dropdown:
    280    eigen chevron, accent-focus, volle breedte. */
     279/* Selects styled the same as inputs instead of the bare native dropdown:
     280   custom chevron, accent focus ring, full width. */
    281281.set-form select {
    282282  appearance: none; -webkit-appearance: none;
  • src/views/pages/admin-shows.ejs

    rbb42dfb r834bcc3  
    6363  .ash-note { font-size: 13px; opacity: .8; margin: 0 0 18px; }
    6464  .ash-form { border: 1px solid rgba(128,128,128,.2); border-radius: 14px; padding: 16px; margin-bottom: 24px; }
    65   /* Mobiel-eerst: één kolom (twee 1fr-kolommen met niet-krimpbare date/text-inputs
    66      liepen samen ~374px > telefoonbreedte). Twee kolommen pas vanaf 520px. */
     65  /* Mobile-first: single column (two 1fr columns with non-shrinkable date/text inputs
     66     combined to ~374px > phone width). Two columns only from 520px onwards. */
    6767  .ash-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
    6868  @media (min-width: 520px) { .ash-grid { grid-template-columns: 1fr 1fr; } }
  • src/views/pages/admin-site-edit.ejs

    rbb42dfb r834bcc3  
    2424        <input type="text" name="title" value="<%= site.title || '' %>" required maxlength="200">
    2525      </label>
    26       <%# Eigenaar (her)toewijzen — ALLEEN god ÉN alleen in hub-modus. In hub geef
    27           je zo elke gebruiker z'n eigen, zelf te beheren Klonkt. In solo (één site,
    28           één eigenaar) is dit zinloos/verwarrend, dus verborgen. %>
     26      <%# Assign (or reassign) owner — ONLY god AND only in hub mode. In hub this
     27          gives each user their own self-managed Klonkt. In solo (one site,
     28          one owner) this is pointless/confusing, so it's hidden. %>
    2929      <% if (user && user.role === 'god' && (typeof tenancy !== 'undefined' && tenancy === 'hub')) { %>
    3030      <label>
     
    250250<script>
    251251(function() {
    252   // Slug-URL-veld: toon de echte host als gedimde prefix (de slug zelf is
    253   // gekleurd via CSS). Hub → host/user/<slug>, anders host/<slug>.
     252  // Slug URL field: show the real host as a dimmed prefix (the slug itself is
     253  // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>.
    254254  var sh = document.getElementById('slug-host');
    255255  if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/');
     
    354354
    355355.site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; }
    356 /* Mobiel: een <fieldset> heeft een browser-default min-inline-size:min-content en
    357    krimpt daardoor NIET mee → het hele formulier werd breder dan het scherm. min-width:0
    358    forceert meekrimpen; de form-velden krijgen max-width:100% + border-box zodat ze
    359    nooit buiten hun fieldset lopen. */
     356/* Mobile: a <fieldset> has a browser-default min-inline-size:min-content and
     357   therefore does NOT shrink → the entire form became wider than the screen. min-width:0
     358   forces it to shrink; form fields get max-width:100% + border-box so they
     359   never overflow their fieldset. */
    360360.site-form fieldset { min-width: 0; }
    361361.site-form input, .site-form select, .site-form textarea { min-width: 0; max-width: 100%; box-sizing: border-box; }
     
    398398.site-form input[readonly] { opacity: 0.6; cursor: not-allowed; background: var(--paper-2); }
    399399
    400 /* Slug als URL-veld: gedimde host-prefix + de slug zelf in de accentkleur. */
     400/* Slug as URL field: dimmed host prefix + the slug itself in the accent colour. */
    401401.slug-url {
    402402  display: flex; align-items: stretch;
     
    425425}
    426426
    427 /* Selects: zelfde look als de inputs, met een eigen chevron i.p.v. de kale
    428    native pijl. De geopende optielijst blijft door de browser gerenderd. */
     427/* Selects: same look as the inputs, with a custom chevron instead of the bare
     428   native arrow. The open option list is still rendered by the browser. */
    429429.site-form select {
    430430  appearance: none;
     
    562562  .photo-controls { gap: .55rem; }
    563563}
    564 /* Mobiel-eerst: platform-keuze op een eigen regel (volle breedte), daaronder de
    565    URL-input + verwijderknop. Voorkomt horizontale overflow op telefoons (de vaste
    566    160px-kolom + niet-krimpbare input liepen samen ~447px > schermbreedte). */
     564/* Mobile-first: platform selector on its own line (full width), URL input + remove
     565   button below. Prevents horizontal overflow on phones (fixed 160px column +
     566   non-shrinkable input together ~447px > screen width). */
    567567.profile-link-row {
    568568  display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;
  • src/views/pages/admin-users.ejs

    rbb42dfb r834bcc3  
    7171
    7272          <div class="ax-user-controls">
    73             <%# Hub: geef deze gebruiker een eigen, zelf te beheren Klonkt — opent het
    74                 nieuw-site-formulier met deze gebruiker al als eigenaar voorgeselecteerd. %>
     73            <%# Hub: give this user their own self-managed Klonkt — opens the
     74                new-site form with this user already pre-selected as owner. %>
    7575            <% if (typeof tenancy !== 'undefined' && tenancy === 'hub' && canMutate) { %>
    7676              <a href="/admin/sites/new?owner=<%= u.id %>" class="ax-icon-btn"
     
    9898              </form>
    9999            <% } else { %>
    100               <%# Lege plek zodat de rol-kolom op elke rij uitlijnt. %>
     100              <%# Empty placeholder so the role column aligns on every row. %>
    101101              <span class="ax-icon-btn-spacer" aria-hidden="true"></span>
    102102            <% } %>
  • src/views/pages/auth-login.ejs

    rbb42dfb r834bcc3  
    11<%
    2   // adminLogin = de verborgen beheerders-loginpagina (/auth/admin): toont het
    3   // gebruikersnaam/wachtwoord-formulier. De publieke /auth/login toont voor
    4   // bezoekers ALLEEN Google-login (luisteraars/fans). Zo staat de admin-login
    5   // niet zichtbaar op de plek waar bezoekers heen gestuurd worden.
     2  // adminLogin = the hidden admin login page (/auth/admin): shows the
     3  // username/password form. The public /auth/login shows visitors
     4  // ONLY Google login (listeners/fans). This keeps the admin login
     5  // off the page where visitors are directed.
    66  var _admin = (typeof adminLogin !== 'undefined' && adminLogin);
    77%>
     
    3232
    3333  <% if (_admin) { %>
    34     <%# Verborgen beheerders-login (wachtwoord). %>
     34    <%# Hidden admin login (password). %>
    3535    <form method="post" action="/auth/login" class="auth-form">
    3636      <% if (typeof next !== 'undefined' && next) { %>
     
    4949    </form>
    5050  <% } else if (googleReady) { %>
    51     <%# Publieke login: alleen Google, voor luisteraars/fans. %>
     51    <%# Public login: Google only, for listeners/fans. %>
    5252    <p class="auth-sub"><%= t('auth.public_sub') %></p>
    5353    <a class="btn btn-google" href="/auth/google<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">
     
    6565
    6666  <% if (!_admin) { %>
    67     <%# Klikbare box voor de beheerder → naar de verborgen wachtwoord-login. %>
     67    <%# Clickable box for the admin → navigates to the hidden password login. %>
    6868    <a class="auth-admin-box" href="/auth/admin<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>">
    6969      <span class="auth-admin-box-icon" aria-hidden="true">🔑</span>
     
    8585  .auth-notice-list li { margin: 4px 0; }
    8686  .auth-sub { color: var(--ink-muted); margin: 0 0 1rem; text-align: center; }
    87   /* Google-knop is wit → tekst altijd donker houden, ook op hover (de globale
    88      a.btn:hover-regel maakt 'm anders bijna-wit op wit = onleesbaar). */
     87  /* Google button is white → keep text dark at all times, including hover (the global
     88     a.btn:hover rule would otherwise make it near-white on white = unreadable). */
    8989  a.btn-google, a.btn-google:hover { color: #1f1f1f; }
    9090  .auth-admin-box {
  • src/views/pages/circle-feed.ejs

    rbb42dfb r834bcc3  
    2626    </div>
    2727    <% } else { %>
    28       <%# Meer dan 5: geen chips (zou een muur worden) — alleen het aantal. %>
     28      <%# More than 5: no chips (would become a wall) — show the count only. %>
    2929      <span class="cirkel-count"><%= t('cfeed.count', { n: sites.length }) %></span>
    3030    <% } %>
     
    3838<% } else { %>
    3939
    40   <%# Dezelfde view als de hoofdpagina: timeline (post-card) + grid (post-tile),
    41       gewisseld door de view-switcher via body[data-feed-view]. De remote-posts
    42       zijn in circle.js naar het lokale post-shape gevormd (met external_url,
    43       zodat de kaarten naar de bron-site linken). %>
     40  <%# Same view as the home page: timeline (post-card) + grid (post-tile),
     41      switched by the view-switcher via body[data-feed-view]. The remote posts
     42      are shaped to the local post format in circle.js (with external_url,
     43      so cards link to the source site). %>
    4444
    4545  <!-- ===== TIMELINE (standaard) ===== -->
  • src/views/pages/fan-gate.ejs

    rbb42dfb r834bcc3  
    11<div class="container fg-navwrap">
    2   <%# Zelfde post-navigatie (Newer/Older) als op een gewone post, zodat een
    3       bezoeker op de fan-gate niet vastloopt maar verder kan bladeren. Zelfde
    4       maten als .post-page (720px / padding 0 1rem) zodat de nav pixel-gelijk is. %>
     2  <%# Same post navigation (Newer/Older) as on a regular post, so a visitor
     3      on the fan gate can still browse rather than getting stuck. Same
     4      dimensions as .post-page (720px / padding 0 1rem) for pixel-perfect alignment. %>
    55  <%- include('../partials/post-nav', { newerPost: (typeof newerPost !== 'undefined' ? newerPost : null), olderPost: (typeof olderPost !== 'undefined' ? olderPost : null) }) %>
    66</div>
     
    1717
    1818<style>
    19   /* EXACT dezelfde class-combinatie (.container + override) én waarden als
    20      .post-page in post.ejs, zodat de post-nav cascade-identiek uitlijnt. */
     19  /* EXACT same class combination (.container + override) and values as
     20     .post-page in post.ejs, so the post-nav aligns identically in the cascade. */
    2121  .fg-navwrap { max-width: 720px; margin: 0.5rem auto 2rem; padding: 0 1rem; }
    2222  .fg { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; }
  • src/views/pages/hub-home.ejs

    rbb42dfb r834bcc3  
    11<div class="hub<%= hub.heroImage ? ' hub--img' : '' %>">
    22
    3   <%# ── HERO (label) — de mini topnav-balk staat erboven (chrome.ejs);
    4         de hero zelf is de "grote" header van de hub-home ──────────────── %>
     3  <%# ── HERO (label) — the mini topnav bar sits above it (chrome.ejs);
     4        the hero itself is the "large" header of the hub home ──────────────── %>
    55  <header class="hub-hero<%= hub.heroImage ? ' has-img' : '' %>"<% if (hub.heroImage) { %> style="--hero-img:url('<%= hub.heroImage %>'); --hero-ov:<%= ((hub.heroOverlay == null ? 45 : hub.heroOverlay)/100).toFixed(2) %>"<% } %>>
    66    <div class="hub-hero-inner container">
     
    1717  <div class="container hub-body">
    1818
    19     <%# ── HOOFDPAGINA (label-/hoofdaccount — geen artiest) ──────────── %>
     19    <%# ── MAIN PAGE (label-/primary account — not an artist) ──────────── %>
    2020    <% if (mainSite) { %>
    2121    <section class="hub-sec hub-sec-main">
     
    3737    <% } %>
    3838
    39     <%# ── ARTIESTEN-ROSTER ─────────────────────────────────────────── %>
     39    <%# ── ARTIST ROSTER ─────────────────────────────────────────── %>
    4040    <% if (artists && artists.length) { %>
    4141    <% var _total = (typeof totalArtists !== 'undefined') ? totalArtists : artists.length; %>
     
    6969    <% } %>
    7070
    71     <%# ── LAATSTE POSTS ────────────────────────────────────────────── %>
     71    <%# ── LATEST POSTS ────────────────────────────────────────────── %>
    7272    <% if (posts && posts.length) { %>
    7373    <section class="hub-sec">
     
    9999.hub { width: 100%; position: relative; }
    100100
    101 /* Hero sluit direct aan op de mini topnav-balk — geen tussenruimte boven de
    102    hub-home (override van de globale main-padding-top). */
     101/* Hero connects directly to the mini topnav bar — no gap above the
     102   hub home (overrides the global main-padding-top). */
    103103.pcms-main { padding-top: 0; }
    104104.hub-hero { margin-top: 0; }
     
    160160.hub-more-link:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, var(--paper-2)); }
    161161
    162 /* ── Hoofdpagina-kaart (label-/hoofdaccount, apart van de artiesten) ── */
     162/* ── Main page card (label-/primary account, separate from the artists) ── */
    163163.hub-sec-main { margin-top: 2.25rem; }
    164164.hub-main-card {
     
    199199}
    200200
    201 /* ── Roster (artiesten) ───────────────────────────────────────────── */
     201/* ── Roster (artists) ───────────────────────────────────────────── */
    202202.roster {
    203203  display: grid;
     
    231231}
    232232
    233 /* ── Feed (laatste posts) ─────────────────────────────────────────── */
     233/* ── Feed (latest posts) ─────────────────────────────────────────── */
    234234.hub-feed { display: flex; flex-direction: column; gap: 0.6rem; }
    235235.feed-item {
  • src/views/pages/post-edit.ejs

    rbb42dfb r834bcc3  
    99// and styling changed.
    1010const _hasCover = !!post.cover_image_url;
    11 // In hub-modus moeten save/create/cancel de /user/<slug>/-prefix dragen, anders
    12 // valt de request terug op de primaire site (siteUrlBase leeg) en rendert de
    13 // post na opslaan headerless (bareChrome). In solo is _base leeg.
     11// In hub mode, save/create/cancel must carry the /user/<slug>/ prefix, otherwise
     12// the request falls back to the primary site (siteUrlBase empty) and the post
     13// renders headerless (bareChrome) after saving. In solo mode _base is empty.
    1414const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
    1515%>
     
    138138             data-placeholder="<%= t('pedit.editor_placeholder') %>"></div>
    139139
    140         <%# Sticky "tik om te bewerken"-hint (alleen zichtbaar op touch + niet-fullscreen
    141             via CSS). Puur visueel (pointer-events:none); de tik op het veld opent fullscreen. %>
     140        <%# Sticky "tap to edit" hint (only visible on touch + non-fullscreen
     141            via CSS). Purely visual (pointer-events:none); tapping the field opens fullscreen. %>
    142142        <div class="pe-edit-hint" aria-hidden="true">✎ <%= t('pedit.tap_to_edit') %></div>
    143143
     
    225225              if (!cb || !box) return;
    226226              var inp = document.getElementById('pe-publish-at');
    227               var SITE_TZ = '<%= timezone || '' %>'; // ingestelde site-tijdzone; leeg = browser-lokaal
     227              var SITE_TZ = '<%= timezone || '' %>'; // configured site timezone; empty = browser local
    228228              var pad = function (n) { return String(n).padStart(2, '0'); };
    229               // Offset (ms) tussen een tijdzone en UTC op een bepaald moment.
     229              // Offset (ms) between a timezone and UTC at a given moment.
    230230              function tzOffset(date, tz) {
    231231                var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
     
    233233                return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime();
    234234              }
    235               // datetime-local "wandtijd" (in de site-zone) → UTC Date.
     235              // datetime-local "wall time" (in the site zone) → UTC Date.
    236236              function wallToUtc(wall) {
    237237                if (!SITE_TZ) return new Date(wall);
     
    239239                return new Date(guess - tzOffset(new Date(guess), SITE_TZ));
    240240              }
    241               // UTC-ISO → "YYYY-MM-DDTHH:MM" wandtijd in de site-zone.
     241              // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone.
    242242              function utcToWall(iso) {
    243243                var d = new Date(iso); if (isNaN(d)) return '';
     
    247247                return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
    248248              }
    249               // Prefill: opgeslagen UTC → wandtijd in de site-zone.
     249              // Prefill: stored UTC → wall time in the site zone.
    250250              if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso);
    251               // "Ingepland voor" in leesbare tijd van de site-zone.
     251              // "Scheduled for" in human-readable time in the site zone.
    252252              var when = document.getElementById('pe-sched-when');
    253253              if (when && when.dataset.iso) {
     
    257257              function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; }
    258258              cb.addEventListener('change', sync); sync();
    259               // Bij opslaan: wandtijd in de site-zone → UTC-ISO via een hidden veld.
     259              // On save: wall time in the site zone → UTC-ISO via a hidden field.
    260260              var form = cb.closest('form');
    261261              if (form) {
     
    466466  width: 32px; height: 32px;
    467467  padding: 0;
    468   touch-action: manipulation; /* snappy taps op mobiel, geen dubbeltik-zoom */
     468  touch-action: manipulation; /* snappy taps on mobile, no double-tap zoom */
    469469  -webkit-user-select: none; user-select: none;
    470470  background: transparent;
     
    478478  -webkit-tap-highlight-color: transparent;
    479479}
    480 /* Hover alleen op echte hover-apparaten — op touch blijft :hover anders "plakken"
    481    na een tik, waardoor de actief/inactief-staat onleesbaar wordt. */
     480/* Hover only on real hover-capable devices — on touch :hover otherwise "sticks"
     481   after a tap, making the active/inactive state unreadable. */
    482482@media (hover: hover) {
    483483  .pe-toolbar button:hover {
     
    487487}
    488488.pe-toolbar button:active { transform: scale(.94); }
    489 /* Actieve opmaak = onmiskenbaar gevuld met de accentkleur. Zo is op mobiel
    490    meteen duidelijk dat bv. vet AAN staat (tik nogmaals = uit). */
     489/* Active formatting = unmistakably filled with the accent colour. On mobile
     490   it's immediately clear when e.g. bold is ON (tap again = off). */
    491491.pe-toolbar button.is-active {
    492492  background: var(--accent);
     
    505505.pe-toolbar-spacer { flex: 1; }
    506506
    507 /* ─── Volledig-scherm schrijfmodus ─── */
     507/* ─── Full-screen writing mode ─── */
    508508.fs-icon-compress { display: none; }
    509509.pe-editor-frame.pe-fs .fs-icon-expand { display: none; }
     
    514514  height: 100dvh;
    515515  background: var(--paper);
    516   overflow: hidden;   /* alléén het tekstveld scrollt, niet het frame zelf */
    517 }
    518 /* In fullscreen vult het schrijfveld de resterende ruimte en scrollt het zelf
    519    (max-height: none overschrijft de mobiele box-begrenzing hieronder). */
     516  overflow: hidden;   /* only the text field scrolls, not the frame itself */
     517}
     518/* In fullscreen the writing field fills the remaining space and scrolls itself
     519   (max-height: none overrides the mobile box limit below). */
    520520.pe-editor-frame.pe-fs .pe-editor {
    521521  flex: 1 1 auto; min-height: 0; height: auto; max-height: none;
    522522}
    523 /* NB: bewust GEEN overflow:hidden op html/body in fullscreen — dat kon blijven
    524    hangen (bv. fullscreen verlaten via navigatie) en blokkeerde dan het scrollen van
    525    de hele pagina. Het fullscreen-frame (position:fixed, inset:0) dekt de pagina al
    526    af, en op touch zijn de scrollbalken verborgen → geen dubbele balk nodig. */
    527 
    528 /* "Klaar"-knop: alleen in fullscreen zichtbaar (links in de toolbar), duidelijke
    529    accent-pill i.p.v. een vierkant icoontje. */
    530 /* Verberg-regel specifieker dan ".pe-toolbar button" (anders wint die en zie je
    531    de Klaar-knop ook inline als kleine knop). Alleen in fullscreen tonen → groot. */
     523/* NB: deliberately NO overflow:hidden on html/body in fullscreen — that could get
     524   stuck (e.g. leaving fullscreen via navigation) and would block scrolling on the
     525   whole page. The fullscreen frame (position:fixed, inset:0) already covers the page,
     526   and on touch scrollbars are hidden → no double bar needed. */
     527
     528/* "Done" button: only visible in fullscreen (left side of toolbar), clear
     529   accent-pill instead of a square icon. */
     530/* Hide rule more specific than ".pe-toolbar button" (otherwise that wins and the
     531   Done button also shows inline as a small button). Only shown in fullscreen → large. */
    532532.pe-toolbar button.pe-fs-done { display: none; }
    533533.pe-editor-frame.pe-fs .pe-fs-done {
     
    556556}
    557557.pe-editor:focus { outline: none; }
    558 /* Inline (niet-fullscreen) groeit het schrijfveld gewoon mee met de inhoud — geen
    559    interne scroll-box (scroll-binnen-scroll is verwarrend). Op mobiel/tablet gaat
    560    typen sowieso fullscreen (zie JS), waar het veld de pagina vult en als enige
    561    scrollt. */
     558/* Inline (non-fullscreen) the writing field simply grows with the content — no
     559   internal scroll-box (scroll-within-scroll is confusing). On mobile/tablet typing
     560   always goes fullscreen (see JS), where the field fills the page and is the sole
     561   scroller. */
    562562.pe-editor-frame:not(.pe-fs) .pe-editor { overflow: visible; }
    563563
    564 /* Touch: het inline content-veld is geen tekstveld maar een tap-vlak → fullscreen
    565    bewerken. Een "✎ Tik om te bewerken"-pill plakt sticky onderaan de container,
    566    zodat de hint altijd in beeld is zonder midden in de tekst te staan. */
     564/* Touch: the inline content field is not a text field but a tap target → fullscreen
     565   editing. A "✎ Tap to edit" pill sticks to the bottom of the container,
     566   so the hint is always visible without sitting in the middle of the text. */
    567567.pe-editor.pe-tap-to-edit { cursor: pointer; }
    568568.pe-edit-hint { display: none; }
     
    571571    display: block;
    572572    position: sticky;
    573     bottom: 4.5rem;   /* boven de sticky Opslaan/Annuleren-balk */
     573    bottom: 4.5rem;   /* above the sticky Save/Cancel bar */
    574574    width: max-content;
    575575    max-width: calc(100% - 2rem);
     
    580580    pointer-events: none; text-align: center; white-space: nowrap;
    581581  }
    582   /* Niet relevant op touch (slepen/selecteren hoort bij inline-bewerken op desktop). */
     582  /* Not relevant on touch (drag/select belongs to inline editing on desktop). */
    583583  .pe-content-hint { display: none; }
    584   /* Inline (niet-fullscreen) op touch: simpel houden — je bewerkt hier toch niet,
    585      dus geen opmaak-toolbar en geen losse rand. Alleen de content-preview + de
    586      "tik om te bewerken"-pill. De toolbar verschijnt pas in fullscreen. */
     584  /* Inline (non-fullscreen) on touch: keep it simple — you don't edit here anyway,
     585     so no formatting toolbar and no border. Just the content preview + the
     586     "tap to edit" pill. The toolbar only appears in fullscreen. */
    587587  .pe-editor-frame:not(.pe-fs) .pe-toolbar { display: none; }
    588588  .pe-editor-frame:not(.pe-fs) { border-top: 0; }
     
    955955}
    956956
    957 /* Vastpinnen: checkbox + ▲▼-stepper met beschrijving (i.p.v. een ruw rang-getal). */
     957/* Pin: checkbox + ▲▼-stepper with description (instead of a raw rank number). */
    958958.pe-pin { display: flex; flex-direction: column; gap: 0.45rem; }
    959959.pe-pin-pos { display: flex; align-items: center; gap: 0.55rem; padding-left: 1.65rem; }
     
    978978  gap: 0.5rem;
    979979  padding: 0.85rem 1rem;
    980   margin: 0.5rem -1rem 0;   /* extend to viewport edges on tight container */
     980  margin: 0.5rem -1rem 0;   /* extend to viewport edges on a narrow container */
    981981  background: color-mix(in srgb, var(--paper) 94%, transparent);
    982982  -webkit-backdrop-filter: blur(8px);
     
    987987.pe-actions-spacer { flex: 1; }
    988988
    989 /* De editor is een focus-scherm: verberg de mobiele site-tabbalk (Home/Zoek/…)
    990    zodat er niet twee balken onderaan stapelen (Opslaan-balk + tabbalk). De
    991    Opslaan/Annuleren-balk wordt dan de enige onderbalk → gewoon op bottom:0,
    992    geen tab-offset meer nodig. Op desktop is de tabbalk toch al verborgen. */
     989/* The editor is a focus screen: hide the mobile site tab bar (Home/Search/…)
     990   so two bars don't stack at the bottom (Save bar + tab bar). The
     991   Save/Cancel bar then becomes the only bottom bar → plain bottom:0,
     992   no tab offset needed. On desktop the tab bar is already hidden. */
    993993body:has(.post-edit-page) .bottom-tab { display: none; }
    994 /* Audiospeler (indien aan het spelen) niet meer 56px optillen voor de nu-verborgen
    995    tabbalk, anders zou die zweven boven de actiebalk. */
     994/* Audio player (if playing) no longer lifted 56px for the now-hidden
     995   tab bar, otherwise it would float above the action bar. */
    996996body:has(.post-edit-page) .audio-player { bottom: 0; }
    997997
    998 /* ── Afbeeldings-editor (draaien / bijsnijden / spiegelen) ── */
     998/* ── Image editor (rotate / crop / mirror) ── */
    999999.imed-backdrop {
    10001000  position: fixed; inset: 0; z-index: 9999;
     
    10151015}
    10161016.imed-stage img { display: block; max-width: 100%; }
    1017 /* Cropper z'n container moet de volle stage-hoogte vullen — anders klapt 'ie dicht
    1018    in een flex-kolom zonder expliciete hoogte → leeg bewerk-venster (geen afbeelding). */
     1017/* Cropper's container must fill the full stage height — otherwise it collapses
     1018   in a flex column without an explicit height → empty edit window (no image). */
    10191019.imed-stage .cropper-container { width: 100% !important; height: 100% !important; }
    10201020.imed-tools {
     
    10521052  }
    10531053
    1054   // ── Afbeeldings-editor (draaien / bijsnijden / spiegelen) ──────────
    1055   // Lazy-load Cropper.js (lokaal gevendord) pas bij het eerste gebruik.
     1054  // ── Image editor (rotate / crop / mirror) ──────────
     1055  // Lazy-load Cropper.js (locally vendored) on first use.
    10561056  let _cropperReady = null;
    10571057  function ensureCropper() {
     
    10731073  }
    10741074
    1075   // Opent de editor voor een gekozen bestand; resolved met een bewerkt File,
    1076   // of null als de gebruiker annuleert. Animatie-GIF's worden niet door de
    1077   // canvas-editor gehaald (zouden statisch worden) — die uploaden direct.
     1075  // Opens the editor for a chosen file; resolves with an edited File,
     1076  // or null if the user cancels. Animated GIFs are not sent through the
     1077  // canvas editor (they would become static) — those upload directly.
    10781078  async function openImageEditor(file) {
    10791079    if (!file || !file.type || !file.type.startsWith('image/')) return file;
    1080     if (file.type === 'image/gif') return file; // behoud animatie
    1081     try { await ensureCropper(); } catch (_) { return file; } // editor onbeschikbaar → direct uploaden
     1080    if (file.type === 'image/gif') return file; // preserve animation
     1081    try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly
    10821082
    10831083    return new Promise((resolve) => {
     
    11181118        cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true });
    11191119      };
    1120       img.onerror = () => { cleanup(); resolve(file); }; // kon niet laden → upload het origineel
     1120      img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original
    11211121      img.src = url;
    11221122
     
    11791179      const edited = await openImageEditor(coverField.files[0]);
    11801180      coverField.value = '';
    1181       if (!edited) return; // geannuleerd
     1181      if (!edited) return; // cancelled
    11821182      coverStatus.classList.remove('is-error');
    11831183      coverStatus.textContent = '<%= t('pedit.js_uploading') %>';
     
    12241224  if (!editor) return;
    12251225
    1226   // Titel alleen automatisch focussen op desktop (muis/trackpad). Op touch zou
    1227   // dit meteen het toetsenbord openen bij het openen van de editor — niet gewenst.
     1226  // Auto-focus the title only on desktop (mouse/trackpad). On touch this would
     1227  // immediately open the keyboard when the editor opens — not desired.
    12281228  try {
    12291229    const titleInput = form && form.querySelector('input[name="title"]');
     
    13291329
    13301330  // ── Toolbar wiring
    1331   // Houd de scrollpositie vast rond een edit-commando. execCommand/insert scrollt
    1332   // standaard de caret in beeld → het beeld "verspringt" bij het aanklikken van een
    1333   // opmaakknop. We leggen ALLE scrollbare voorouders (editor, frame, #pcms-main, …)
    1334   // + de pagina vast en zetten ze terug — sync én over een paar frames, want Chrome
    1335   // scrollt soms pas een frame later. De gebruiker scrollt zo zelf.
     1331  // Lock the scroll position around an edit command. execCommand/insert scrolls
     1332  // the caret into view by default → the view "jumps" when clicking a formatting
     1333  // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …)
     1334  // + the page and restore them — sync and over a few frames, because Chrome
     1335  // sometimes scrolls a frame later. The user scrolls themselves.
    13361336  function scrollableAncestors(el) {
    13371337    const list = [];
     
    13451345  }
    13461346  function keepScroll(fn) {
    1347     // In fullscreen staat de pagina vast (body overflow:hidden) en mag het veld
    1348     // gewoon naar de caret scrollen — geen pagina-sprong mogelijk, dus niets fixen.
     1347    // In fullscreen the page is locked (body overflow:hidden) and the field may
     1348    // scroll to the caret freely — no page jump possible, so nothing to fix.
    13491349    const frame = document.querySelector('.pe-editor-frame');
    13501350    if (frame && frame.classList.contains('pe-fs')) { fn(); return; }
     
    13891389    execCmd('createLink', url);
    13901390  }
    1391   // De huidige selectie zit in een <blockquote> binnen de editor? Geef 'm terug.
     1391  // Is the current selection inside a <blockquote> within the editor? Return it.
    13921392  function blockquoteAncestor() {
    13931393    const sel = window.getSelection();
     
    14001400    return null;
    14011401  }
    1402   // Echte toggle: execCommand('formatBlock','blockquote') zet 'm wél AAN maar
    1403   // krijgt 'm nooit meer UIT (browser-quirk). Staat de caret al in een quote →
    1404   // pak de wrapper uit; anders pas blockquote toe.
     1402  // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but
     1403  // can never turn it OFF (browser quirk). If the caret is already in a quote →
     1404  // unwrap it; otherwise apply blockquote.
    14051405  function toggleBlockquote() {
    14061406    keepScroll(function () {
     
    14091409      if (bq) {
    14101410        const parent = bq.parentNode;
    1411         // Inhoud uit de quote halen, op z'n plek, en de lege wrapper verwijderen.
     1411        // Extract content from the quote in place, then remove the empty wrapper.
    14121412        const ref = bq;
    14131413        let firstMoved = null;
     
    14181418        }
    14191419        parent.removeChild(bq);
    1420         // Caret terugzetten in de uitgepakte inhoud.
     1420        // Restore the caret inside the unwrapped content.
    14211421        if (firstMoved) {
    14221422          const sel = window.getSelection();
     
    14361436
    14371437  if (toolbar) {
    1438     // CRUCIAAL (mobiel + desktop): voorkom dat een toolbar-knop de focus/selectie
    1439     // uit het editor-veld steelt. Zonder dit raakt de selectie kwijt bij het tikken
    1440     // → execCommand werkt op een lege selectie (vet kan niet meer UIT) én de browser
    1441     // scrollt de caret opnieuw in beeld (de "sprong naar beneden"). preventDefault op
    1442     // mousedown houdt de focus in de editor; de click blijft gewoon vuren.
     1438    // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection
     1439    // from the editor field. Without this the selection is lost on tap
     1440    // → execCommand operates on an empty selection (bold can no longer be toggled OFF)
     1441    // and the browser scrolls the caret back into view (the "jump down"). preventDefault
     1442    // on mousedown keeps focus in the editor; the click still fires normally.
    14431443    toolbar.addEventListener('mousedown', (e) => {
    14441444      if (e.target.closest('button')) e.preventDefault();
     
    14571457  }
    14581458
    1459   // ── Volledig-scherm schrijfmodus: het schrijfveld vult de hele pagina.
     1459  // ── Full-screen writing mode: the writing field fills the whole page.
    14601460  const fsBtn = document.getElementById('pe-fullscreen-btn');
    14611461  const editorFrame = document.querySelector('.pe-editor-frame');
    14621462  const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
    14631463
    1464   // Op mobiel duwt het toetsenbord de zichtbare (visual) viewport omhoog terwijl
    1465   // een position:fixed-frame aan de LAYOUT-viewport blijft hangen → de toolbar
    1466   // schuift uit beeld. Houd het fullscreen-frame daarom gelijk aan de visual
    1467   // viewport (top + hoogte), zodat de toolbar altijd bovenaan zichtbaar blijft.
     1464  // On mobile the keyboard pushes the visible (visual) viewport up while
     1465  // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar
     1466  // slides out of view. Keep the fullscreen frame aligned to the visual
     1467  // viewport (top + height) so the toolbar stays visible at the top.
    14681468  function syncFsViewport() {
    14691469    if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
     
    14991499      }
    15001500    }
    1501     // Op touch is het veld inline NIET bewerkbaar; alleen in fullscreen wel.
     1501    // On touch the field is NOT editable inline; only in fullscreen.
    15021502    if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false');
    15031503    if (on) {
     
    15051505    } else {
    15061506      if (isTouch) editor.blur();
    1507       // Bij sluiten: naar de TOP van de content scrollen i.p.v. ergens onderaan
    1508       // (footer) te blijven hangen.
     1507      // On close: scroll to the TOP of the content instead of staying
     1508      // somewhere at the bottom (footer).
    15091509      requestAnimationFrame(function () {
    15101510        try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {}
     
    15121512    }
    15131513  }
    1514   // De fullscreen schrijf-"pagina": openen pusht een history-state zodat de browser-
    1515   // back-knop (en de Klaar-knop) 'm sluit en je terug bent in het formulier — voelt
    1516   // als een aparte pagina, maar álle formuliervelden blijven intact (zelfde DOM).
     1514  // The fullscreen writing "page": opening pushes a history state so the browser
     1515  // back button (and the Done button) closes it and returns you to the form — feels
     1516  // like a separate page, but all form fields remain intact (same DOM).
    15171517  function openFs() {
    15181518    if (isFs()) return;
     
    15221522  function closeFs() {
    15231523    if (!isFs()) return;
    1524     if (history.state && history.state.peFs) history.back(); // → popstate sluit af
     1524    if (history.state && history.state.peFs) history.back(); // → popstate closes it
    15251525    else applyFs(false);
    15261526  }
     
    15341534  });
    15351535
    1536   // Op mobiel/tablet (touch): het content-veld is INLINE NIET bewerkbaar — het is
    1537   // dan geen tekstveld. Eén tik → fullscreen, waar het wél bewerkbaar wordt
    1538   // (toggleFullscreen zet contenteditable aan/uit). Zo kun je nooit inline typen.
     1536  // On mobile/tablet (touch): the content field is NOT editable inline — it is
     1537  // not a text field there. One tap → fullscreen, where it becomes editable
     1538  // (toggleFullscreen toggles contenteditable). This prevents inline typing.
    15391539  if (isTouch) {
    15401540    editor.setAttribute('contenteditable', 'false');
     
    15551555      try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
    15561556    }
    1557     // Quote-knop: actief als de caret in een <blockquote> staat (toggle-feedback).
     1557    // Quote button: active when the caret is inside a <blockquote> (toggle feedback).
    15581558    const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
    15591559    if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
     
    15881588  async function uploadAndInsertImage(file) {
    15891589    const edited = await openImageEditor(file);
    1590     if (!edited) return; // geannuleerd
     1590    if (!edited) return; // cancelled
    15911591    contentStatus.classList.remove('is-error');
    15921592    contentStatus.textContent = '<%= t('pedit.js_uploading') %>';
     
    16541654  }
    16551655
    1656   // ── Embed insert: plak een platform-URL -> [[embed:url]]-chip die server-side
    1657   //    een iframe wordt (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
     1656  // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes
     1657  //    an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
    16581658  const embedBtn = document.getElementById('insert-embed-btn');
    16591659  if (embedBtn) {
     
    16741674    const tpEmpty  = document.getElementById('tp-empty');
    16751675    const tpSearch = document.getElementById('tp-search');
    1676     let tpCache = null;       // cached tracks list (fetched once per page load)
     1676    let tpCache = null;       // cached track list (fetched once per page load)
    16771677    let tpLastFocus = null;   // element to restore focus to on close
    16781678
     
    18701870<script>
    18711871(function () {
    1872   // Vastpinnen: checkbox toggelt het verborgen rang-veld (0 = niet gepind),
    1873   // ▲▼ verschuift de plek, met een leesbare beschrijving i.p.v. een ruw getal.
     1872  // Pin: checkbox toggles the hidden rank field (0 = not pinned),
     1873  // ▲▼ shifts the position, with a readable description instead of a raw number.
    18741874  var toggle = document.getElementById('pin-toggle');
    18751875  var rank   = document.getElementById('pin-rank');
    18761876  var pos    = document.getElementById('pin-pos');
    18771877  var label  = document.getElementById('pin-label');
    1878   var up     = document.getElementById('pin-up');    // hoger = lager getal (richting 1/bovenaan)
     1878  var up     = document.getElementById('pin-up');    // higher = lower number (towards 1/top)
    18791879  var down   = document.getElementById('pin-down');
    18801880  if (!toggle || !rank || !pos) return;
     
    19001900
    19011901(function () {
    1902   // De Opslaan/Annuleren-balk (position: sticky; bottom:0) net boven twee mogelijke
    1903   // obstakels houden door een dynamische bottom-offset te zetten = het grootste van:
    1904   //  1) de hoogte van het toetsenbord-deel dat de layout-viewport NIET dekt
    1905   //     (op iOS verschuift de visual viewport; op Android verkleint de layout-
    1906   //     viewport door interactive-widget=resizes-content → offset ≈ 0);
    1907   //  2) de hoogte van de spelende audiospeler (fixed, z-index 1000).
    1908   // We blijven bij sticky (geen fixed/top-gegoochel → geen balk midden in beeld).
     1902  // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible
     1903  // obstacles by setting a dynamic bottom offset = the greater of:
     1904  //  1) the height of the keyboard area NOT covered by the layout viewport
     1905  //     (on iOS the visual viewport shifts; on Android the layout viewport shrinks
     1906  //     due to interactive-widget=resizes-content → offset ≈ 0);
     1907  //  2) the height of the playing audio player (fixed, z-index 1000).
     1908  // We stick with sticky (no fixed/top tricks → no bar floating in the middle).
    19091909  var bar = document.querySelector('.pe-actions');
    19101910  if (!bar) return;
     
    19221922  window.addEventListener('resize', position);
    19231923  if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); }
    1924   // has-audio-player wisselt via een body-class → daarop reageren.
     1924  // has-audio-player is toggled via a body class → observe it.
    19251925  try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
    19261926})();
  • src/views/pages/post.ejs

    rbb42dfb r834bcc3  
    11<%
    2 // In hub-mode is de site bereikbaar onder /user/<slug>; alle site-scoped links
    3 // (tag, edit, delete, comments) moeten met die prefix anders vallen ze terug op
    4 // de primaire site → 404 / verkeerde site. In solo is _base = ''.
     2// In hub mode the site is accessible under /user/<slug>; all site-scoped links
     3// (tag, edit, delete, comments) must carry that prefix otherwise they fall back
     4// to the primary site → 404 / wrong site. In solo mode _base = ''.
    55const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
    66%>
    77<article class="container post-page">
    88
    9   <%# Navigatie over ALLE posts — BOVEN de post (gedeeld partial). %>
     9  <%# Navigation across ALL posts — ABOVE the post (shared partial). %>
    1010  <%- include('../partials/post-nav', { newerPost: newerPost, olderPost: olderPost }) %>
    1111
     
    7979
    8080    <% if (typeof currentPath === 'string' && currentPath && currentPath.indexOf('/' + post.slug) === 0) { %>
    81       <% /* If the URL has ?pending=1, the previous submit landed in the queue */ %>
     81      <% /* If the URL has ?pending=1, the previous submit landed in the moderation queue */ %>
    8282    <% } %>
    8383    <script>
     
    164164                        </div>
    165165
    166                         <%# Reageren op een antwoord — landt (via resolvedParent op de
    167                             server) in dezelfde thread, dus 1 niveau diep blijft behouden. %>
     166                        <%# Replying to a reply — lands (via resolvedParent on the
     167                            server) in the same thread, so the 1-level depth is preserved. %>
    168168                        <% if (user && canMutate) { %>
    169169                          <form method="post" action="<%= _base %>/comments" class="comment-reply-form" hidden data-reply-form-for="<%= r.id %>">
     
    431431.comment-form button { align-self: flex-start; }
    432432.comment-reply-form-actions { display: flex; gap: 0.5rem; }
    433 /* Reply-formulier pas tonen NA klik op REPLY. De display:flex hierboven overschreef
    434    anders het [hidden]-attribuut → formulier stond altijd open. */
     433/* Show the reply form only AFTER clicking REPLY. The display:flex above would
     434   otherwise override the [hidden] attribute → form was always open. */
    435435.comment-reply-form[hidden] { display: none; }
    436 /* DELETE-knop zit in een <form>; display:contents laat 'm als directe flex-sibling
    437    van REPLY meedoen → pixel-perfecte uitlijning + gelijke gap. */
     436/* DELETE button is inside a <form>; display:contents makes it a direct flex-sibling
     437   of REPLY → pixel-perfect alignment + equal gap. */
    438438.comment-actions form { display: contents; }
    439439
  • src/views/partials/bottom-tab.ejs

    rbb42dfb r834bcc3  
    1515const _ownProfile  = user ? ('/users/' + user.username) : null;
    1616const _isHub       = (typeof tenancy !== 'undefined' && tenancy === 'hub');
    17 // Home gaat in hub-modus ALTIJD naar de hub-root (/), niet naar de sub-home van
    18 // een artiest. In solo/circle is _siteUrlBase leeg, dus dat blijft '/'.
     17// Home in hub mode ALWAYS goes to the hub root (/), not to an artist's sub-home.
     18// In solo/circle _siteUrlBase is empty, so it stays '/'.
    1919const _homeHref    = (_isHub ? '' : _siteUrlBase) + '/';
    2020
     
    2727
    2828let _active = null;
    29 // In hub is Home alleen actief op de échte hub-root, niet op een artiest-sub-home.
     29// In hub, Home is only active on the real hub root, not on an artist sub-home.
    3030if (_isHub ? (_path === '/') : (_p === '/' || _p === '')) _active = 'home';
    3131else if (_p.indexOf('/search') === 0 || _p.indexOf('/tag/') === 0 || _p.indexOf('/type/') === 0) _active = 'search';
     
    3838
    3939  <!-- Home -->
    40   <%# htmx-navigatie (geen full reload): de persistente audioplayer in document.body
    41       overleeft dan, dus de muziek verspringt niet bij het navigeren. Op mobiel is de
    42       top-balk toch verborgen, dus een context-reset via full-load is hier niet nodig. %>
     40  <%# htmx navigation (no full reload): the persistent audio player in document.body
     41      survives, so music does not skip when navigating. On mobile the top bar is hidden
     42      anyway, so a context reset via full-load is not needed here. %>
    4343  <a class="bottom-tab-item<%= _active === 'home' ? ' is-active' : '' %>"
    4444     href="<%= _homeHref %>"
     
    308308
    309309<script>
    310 // Bottom-tab zoekknop → opent #search-overlay. Via event-delegatie + de overlay
    311 // ELKE klik opnieuw opzoeken: de overlay zit in de chrome die bij htmx-navigatie
    312 // OOB vervangen wordt, dus een één-keer-opgevangen referentie verschaalt
    313 // (knop deed daarna niets). Guard tegen dubbel-wiren.
     310// Bottom-tab search button → opens #search-overlay. Using event delegation + looking
     311// up the overlay on EVERY click: the overlay lives in the chrome that is OOB-replaced
     312// during htmx navigation, so a once-captured reference goes stale
     313// (button did nothing afterwards). Guard against double-wiring.
    314314(function() {
    315315  if (window.__btSearchWired) return;
  • src/views/partials/footer.ejs

    rbb42dfb r834bcc3  
    1313      <span>© <%= new Date().getFullYear() %> <%= (site && site.title) || 'Klonkt' %></span>
    1414    </div>
    15     <%# Install-app knop — in de footer-rij (midden), bespaart een hele extra rij.
    16         JS detecteert platform & opent een modal met PWA-install-stappen. %>
     15    <%# Install-app button — placed in the footer row (centre), saves a whole extra row.
     16        JS detects the platform & opens a modal with PWA install steps. %>
    1717    <button type="button" class="install-app-btn install-app-btn--footer" data-pcms-install-app>
    1818      <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 20h14v-2H5v2zm7-18L5.33 9h3.84v6h5.66V9h3.84L12 2z" fill="currentColor"/></svg>
     
    114114}
    115115.footer-lang:hover { border-color: var(--accent); }
    116 /* Open dropdown-opties leesbaar (anders wit-op-wit op dark mode). */
     116/* Make open dropdown options readable (otherwise white-on-white in dark mode). */
    117117.footer-lang option { background: var(--paper); color: var(--ink); }
    118 /* Op desktop staat de taalkeuze in de header-nav → footer-versie verbergen. */
     118/* On desktop the language picker lives in the header nav → hide the footer version. */
    119119@media (min-width: 768px) { .footer-lang { display: none; } }
    120120.footer-version { color: inherit; text-decoration: none; }
     
    144144}
    145145
    146 /* Compacte install-knop ín de footer-rij (midden) i.p.v. een grote losse CTA
    147    op een eigen regel — bespaart verticale ruimte. */
     146/* Compact install button inside the footer row (centre) instead of a large standalone
     147   CTA on its own line — saves vertical space. */
    148148.install-app-btn--footer {
    149149  flex: 0 0 auto;
    150   width: auto !important;          /* override de mobile .install-app-btn width:100% */
     150  width: auto !important;          /* override the mobile .install-app-btn width:100% */
    151151  padding: 0.42rem 0.95rem;
    152152  font-size: 0.82rem;
     
    154154}
    155155.install-app-btn--footer svg { width: 15px; height: 15px; }
    156 /* Op smal scherm: laat de drie items netjes centreren als ze wrappen. */
     156/* On narrow screens: let the three items centre neatly when they wrap. */
    157157@media (max-width: 600px) {
    158158  .site-footer-inner { justify-content: center; text-align: center; }
  • src/views/partials/like-button.ejs

    rbb42dfb r834bcc3  
    1 <%# Like-/favorietknop voor een post. Ingelogd → htmx-toggle (POST /posts/:id/like,
    2     swap outerHTML met deze partial terug). Niet ingelogd → link naar login.
    3     Verwacht: post (met .id), likedByMe, likeCount, loggedIn, loginNext. %>
     1<%# Like/favourite button for a post. Logged in → htmx toggle (POST /posts/:id/like,
     2    swaps outerHTML back with this partial). Not logged in → link to login.
     3    Expects: post (with .id), likedByMe, likeCount, loggedIn, loginNext. %>
    44<%
    55  var _liked = (typeof likedByMe !== 'undefined' && likedByMe);
  • src/views/partials/post-nav.ejs

    rbb42dfb r834bcc3  
    1 <%# Post-navigatie (Newer links / Older rechts) — gedeeld door post.ejs én
    2     fan-gate.ejs. Verwacht newerPost + olderPost in scope. Ontbreekt een buur,
    3     dan een uitgegrijsde placeholder zodat elke post dezelfde structuur houdt. %>
     1<%# Post navigation (Newer link left / Older right) — shared by post.ejs and
     2    fan-gate.ejs. Expects newerPost + olderPost in scope. If a neighbour is missing,
     3    a greyed-out placeholder keeps every post in the same two-card structure. %>
    44<% if ((typeof newerPost !== 'undefined' && newerPost) || (typeof olderPost !== 'undefined' && olderPost)) { %>
    55  <nav class="post-nav post-nav--top">
     
    3535<style>
    3636.post-nav { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 3rem; padding-top: 2rem; border-top: 1px solid var(--rule); }
    37 /* Boven de post: geen lijn/gap erboven, maar een scheidingslijn ERONDER (naar de content). */
     37/* Above the post: no line/gap above it, but a dividing line BELOW (toward the content). */
    3838.post-nav--top { margin-top: 0; padding-top: 0; border-top: 0; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--rule); }
    3939.post-nav-prev, .post-nav-next { display: flex; flex-direction: column; padding: 1rem; background: var(--paper-2); border-radius: 6px; text-decoration: none; color: var(--ink); }
    40 /* Lege plek (geen nieuwere/oudere post): zelfde kaart, uitgegrijsd + niet klikbaar,
    41    zodat elke post dezelfde twee-kaarten-structuur houdt. */
     40/* Empty slot (no newer/older post): same card, greyed out + not clickable,
     41   so every post keeps the same two-card structure. */
    4242.post-nav-prev.is-empty, .post-nav-next.is-empty { opacity: 0.4; cursor: default; }
    4343.post-nav-prev.is-empty .post-nav-title, .post-nav-next.is-empty .post-nav-title { font-weight: 400; font-style: italic; color: var(--ink-muted); }
  • src/views/partials/shared-styles.ejs

    rbb42dfb r834bcc3  
    66  border-bottom: 1px solid var(--rule);
    77  background: var(--paper);
    8   /* Geen extra margin-top: de pill wordt zo gecentreerd tussen de profielkop-
    9      streep (boven) en de eigen border (onder). De gelijke lucht boven/onder de
    10      pill komt uit de symmetrische container-padding hieronder. De margin-bottom
    11      (1.5rem in style.css) blijft lucht naar de feed. */
     8  /* No extra margin-top: this centres the pill between the profile-header
     9     rule (above) and the own border (below). Equal space above/below the
     10     pill comes from the symmetric container padding below. The margin-bottom
     11     (1.5rem in style.css) keeps breathing room toward the feed. */
    1212  margin-top: 0;
    1313}
    1414.view-switcher-wrap .container {
    1515  display: flex; align-items: center; justify-content: center; gap: 0.75rem;
    16   /* Symmetrische padding → even veel lucht boven als onder de pill (≈8px),
    17      gecentreerd tussen de twee lijnen. */
     16  /* Symmetric padding → equal space above and below the pill (≈8px),
     17     centred between the two dividing lines. */
    1818  padding: 0.5rem 1rem;
    1919  max-width: 1200px; margin: 0 auto;
     
    3636}
    3737.view-switch-btn:hover { color: var(--ink); }
    38 /* Actief = wit (lichte achtergrond, donkere tekst). Via aria-selected (element-
    39    attribuut, door syncAria gezet + op htmx:afterSettle) → htmx-robuust. Op niet-
    40    feed-pagina's zet syncAria aria-selected=false → beide grijs. */
     38/* Active = white (light background, dark text). Via aria-selected (element
     39   attribute, set by syncAria + on htmx:afterSettle) → htmx-robust. On non-
     40   feed pages syncAria sets aria-selected=false → both grey. */
    4141.view-switch-btn[aria-selected="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
    4242
    43 /* Agenda = een EIGEN pill (los van Tijdlijn/Grid, zodat de feed-toggle nooit
    44    verstoord raakt). Zelfde look als de view-switcher-pill. Zichtbaar in alle modi. */
    45 .agenda-pill { display: inline-flex; align-items: center; }   /* geen box — losse icoon+tekst link */
     43/* Agenda = its OWN pill (separate from Timeline/Grid, so the feed-toggle is never
     44   disturbed). Same look as the view-switcher pill. Visible in all modes. */
     45.agenda-pill { display: inline-flex; align-items: center; }   /* no box — standalone icon+text link */
    4646.vs-agenda {
    4747  display: inline-flex; align-items: center; gap: 0.4rem;
     
    5353}
    5454.vs-agenda:hover { color: var(--ink); }
    55 /* Actief (op de agendapagina) = helder/wit. Element-class (in de OOB-gerenderde
    56    chrome) i.p.v. body-class-CSS → werkt betrouwbaar ook na htmx-navigatie. */
     55/* Active (on the agenda page) = bright/white. Element-class (in the OOB-rendered
     56   chrome) rather than body-class CSS → stays reliable after htmx navigation. */
    5757.vs-agenda.is-active { color: var(--ink); font-weight: 600; }
    5858
    59 /* Mobiel: Agenda op een eigen rij bovenaan, en Solo/Cirkel + Tijdlijn/Grid samen
    60    op de rij eronder (samen ~330px → past binnen een telefoon, i.p.v. de oude
    61    onevenwichtige split Agenda+Solo / Tijdlijn). */
     59/* Mobile: Agenda on its own row at the top, Solo/Circle + Timeline/Grid together
     60   on the row below (together ~330px → fits within a phone, instead of the old
     61   unbalanced split Agenda+Solo / Timeline). */
    6262@media (max-width: 520px) {
    6363  .view-switcher-wrap .agenda-pill { flex: 0 0 100%; justify-content: center; }
     
    9292.on-admin .view-switcher-wrap { display: none; }
    9393
    94 /* Op een losse post is er geen feed om te schakelen, dus geen knop hoort "actief"
    95    (gekleurd) te ogen. De actieve staat zit in style.css op body[data-feed-view]
    96    / body[data-grid-cols] (background:var(--ink)); we overrulen die SPECIFIEKER
    97    via body.on-post zodat de knoppen neutraal/grijs worden — maar wél klikbaar
    98    blijven (de klik-handler navigeert dan naar de feed in die weergave). De
    99    body-class wisselt client-side bij HTMX-nav, dus dit werkt ook zonder reload. */
     94/* On a standalone post there is no feed to switch, so no button should look "active"
     95   (coloured). The active state lives in style.css on body[data-feed-view]
     96   / body[data-grid-cols] (background:var(--ink)); we override that MORE SPECIFICALLY
     97   via body.on-post so the buttons become neutral/grey — but remain clickable
     98   (the click-handler then navigates to the feed in that view). The
     99   body-class switches client-side on HTMX-nav, so this works without a reload too. */
    100100body.on-post[data-feed-view="timeline"] .view-switch-btn[data-view="timeline"],
    101101body.on-post[data-feed-view="grid"] .view-switch-btn[data-view="grid"],
  • src/views/partials/topnav.ejs

    rbb42dfb r834bcc3  
    2727        <a href="<%= _siteUrlBase %>/" class="nav-link nav-link-admin"><%= t('nav.back_to_site') %></a>
    2828      <% } else if (typeof tenancy !== 'undefined' && tenancy === 'hub') { %>
    29         <%# Hub: de brand gaat naar de hub-home via HTMX. De chrome wordt in hub-modus
    30             altijd gerenderd en via body.on-hub (CSS) verborgen op de landing, dus htmx
    31             kan schakelen zonder full-reload → de audioplayer blijft leven. %>
     29        <%# Hub: the brand navigates to the hub home via HTMX. The chrome is always
     30            rendered in hub mode and hidden on the landing via body.on-hub (CSS), so htmx
     31            can switch without a full reload → the audio player stays alive. %>
    3232        <a class="nav-hub-home" href="/" aria-label="Home" title="Home"
    3333           hx-get="/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/" hx-indicator="#pcms-loading">
     
    207207<script>
    208208(function() {
    209   // EÉN keer bedraden. Deze chrome (topnav) wordt bij htmx-navigatie out-of-band
    210   // opnieuw ingevoegd → zonder deze guard zou dit script bij elke navigatie EXTRA
    211   // listeners stapelen, waardoor de thema-toggle 2× (of vaker) vuurde = geen netto
    212   // wisseling ("toggle werkt niet meer"). Alles hieronder is event-delegatie op
    213   // body/document, dus het werkt ook voor knoppen die ná deze run (OOB) verschijnen.
     209  // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation
     210  // → without this guard the script would stack EXTRA listeners on every navigation,
     211  // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working").
     212  // Everything below uses event delegation on body/document, so it also works for
     213  // buttons that appear after this run (OOB).
    214214  if (window.__pcmsChromeWired) return;
    215215  window.__pcmsChromeWired = true;
     
    229229    if (e.target.closest('#search-toggle')) { openSearch(); return; }
    230230    if (e.target.closest('#search-close')) { closeSearch(); return; }
    231     // Open dropdowns (gebruikersmenu + taalkeuze) sluiten bij klik erbuiten.
     231    // Close open dropdowns (user menu + language picker) on click outside.
    232232    document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) {
    233233      if (!d.contains(e.target)) d.removeAttribute('open');
     
    239239  });
    240240
    241   // ── Live resultaten tijdens typen ───────────────────────────────
     241  // ── Live results while typing ───────────────────────────────────
    242242  var _ST = {
    243243    posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>',
     
    280280    }, 200);
    281281  });
    282   // Klik op een suggestie → overlay sluiten (de link/boost doet de navigatie).
     282  // Click on a suggestion → close the overlay (the link/boost handles navigation).
    283283  document.addEventListener('click', function(e) {
    284284    if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; }
     
    313313}
    314314
    315 /* Hub-home brand is WÉL zichtbaar — de thuisknop op artiest-/hub-pagina's. */
     315/* Hub-home brand IS visible — the home button on artist/hub pages. */
    316316.masthead-brand .nav-hub-home {
    317317  display: inline-flex; align-items: center; justify-content: center;
     
    436436  min-width: 220px;
    437437  padding: .35rem;
    438   /* Frosted glass: transparant + zware blur zodat het frosted effect duidelijk is. */
     438  /* Frosted glass: transparent + heavy blur so the frosted effect is clearly visible. */
    439439  background: color-mix(in srgb, var(--paper) 60%, transparent);
    440440  backdrop-filter: blur(34px) saturate(190%);
     
    565565.ss-all { display: block; margin-top: .35rem; padding: .55rem .6rem; border-top: 1px solid var(--rule); color: var(--accent); font-weight: 600; text-decoration: none; font-size: .9rem; }
    566566.ss-all:hover { text-decoration: underline; }
    567 /* Meldingen-bel + ongelezen-badge */
     567/* Notification bell + unread badge */
    568568.nav-notif { position: relative; }
    569569.notif-badge {
  • src/views/partials/track-editor.ejs

    rbb42dfb r834bcc3  
    195195}
    196196
    197 /* ── Credit-veld met ©-invoegknop ──────────────────────────── */
     197/* ── Credit field with © insert button ──────────────────────────── */
    198198.te-credit-row { display: flex; gap: 0.4rem; align-items: stretch; }
    199199.te-credit-row input { flex: 1; min-width: 0; }
     
    638638    }
    639639
    640     // ── ©-teken invoegen in het credit-veld ─────────────────
     640    // ── Insert © symbol into the credit field ─────────────────
    641641    const copyrBtn = $('#te-credit-copyr');
    642642    if (copyrBtn) {
     
    693693    });
    694694
    695     // ── Auto-duur ───────────────────────────────────────────
    696     // De server bepaalt de duur al automatisch bij upload. Dit is de vangnet/
    697     // UX-laag: opent een admin een bestaande track zónder duur, dan lezen we 'm
    698     // hier uit de audio-metadata en vullen het veld — zodat je nooit seconden
    699     // hoeft te typen. Bestaande waarde wordt nooit overschreven. We halen de
    700     // bytes via dezelfde header-gate als de speler (X-Audio-Player).
     695    // ── Auto-duration ───────────────────────────────────────────
     696    // The server already determines duration automatically on upload. This is the
     697    // fallback/UX layer: if an admin opens an existing track without a duration,
     698    // we read it from the audio metadata and fill the field — so you never need
     699    // to type seconds manually. An existing value is never overwritten. We fetch
     700    // the bytes via the same header gate as the player (X-Audio-Player).
    701701    (async function autoDuration() {
    702702      const durEl = $('#te-duration');
    703703      if (!durEl || !track.stream_url) return;
    704       if (durEl.value && Number(durEl.value) > 0) return;  // al ingevuld → met rust laten
     704      if (durEl.value && Number(durEl.value) > 0) return;  // already filled → leave it alone
    705705      let objUrl = null;
    706706      try {
Note: See TracChangeset for help on using the changeset viewer.