source: Klonkt/src/views/shell.ejs@ 48481cd

main
Last change on this file since 48481cd was e46ebaa, checked in by Robin <roboburr@…>, 3 weeks ago

Lezen: snappen dat je laat lezen, en geen lege schermen meer

Barts meldingen van 20 augustus, in een stuk of zes rondes bijgesteld. De
uitkomst is korter dan de weg ernaartoe, en die weg is het waard om vast te
leggen omdat ik er twee keer overheen ben geschoten.

SNAPPEN. De regel is nu in drie zinnen te zeggen:

omhoog scrollen nooit snappen -- je zoekt iets terug en bepaalt zelf

waar je stopt

omlaag, ver weg vrij scrollen
omlaag, laatste
150px van een
bericht vangt, en je landt op de bovenkant van het volgende

Er is geen top-zone: de bovenkant van het bericht waar je IN zit is geen
doelwit meer. Dat was de oorspronkelijke klacht -- je las een paar regels
verder en proximity trok je terug naar de bovenrand, waardoor het leek of
het scrollen vastliep. De grens die wel vangt is de onderkant van je huidige
bericht, en dat is hetzelfde punt als de bovenkant van het volgende.

Onderweg twee doodlopende wegen, allebei door Robin gecorrigeerd:

  • Eerst zette ik de snap UIT voor berichten langer dan het scherm. Op een telefoon is bijna elk bericht net iets langer dan de viewport, dus daar verdween het snappen overal: op 812px hoog kregen berichten van 1009 en zelfs 839 al geen snap meer.
  • Toen probeerde ik de vangzone aan de VOET van het bericht te hangen ("ben je de reacties voorbij"). Bij een kort bericht staat die voet middenin de lege ruimte die min-height maakte, dus de zone begon op een willekeurige plek. Gelukkig ving Robin dat voordat het uitgerold stond.

Het moest dus een getal in pixels zijn, en 150 is dat getal.

GEEN LEGE SCHERMEN MEER. min-height:100svh rekte elk bericht op tot een vol
scherm. Bij een kort bericht gaf dat een halve pagina leegte onder "Replies
and reactions" -- je keek naar niets en moest doorscrollen om te ontdekken
dat er nog iets kwam. Weg dus: berichten hebben hun eigen hoogte (gemeten:
360/1009/839/250 waar het eerst 888/888/888/888 was) en de stroom is gewoon
bericht na bericht. Het snappen hangt aan scroll-snap-align en niet aan die
hoogte, dus dat werkt onveranderd.

VLOEIEND: scroll-behavior:smooth, want zonder dat springt een snap er in een
frame naartoe en voelt het als een hik. Uit bij prefers-reduced-motion.

TERUG NAAR BOVEN, rechtsonder, in twee stappen: eerst naar het begin van DIT
bericht, daarna pas naar de kop van de pagina. Die knop is de enige die naar
een bovenkant mag springen, en hij heeft het snappen daar niet voor nodig --
scrollIntoView stuurt zelf. Nagemeten, juist omdat omhoog-snappen uit staat:
1706 -> 1206, exact de bovenkant.

COVERS nemen de volle kolombreedte met de verhouding intact. De ronde ervoor
haalde het opblazen eruit maar liet kleine afbeeldingen op ware grootte
staan, en dat was als postzegel in een brede kolom te klein.

DE CIRKEL. Lezen blijft daar dood (berichten van anderen), maar een tijdlijn
werkt er juist bij uitstek. Die dwong ik eerst af zodra de site geen
Lezen-site was, en dan kon je in de cirkel geen Grid meer kiezen. Nu is Grid
de landing en staat Tijdlijn ernaast: bij feed_alt_view=timeline altijd, bij
auto op desktop. De cirkel krijgt zijn eigen geheugen terug, met een eigen
sleutel zodat een keuze daar je thuisweergave niet aanraakt.

style.css naar v77 en MOD_V naar 13.

Suite 1165/1165.

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

  • Property mode set to 100644
