Changeset 834bcc3 in Klonkt for src/views


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/views
Files:
21 edited

Legend:

Unmodified
Added
Removed
  • 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.