source: Klonkt/src/views/shell.ejs

main
Last change on this file was aa521c3, checked in by Robin <roboburr@…>, 4 days ago

MOD_V naar 64: lib.js, read.js en post-edit.js wijzigden

Vergeten bij 369aa60, en het viel op zoals het hoort op te vallen: op dev
gaf post-edit.js "lib.js does not provide an export named VENDOR_V",
terwijl de server die export wel degelijk serveerde. De browser had
lib.js?v=63 nog uit zijn cache -- zelfde URL, gewijzigde inhoud, een jaar
max-age. Dat is letterlijk het gedrag dat shaer-724 beschrijft, nu op mijn
eigen commit.

Bewijst meteen dat de rest van de keten werkt: zonder de importmap was
lib.js helemaal kaal opgehaald en had geen enkele bump hem ooit ververst.

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