File size: 50.6 KB
Line 
1<%
2// ── Helpers used inside this template ─────────────────────────────
3// Escape for double-quoted HTML attributes. IMPORTANT: emit this with the RAW EJS output
4// tag, never the escaping one — escaping it a second time turned og:title "Jason's" into the
5// double-escaped "Jason&amp;#39;s", and naive OG scrapers (Signal/WhatsApp) show that literally.
6// We deliberately do NOT escape the apostrophe: it is safe inside a double-quoted attribute and
7// a literal apostrophe is what link-preview scrapers expect.
8function _e(s) {
9 return String(s == null ? '' : s)
10 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
11 .replace(/"/g, '&quot;');
12}
13
14const safeSite = site || {};
15const safeUrlBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
16const safeAccent = safeSite.accent && /^#[0-9a-fA-F]{6}$/.test(safeSite.accent) ? safeSite.accent : '#e8b04b';
17const lang = safeSite.language || 'nl';
18const ogLocale = safeSite.og_locale || (lang === 'nl' ? 'nl_NL' : (lang.length === 2 ? lang + '_' + lang.toUpperCase() : 'en_US'));
19const homePath = safeUrlBase + '/';
20const isPostPage = bodyClass && bodyClass.indexOf('on-post') >= 0;
21const isHomePage = bodyClass && bodyClass.indexOf('on-home') >= 0;
22const isSpecialPg = bodyClass && bodyClass.indexOf('on-special') >= 0;
23const isAdminPage = bodyClass && bodyClass.indexOf('on-admin') >= 0;
24
25// ── <title> via site.title_template ──────────────────────────────
26// Template: '{title} — {site}'. If pageTitle equals site.title (homepage) we
27// just use the site title alone, otherwise apply the template.
28const _siteTitle = safeSite.title || 'Klonkt';
29const _rawTitle = pageTitle || _siteTitle;
30const _tpl = safeSite.title_template || '{title} — {site}';
31const _finalTitle = (_rawTitle === _siteTitle)
32 ? _siteTitle
33 : _tpl.replace('{title}', _rawTitle).replace('{site}', _siteTitle);
34
35// ── Robots: noindex on listing pages and on per-post override ─────
36let _shouldIndex = safeSite.robots_index !== 0;
37if (typeof post !== 'undefined' && post && post.noindex) _shouldIndex = false;
38// Listing pages (search/tag/type/archive) shouldn't be indexed (dupe content)
39if (currentPath) {
40 if (/^\/(?:search|tag|type|archive|users|account|admin)(?:$|\/)/.test(currentPath)) {
41 _shouldIndex = false;
42 }
43}
44// Special-flagged views from routes opt out too
45if (isSpecialPg && (currentPath === '/search' || /^\/(tag|type|archive|users)\//.test(currentPath))) {
46 _shouldIndex = false;
47}
48
49// ── Canonical URL: per-site override (admin SEO), else the .env base
50// (PUBLIC_BASE_URL, via ogOrigin → falls back to the request host) ──
51let _canonical = null;
52const _canonBase = safeSite.canonical || (typeof ogOrigin !== 'undefined' ? ogOrigin : '');
53if (_canonBase) {
54 const _base = _canonBase.replace(/\/+$/, '');
55 let _path = '/';
56 if (typeof post !== 'undefined' && post && post.slug) _path = '/' + post.slug;
57 else if (currentPath) _path = currentPath;
58 _canonical = _base + _path;
59}
60
61// ── Social bits (OG/Twitter) ──────────────────────────────────────
62const _socialTitle = (typeof post !== 'undefined' && post && post.title) ? post.title : _siteTitle;
63const _socialDescr = (typeof socialDescr !== 'undefined' && socialDescr)
64 ? socialDescr
65 : (safeSite.default_description || safeSite.description || '');
66// og:image — custom (post/site) first; otherwise the auto-generated themed card
67// (/og/<slug>.png), so every site has a branded social preview by default.
68let _socialImage = '', _ogGenerated = false;
69if (typeof socialImage !== 'undefined' && socialImage) _socialImage = socialImage;
70else if (safeSite.og_image_default) _socialImage = safeSite.og_image_default;
71else if (safeSite.default_cover) _socialImage = safeSite.default_cover;
72else if (safeSite.slug && typeof ogOrigin !== 'undefined' && ogOrigin) {
73 _socialImage = ogOrigin + '/og/' + encodeURIComponent(safeSite.slug) + '.png';
74 _ogGenerated = true;
75}
76// og:image / twitter:image / JSON-LD image MUST be absolute (OGP spec). A post cover arrives as
77// a relative /media/... path; strict scrapers (WhatsApp/Signal/some fediverse clients) won't
78// resolve it against the page URL → no preview image. Absolutize against the canonical origin.
79if (_socialImage && _socialImage.charAt(0) === '/' && _socialImage.charAt(1) !== '/' && typeof ogOrigin !== 'undefined' && ogOrigin) {
80 _socialImage = ogOrigin + _socialImage;
81}
82const _ogType = isPostPage ? 'article' : 'website';
83
84// ── JSON-LD ───────────────────────────────────────────────────────
85const _publisher = {
86 '@type': safeSite.schema_type === 'Organization' ? 'Organization' : 'Person',
87 name: safeSite.publisher_name || _siteTitle,
88 url: safeSite.publisher_url || (_canonical ? _canonical.split(/(?<=^[^/]*\/\/[^/]+)\//)[0] + '/' : null),
89};
90if (safeSite.publisher_logo) {
91 _publisher.logo = { '@type': 'ImageObject', url: safeSite.publisher_logo };
92}
93// Dezelfde koppeling die de fediverse-actor draagt, hier in de JSON-LD
94// (shaer-mbz). sameAs is schema.org-eigen, dus dit is geen extra vocabulaire --
95// het is hetzelfde feit, verteld aan de andere lezer.
96if (safeSite.mb_artist_id) {
97 _publisher.sameAs = 'https://musicbrainz.org/artist/' + safeSite.mb_artist_id;
98}
99let _jsonLd = null;
100if (typeof post !== 'undefined' && post && post.slug) {
101 _jsonLd = {
102 '@context': 'https://schema.org',
103 '@type': (post.type === 'foto' || post.type === 'video') ? 'CreativeWork' : 'Article',
104 headline: post.title || _siteTitle,
105 description: _socialDescr,
106 datePublished: post.published_at || post.created_at || new Date().toISOString(),
107 dateModified: post.updated_at || post.published_at || new Date().toISOString(),
108 publisher: _publisher,
109 };
110 if (_socialImage) _jsonLd.image = _socialImage;
111 if (post.author_username) _jsonLd.author = { '@type': 'Person', name: post.author_username };
112 if (Array.isArray(post.tags) && post.tags.length) _jsonLd.keywords = post.tags.join(', ');
113} else if (isHomePage) {
114 _jsonLd = {
115 '@context': 'https://schema.org',
116 '@type': 'WebSite',
117 name: _siteTitle,
118 description: safeSite.description || safeSite.default_description || '',
119 publisher: _publisher,
120 };
121}
122%><!DOCTYPE html>
123<html lang="<%- _e(lang) %>" data-palette="<%- _e((typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt')) %>">
124<head>
125<meta charset="utf-8">
126<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
127<meta name="color-scheme" content="dark light">
128
129<title><%= _finalTitle %></title>
130<meta name="description" content="<%- _e(_socialDescr) %>">
131<meta name="theme-color" content="<%- _e(safeAccent) %>">
132<meta name="robots" content="<%= _shouldIndex ? 'index,follow' : 'noindex,nofollow' %>">
133<% if (safeSite.author) { %><meta name="author" content="<%- _e(safeSite.author) %>"><% } %>
134<% if (_canonical) { %><link rel="canonical" href="<%- _e(_canonical) %>"><% } %>
135
136<!-- Search-engine verification -->
137<% if (safeSite.google_verification) { %><meta name="google-site-verification" content="<%- _e(safeSite.google_verification) %>"><% } %>
138<% if (safeSite.bing_verification) { %><meta name="msvalidate.01" content="<%- _e(safeSite.bing_verification) %>"><% } %>
139<% if (safeSite.pinterest_verification) { %><meta name="p:domain_verify" content="<%- _e(safeSite.pinterest_verification) %>"><% } %>
140<% if (safeSite.yandex_verification) { %><meta name="yandex-verification" content="<%- _e(safeSite.yandex_verification) %>"><% } %>
141
142<!-- Feed autodiscovery -->
143<% if (site) { %>
144<link rel="alternate" type="application/rss+xml" title="<%- _e(_siteTitle) %> — RSS" href="<%- _e(safeUrlBase + '/feed.xml') %>">
145<link rel="alternate" type="application/atom+xml" title="<%- _e(_siteTitle) %> — Atom" href="<%- _e(safeUrlBase + '/atom.xml') %>">
146<% } %>
147
148<!-- PWA -->
149<link rel="manifest" href="<%- _e(safeUrlBase + '/manifest.webmanifest') %>">
150<meta name="mobile-web-app-capable" content="yes">
151<meta name="apple-mobile-web-app-capable" content="yes">
152<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
153<meta name="apple-mobile-web-app-title" content="<%- _e(_siteTitle.slice(0, 16)) %>">
154<link rel="apple-touch-icon" href="<%- _e(safeSite.profile_photo || '/favicon.ico') %>">
155<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=sf">
156<link rel="alternate icon" href="/favicon.ico?v=sf">
157
158<!-- OpenGraph -->
159<meta property="og:type" content="<%= _ogType %>">
160<meta property="og:title" content="<%- _e(_socialTitle) %>">
161<meta property="og:description" content="<%- _e(_socialDescr) %>">
162<meta property="og:site_name" content="<%- _e(_siteTitle) %>">
163<meta property="og:locale" content="<%- _e(ogLocale) %>">
164<% if (_socialImage) { %>
165<meta property="og:image" content="<%- _e(_socialImage) %>">
166<meta property="og:image:alt" content="<%- _e(_socialTitle) %>">
167<% if (_ogGenerated) { %>
168<meta property="og:image:width" content="1200">
169<meta property="og:image:height" content="630">
170<meta property="og:image:type" content="image/png">
171<% } %>
172<% } %>
173<% if (_canonical) { %><meta property="og:url" content="<%- _e(_canonical) %>"><% } %>
174<%
175// Fediverse/social PLAYER card for posts with audio: instead of shipping the raw
176// mp3, point at our embeddable player (/embed?post=slug) so Mastodon shows an
177// inline player that streams via the gated /audio/stream (no downloadable file).
178const _postAudio = !!(typeof post !== 'undefined' && post
179 && typeof postHasPlayableAudio !== 'undefined' && postHasPlayableAudio
180 && typeof premiumUnlocked !== 'undefined' && premiumUnlocked);
181const _embedUrl = _postAudio
182 ? ((typeof ogOrigin !== 'undefined' && ogOrigin ? ogOrigin : '') + safeUrlBase + '/embed?post=' + encodeURIComponent(post.slug))
183 : '';
184%>
185<% if (_postAudio) { %>
186<meta property="og:video" content="<%- _e(_embedUrl) %>">
187<meta property="og:video:secure_url" content="<%- _e(_embedUrl) %>">
188<meta property="og:video:type" content="text/html">
189<meta property="og:video:width" content="480">
190<meta property="og:video:height" content="480">
191<% } %>
192<% if (typeof post !== 'undefined' && post && post.published_at) { %>
193<meta property="article:published_time" content="<%- _e(post.published_at) %>">
194<% if (post.author_username) { %><meta property="article:author" content="<%- _e(post.author_username) %>"><% } %>
195<% } %>
196<% if (safeSite.facebook_app_id) { %><meta property="fb:app_id" content="<%- _e(safeSite.facebook_app_id) %>"><% } %>
197
198<!-- Twitter Cards -->
199<meta name="twitter:card" content="<%= _postAudio ? 'player' : (_socialImage ? 'summary_large_image' : 'summary') %>">
200<% if (_postAudio) { %>
201<meta name="twitter:player" content="<%- _e(_embedUrl) %>">
202<meta name="twitter:player:width" content="480">
203<meta name="twitter:player:height" content="480">
204<% } %>
205<meta name="twitter:title" content="<%- _e(_socialTitle) %>">
206<meta name="twitter:description" content="<%- _e(_socialDescr) %>">
207<% if (_socialImage) { %><meta name="twitter:image" content="<%- _e(_socialImage) %>"><% } %>
208<% if (safeSite.twitter) { %><meta name="twitter:creator" content="<%- _e(safeSite.twitter) %>"><meta name="twitter:site" content="<%- _e(safeSite.twitter) %>"><% } %>
209
210<% if (_jsonLd) { %>
211<script type="application/ld+json"><%- JSON.stringify(_jsonLd) %></script>
212<% } %>
213<% if (typeof musicLd !== 'undefined' && musicLd) { %>
214<%# Music posts also carry standard schema.org MusicRecording/MusicAlbum data (Phase 1 of
215 music federation): real web standard, read by search engines + generic consumers. %>
216<script type="application/ld+json"><%- JSON.stringify(musicLd) %></script>
217<% } %>
218
219<!-- Self-hosted fonts (privacy-first) -->
220<link rel="preload" href="/assets/fonts/literata-latin-opsz-normal.woff2" as="font" type="font/woff2" crossorigin>
221<link rel="preload" href="/assets/fonts/fraunces-latin-full-normal.woff2" as="font" type="font/woff2" crossorigin>
222
223<!-- v9 stylesheet (full palette system) -->
224<link rel="stylesheet" href="/assets/css/style.css?v=77">
225<script>
226/* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
227 BOTH Safari and the installed PWA (standalone), even with viewport-fit=cover, so the
228 masthead can't clear the camera. We measure env directly and, when it comes back empty
229 on a notched iPhone, fall back to a fixed inset (a bit larger in standalone for the
230 Dynamic Island). We set --ios-safe-top AND force the masthead padding inline — the
231 latter survives a stale cached stylesheet that lacks the var. Re-applied after htmx
232 chrome swaps (the top-nav is out-of-band swapped on navigation). */
233(function(){
234 var ua = navigator.userAgent || '';
235 var isIOS = /iP(hone|od|ad)/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
236 if (!isIOS) return;
237 var standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || navigator.standalone === true;
238 function apply(){
239 if (!document.body) return;
240 var p = document.createElement('div');
241 p.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;padding-top:env(safe-area-inset-top,0px);visibility:hidden;pointer-events:none';
242 document.body.appendChild(p);
243 var rawEnv = parseFloat(getComputedStyle(p).paddingTop) || 0;
244 p.remove();
245 var i = rawEnv;
246 if (i < 20 && Math.max(screen.width, screen.height) >= 812) {
247 // Portrait: the island sits at the TOP → full inset (UNCHANGED: standalone?59:47).
248 // Landscape: the island moves to the SIDE, so the top inset is ~0 → no top padding
249 // (only the masthead's own base padding remains). env() can't tell us (returns 0),
250 // so we key off orientation directly.
251 i = (window.innerWidth > window.innerHeight) ? 0 : (standalone ? 59 : 47);
252 }
253 document.documentElement.style.setProperty('--ios-safe-top', i + 'px');
254 var mh = document.querySelector('.masthead');
255 var mhHidden = !mh || getComputedStyle(mh).display === 'none' || mh.offsetHeight === 0;
256 if (mhHidden) {
257 /* Mobile: the masthead is hidden (bottom-tab replaces it ≤767px) so it can't carry
258 the inset. Pad the body instead → the profile-header clears the island/notch. */
259 document.body.style.paddingTop = i + 'px';
260 if (mh) mh.style.paddingTop = '';
261 } else {
262 /* Desktop/landscape: the sticky masthead carries the inset in its own padding. */
263 document.body.style.paddingTop = '';
264 mh.style.paddingTop = 'calc(.55rem + ' + i + 'px)';
265 }
266 }
267 if (document.body) apply(); else document.addEventListener('DOMContentLoaded', apply);
268 document.addEventListener('htmx:afterSettle', apply);
269 window.addEventListener('orientationchange', function(){ setTimeout(apply, 250); });
270})();
271</script>
272
273<!-- Audio player styles: loaded on every page so the mini-player works
274 anywhere (admin previews, post embeds, etc). The player itself is
275 a singleton — see the script tag near </body>. -->
276<link rel="stylesheet" href="/assets/css/audio.css?v=11">
277<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) in huisstijl. -->
278<link rel="stylesheet" href="/assets/css/embed.css?v=9">
279
280<%- include('partials/shared-styles') %>
281
282<!-- v1 P55 — Inline the saved site accent. The base stylesheet only sets a
283 fallback (#c2410c orange) and palette blocks don't define --accent at
284 all, so without this override the saved accent never reaches the page.
285 :root + [data-palette] hits both unscoped and palette-scoped variants;
286 source-order wins on equal specificity, and this comes after style.css. -->
287<style id="pcms-site-accent">
288 :root,
289 [data-palette] {
290 --accent: <%- _e(safeAccent) %>;
291 --accent-soft: color-mix(in srgb, <%- _e(safeAccent) %> 80%, white);
292 --accent-tint: color-mix(in srgb, <%- _e(safeAccent) %> 12%, transparent);
293 }
294</style>
295
296<!-- Per-site custom CSS injection -->
297<% if (safeSite.custom_css) { %>
298<style id="pcms-custom-css"><%- safeSite.custom_css %></style>
299<% } %>
300
301<!-- Apply theme ASAP, before paint. Precedence (first match wins):
302 1. localStorage override (visitor toggled earlier on this browser)
303 2. Site default (theme_override + palette) — what new visitors see
304 3. Device prefers-color-scheme (only if site default is empty/auto)
305 4. 'dark' as last-ditch fallback
306 Note: PALETTE never has a localStorage layer anymore. There's no UI for
307 visitors to pick a palette, so any cached pcms-palette is stale data
308 from old code paths and gets cleaned up here. Site default always wins
309 for palette. -->
310<script>
311 (function() {
312 try {
313 var siteDefault = '<%= safeSite.theme_override || "" %>';
314 var sitePalette = '<%= (typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt') %>';
315
316 // One-time cleanup: drop the orphan pcms-palette key set by P43-P57
317 // bootstrap. After this it never re-appears because nothing writes it.
318 try { localStorage.removeItem('pcms-palette'); } catch(_) {}
319
320 // Theme: localStorage > site override > device pref > dark
321 var storedTheme = null;
322 try { storedTheme = localStorage.getItem('pcms-theme'); } catch(_) {}
323 var deviceDark = window.matchMedia &&
324 window.matchMedia('(prefers-color-scheme: dark)').matches;
325 var t = storedTheme
326 || siteDefault
327 || (deviceDark ? 'dark' : 'light');
328
329 // Palette: site default only.
330 document.documentElement.setAttribute('data-theme', t);
331 document.documentElement.setAttribute('data-palette', sitePalette);
332 } catch(e) {
333 document.documentElement.setAttribute('data-theme', 'dark');
334 }
335 })();
336</script>
337
338<!-- HTMX — bundled locally from node_modules at boot, zero third-party requests -->
339<script src="/assets/js/htmx.min.js"></script>
340
341<!-- Per-site custom <head> HTML (analytics, verification, etc.) -->
342<% if (safeSite.custom_head_html) { %>
343<%- safeSite.custom_head_html %>
344<% } %>
345</head>
346
347<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-js="chrome<%= (typeof pageJs !== 'undefined' && pageJs) ? ' ' + pageJs : '' %>" data-feed-view="<%- _e(safeSite.feed_view_default === 'grid' ? 'grid' : (["timeline","auto"].indexOf(safeSite.feed_alt_view) >= 0 ? safeSite.feed_alt_view : "reader")) %>" data-feed-alt="<%- _e(["timeline","auto"].indexOf(safeSite.feed_alt_view) >= 0 ? safeSite.feed_alt_view : "reader") %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
348
349<% if (typeof isViewer !== 'undefined' && isViewer) { %>
350 <div class="viewer-banner" role="status">
351 <span class="viewer-banner-ico" aria-hidden="true">👁️</span>
352 <span class="viewer-banner-text"><strong>Kijker-modus</strong> — je kunt alles bekijken, maar niets wijzigen.</span>
353 </div>
354 <style>
355 .viewer-banner {
356 position: sticky; top: 0; z-index: 60;
357 display: flex; align-items: center; justify-content: center; gap: 0.5rem;
358 padding: 0.5rem 1rem;
359 background: linear-gradient(90deg,
360 color-mix(in srgb, var(--accent) 88%, #000) 0%,
361 var(--accent) 100%);
362 color: #fff;
363 font-size: 0.85rem; line-height: 1.3;
364 box-shadow: 0 1px 6px color-mix(in srgb, var(--accent) 45%, transparent);
365 }
366 .viewer-banner-ico { font-size: 1rem; }
367 .viewer-banner-text strong { font-weight: 700; }
368 </style>
369<% } %>
370
371<%# Site-chrome (topnav + profielkop + view-switcher) in één vaste slot #pcms-chrome.
372 Bij htmx-navigatie wordt dit slot out-of-band ververst (zie chrome.ejs +
373 render.js), zodat de kop ALTIJD bij de nieuwe pagina/artiest hoort terwijl de
374 audioplayer (los in document.body) blijft leven → geen verspringen. Op de
375 hub-landing is het slot leeg: de hero is daar de header. %>
376<%- include('partials/chrome') %>
377
378<%# Geen hx-history-elt: htmx' eigen history staat uit (zie de link-boost).
379 Back/forward wordt door onze popstate-listener gedaan, die de partial
380 her-fetcht (incl. correcte OOB-chrome). Met hx-history-elt + htmx-history
381 aan dumpte htmx de partial ongefilterd hier → dubbele kop. %>
382<main id="pcms-main" class="pcms-main">
383 <div id="pcms-loading" class="pcms-loading" aria-hidden="true"></div>
384 <%- pageContent %>
385</main>
386
387<%- include('partials/footer') %>
388
389<!-- Mobile bottom-tab navigation (auto-hidden ≥768px). Toont overal — óók op de
390 hub-landing (zodat mobiel altijd Home/Zoek/Inloggen heeft) — behalve op de
391 auth-focusschermen. -->
392<% if (!(typeof bodyClass === 'string' && bodyClass.indexOf('on-auth') >= 0)) { %>
393<%- include('partials/bottom-tab') %>
394<% } %>
395
396<!-- Mobile profile sheet (auto-hidden ≥768px; only rendered when logged in) -->
397<% if (user) { %>
398<%- include('partials/profile-sheet') %>
399<% } %>
400
401<!-- Audio player: load on every page (admin + public) so window.pcmsAudioPlayer
402 is always available. The PCMS_SITE_TRACKS bootstrap is still gated on
403 enable_audio_player since it's a public-page concept (auto-discovered
404 tracks from rendered post embeds).
405
406 ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
407 Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
408<script src="/assets/js/audio-player.js?v=32"></script>
409<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) via de echte
410 player-API's + gedeelde mutual-exclusion registry met de site-speler. -->
411<script src="/assets/js/embed-player.js?v=17" defer></script>
412<% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %>
413 <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
414<% } %>
415
416<!-- Install-app button: detects platform + shows install instructions modal -->
417<script src="/assets/js/install-app.js?v=2" defer></script>
418
419<!-- Service Worker registration -->
420<script>
421 if ('serviceWorker' in navigator) {
422 navigator.serviceWorker.register('/sw.js').catch(() => {});
423 }
424</script>
425
426<!-- HTMX navigation: keep body class in sync with the swapped page.
427 The server emits HX-Trigger-After-Settle: { pcmsNav: { bodyClass } } via
428 renderPage() in middleware/render.js. Without this listener the body
429 class stays whatever the initial page-load set, so the profile-header
430 never collapses/expands when navigating home → post → home via HTMX. -->
431<script>
432(function() {
433 // De page-context body-classes. Bij pcmsNav strippen we ze allemaal en zetten
434 // we opnieuw wat de server stuurde. 'on-auth' staat erbij zodat het login-
435 // focusscherm óók via htmx z'n styling krijgt (geen chrome, geen tab, audio op 0).
436 // ALLES wat de server als page-class kan sturen moet hier staan: wat er niet in
437 // staat wordt bij een htmx-navigatie stilletjes weggefilterd.
438 var PAGE_CLASSES = ['on-home','on-post','on-special','on-archive','on-search','on-admin','on-auth',
439 'on-shows','on-downloads','on-download','on-epk','on-linkbio','on-newsletter',
440 'on-tag','on-type','on-user','on-cirkel','on-hub','on-chat'];
441
442 document.body.addEventListener('pcmsNav', function(ev) {
443 var d = ev.detail || {};
444 // Per-site accent + palette bijwerken (head wordt niet mee-geswapt bij htmx-nav,
445 // dus zonder dit erft een artiest de kleuren van de vorige pagina).
446 if (d.accent && /^#[0-9a-fA-F]{6}$/.test(d.accent)) {
447 var sa = document.getElementById('pcms-site-accent');
448 if (sa) {
449 sa.textContent =
450 ':root,[data-palette]{--accent:' + d.accent +
451 ';--accent-soft:color-mix(in srgb,' + d.accent + ' 80%,white)' +
452 ';--accent-tint:color-mix(in srgb,' + d.accent + ' 12%,transparent);}';
453 }
454 }
455 if (d.palette && /^[a-z0-9-]+$/i.test(d.palette)) {
456 document.documentElement.setAttribute('data-palette', d.palette);
457 }
458
459 // Welke modules deze pagina wil (shaer-bqr). De bootstrap hieronder leest
460 // dit zodra deze handler klaar is.
461 if (typeof d.js === 'string' && /^[a-z0-9 -]*$/.test(d.js)) {
462 document.body.setAttribute('data-js', d.js);
463 }
464
465 var next = d.bodyClass;
466 if (!next) return;
467 // De server kan meerdere page-classes sturen ("on-special on-auth"). Strip
468 // alle bekende en zet ALLE meegestuurde page-classes terug (niet alleen de
469 // eerste) — anders mist 'on-auth' bij htmx-nav en blijft het login-scherm
470 // de chrome/tab van de vorige pagina houden.
471 var matched = String(next).split(/\s+/).filter(function(c) {
472 return PAGE_CLASSES.indexOf(c) >= 0;
473 });
474 if (!matched.length) return;
475 PAGE_CLASSES.forEach(function(c) { document.body.classList.remove(c); });
476 matched.forEach(function(c) { document.body.classList.add(c); });
477 });
478
479 // (Back/forward wordt afgehandeld door de popstate-listener in de link-boost
480 // hieronder — die her-fetcht de partial via htmx.ajax, wat de OOB-chrome +
481 // de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere
482 // htmx:historyRestore-handler is vervallen nu htmx-history uitstaat.)
483})();
484</script>
485
486<!-- Modules laden (shaer-bqr). Inline script in gewisselde inhoud wordt door de
487 CSP geweigerd: de nonce rouleert per verzoek, dus een script dat via htmx
488 binnenkomt draagt er een die dit document niet kent (shaer-0i6). Alles wat
489 bij een pagina hoort komt daarom uit een module, en die wordt HIER geladen —
490 vanuit de shell, die alleen bij een volledige laadbeurt binnenkomt en dus
491 wél de goede nonce heeft.
492
493 Een dynamische import vanuit een vertrouwd (genonced) script is precies waar
494 'strict-dynamic' voor bedoeld is, dus de module zelf heeft geen nonce nodig. -->
495<script>
496(function () {
497 if (window.__modBoot) return;
498 window.__modBoot = true;
499
500 // ?v=N — cache-buster voor ALLE modules in assets/js/mod, net als bij
501 // audio-player.js hierboven. BUMP BIJ ELKE WIJZIGING IN DIE MAP. Zonder
502 // query staat /assets op max-age=1y, en dan blijft een browser die de
503 // pagina eerder bezocht een jaar lang de oude module draaien: een
504 // reparatie bereikt precies de bezoekers die hem al hebben.
505 // Eén nummer voor de hele map. Te vaak bumpen kost één download; te weinig
506 // bumpen kost een bugfix die nooit aankomt.
507 var MOD_V = 13;
508
509 // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
510 // die `init` exporteert draait die bij ELKE paginawissel waarop hij actief
511 // is -- dat is het gedrag van de oude inline scripts, die per render
512 // draaiden. Een module zonder init draait alleen zijn top-level, een keer.
513 var loaded = {};
514 function start(name, m) {
515 if (!m || typeof m.init !== 'function') return;
516 try { m.init(); } catch (e) { console.warn('[mod] ' + name + ' init:', e && e.message); }
517 }
518 function load() {
519 var names = (document.body.getAttribute('data-js') || '').trim().split(/\s+/);
520 names.forEach(function (name) {
521 // Streng: deze waarde komt uit een template en wordt een PAD. Alleen
522 // kleine letters, cijfers en streepjes; nooit een punt of een schuine
523 // streep.
524 if (!name || !/^[a-z0-9-]+$/.test(name)) return;
525 if (loaded[name]) { start(name, loaded[name]); return; }
526 loaded[name] = 1;
527 import('/assets/js/mod/' + name + '.js?v=' + MOD_V).then(function (m) {
528 loaded[name] = m;
529 start(name, m);
530 }).catch(function (e) {
531 console.warn('[mod] ' + name + ' laadde niet:', e && e.message);
532 });
533 });
534 }
535 load();
536 // Bij een htmx-navigatie wisselt de INHOUD, niet de body. De nav-trigger
537 // hieronder zet data-js opnieuw; daarna halen we op wat er nieuw bij staat.
538 // Een module die er al is wordt niet opnieuw geimporteerd -- vandaar dat elke
539 // module gedelegeerd moet werken en tegen een tweede aanroep moet kunnen.
540 document.body.addEventListener('pcmsNav', load);
541})();
542</script>
543
544<!-- View switcher + grid-cols persistence (event delegation: works for switcher
545 elements rendered later by HTMX, e.g. when navigating back to home). -->
546<script>
547(function() {
548 var body = document.body;
549
550 // DE CIRKEL HEEFT MAAR EEN WEERGAVE: Grid.
551 //
552 // Lezen is daar geen optie -- dat zijn berichten van ANDEREN, van hun servers,
553 // en "het hele stuk" hebben wij daar niet in handen. De knop staat er wel,
554 // maar dood (zie view-switcher.ejs). Er valt dus niets te kiezen en dus ook
555 // niets te onthouden: de eigen cirkel-voorkeur die hier stond
556 // ('pcms-cirkel-view') is vervallen, want hij kon nog maar een waarde hebben.
557 //
558 // Wat WEL blijft: de cirkel raakt je solo-keuze niet aan. Die staat in
559 // 'pcms-feed-view' en wordt hier nergens geschreven, zodat je thuiskomt in de
560 // weergave die je achterliet.
561 var CIRKEL_SLEUTEL = 'pcms-cirkel-view';
562 function opCirkel() { return body.classList.contains('on-cirkel'); }
563
564 /** Zet de weergave die bij DEZE pagina hoort. Draait bij init en bij elke nav. */
565 function pasWeergaveToe() {
566 try {
567 if (opCirkel()) {
568 // Lezen kan hier niet -- dat zijn berichten van ANDEREN, van hun servers,
569 // en "het hele stuk" hebben wij niet in handen. De knop staat er wel maar
570 // dood. Een TIJDLIJN kan hier juist bij uitstek: dat is de natuurlijke
571 // vorm van een cirkel.
572 //
573 // Beschikbaar, niet verplicht. Dit dwong eerst 'timeline' af zodra de
574 // site geen Lezen-site was, en dan kon je in de cirkel geen Grid meer
575 // kiezen. Nu is Grid de landing en is Tijdlijn een keuze ernaast.
576 var altC = body.dataset.feedAlt || 'reader';
577 var tijdlijnHier = altC === 'timeline'
578 || (altC === 'auto' && window.matchMedia('(min-width: 768px)').matches);
579 var magC = tijdlijnHier ? ['grid', 'timeline'] : ['grid'];
580 // Een EIGEN geheugen, en niet dat van solo. Twee redenen: hier kun je
581 // alleen uit Grid en Tijdlijn kiezen, dus een solo-keuze 'reader' zegt
582 // hier niets; en andersom mag een tuimeling hier je thuisweergave niet
583 // veranderen -- dan kom je thuis in iets anders dan je achterliet.
584 // (Dit geheugen stond er eerder al, verdween toen de cirkel nog maar een
585 // mogelijkheid had, en is nu weer nodig omdat Tijdlijn een echte keuze is.)
586 var vC = null;
587 try { vC = localStorage.getItem(CIRKEL_SLEUTEL); } catch (e) { /* geen opslag */ }
588 body.dataset.feedView = magC.indexOf(vC) >= 0 ? vC : 'grid';
589 return;
590 }
591 var v = localStorage.getItem('pcms-feed-view');
592 // 'timeline' is sinds 20-8 weer een geldige keuze, maar alleen op een site
593 // die hem AANBIEDT. Wie de tijdlijn koos op de ene site en daarna een
594 // Lezen-site bezoekt, moet daar niet op een weergave landen die er geen
595 // knop heeft: dan staat de pil grijs en is de feed niet wat hij koos.
596 // Vandaar de toets tegen wat deze site biedt, in plaats van een vaste
597 // vertaling naar 'reader'.
598 var alt = body.dataset.feedAlt || 'reader';
599 var mag = alt === 'auto' ? ['grid', 'reader', 'timeline'] : ['grid', alt];
600 if (mag.indexOf(v) >= 0) body.dataset.feedView = v;
601 } catch (e) { /* geen opslag: de server-standaard blijft staan */ }
602 }
603 pasWeergaveToe();
604
605 try {
606 var c = parseInt(localStorage.getItem('pcms-grid-cols'), 10);
607 if (c === 2 || c === 3 || c === 4) body.dataset.gridCols = String(c);
608 } catch (e) {}
609
610 function syncAria() {
611 // Alleen op een feed-pagina hoort Tijdlijn/Grid 'actief' (wit) te zijn; op
612 // agenda/downloads/post/etc. beide grijs. Inline feed-check (FEED_PAGE_CLASSES
613 // staat verderop, maar deze functie draait al bij init).
614 var _feedC = ['on-home','on-tag','on-type','on-user','on-cirkel'];
615 var _onFeed = _feedC.some(function(c){ return body.classList.contains(c); });
616 // Een weergave die op DEZE pagina niet KAN, kan ook niet actief zijn. In de
617 // cirkel staat Lezen er wel maar uitgeschakeld; pasWeergaveToe() heeft
618 // feedView daar al op 'grid' gezet, dus de terugval hieronder vuurt er niet.
619 // Hij blijft staan voor het geval een pagina een knop mist: zonder terugval
620 // staat de hele pil grijs terwijl er wel degelijk iets te zien is, en dan
621 // ziet de lezer nergens meer waar hij is. De solo-VOORKEUR blijft intact.
622 var _view = String(body.dataset.feedView || 'reader');
623 if (!/^[a-z]+$/.test(_view)) _view = 'reader';
624 if (!document.querySelector('.view-switch-btn[data-view="' + _view + '"]')) _view = 'reader';
625 document.querySelectorAll('.view-switch-btn').forEach(function(b) {
626 b.setAttribute('aria-selected', (_onFeed && !b.disabled && b.dataset.view === _view) ? 'true' : 'false');
627 });
628 document.querySelectorAll('.grid-cols-btn').forEach(function(b) {
629 b.classList.toggle('is-active', b.dataset.cols === body.dataset.gridCols);
630 });
631 }
632 syncAria();
633 // Re-sync after HTMX brings in new content (e.g. navigating back to home).
634 // pcmsNav vuurt ná de body-class-update (zie de pcmsNav-listener hierboven), dus
635 // dáár weet syncAria de juiste pagina-class — los van de afterSettle-timing.
636 // Een vertraagde herhaling wint eventuele resterende races (OOB-chrome-swap).
637 // Eerst de weergave die bij de NIEUWE pagina hoort, dan pas de pil bijwerken:
638 // van de cirkel naar huis en terug wisselt niet alleen de knop maar ook welke
639 // onthouden keuze telt.
640 document.body.addEventListener('htmx:afterSettle', function(){ pasWeergaveToe(); syncAria(); setTimeout(syncAria, 60); });
641 document.body.addEventListener('pcmsNav', function(){ pasWeergaveToe(); syncAria(); setTimeout(syncAria, 60); });
642
643 // Pages where the body actually has a feed to toggle. On these the
644 // click stays put — it just flips body[data-feed-view] and CSS does
645 // the rest. Anywhere else (post detail, account, search, auth) we
646 // navigate to home in the chosen view, so the switcher is never
647 // a dead control.
648 // NB: 'on-archive' staat hier bewust NIET tussen — op het archief is er geen
649 // in-place timeline/grid-toggle; een klik op de switcher springt terug naar de
650 // feed in de gekozen weergave (zie de !isFeedPage()-tak hieronder).
651 var FEED_PAGE_CLASSES = ['on-home', 'on-tag', 'on-type', 'on-user', 'on-cirkel'];
652 function isFeedPage() {
653 for (var i = 0; i < FEED_PAGE_CLASSES.length; i++) {
654 if (body.classList.contains(FEED_PAGE_CLASSES[i])) return true;
655 }
656 return false;
657 }
658
659 // Event delegation — single listener handles current and future buttons.
660 document.addEventListener('click', function(e) {
661 var sw = e.target.closest('.view-switch-btn');
662 if (sw) {
663 // Een dode knop doet niets. Een <button disabled> vuurt in de meeste
664 // browsers geen click, maar deze listener hangt op document en vangt ook
665 // wat er langs de randen komt -- dus expliciet, niet op goed vertrouwen.
666 if (sw.disabled) return;
667 var view = sw.dataset.view;
668 body.dataset.feedView = view;
669 // Op de cirkel schrijven we NIETS. Daar is Grid de enige mogelijkheid, dus
670 // er valt niets te onthouden -- en je solo-keuze mag er zeker niet door
671 // veranderen: die moet je terugvinden zoals je hem achterliet.
672 try {
673 // De cirkel heeft zijn eigen geheugen, zodat een keuze daar je
674 // thuisweergave niet aanraakt en andersom.
675 if (opCirkel()) localStorage.setItem(CIRKEL_SLEUTEL, view);
676 else localStorage.setItem('pcms-feed-view', view);
677 } catch(_) {}
678 syncAria();
679 // On non-feed pages the switcher acts as a navigation: take the
680 // user back to home in the chosen view. Use HTMX if available so
681 // the page transition matches the rest of the site's nav.
682 if (!isFeedPage()) {
683 // Naar de SITE-home in de gekozen view (siteUrlBase), niet de globale '/'
684 // — in hub is '/' de hub-overview, niet de tijdlijn van deze artiest.
685 var base = body.dataset.siteBase || '';
686 if (window.htmx) {
687 window.htmx.ajax('GET', base + '/?partial=1', { target: '#pcms-main', swap: 'innerHTML' });
688 history.pushState({}, '', base + '/');
689 } else {
690 location.href = base + '/';
691 }
692 }
693 return;
694 }
695 var gc = e.target.closest('.grid-cols-btn');
696 if (gc) {
697 body.dataset.gridCols = gc.dataset.cols;
698 try { localStorage.setItem('pcms-grid-cols', gc.dataset.cols); } catch(_) {}
699 syncAria();
700 }
701 });
702})();
703</script>
704
705<!-- Globale link-boost: alle interne navigatie-links lopen via htmx in #pcms-main,
706 zodat de audioplayer (los in document.body) blijft spelen i.p.v. te verspringen
707 bij een full page-load. Werkt overal — Beheer, Account, posts, sites — zonder
708 elke link los htmx te maken. Links die écht een volledige load nodig hebben
709 (uitloggen/auth, downloads, feeds, media, assets, bestanden) worden overgeslagen,
710 net als links die al hun eigen hx-* hebben. -->
711<script>
712(function () {
713 if (!window.htmx) return;
714 // htmx' EIGEN history-afhandeling volledig uitzetten. We doen back/forward zelf
715 // via de popstate-listener hieronder (htmx.ajax → verwerkt de OOB-chrome netjes).
716 // Lieten we htmx z'n gang gaan, dan herstelde 'ie #pcms-main door de partial
717 // (inclusief de <div id=pcms-chrome hx-swap-oob>) ONGEFILTERD in #pcms-main te
718 // dumpen → een tweede, geneste kop = de pagina dubbel. Eén mechanisme nu.
719 try { window.htmx.config.historyEnabled = false; } catch (_) {}
720
721 function fullLoad(a, url) {
722 if (a.hasAttribute('download') || a.hasAttribute('data-full-load')) return true;
723 if (a.hasAttribute('hx-get') || a.hasAttribute('hx-post') || a.hasAttribute('hx-boost')) return true;
724 if (a.target && a.target !== '_self') return true;
725 if (a.getAttribute('rel') === 'external') return true;
726 var p = url.pathname;
727 // Sessie/redirect-acties → volledige navigatie (cookies, Google-redirect).
728 // Maar de auth-FORMULIERpagina's (/auth/admin, /auth/login, /auth/register,
729 // /auth/reset…) mogen wél via htmx, zodat de audiospeler blijft doorspelen
730 // i.p.v. te herstarten/verspringen bij een volledige page-load.
731 if (/^\/(logout|oauth)(?:\/|$)/.test(p)) return true;
732 if (/^\/auth\/(logout|google)(?:\/|$)/.test(p)) return true;
733 // Feeds, PWA, service-worker, statics, media-streams, downloads.
734 if (/^\/(feed|atom|sitemap|manifest|robots|sw\.js|assets|media|audio|uploads)(?:\/|\.|$)/.test(p)) return true;
735 if (/\.[a-z0-9]{2,5}$/i.test(p)) return true; // bestandsextensie → laat de browser 't halen
736 return false;
737 }
738
739 var lastPath = location.pathname + location.search;
740 var navTimer = null;
741
742 // Eén plek voor alle programmatische navigatie-swaps. Annuleert eerst een nog
743 // lopende request op #pcms-main (anti-race: bij snel klikken/terug-gaan kan een
744 // trage oude response anders een nieuwe pagina overschrijven → "kale content").
745 function doNav(dest) {
746 try { window.htmx.trigger('#pcms-main', 'htmx:abort'); } catch (_) {}
747 window.htmx.ajax('GET', dest, { target: '#pcms-main', swap: 'innerHTML' });
748 }
749
750 document.addEventListener('click', function (e) {
751 if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
752 var a = e.target.closest('a[href]');
753 if (!a) return;
754 var href = a.getAttribute('href');
755 if (!href || href.charAt(0) === '#') return;
756 var url; try { url = new URL(a.href, location.href); } catch (_) { return; }
757 if (url.origin !== location.origin) return;
758 if (fullLoad(a, url)) return;
759 e.preventDefault();
760 var dest = url.pathname + url.search;
761 if (dest !== lastPath) history.pushState({ b: 1 }, '', dest);
762 lastPath = dest;
763 if (navTimer) { clearTimeout(navTimer); navTimer = null; }
764 doNav(dest);
765 try { window.scrollTo(0, 0); } catch (_) {}
766 });
767
768 window.addEventListener('popstate', function () {
769 var here = location.pathname + location.search;
770 if (here === lastPath) return;
771 lastPath = here;
772 // Debounce: bij heel snel/herhaald terug-vooruit niet elke tussenpagina ophalen,
773 // alleen de LAATSTE bestemming. Voorkomt overlappende swaps ("kale content").
774 if (navTimer) clearTimeout(navTimer);
775 navTimer = setTimeout(function () {
776 navTimer = null;
777 doNav(location.pathname + location.search);
778 }, 90);
779 });
780
781 // Links MÉT eigen hx-get + hx-push-url (post-card/post-tile/topnav/…) lopen NIET
782 // via de boost hierboven, en hx-push-url is een no-op nu htmx-history uitstaat.
783 // Doe daarom de adresbalk-update hier zelf zodra htmx swapt. Programmatische
784 // htmx.ajax-calls (boost/popstate) hebben geen elt met hx-push-url → geen dubbel.
785 document.body.addEventListener('htmx:beforeRequest', function (evt) {
786 try {
787 var elt = evt.detail && evt.detail.elt; // het TRIGGERENDE element (de link)
788 if (!elt || !elt.closest) return;
789 var node = elt.closest('[hx-push-url]');
790 if (!node) return;
791 var u = node.getAttribute('hx-push-url');
792 if (!u || u === 'false') return;
793 if (u !== (location.pathname + location.search)) history.pushState({ b: 1 }, '', u);
794 lastPath = u;
795 } catch (_) {}
796 });
797
798 // Spring naar boven na ELKE navigatie-swap van #pcms-main. De boost hierboven
799 // scrollt al, maar links met hun eigen hx-get (post-nav Newer/Older, post-kaarten)
800 // lopen NIET via de boost → zonder dit blijf je op de oude scrollpositie hangen
801 // bij het openen van een gerelateerde/volgende post. Alleen #pcms-main, zodat
802 // in-page swaps (comments e.d.) en de OOB-chrome-swap niet meescrollen.
803 document.body.addEventListener('htmx:afterSwap', function (evt) {
804 var t = evt.detail && evt.detail.target;
805 if (t && t.id === 'pcms-main') { try { window.scrollTo(0, 0); } catch (_) {} }
806 });
807})();
808</script>
809
810<!-- Mobiel toetsenbord vs. site-layout: zet body.kb-open zodra het toetsenbord
811 open is (visual viewport fors korter dan het venster) ÉN er een invoerveld
812 focus heeft. CSS verbergt dan de vaste onderbalken (bottom-tab + mini-speler)
813 zodat ze niet over het invoerveld zweven. -->
814<script>
815(function () {
816 var vv = window.visualViewport;
817 if (!vv) return;
818 function isField(el) {
819 if (!el) return false;
820 var t = el.tagName;
821 return t === 'INPUT' || t === 'TEXTAREA' || el.isContentEditable;
822 }
823 function update() {
824 var open = (window.innerHeight - vv.height) > 150 && isField(document.activeElement);
825 document.body.classList.toggle('kb-open', open);
826 }
827 vv.addEventListener('resize', update);
828 vv.addEventListener('scroll', update);
829 document.addEventListener('focusin', function () { setTimeout(update, 60); });
830 document.addEventListener('focusout', function () { setTimeout(update, 60); });
831})();
832</script>
833
834<!-- Afbeeldingen lastiger op te slaan: rechtsklik-menu + slepen blokkeren op <img>.
835 Frictie, geen echte beveiliging (DevTools/screenshot blijven kunnen). -->
836<script>
837(function () {
838 document.addEventListener('contextmenu', function (e) {
839 if (e.target && e.target.tagName === 'IMG') e.preventDefault();
840 });
841 document.addEventListener('dragstart', function (e) {
842 if (e.target && e.target.tagName === 'IMG') e.preventDefault();
843 });
844})();
845</script>
846
847<!-- Auto-resize: elk <textarea> groeit mee met de inhoud i.p.v. intern te scrollen
848 (scroll-binnen-scroll is verwarrend). Site-breed; ook na htmx-swaps. -->
849<script>
850(function () {
851 function autoSize(ta) {
852 if (!ta || ta.tagName !== 'TEXTAREA') return;
853 // Skip hidden textareas (e.g. inside a closed <details> or an unopened reply
854 // box): measuring scrollHeight there yields a bad height that sticks as inline
855 // style and makes the field open huge. They get sized on focus once visible.
856 if (ta.offsetParent === null && ta.offsetHeight === 0) return;
857 ta.style.height = 'auto';
858 var maxH = parseFloat(getComputedStyle(ta).maxHeight);
859 var sh = ta.scrollHeight;
860 var h = (maxH && !isNaN(maxH)) ? Math.min(sh, maxH) : sh; // respect a CSS max-height
861 ta.style.height = h + 'px';
862 ta.style.overflowY = sh > h ? 'auto' : 'hidden';
863 }
864 function sizeAll(root) {
865 (root || document).querySelectorAll('textarea').forEach(autoSize);
866 }
867 document.addEventListener('input', function (e) { autoSize(e.target); });
868 document.addEventListener('focusin', function (e) { autoSize(e.target); });
869 // Init + opnieuw na htmx-navigatie/partials.
870 sizeAll();
871 document.body.addEventListener('htmx:afterSettle', function () { sizeAll(); });
872 window.addEventListener('load', function () { sizeAll(); });
873})();
874</script>
875
876<!-- PWA install prompt — show button when browser fires beforeinstallprompt -->
877<script>
878(function() {
879 var btn = document.getElementById('pwa-install-btn');
880 if (!btn) return;
881 var deferred = null;
882 window.addEventListener('beforeinstallprompt', function(e) {
883 e.preventDefault();
884 deferred = e;
885 btn.hidden = false;
886 });
887 btn.addEventListener('click', async function() {
888 if (!deferred) return;
889 btn.hidden = true;
890 deferred.prompt();
891 try { await deferred.userChoice; } catch(e) {}
892 deferred = null;
893 });
894 window.addEventListener('appinstalled', function() {
895 btn.hidden = true;
896 deferred = null;
897 });
898})();
899</script>
900
901<!-- NSFW / sensitive content: click a veil/reveal to un-blur. Capture-phase so the
902 click reveals instead of following the card link or firing htmx navigation. -->
903<script>
904// Cover fade-in: a cover image that's still loading is hidden so the accent-gradient
905// placeholder behind it shows; it fades in once loaded. Cached/complete images stay
906// visible (no flash). Runs on load + htmx swaps.
907(function () {
908 if (window.__coverFadeWired) return; window.__coverFadeWired = true;
909 function scan(root) {
910 (root || document).querySelectorAll('img.grid-tile-img, .post-list-cover img, .tl-media-img img').forEach(function (img) {
911 if (img.dataset.fade) return; img.dataset.fade = '1';
912 if (img.complete && img.naturalWidth > 0) return; // already loaded → leave visible
913 img.classList.add('is-loading');
914 var done = function () { img.classList.remove('is-loading'); };
915 img.addEventListener('load', done, { once: true });
916 img.addEventListener('error', done, { once: true });
917 });
918 }
919 scan(document);
920 document.body.addEventListener('htmx:afterSettle', function (e) { scan(e.target); });
921})();
922
923// Light anti-grab friction: suppress the right-click menu on visual media (covers, images,
924// videos) so the art isn't one right-click away from "Save as". Friction, NOT protection —
925// the files are public and reachable via devtools/network. Middle/Ctrl-click (open in new
926// tab) still works; only the context menu is blocked. Delegated → covers htmx-swapped content.
927(function () {
928 if (window.__noMediaCtxWired) return; window.__noMediaCtxWired = true;
929 document.addEventListener('contextmenu', function (e) {
930 if (e.target.closest('img, video, .grid-tile, .post-list-cover, .post-cover, .tl-media-img')) {
931 e.preventDefault();
932 }
933 });
934})();
935
936// iOS animated-cover → video: an animated WebP is janky on iOS Safari, so on iOS we swap any
937// <img data-ios-mp4="…"> for a muted, looping, inline <video> (the WebP's matching MP4). Every
938// other browser keeps the crisp WebP. Runs on load + htmx swaps.
939(function () {
940 if (window.__iosVideoWired) return; window.__iosVideoWired = true;
941 var ua = navigator.userAgent || '';
942 var IS_IOS = /iP(hone|od|ad)/.test(navigator.platform || '') || /iPad|iPhone|iPod/.test(ua) ||
943 (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1); // iPadOS reports as Mac
944 if (!IS_IOS) return;
945 function swap(root) {
946 (root || document).querySelectorAll('img[data-ios-mp4]').forEach(function (img) {
947 var mp4 = img.getAttribute('data-ios-mp4');
948 if (!mp4 || img.dataset.iosSwapped) return;
949 img.dataset.iosSwapped = '1';
950 var v = document.createElement('video');
951 v.src = mp4; v.muted = true; v.loop = true; v.autoplay = true;
952 v.setAttribute('muted', ''); v.setAttribute('playsinline', ''); v.setAttribute('webkit-playsinline', '');
953 v.poster = img.getAttribute('src') || '';
954 v.className = img.className;
955 if (img.getAttribute('style')) v.setAttribute('style', img.getAttribute('style'));
956 if (img.parentNode) img.parentNode.replaceChild(v, img);
957 var p = v.play && v.play(); if (p && p.catch) p.catch(function () {});
958 });
959 }
960 swap(document);
961 document.body.addEventListener('htmx:afterSettle', function (e) { swap(e.target); });
962})();
963</script>
964
965<script>
966(function () {
967 if (window.__nsfwWired) return; window.__nsfwWired = true;
968 document.addEventListener('click', function (e) {
969 var hit = e.target.closest && e.target.closest('.nsfw-veil, .nsfw-reveal');
970 if (!hit) return;
971 e.preventDefault(); e.stopPropagation();
972 var box = hit.closest('.nsfw-media, .nsfw-gate');
973 if (box) box.classList.add('is-shown');
974 }, true);
975})();
976</script>
977
978<script>
979// Delegated replacements for inline on* handlers, so the CSP needs no
980// script-src-attr 'unsafe-inline'. Document-level → also covers htmx-swapped content.
981(function () {
982 if (window.__pcmsHandlersWired) return; window.__pcmsHandlersWired = true;
983 // Confirm before submitting a form that carries data-confirm.
984 document.addEventListener('submit', function (e) {
985 var f = e.target;
986 if (f && f.dataset && f.dataset.confirm && !window.confirm(f.dataset.confirm)) e.preventDefault();
987 });
988 // Auto-submit a form / switch language when a <select> changes.
989 document.addEventListener('change', function (e) {
990 var el = e.target;
991 if (!el || !el.dataset) return;
992 if (el.dataset.autosubmit !== undefined && el.form) el.form.submit();
993 else if (el.dataset.langSwitch !== undefined) {
994 location.href = '/lang/' + encodeURIComponent(el.value) + '?r=' + encodeURIComponent(location.pathname + location.search);
995 }
996 });
997 // Misc click helpers (select-all in a field, history-back button).
998 document.addEventListener('click', function (e) {
999 var el = e.target.closest && e.target.closest('[data-selectall],[data-back],[data-share]');
1000 if (!el) return;
1001 if (el.dataset.selectall !== undefined && el.select) el.select();
1002 if (el.dataset.back !== undefined) { e.preventDefault(); history.back(); }
1003 if (el.dataset.share !== undefined) {
1004 e.preventDefault();
1005 var url = location.href, title = el.dataset.shareTitle || document.title;
1006 if (navigator.share) { navigator.share({ title: title, url: url }).catch(function () {}); }
1007 else if (navigator.clipboard && navigator.clipboard.writeText) {
1008 navigator.clipboard.writeText(url).then(function () {
1009 var fb = document.getElementById('post-share-feedback');
1010 if (fb) { fb.hidden = false; setTimeout(function () { fb.hidden = true; }, 2000); }
1011 }).catch(function () { window.prompt('Copy link:', url); });
1012 } else { window.prompt('Copy link:', url); }
1013 }
1014 });
1015 // Image fallback (the error event doesn't bubble → capture phase).
1016 document.addEventListener('error', function (e) {
1017 var el = e.target;
1018 if (el && el.tagName === 'IMG' && el.dataset && el.dataset.fallback !== undefined && el.parentElement) {
1019 el.parentElement.innerHTML = '<span class="pl-cover-empty">⚠️</span>';
1020 }
1021 }, true);
1022})();
1023</script>
1024
1025<!-- Per-site custom footer HTML -->
1026<% if (safeSite.custom_foot_html) { %>
1027<%- safeSite.custom_foot_html %>
1028<% } %>
1029
1030</body>
1031</html>
Note: See TracBrowser for help on using the repository browser.