- Timestamp:
- 06/23/2026 06:14:27 PM (3 months ago)
- Branches:
- main
- Children:
- d774679
- Parents:
- bb42dfb
- Location:
- src/views
- Files:
-
- 21 edited
-
pages/account.ejs (modified) (3 diffs)
-
pages/admin-circle.ejs (modified) (2 diffs)
-
pages/admin-google.ejs (modified) (1 diff)
-
pages/admin-seo.ejs (modified) (1 diff)
-
pages/admin-settings.ejs (modified) (10 diffs)
-
pages/admin-shows.ejs (modified) (1 diff)
-
pages/admin-site-edit.ejs (modified) (6 diffs)
-
pages/admin-users.ejs (modified) (2 diffs)
-
pages/auth-login.ejs (modified) (5 diffs)
-
pages/circle-feed.ejs (modified) (2 diffs)
-
pages/fan-gate.ejs (modified) (2 diffs)
-
pages/hub-home.ejs (modified) (8 diffs)
-
pages/post-edit.ejs (modified) (44 diffs)
-
pages/post.ejs (modified) (4 diffs)
-
partials/bottom-tab.ejs (modified) (4 diffs)
-
partials/footer.ejs (modified) (4 diffs)
-
partials/like-button.ejs (modified) (1 diff)
-
partials/post-nav.ejs (modified) (2 diffs)
-
partials/shared-styles.ejs (modified) (4 diffs)
-
partials/topnav.ejs (modified) (8 diffs)
-
partials/track-editor.ejs (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/views/pages/account.ejs
rbb42dfb r834bcc3 90 90 </section> 91 91 92 <%# ── PERSO ONLIJKE TAAL ───────────────────────────────────── %>92 <%# ── PERSONAL LANGUAGE ──────────────────────────────────── %> 93 93 <% if (canMutate) { %> 94 94 <section class="ax-card"> … … 110 110 <% } %> 111 111 112 <%# ── SITE ( eigenaar mag de naam van z'n site aanpassen)── %>112 <%# ── SITE (owner may update their site name) ─────────── %> 113 113 <% if (typeof editableSite !== 'undefined' && editableSite && canMutate) { %> 114 114 <section class="ax-card"> … … 130 130 <% } %> 131 131 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). %> 134 134 <% if (canMutate) { %> 135 135 <% if (hasPassword) { %> -
src/views/pages/admin-circle.ejs
rbb42dfb r834bcc3 79 79 <span class="circ-badge">⏸ <%= _cPaused %></span> 80 80 <% } 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". %> 82 82 <span class="circ-badge"><%= l.status %></span> 83 83 <% if (l.last_error) { %><div class="circ-reason"><%= l.last_error %></div><% } %> … … 123 123 .circ-status { display: flex; flex-wrap: wrap; align-items: center; gap: .35rem .5rem; margin-top: .25rem; font-size: .78rem; color: var(--ink-muted); } 124 124 .circ-meta { color: var(--ink-muted); } 125 /* Re den op een eigen regel onder de badge; normale woordafbreking (geenbreak-all126 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). */ 127 127 .circ-reason { flex-basis: 100%; line-height: 1.4; color: var(--ink-muted); word-break: normal; overflow-wrap: anywhere; } 128 128 .btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; } -
src/views/pages/admin-google.ejs
rbb42dfb r834bcc3 103 103 .set-redirect-warn { margin: 0.85rem 0 0; font-size: 0.85rem; color: var(--ink-muted); } 104 104 .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) */ 106 106 .gg-steps { list-style: none; margin: 0 0 1rem; padding: 0; display: flex; flex-direction: column; gap: 0.85rem; } 107 107 .gg-steps > li { display: flex; gap: 0.7rem; align-items: flex-start; } -
src/views/pages/admin-seo.ejs
rbb42dfb r834bcc3 110 110 111 111 .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. */ 114 114 .site-form fieldset { min-width: 0; } 115 115 .site-form input, .site-form textarea, .site-form select { min-width: 0; } -
src/views/pages/admin-settings.ejs
rbb42dfb r834bcc3 40 40 </section> 41 41 42 <%# ── Standaardtaal voor bezoekers ── %>42 <%# ── Default language for visitors ── %> 43 43 <section class="set-card" style="margin-top:1rem"> 44 44 <h2><%= t('aset.default_lang') %></h2> … … 58 58 </section> 59 59 60 <%# ── Ti jdzone ── %>60 <%# ── Timezone ── %> 61 61 <section class="set-card" style="margin-top:1rem"> 62 62 <h2><%= t('aset.timezone') %></h2> … … 78 78 </section> 79 79 80 <%# Mod us-specifieke config — staat direct onder de Modus-kaart zodat 'ie bij81 de gekozen modus hoort (Hub bij hub, Cirkel bijcircle). %>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). %> 82 82 <% if (tenancy === 'hub') { %> 83 83 <section class="set-card set-card--mode" style="margin-top:1rem"> … … 141 141 <% } %> 142 142 143 <%# Premium (Patreon) — alleen zichtbaar als de self-hoster de premium-laag aanzet144 (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. %> 145 145 <% if (premium && premium.enabled) { %> 146 146 <section class="set-card" style="margin-top:1rem"> … … 162 162 <% } %> 163 163 164 <%# Google -login staat op een eigen Beheer-pagina (compacte link hier). %>164 <%# Google login has its own admin page (compact link shown here). %> 165 165 <section class="set-card" style="margin-top:1rem"> 166 166 <h2><%= t('aset.google_login') %></h2> … … 170 170 </section> 171 171 172 <%# N ieuwsbrief-aanmelding in de footer (premium). %>172 <%# Newsletter signup in the footer (premium). %> 173 173 <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %> 174 174 <section class="set-card" style="margin-top:1rem"> … … 185 185 <% } %> 186 186 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. %> 188 188 <section class="set-card" style="margin-top:1rem"> 189 189 <h2><%= t('aset.email_smtp') %></h2> … … 233 233 .admin-tagline a { color: var(--accent); } 234 234 .set-card { background: var(--paper-2); border: 1px solid var(--rule); border-radius: 12px; padding: 1.25rem; } 235 /* Mod us-specifieke kaart (Hub/Cirkel): accent-rand links zodat 'ie zichtbaar bij236 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. */ 237 237 .set-card--mode { border-left: 3px solid var(--accent); } 238 238 .set-card h2 { font-family: var(--font-display, serif); font-size: 1.25rem; margin: 0 0 0.5rem; } 239 239 .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) */ 241 241 .set-google-guide { margin: 0 0 1rem; border: 1px solid var(--rule); border-radius: 8px; background: var(--paper); } 242 242 .set-google-guide > summary { cursor: pointer; padding: 0.6rem 0.85rem; font-size: 0.9rem; font-weight: 600; color: var(--accent); list-style: none; } … … 249 249 .set-google-guide .set-help { padding: 0 0.85rem; } 250 250 .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.) */ 253 253 .set-form > label:not(.set-toggle):not(.set-opt) { display: flex; flex-direction: column; gap: 0.3rem; } 254 254 .set-form > label:not(.set-toggle):not(.set-opt) > span { font-size: 0.8rem; font-weight: 600; color: var(--ink-soft, var(--ink-muted)); } … … 277 277 .set-field textarea { resize: vertical; } 278 278 .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. */ 281 281 .set-form select { 282 282 appearance: none; -webkit-appearance: none; -
src/views/pages/admin-shows.ejs
rbb42dfb r834bcc3 63 63 .ash-note { font-size: 13px; opacity: .8; margin: 0 0 18px; } 64 64 .ash-form { border: 1px solid rgba(128,128,128,.2); border-radius: 14px; padding: 16px; margin-bottom: 24px; } 65 /* Mobi el-eerst: één kolom (twee 1fr-kolommen met niet-krimpbare date/text-inputs66 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. */ 67 67 .ash-grid { display: grid; grid-template-columns: 1fr; gap: 10px; } 68 68 @media (min-width: 520px) { .ash-grid { grid-template-columns: 1fr 1fr; } } -
src/views/pages/admin-site-edit.ejs
rbb42dfb r834bcc3 24 24 <input type="text" name="title" value="<%= site.title || '' %>" required maxlength="200"> 25 25 </label> 26 <%# Eigenaar (her)toewijzen — ALLEEN god ÉN alleen in hub-modus. In hub geef27 je zo elke gebruiker z'n eigen, zelf te beheren Klonkt. In solo (éénsite,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. %> 29 29 <% if (user && user.role === 'god' && (typeof tenancy !== 'undefined' && tenancy === 'hub')) { %> 30 30 <label> … … 250 250 <script> 251 251 (function() { 252 // Slug -URL-veld: toon de echte host als gedimde prefix (de slug zelf is253 // gekleurd via CSS). Hub → host/user/<slug>, andershost/<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>. 254 254 var sh = document.getElementById('slug-host'); 255 255 if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/'); … … 354 354 355 355 .site-form { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; } 356 /* Mobi el: een <fieldset> heeft een browser-default min-inline-size:min-content en357 krimpt daardoor NIET mee → het hele formulier werd breder dan het scherm. min-width:0358 force ert meekrimpen; de form-velden krijgen max-width:100% + border-box zodat ze359 n ooit 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. */ 360 360 .site-form fieldset { min-width: 0; } 361 361 .site-form input, .site-form select, .site-form textarea { min-width: 0; max-width: 100%; box-sizing: border-box; } … … 398 398 .site-form input[readonly] { opacity: 0.6; cursor: not-allowed; background: var(--paper-2); } 399 399 400 /* Slug a ls 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. */ 401 401 .slug-url { 402 402 display: flex; align-items: stretch; … … 425 425 } 426 426 427 /* Selects: zelfde look als de inputs, met een eigen chevron i.p.v. de kale428 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. */ 429 429 .site-form select { 430 430 appearance: none; … … 562 562 .photo-controls { gap: .55rem; } 563 563 } 564 /* Mobi el-eerst: platform-keuze op een eigen regel (volle breedte), daaronder de565 URL-input + verwijderknop. Voorkomt horizontale overflow op telefoons (de vaste566 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). */ 567 567 .profile-link-row { 568 568 display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem; -
src/views/pages/admin-users.ejs
rbb42dfb r834bcc3 71 71 72 72 <div class="ax-user-controls"> 73 <%# Hub: g eef deze gebruiker een eigen, zelf te beheren Klonkt — opent het74 n ieuw-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. %> 75 75 <% if (typeof tenancy !== 'undefined' && tenancy === 'hub' && canMutate) { %> 76 76 <a href="/admin/sites/new?owner=<%= u.id %>" class="ax-icon-btn" … … 98 98 </form> 99 99 <% } else { %> 100 <%# Lege plek zodat de rol-kolom op elke rij uitlijnt. %>100 <%# Empty placeholder so the role column aligns on every row. %> 101 101 <span class="ax-icon-btn-spacer" aria-hidden="true"></span> 102 102 <% } %> -
src/views/pages/auth-login.ejs
rbb42dfb r834bcc3 1 1 <% 2 // adminLogin = de verborgen beheerders-loginpagina (/auth/admin): toont het3 // gebruikersnaam/wachtwoord-formulier. De publieke /auth/login toont voor4 // bezoekers ALLEEN Google-login (luisteraars/fans). Zo staat de admin-login5 // 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. 6 6 var _admin = (typeof adminLogin !== 'undefined' && adminLogin); 7 7 %> … … 32 32 33 33 <% if (_admin) { %> 34 <%# Verborgen beheerders-login (wachtwoord). %>34 <%# Hidden admin login (password). %> 35 35 <form method="post" action="/auth/login" class="auth-form"> 36 36 <% if (typeof next !== 'undefined' && next) { %> … … 49 49 </form> 50 50 <% } else if (googleReady) { %> 51 <%# Publi eke login: alleen Google, voor luisteraars/fans. %>51 <%# Public login: Google only, for listeners/fans. %> 52 52 <p class="auth-sub"><%= t('auth.public_sub') %></p> 53 53 <a class="btn btn-google" href="/auth/google<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>"> … … 65 65 66 66 <% 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. %> 68 68 <a class="auth-admin-box" href="/auth/admin<%= (typeof next !== 'undefined' && next) ? '?next=' + encodeURIComponent(next) : '' %>"> 69 69 <span class="auth-admin-box-icon" aria-hidden="true">🔑</span> … … 85 85 .auth-notice-list li { margin: 4px 0; } 86 86 .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 globale88 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). */ 89 89 a.btn-google, a.btn-google:hover { color: #1f1f1f; } 90 90 .auth-admin-box { -
src/views/pages/circle-feed.ejs
rbb42dfb r834bcc3 26 26 </div> 27 27 <% } else { %> 28 <%# M eer 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. %> 29 29 <span class="cirkel-count"><%= t('cfeed.count', { n: sites.length }) %></span> 30 30 <% } %> … … 38 38 <% } else { %> 39 39 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-posts42 zijn in circle.js naar het lokale post-shape gevormd (metexternal_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). %> 44 44 45 45 <!-- ===== TIMELINE (standaard) ===== --> -
src/views/pages/fan-gate.ejs
rbb42dfb r834bcc3 1 1 <div class="container fg-navwrap"> 2 <%# Zelfde post-navigatie (Newer/Older) als op een gewone post, zodat een3 bezoeker op de fan-gate niet vastloopt maar verder kan bladeren. Zelfde4 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. %> 5 5 <%- include('../partials/post-nav', { newerPost: (typeof newerPost !== 'undefined' ? newerPost : null), olderPost: (typeof olderPost !== 'undefined' ? olderPost : null) }) %> 6 6 </div> … … 17 17 18 18 <style> 19 /* EXACT dezelfde class-combinatie (.container + override) én waarden als20 .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. */ 21 21 .fg-navwrap { max-width: 720px; margin: 0.5rem auto 2rem; padding: 0 1rem; } 22 22 .fg { max-width: 480px; margin: 0 auto; padding: 56px 18px; text-align: center; } -
src/views/pages/hub-home.ejs
rbb42dfb r834bcc3 1 1 <div class="hub<%= hub.heroImage ? ' hub--img' : '' %>"> 2 2 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 ──────────────── %> 5 5 <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) %>"<% } %>> 6 6 <div class="hub-hero-inner container"> … … 17 17 <div class="container hub-body"> 18 18 19 <%# ── HOOFDPAGINA (label-/hoofdaccount — geen artiest) ──────────── %>19 <%# ── MAIN PAGE (label-/primary account — not an artist) ──────────── %> 20 20 <% if (mainSite) { %> 21 21 <section class="hub-sec hub-sec-main"> … … 37 37 <% } %> 38 38 39 <%# ── ARTI ESTEN-ROSTER ─────────────────────────────────────────── %>39 <%# ── ARTIST ROSTER ─────────────────────────────────────────── %> 40 40 <% if (artists && artists.length) { %> 41 41 <% var _total = (typeof totalArtists !== 'undefined') ? totalArtists : artists.length; %> … … 69 69 <% } %> 70 70 71 <%# ── LA ATSTEPOSTS ────────────────────────────────────────────── %>71 <%# ── LATEST POSTS ────────────────────────────────────────────── %> 72 72 <% if (posts && posts.length) { %> 73 73 <section class="hub-sec"> … … 99 99 .hub { width: 100%; position: relative; } 100 100 101 /* Hero sluit direct aan op de mini topnav-balk — geen tussenruimte boven de102 hub -home (override van de globalemain-padding-top). */101 /* Hero connects directly to the mini topnav bar — no gap above the 102 hub home (overrides the global main-padding-top). */ 103 103 .pcms-main { padding-top: 0; } 104 104 .hub-hero { margin-top: 0; } … … 160 160 .hub-more-link:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, var(--paper-2)); } 161 161 162 /* ── Hoofdpagina-kaart (label-/hoofdaccount, apart van de artiesten) ── */162 /* ── Main page card (label-/primary account, separate from the artists) ── */ 163 163 .hub-sec-main { margin-top: 2.25rem; } 164 164 .hub-main-card { … … 199 199 } 200 200 201 /* ── Roster (arti esten) ───────────────────────────────────────────── */201 /* ── Roster (artists) ───────────────────────────────────────────── */ 202 202 .roster { 203 203 display: grid; … … 231 231 } 232 232 233 /* ── Feed (la atsteposts) ─────────────────────────────────────────── */233 /* ── Feed (latest posts) ─────────────────────────────────────────── */ 234 234 .hub-feed { display: flex; flex-direction: column; gap: 0.6rem; } 235 235 .feed-item { -
src/views/pages/post-edit.ejs
rbb42dfb r834bcc3 9 9 // and styling changed. 10 10 const _hasCover = !!post.cover_image_url; 11 // In hub -modus moeten save/create/cancel de /user/<slug>/-prefix dragen, anders12 // valt de request terug op de primaire site (siteUrlBase leeg) en rendert de13 // 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. 14 14 const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; 15 15 %> … … 138 138 data-placeholder="<%= t('pedit.editor_placeholder') %>"></div> 139 139 140 <%# Sticky "t ik om te bewerken"-hint (alleen zichtbaar op touch + niet-fullscreen141 via CSS). Pu ur visueel (pointer-events:none); de tik op het veld opentfullscreen. %>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. %> 142 142 <div class="pe-edit-hint" aria-hidden="true">✎ <%= t('pedit.tap_to_edit') %></div> 143 143 … … 225 225 if (!cb || !box) return; 226 226 var inp = document.getElementById('pe-publish-at'); 227 var SITE_TZ = '<%= timezone || '' %>'; // ingestelde site-tijdzone; leeg = browser-lokaal227 var SITE_TZ = '<%= timezone || '' %>'; // configured site timezone; empty = browser local 228 228 var pad = function (n) { return String(n).padStart(2, '0'); }; 229 // Offset (ms) tussen een tijdzone en UTC op een bepaaldmoment.229 // Offset (ms) between a timezone and UTC at a given moment. 230 230 function tzOffset(date, tz) { 231 231 var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); … … 233 233 return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime(); 234 234 } 235 // datetime-local "wa ndtijd" (in de site-zone) → UTC Date.235 // datetime-local "wall time" (in the site zone) → UTC Date. 236 236 function wallToUtc(wall) { 237 237 if (!SITE_TZ) return new Date(wall); … … 239 239 return new Date(guess - tzOffset(new Date(guess), SITE_TZ)); 240 240 } 241 // UTC-ISO → "YYYY-MM-DDTHH:MM" wa ndtijd in de site-zone.241 // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone. 242 242 function utcToWall(iso) { 243 243 var d = new Date(iso); if (isNaN(d)) return ''; … … 247 247 return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute; 248 248 } 249 // Prefill: opgeslagen UTC → wandtijd in de site-zone.249 // Prefill: stored UTC → wall time in the site zone. 250 250 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. 252 252 var when = document.getElementById('pe-sched-when'); 253 253 if (when && when.dataset.iso) { … … 257 257 function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; } 258 258 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. 260 260 var form = cb.closest('form'); 261 261 if (form) { … … 466 466 width: 32px; height: 32px; 467 467 padding: 0; 468 touch-action: manipulation; /* snappy taps o p mobiel, geen dubbeltik-zoom */468 touch-action: manipulation; /* snappy taps on mobile, no double-tap zoom */ 469 469 -webkit-user-select: none; user-select: none; 470 470 background: transparent; … … 478 478 -webkit-tap-highlight-color: transparent; 479 479 } 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. */ 482 482 @media (hover: hover) { 483 483 .pe-toolbar button:hover { … … 487 487 } 488 488 .pe-toolbar button:active { transform: scale(.94); } 489 /* Acti eve opmaak = onmiskenbaar gevuld met de accentkleur. Zo is op mobiel490 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). */ 491 491 .pe-toolbar button.is-active { 492 492 background: var(--accent); … … 505 505 .pe-toolbar-spacer { flex: 1; } 506 506 507 /* ─── Volledig-scherm schrijfmodus─── */507 /* ─── Full-screen writing mode ─── */ 508 508 .fs-icon-compress { display: none; } 509 509 .pe-editor-frame.pe-fs .fs-icon-expand { display: none; } … … 514 514 height: 100dvh; 515 515 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 zelf519 (max-height: none over schrijft 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). */ 520 520 .pe-editor-frame.pe-fs .pe-editor { 521 521 flex: 1 1 auto; min-height: 0; height: auto; max-height: none; 522 522 } 523 /* NB: bewust GEEN overflow:hidden op html/body in fullscreen — dat kon blijven524 hangen (bv. fullscreen verlaten via navigatie) en blokkeerde dan het scrollen van525 de hele pagina. Het fullscreen-frame (position:fixed, inset:0) dekt de pagina al526 a f, en op touch zijn de scrollbalken verborgen → geen dubbele balk nodig. */527 528 /* " Klaar"-knop: alleen in fullscreen zichtbaar (links in de toolbar), duidelijke529 accent-pill i .p.v. een vierkant icoontje. */530 /* Verberg-regel specifieker dan ".pe-toolbar button" (anders wint die en zie je531 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. */ 532 532 .pe-toolbar button.pe-fs-done { display: none; } 533 533 .pe-editor-frame.pe-fs .pe-fs-done { … … 556 556 } 557 557 .pe-editor:focus { outline: none; } 558 /* Inline (n iet-fullscreen) groeit het schrijfveld gewoon mee met de inhoud — geen559 intern e scroll-box (scroll-binnen-scroll is verwarrend). Op mobiel/tablet gaat560 typen sowieso fullscreen (zie JS), waar het veld de pagina vult en als enige561 scroll t. */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. */ 562 562 .pe-editor-frame:not(.pe-fs) .pe-editor { overflow: visible; } 563 563 564 /* Touch: het inline content-veld is geen tekstveld maar een tap-vlak→ fullscreen565 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. */ 567 567 .pe-editor.pe-tap-to-edit { cursor: pointer; } 568 568 .pe-edit-hint { display: none; } … … 571 571 display: block; 572 572 position: sticky; 573 bottom: 4.5rem; /* boven de sticky Opslaan/Annuleren-balk*/573 bottom: 4.5rem; /* above the sticky Save/Cancel bar */ 574 574 width: max-content; 575 575 max-width: calc(100% - 2rem); … … 580 580 pointer-events: none; text-align: center; white-space: nowrap; 581 581 } 582 /* N iet relevant op touch (slepen/selecteren hoort bij inline-bewerken opdesktop). */582 /* Not relevant on touch (drag/select belongs to inline editing on desktop). */ 583 583 .pe-content-hint { display: none; } 584 /* Inline (n iet-fullscreen) op touch: simpel houden — je bewerkt hier toch niet,585 dus geen opmaak-toolbar en geen losse rand. Alleen de content-preview + de586 "t ik 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. */ 587 587 .pe-editor-frame:not(.pe-fs) .pe-toolbar { display: none; } 588 588 .pe-editor-frame:not(.pe-fs) { border-top: 0; } … … 955 955 } 956 956 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). */ 958 958 .pe-pin { display: flex; flex-direction: column; gap: 0.45rem; } 959 959 .pe-pin-pos { display: flex; align-items: center; gap: 0.55rem; padding-left: 1.65rem; } … … 978 978 gap: 0.5rem; 979 979 padding: 0.85rem 1rem; 980 margin: 0.5rem -1rem 0; /* extend to viewport edges on tightcontainer */980 margin: 0.5rem -1rem 0; /* extend to viewport edges on a narrow container */ 981 981 background: color-mix(in srgb, var(--paper) 94%, transparent); 982 982 -webkit-backdrop-filter: blur(8px); … … 987 987 .pe-actions-spacer { flex: 1; } 988 988 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). De991 Opslaan/Annuleren-balk wordt dan de enige onderbalk → gewoon opbottom: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. */ 993 993 body:has(.post-edit-page) .bottom-tab { display: none; } 994 /* Audio speler (indien aan het spelen) niet meer 56px optillen voor de nu-verborgen995 tab balk, 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. */ 996 996 body:has(.post-edit-page) .audio-player { bottom: 0; } 997 997 998 /* ── Afbeeldings-editor (draaien / bijsnijden / spiegelen) ── */998 /* ── Image editor (rotate / crop / mirror) ── */ 999 999 .imed-backdrop { 1000 1000 position: fixed; inset: 0; z-index: 9999; … … 1015 1015 } 1016 1016 .imed-stage img { display: block; max-width: 100%; } 1017 /* Cropper z'n container moet de volle stage-hoogte vullen — anders klapt 'ie dicht1018 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). */ 1019 1019 .imed-stage .cropper-container { width: 100% !important; height: 100% !important; } 1020 1020 .imed-tools { … … 1052 1052 } 1053 1053 1054 // ── Afbeeldings-editor (draaien / bijsnijden / spiegelen) ──────────1055 // Lazy-load Cropper.js (lo kaal gevendord) pas bij het eerste gebruik.1054 // ── Image editor (rotate / crop / mirror) ────────── 1055 // Lazy-load Cropper.js (locally vendored) on first use. 1056 1056 let _cropperReady = null; 1057 1057 function ensureCropper() { … … 1073 1073 } 1074 1074 1075 // Open t de editor voor een gekozen bestand; resolved met een bewerktFile,1076 // o f null als de gebruiker annuleert. Animatie-GIF's worden niet door de1077 // 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. 1078 1078 async function openImageEditor(file) { 1079 1079 if (!file || !file.type || !file.type.startsWith('image/')) return file; 1080 if (file.type === 'image/gif') return file; // behoud animatie1081 try { await ensureCropper(); } catch (_) { return file; } // editor onbeschikbaar → direct uploaden1080 if (file.type === 'image/gif') return file; // preserve animation 1081 try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly 1082 1082 1083 1083 return new Promise((resolve) => { … … 1118 1118 cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true }); 1119 1119 }; 1120 img.onerror = () => { cleanup(); resolve(file); }; // kon niet laden → upload het origineel1120 img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original 1121 1121 img.src = url; 1122 1122 … … 1179 1179 const edited = await openImageEditor(coverField.files[0]); 1180 1180 coverField.value = ''; 1181 if (!edited) return; // geannuleerd1181 if (!edited) return; // cancelled 1182 1182 coverStatus.classList.remove('is-error'); 1183 1183 coverStatus.textContent = '<%= t('pedit.js_uploading') %>'; … … 1224 1224 if (!editor) return; 1225 1225 1226 // Titel alleen automatisch focussen op desktop (muis/trackpad). Op touch zou1227 // 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. 1228 1228 try { 1229 1229 const titleInput = form && form.querySelector('input[name="title"]'); … … 1329 1329 1330 1330 // ── Toolbar wiring 1331 // Houd de scrollpositie vast rond een edit-commando. execCommand/insert scrollt1332 // standaard de caret in beeld → het beeld "verspringt" bij het aanklikken van een1333 // opmaakknop. We leggen ALLE scrollbare voorouders (editor, frame, #pcms-main, …)1334 // + de pagina vast en zetten ze terug — sync én over een paar frames, wantChrome1335 // s crollt 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. 1336 1336 function scrollableAncestors(el) { 1337 1337 const list = []; … … 1345 1345 } 1346 1346 function keepScroll(fn) { 1347 // In fullscreen staat de pagina vast (body overflow:hidden) en mag het veld1348 // 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. 1349 1349 const frame = document.querySelector('.pe-editor-frame'); 1350 1350 if (frame && frame.classList.contains('pe-fs')) { fn(); return; } … … 1389 1389 execCmd('createLink', url); 1390 1390 } 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. 1392 1392 function blockquoteAncestor() { 1393 1393 const sel = window.getSelection(); … … 1400 1400 return null; 1401 1401 } 1402 // Echte toggle: execCommand('formatBlock','blockquote') zet 'm wél AAN maar1403 // krijgt 'm nooit meer UIT (browser-quirk). Staat de caret al in eenquote →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. 1405 1405 function toggleBlockquote() { 1406 1406 keepScroll(function () { … … 1409 1409 if (bq) { 1410 1410 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. 1412 1412 const ref = bq; 1413 1413 let firstMoved = null; … … 1418 1418 } 1419 1419 parent.removeChild(bq); 1420 // Caret terugzetten in de uitgepakte inhoud.1420 // Restore the caret inside the unwrapped content. 1421 1421 if (firstMoved) { 1422 1422 const sel = window.getSelection(); … … 1436 1436 1437 1437 if (toolbar) { 1438 // CRUCIA AL (mobiel + desktop): voorkom dat een toolbar-knop de focus/selectie1439 // uit het editor-veld steelt. Zonder dit raakt de selectie kwijt bij het tikken1440 // → execCommand werkt op een lege selectie (vet kan niet meer UIT) én de browser1441 // scrollt de caret opnieuw in beeld (de "sprong naar beneden"). preventDefault op1442 // 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. 1443 1443 toolbar.addEventListener('mousedown', (e) => { 1444 1444 if (e.target.closest('button')) e.preventDefault(); … … 1457 1457 } 1458 1458 1459 // ── Volledig-scherm schrijfmodus: het schrijfveld vult de hele pagina.1459 // ── Full-screen writing mode: the writing field fills the whole page. 1460 1460 const fsBtn = document.getElementById('pe-fullscreen-btn'); 1461 1461 const editorFrame = document.querySelector('.pe-editor-frame'); 1462 1462 const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches); 1463 1463 1464 // O p mobiel duwt het toetsenbord de zichtbare (visual) viewport omhoog terwijl1465 // een position:fixed-frame aan de LAYOUT-viewport blijft hangen → de toolbar1466 // s chuift uit beeld. Houd het fullscreen-frame daarom gelijk aan de visual1467 // viewport (top + h oogte), 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. 1468 1468 function syncFsViewport() { 1469 1469 if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return; … … 1499 1499 } 1500 1500 } 1501 // O p touch is het veld inline NIET bewerkbaar; alleen in fullscreen wel.1501 // On touch the field is NOT editable inline; only in fullscreen. 1502 1502 if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false'); 1503 1503 if (on) { … … 1505 1505 } else { 1506 1506 if (isTouch) editor.blur(); 1507 // Bij sluiten: naar de TOP van de content scrollen i.p.v. ergens onderaan1508 // (footer) te blijven hangen.1507 // On close: scroll to the TOP of the content instead of staying 1508 // somewhere at the bottom (footer). 1509 1509 requestAnimationFrame(function () { 1510 1510 try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {} … … 1512 1512 } 1513 1513 } 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 — voelt1516 // 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). 1517 1517 function openFs() { 1518 1518 if (isFs()) return; … … 1522 1522 function closeFs() { 1523 1523 if (!isFs()) return; 1524 if (history.state && history.state.peFs) history.back(); // → popstate sluit af1524 if (history.state && history.state.peFs) history.back(); // → popstate closes it 1525 1525 else applyFs(false); 1526 1526 } … … 1534 1534 }); 1535 1535 1536 // O p mobiel/tablet (touch): het content-veld is INLINE NIET bewerkbaar — het is1537 // dan geen tekstveld. Eén tik → fullscreen, waar het wél bewerkbaar wordt1538 // (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. 1539 1539 if (isTouch) { 1540 1540 editor.setAttribute('contenteditable', 'false'); … … 1555 1555 try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {} 1556 1556 } 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). 1558 1558 const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]'); 1559 1559 if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor()); … … 1588 1588 async function uploadAndInsertImage(file) { 1589 1589 const edited = await openImageEditor(file); 1590 if (!edited) return; // geannuleerd1590 if (!edited) return; // cancelled 1591 1591 contentStatus.classList.remove('is-error'); 1592 1592 contentStatus.textContent = '<%= t('pedit.js_uploading') %>'; … … 1654 1654 } 1655 1655 1656 // ── Embed insert: p lak een platform-URL -> [[embed:url]]-chip die server-side1657 // 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). 1658 1658 const embedBtn = document.getElementById('insert-embed-btn'); 1659 1659 if (embedBtn) { … … 1674 1674 const tpEmpty = document.getElementById('tp-empty'); 1675 1675 const tpSearch = document.getElementById('tp-search'); 1676 let tpCache = null; // cached track slist (fetched once per page load)1676 let tpCache = null; // cached track list (fetched once per page load) 1677 1677 let tpLastFocus = null; // element to restore focus to on close 1678 1678 … … 1870 1870 <script> 1871 1871 (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. 1874 1874 var toggle = document.getElementById('pin-toggle'); 1875 1875 var rank = document.getElementById('pin-rank'); 1876 1876 var pos = document.getElementById('pin-pos'); 1877 1877 var label = document.getElementById('pin-label'); 1878 var up = document.getElementById('pin-up'); // h oger = lager getal (richting 1/bovenaan)1878 var up = document.getElementById('pin-up'); // higher = lower number (towards 1/top) 1879 1879 var down = document.getElementById('pin-down'); 1880 1880 if (!toggle || !rank || !pos) return; … … 1900 1900 1901 1901 (function () { 1902 // De Opslaan/Annuleren-balk (position: sticky; bottom:0) net boven twee mogelijke1903 // obsta kels houden door een dynamische bottom-offset te zetten = het grootste van:1904 // 1) de hoogte van het toetsenbord-deel dat de layout-viewport NIET dekt1905 // (o p iOS verschuift de visual viewport; op Android verkleint de layout-1906 // viewport doorinteractive-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). 1909 1909 var bar = document.querySelector('.pe-actions'); 1910 1910 if (!bar) return; … … 1922 1922 window.addEventListener('resize', position); 1923 1923 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. 1925 1925 try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {} 1926 1926 })(); -
src/views/pages/post.ejs
rbb42dfb r834bcc3 1 1 <% 2 // In hub -mode is de site bereikbaar onder /user/<slug>; allesite-scoped links3 // (tag, edit, delete, comments) m oeten met die prefix anders vallen ze terug op4 // 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 = ''. 5 5 const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : ''; 6 6 %> 7 7 <article class="container post-page"> 8 8 9 <%# Navigati e over ALLE posts — BOVEN de post (gedeeld partial). %>9 <%# Navigation across ALL posts — ABOVE the post (shared partial). %> 10 10 <%- include('../partials/post-nav', { newerPost: newerPost, olderPost: olderPost }) %> 11 11 … … 79 79 80 80 <% 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 */ %> 82 82 <% } %> 83 83 <script> … … 164 164 </div> 165 165 166 <%# Re ageren op een antwoord — landt (via resolvedParent op de167 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. %> 168 168 <% if (user && canMutate) { %> 169 169 <form method="post" action="<%= _base %>/comments" class="comment-reply-form" hidden data-reply-form-for="<%= r.id %>"> … … 431 431 .comment-form button { align-self: flex-start; } 432 432 .comment-reply-form-actions { display: flex; gap: 0.5rem; } 433 /* Reply-formulier pas tonen NA klik op REPLY. De display:flex hierboven overschreef434 anders het [hidden]-attribuut → formulier stond altijdopen. */433 /* Show the reply form only AFTER clicking REPLY. The display:flex above would 434 otherwise override the [hidden] attribute → form was always open. */ 435 435 .comment-reply-form[hidden] { display: none; } 436 /* DELETE -knop zit in een <form>; display:contents laat 'm als directeflex-sibling437 van REPLY meedoen → pixel-perfecte uitlijning + gelijkegap. */436 /* DELETE button is inside a <form>; display:contents makes it a direct flex-sibling 437 of REPLY → pixel-perfect alignment + equal gap. */ 438 438 .comment-actions form { display: contents; } 439 439 -
src/views/partials/bottom-tab.ejs
rbb42dfb r834bcc3 15 15 const _ownProfile = user ? ('/users/' + user.username) : null; 16 16 const _isHub = (typeof tenancy !== 'undefined' && tenancy === 'hub'); 17 // Home gaat in hub-modus ALTIJD naar de hub-root (/), niet naar de sub-home van18 // 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 '/'. 19 19 const _homeHref = (_isHub ? '' : _siteUrlBase) + '/'; 20 20 … … 27 27 28 28 let _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. 30 30 if (_isHub ? (_path === '/') : (_p === '/' || _p === '')) _active = 'home'; 31 31 else if (_p.indexOf('/search') === 0 || _p.indexOf('/tag/') === 0 || _p.indexOf('/type/') === 0) _active = 'search'; … … 38 38 39 39 <!-- Home --> 40 <%# htmx -navigatie (geen full reload): de persistente audioplayer in document.body41 overleeft dan, dus de muziek verspringt niet bij het navigeren. Op mobiel is de42 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. %> 43 43 <a class="bottom-tab-item<%= _active === 'home' ? ' is-active' : '' %>" 44 44 href="<%= _homeHref %>" … … 308 308 309 309 <script> 310 // Bottom-tab zoekknop → opent #search-overlay. Via event-delegatie + de overlay311 // ELKE klik opnieuw opzoeken: de overlay zit in de chrome die bij htmx-navigatie312 // OOB vervangen wordt, dus een één-keer-opgevangen referentie verschaalt313 // ( 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. 314 314 (function() { 315 315 if (window.__btSearchWired) return; -
src/views/partials/footer.ejs
rbb42dfb r834bcc3 13 13 <span>© <%= new Date().getFullYear() %> <%= (site && site.title) || 'Klonkt' %></span> 14 14 </div> 15 <%# Install-app knop — in de footer-rij (midden), bespaart een hele extra rij.16 JS detect eert 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. %> 17 17 <button type="button" class="install-app-btn install-app-btn--footer" data-pcms-install-app> 18 18 <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 20h14v-2H5v2zm7-18L5.33 9h3.84v6h5.66V9h3.84L12 2z" fill="currentColor"/></svg> … … 114 114 } 115 115 .footer-lang:hover { border-color: var(--accent); } 116 /* Open dropdown-opties leesbaar (anders wit-op-wit opdark mode). */116 /* Make open dropdown options readable (otherwise white-on-white in dark mode). */ 117 117 .footer-lang option { background: var(--paper); color: var(--ink); } 118 /* O p 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. */ 119 119 @media (min-width: 768px) { .footer-lang { display: none; } } 120 120 .footer-version { color: inherit; text-decoration: none; } … … 144 144 } 145 145 146 /* Compact e install-knop ín de footer-rij (midden) i.p.v. een grote losse CTA147 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. */ 148 148 .install-app-btn--footer { 149 149 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% */ 151 151 padding: 0.42rem 0.95rem; 152 152 font-size: 0.82rem; … … 154 154 } 155 155 .install-app-btn--footer svg { width: 15px; height: 15px; } 156 /* O p smal scherm: laat de drie items netjes centreren als ze wrappen. */156 /* On narrow screens: let the three items centre neatly when they wrap. */ 157 157 @media (max-width: 600px) { 158 158 .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 naarlogin.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. %> 4 4 <% 5 5 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 én2 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. %> 4 4 <% if ((typeof newerPost !== 'undefined' && newerPost) || (typeof olderPost !== 'undefined' && olderPost)) { %> 5 5 <nav class="post-nav post-nav--top"> … … 35 35 <style> 36 36 .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). */ 38 38 .post-nav--top { margin-top: 0; padding-top: 0; border-top: 0; margin-bottom: 2rem; padding-bottom: 2rem; border-bottom: 1px solid var(--rule); } 39 39 .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. */ 42 42 .post-nav-prev.is-empty, .post-nav-next.is-empty { opacity: 0.4; cursor: default; } 43 43 .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 6 6 border-bottom: 1px solid var(--rule); 7 7 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 de10 pill komt uit de symmetrische container-padding hieronder. De margin-bottom11 (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. */ 12 12 margin-top: 0; 13 13 } 14 14 .view-switcher-wrap .container { 15 15 display: flex; align-items: center; justify-content: center; gap: 0.75rem; 16 /* Symmetri sche 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. */ 18 18 padding: 0.5rem 1rem; 19 19 max-width: 1200px; margin: 0 auto; … … 36 36 } 37 37 .view-switch-btn:hover { color: var(--ink); } 38 /* Acti ef = wit (lichte achtergrond, donkere tekst). Via aria-selected (element-39 attribu ut, 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. */ 41 41 .view-switch-btn[aria-selected="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); } 42 42 43 /* Agenda = een EIGEN pill (los van Tijdlijn/Grid, zodat de feed-toggle nooit44 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 */ 46 46 .vs-agenda { 47 47 display: inline-flex; align-items: center; gap: 0.4rem; … … 53 53 } 54 54 .vs-agenda:hover { color: var(--ink); } 55 /* Acti ef (op de agendapagina) = helder/wit. Element-class (in de OOB-gerenderde56 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. */ 57 57 .vs-agenda.is-active { color: var(--ink); font-weight: 600; } 58 58 59 /* Mobi el: Agenda op een eigen rij bovenaan, en Solo/Cirkel + Tijdlijn/Grid samen60 o p de rij eronder (samen ~330px → past binnen een telefoon, i.p.v. de oude61 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). */ 62 62 @media (max-width: 520px) { 63 63 .view-switcher-wrap .agenda-pill { flex: 0 0 100%; justify-content: center; } … … 92 92 .on-admin .view-switcher-wrap { display: none; } 93 93 94 /* O p 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 opbody[data-feed-view]96 / body[data-grid-cols] (background:var(--ink)); we overr ulen die SPECIFIEKER97 via body.on-post zodat de knoppen neutraal/grijs worden — maar wél klikbaar98 blijven (de klik-handler navigeert dan naar de feed in die weergave). De99 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. */ 100 100 body.on-post[data-feed-view="timeline"] .view-switch-btn[data-view="timeline"], 101 101 body.on-post[data-feed-view="grid"] .view-switch-btn[data-view="grid"], -
src/views/partials/topnav.ejs
rbb42dfb r834bcc3 27 27 <a href="<%= _siteUrlBase %>/" class="nav-link nav-link-admin"><%= t('nav.back_to_site') %></a> 28 28 <% } else if (typeof tenancy !== 'undefined' && tenancy === 'hub') { %> 29 <%# Hub: de brand gaat naar de hub-home via HTMX. De chrome wordt in hub-modus30 altijd gerenderd en via body.on-hub (CSS) verborgen op de landing, dushtmx31 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. %> 32 32 <a class="nav-hub-home" href="/" aria-label="Home" title="Home" 33 33 hx-get="/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/" hx-indicator="#pcms-loading"> … … 207 207 <script> 208 208 (function() { 209 // EÉN keer bedraden. Deze chrome (topnav) wordt bij htmx-navigatie out-of-band210 // opnieuw ingevoegd → zonder deze guard zou dit script bij elke navigatie EXTRA211 // listeners stapelen, waardoor de thema-toggle 2× (of vaker) vuurde = geen netto212 // wisseling ("toggle werkt niet meer"). Alles hieronder is event-delegatie op213 // b ody/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). 214 214 if (window.__pcmsChromeWired) return; 215 215 window.__pcmsChromeWired = true; … … 229 229 if (e.target.closest('#search-toggle')) { openSearch(); return; } 230 230 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. 232 232 document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) { 233 233 if (!d.contains(e.target)) d.removeAttribute('open'); … … 239 239 }); 240 240 241 // ── Live result aten tijdens typen───────────────────────────────241 // ── Live results while typing ─────────────────────────────────── 242 242 var _ST = { 243 243 posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>', … … 280 280 }, 200); 281 281 }); 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). 283 283 document.addEventListener('click', function(e) { 284 284 if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; } … … 313 313 } 314 314 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. */ 316 316 .masthead-brand .nav-hub-home { 317 317 display: inline-flex; align-items: center; justify-content: center; … … 436 436 min-width: 220px; 437 437 padding: .35rem; 438 /* Frosted glass: transpar ant + zware blur zodat het frosted effect duidelijk is. */438 /* Frosted glass: transparent + heavy blur so the frosted effect is clearly visible. */ 439 439 background: color-mix(in srgb, var(--paper) 60%, transparent); 440 440 backdrop-filter: blur(34px) saturate(190%); … … 565 565 .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; } 566 566 .ss-all:hover { text-decoration: underline; } 567 /* Meldingen-bel + ongelezen-badge */567 /* Notification bell + unread badge */ 568 568 .nav-notif { position: relative; } 569 569 .notif-badge { -
src/views/partials/track-editor.ejs
rbb42dfb r834bcc3 195 195 } 196 196 197 /* ── Credit -veld met ©-invoegknop──────────────────────────── */197 /* ── Credit field with © insert button ──────────────────────────── */ 198 198 .te-credit-row { display: flex; gap: 0.4rem; align-items: stretch; } 199 199 .te-credit-row input { flex: 1; min-width: 0; } … … 638 638 } 639 639 640 // ── ©-teken invoegen in het credit-veld ─────────────────640 // ── Insert © symbol into the credit field ───────────────── 641 641 const copyrBtn = $('#te-credit-copyr'); 642 642 if (copyrBtn) { … … 693 693 }); 694 694 695 // ── Auto-du ur───────────────────────────────────────────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 'm698 // hier uit de audio-metadata en vullen het veld — zodat je nooit seconden699 // hoeft te typen. Bestaande waarde wordt nooit overschreven. We halen de700 // 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). 701 701 (async function autoDuration() { 702 702 const durEl = $('#te-duration'); 703 703 if (!durEl || !track.stream_url) return; 704 if (durEl.value && Number(durEl.value) > 0) return; // al ingevuld → met rust laten704 if (durEl.value && Number(durEl.value) > 0) return; // already filled → leave it alone 705 705 let objUrl = null; 706 706 try {
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)