source: Klonkt/src/views/shell.ejs@ 87696ce

main
Last change on this file since 87696ce was 87696ce, checked in by Bart <bart@…>, 3 weeks ago

De mod/-modules krijgen een cache-buster: MOD_V

/assets staat buiten development op max-age=1y, en de dynamische import van
assets/js/mod/*.js had als enige geen ?v= -- style.css, audio.css en
audio-player.js hebben er al jaren een. Gevolg: wie de pagina eerder bezocht,
draaide een jaar lang de oude module. Een reparatie bereikt dan precies de
bezoekers die de fout al hebben.

Gemerkt bij read.js: de gerepareerde module stond goed op de server (curl
bevestigde het) maar de browser bleef de oude draaien, ook na herladen en na het
wissen van de service-worker-caches. En de hernoemde klasse maakte het zichtbaar:
oude JS zet read-chrome-weg, nieuwe CSS luistert naar read-chrome-hidden, dus de
balken schoven bij die bezoekers helemaal niet meer weg.

Eén nummer voor de hele map, met de hand te bumpen; de regel staat nu ook in
CLAUDE.md en AGENTS.md, want een discipline die nergens staat is er geen.

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

  • Property mode set to 100644
File size: 45.8 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=67">
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 || 'timeline') %>" 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 var PAGE_CLASSES = ['on-home','on-post','on-special','on-archive','on-search','on-admin','on-auth',
437 'on-shows','on-downloads','on-download','on-epk','on-linkbio','on-newsletter',
438 'on-tag','on-type','on-user','on-cirkel','on-hub','on-chat'];
439
440 document.body.addEventListener('pcmsNav', function(ev) {
441 var d = ev.detail || {};
442 // Per-site accent + palette bijwerken (head wordt niet mee-geswapt bij htmx-nav,
443 // dus zonder dit erft een artiest de kleuren van de vorige pagina).
444 if (d.accent && /^#[0-9a-fA-F]{6}$/.test(d.accent)) {
445 var sa = document.getElementById('pcms-site-accent');
446 if (sa) {
447 sa.textContent =
448 ':root,[data-palette]{--accent:' + d.accent +
449 ';--accent-soft:color-mix(in srgb,' + d.accent + ' 80%,white)' +
450 ';--accent-tint:color-mix(in srgb,' + d.accent + ' 12%,transparent);}';
451 }
452 }
453 if (d.palette && /^[a-z0-9-]+$/i.test(d.palette)) {
454 document.documentElement.setAttribute('data-palette', d.palette);
455 }
456
457 // Welke modules deze pagina wil (shaer-bqr). De bootstrap hieronder leest
458 // dit zodra deze handler klaar is.
459 if (typeof d.js === 'string' && /^[a-z0-9 -]*$/.test(d.js)) {
460 document.body.setAttribute('data-js', d.js);
461 }
462
463 var next = d.bodyClass;
464 if (!next) return;
465 // De server kan meerdere page-classes sturen ("on-special on-auth"). Strip
466 // alle bekende en zet ALLE meegestuurde page-classes terug (niet alleen de
467 // eerste) — anders mist 'on-auth' bij htmx-nav en blijft het login-scherm
468 // de chrome/tab van de vorige pagina houden.
469 var matched = String(next).split(/\s+/).filter(function(c) {
470 return PAGE_CLASSES.indexOf(c) >= 0;
471 });
472 if (!matched.length) return;
473 PAGE_CLASSES.forEach(function(c) { document.body.classList.remove(c); });
474 matched.forEach(function(c) { document.body.classList.add(c); });
475 });
476
477 // (Back/forward wordt afgehandeld door de popstate-listener in de link-boost
478 // hieronder — die her-fetcht de partial via htmx.ajax, wat de OOB-chrome +
479 // de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere
480 // htmx:historyRestore-handler is vervallen nu htmx-history uitstaat.)
481})();
482</script>
483
484<!-- Modules laden (shaer-bqr). Inline script in gewisselde inhoud wordt door de
485 CSP geweigerd: de nonce rouleert per verzoek, dus een script dat via htmx
486 binnenkomt draagt er een die dit document niet kent (shaer-0i6). Alles wat
487 bij een pagina hoort komt daarom uit een module, en die wordt HIER geladen —
488 vanuit de shell, die alleen bij een volledige laadbeurt binnenkomt en dus
489 wél de goede nonce heeft.
490
491 Een dynamische import vanuit een vertrouwd (genonced) script is precies waar
492 'strict-dynamic' voor bedoeld is, dus de module zelf heeft geen nonce nodig. -->
493<script>
494(function () {
495 if (window.__modBoot) return;
496 window.__modBoot = true;
497
498 // ?v=N — cache-buster voor ALLE modules in assets/js/mod, net als bij
499 // audio-player.js hierboven. BUMP BIJ ELKE WIJZIGING IN DIE MAP. Zonder
500 // query staat /assets op max-age=1y, en dan blijft een browser die de
501 // pagina eerder bezocht een jaar lang de oude module draaien: een
502 // reparatie bereikt precies de bezoekers die hem al hebben.
503 // Eén nummer voor de hele map. Te vaak bumpen kost één download; te weinig
504 // bumpen kost een bugfix die nooit aankomt.
505 var MOD_V = 1;
506
507 // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
508 // die `init` exporteert draait die bij ELKE paginawissel waarop hij actief
509 // is -- dat is het gedrag van de oude inline scripts, die per render
510 // draaiden. Een module zonder init draait alleen zijn top-level, een keer.
511 var loaded = {};
512 function start(name, m) {
513 if (!m || typeof m.init !== 'function') return;
514 try { m.init(); } catch (e) { console.warn('[mod] ' + name + ' init:', e && e.message); }
515 }
516 function load() {
517 var names = (document.body.getAttribute('data-js') || '').trim().split(/\s+/);
518 names.forEach(function (name) {
519 // Streng: deze waarde komt uit een template en wordt een PAD. Alleen
520 // kleine letters, cijfers en streepjes; nooit een punt of een schuine
521 // streep.
522 if (!name || !/^[a-z0-9-]+$/.test(name)) return;
523 if (loaded[name]) { start(name, loaded[name]); return; }
524 loaded[name] = 1;
525 import('/assets/js/mod/' + name + '.js?v=' + MOD_V).then(function (m) {
526 loaded[name] = m;
527 start(name, m);
528 }).catch(function (e) {
529 console.warn('[mod] ' + name + ' laadde niet:', e && e.message);
530 });
531 });
532 }
533 load();
534 // Bij een htmx-navigatie wisselt de INHOUD, niet de body. De nav-trigger
535 // hieronder zet data-js opnieuw; daarna halen we op wat er nieuw bij staat.
536 // Een module die er al is wordt niet opnieuw geimporteerd -- vandaar dat elke
537 // module gedelegeerd moet werken en tegen een tweede aanroep moet kunnen.
538 document.body.addEventListener('pcmsNav', load);
539})();
540</script>
541
542<!-- View switcher + grid-cols persistence (event delegation: works for switcher
543 elements rendered later by HTMX, e.g. when navigating back to home). -->
544<script>
545(function() {
546 var body = document.body;
547 // Restore feed view + grid cols from localStorage (overrides server default)
548 try {
549 var v = localStorage.getItem('pcms-feed-view');
550 if (v === 'timeline' || v === 'grid') body.dataset.feedView = v;
551 var c = parseInt(localStorage.getItem('pcms-grid-cols'), 10);
552 if (c === 2 || c === 3 || c === 4) body.dataset.gridCols = String(c);
553 } catch (e) {}
554
555 function syncAria() {
556 // Alleen op een feed-pagina hoort Tijdlijn/Grid 'actief' (wit) te zijn; op
557 // agenda/downloads/post/etc. beide grijs. Inline feed-check (FEED_PAGE_CLASSES
558 // staat verderop, maar deze functie draait al bij init).
559 var _feedC = ['on-home','on-tag','on-type','on-user','on-cirkel'];
560 var _onFeed = _feedC.some(function(c){ return body.classList.contains(c); });
561 document.querySelectorAll('.view-switch-btn').forEach(function(b) {
562 b.setAttribute('aria-selected', (_onFeed && b.dataset.view === body.dataset.feedView) ? 'true' : 'false');
563 });
564 document.querySelectorAll('.grid-cols-btn').forEach(function(b) {
565 b.classList.toggle('is-active', b.dataset.cols === body.dataset.gridCols);
566 });
567 }
568 syncAria();
569 // Re-sync after HTMX brings in new content (e.g. navigating back to home).
570 // pcmsNav vuurt ná de body-class-update (zie de pcmsNav-listener hierboven), dus
571 // dáár weet syncAria de juiste pagina-class — los van de afterSettle-timing.
572 // Een vertraagde herhaling wint eventuele resterende races (OOB-chrome-swap).
573 document.body.addEventListener('htmx:afterSettle', function(){ syncAria(); setTimeout(syncAria, 60); });
574 document.body.addEventListener('pcmsNav', function(){ syncAria(); setTimeout(syncAria, 60); });
575
576 // Pages where the body actually has a feed to toggle. On these the
577 // click stays put — it just flips body[data-feed-view] and CSS does
578 // the rest. Anywhere else (post detail, account, search, auth) we
579 // navigate to home in the chosen view, so the switcher is never
580 // a dead control.
581 // NB: 'on-archive' staat hier bewust NIET tussen — op het archief is er geen
582 // in-place timeline/grid-toggle; een klik op de switcher springt terug naar de
583 // feed in de gekozen weergave (zie de !isFeedPage()-tak hieronder).
584 var FEED_PAGE_CLASSES = ['on-home', 'on-tag', 'on-type', 'on-user', 'on-cirkel'];
585 function isFeedPage() {
586 for (var i = 0; i < FEED_PAGE_CLASSES.length; i++) {
587 if (body.classList.contains(FEED_PAGE_CLASSES[i])) return true;
588 }
589 return false;
590 }
591
592 // Event delegation — single listener handles current and future buttons.
593 document.addEventListener('click', function(e) {
594 var sw = e.target.closest('.view-switch-btn');
595 if (sw) {
596 var view = sw.dataset.view;
597 body.dataset.feedView = view;
598 try { localStorage.setItem('pcms-feed-view', view); } catch(_) {}
599 syncAria();
600 // On non-feed pages the switcher acts as a navigation: take the
601 // user back to home in the chosen view. Use HTMX if available so
602 // the page transition matches the rest of the site's nav.
603 if (!isFeedPage()) {
604 // Naar de SITE-home in de gekozen view (siteUrlBase), niet de globale '/'
605 // — in hub is '/' de hub-overview, niet de tijdlijn van deze artiest.
606 var base = body.dataset.siteBase || '';
607 if (window.htmx) {
608 window.htmx.ajax('GET', base + '/?partial=1', { target: '#pcms-main', swap: 'innerHTML' });
609 history.pushState({}, '', base + '/');
610 } else {
611 location.href = base + '/';
612 }
613 }
614 return;
615 }
616 var gc = e.target.closest('.grid-cols-btn');
617 if (gc) {
618 body.dataset.gridCols = gc.dataset.cols;
619 try { localStorage.setItem('pcms-grid-cols', gc.dataset.cols); } catch(_) {}
620 syncAria();
621 }
622 });
623})();
624</script>
625
626<!-- Globale link-boost: alle interne navigatie-links lopen via htmx in #pcms-main,
627 zodat de audioplayer (los in document.body) blijft spelen i.p.v. te verspringen
628 bij een full page-load. Werkt overal — Beheer, Account, posts, sites — zonder
629 elke link los htmx te maken. Links die écht een volledige load nodig hebben
630 (uitloggen/auth, downloads, feeds, media, assets, bestanden) worden overgeslagen,
631 net als links die al hun eigen hx-* hebben. -->
632<script>
633(function () {
634 if (!window.htmx) return;
635 // htmx' EIGEN history-afhandeling volledig uitzetten. We doen back/forward zelf
636 // via de popstate-listener hieronder (htmx.ajax → verwerkt de OOB-chrome netjes).
637 // Lieten we htmx z'n gang gaan, dan herstelde 'ie #pcms-main door de partial
638 // (inclusief de <div id=pcms-chrome hx-swap-oob>) ONGEFILTERD in #pcms-main te
639 // dumpen → een tweede, geneste kop = de pagina dubbel. Eén mechanisme nu.
640 try { window.htmx.config.historyEnabled = false; } catch (_) {}
641
642 function fullLoad(a, url) {
643 if (a.hasAttribute('download') || a.hasAttribute('data-full-load')) return true;
644 if (a.hasAttribute('hx-get') || a.hasAttribute('hx-post') || a.hasAttribute('hx-boost')) return true;
645 if (a.target && a.target !== '_self') return true;
646 if (a.getAttribute('rel') === 'external') return true;
647 var p = url.pathname;
648 // Sessie/redirect-acties → volledige navigatie (cookies, Google-redirect).
649 // Maar de auth-FORMULIERpagina's (/auth/admin, /auth/login, /auth/register,
650 // /auth/reset…) mogen wél via htmx, zodat de audiospeler blijft doorspelen
651 // i.p.v. te herstarten/verspringen bij een volledige page-load.
652 if (/^\/(logout|oauth)(?:\/|$)/.test(p)) return true;
653 if (/^\/auth\/(logout|google)(?:\/|$)/.test(p)) return true;
654 // Feeds, PWA, service-worker, statics, media-streams, downloads.
655 if (/^\/(feed|atom|sitemap|manifest|robots|sw\.js|assets|media|audio|uploads)(?:\/|\.|$)/.test(p)) return true;
656 if (/\.[a-z0-9]{2,5}$/i.test(p)) return true; // bestandsextensie → laat de browser 't halen
657 return false;
658 }
659
660 var lastPath = location.pathname + location.search;
661 var navTimer = null;
662
663 // Eén plek voor alle programmatische navigatie-swaps. Annuleert eerst een nog
664 // lopende request op #pcms-main (anti-race: bij snel klikken/terug-gaan kan een
665 // trage oude response anders een nieuwe pagina overschrijven → "kale content").
666 function doNav(dest) {
667 try { window.htmx.trigger('#pcms-main', 'htmx:abort'); } catch (_) {}
668 window.htmx.ajax('GET', dest, { target: '#pcms-main', swap: 'innerHTML' });
669 }
670
671 document.addEventListener('click', function (e) {
672 if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
673 var a = e.target.closest('a[href]');
674 if (!a) return;
675 var href = a.getAttribute('href');
676 if (!href || href.charAt(0) === '#') return;
677 var url; try { url = new URL(a.href, location.href); } catch (_) { return; }
678 if (url.origin !== location.origin) return;
679 if (fullLoad(a, url)) return;
680 e.preventDefault();
681 var dest = url.pathname + url.search;
682 if (dest !== lastPath) history.pushState({ b: 1 }, '', dest);
683 lastPath = dest;
684 if (navTimer) { clearTimeout(navTimer); navTimer = null; }
685 doNav(dest);
686 try { window.scrollTo(0, 0); } catch (_) {}
687 });
688
689 window.addEventListener('popstate', function () {
690 var here = location.pathname + location.search;
691 if (here === lastPath) return;
692 lastPath = here;
693 // Debounce: bij heel snel/herhaald terug-vooruit niet elke tussenpagina ophalen,
694 // alleen de LAATSTE bestemming. Voorkomt overlappende swaps ("kale content").
695 if (navTimer) clearTimeout(navTimer);
696 navTimer = setTimeout(function () {
697 navTimer = null;
698 doNav(location.pathname + location.search);
699 }, 90);
700 });
701
702 // Links MÉT eigen hx-get + hx-push-url (post-card/post-tile/topnav/…) lopen NIET
703 // via de boost hierboven, en hx-push-url is een no-op nu htmx-history uitstaat.
704 // Doe daarom de adresbalk-update hier zelf zodra htmx swapt. Programmatische
705 // htmx.ajax-calls (boost/popstate) hebben geen elt met hx-push-url → geen dubbel.
706 document.body.addEventListener('htmx:beforeRequest', function (evt) {
707 try {
708 var elt = evt.detail && evt.detail.elt; // het TRIGGERENDE element (de link)
709 if (!elt || !elt.closest) return;
710 var node = elt.closest('[hx-push-url]');
711 if (!node) return;
712 var u = node.getAttribute('hx-push-url');
713 if (!u || u === 'false') return;
714 if (u !== (location.pathname + location.search)) history.pushState({ b: 1 }, '', u);
715 lastPath = u;
716 } catch (_) {}
717 });
718
719 // Spring naar boven na ELKE navigatie-swap van #pcms-main. De boost hierboven
720 // scrollt al, maar links met hun eigen hx-get (post-nav Newer/Older, post-kaarten)
721 // lopen NIET via de boost → zonder dit blijf je op de oude scrollpositie hangen
722 // bij het openen van een gerelateerde/volgende post. Alleen #pcms-main, zodat
723 // in-page swaps (comments e.d.) en de OOB-chrome-swap niet meescrollen.
724 document.body.addEventListener('htmx:afterSwap', function (evt) {
725 var t = evt.detail && evt.detail.target;
726 if (t && t.id === 'pcms-main') { try { window.scrollTo(0, 0); } catch (_) {} }
727 });
728})();
729</script>
730
731<!-- Mobiel toetsenbord vs. site-layout: zet body.kb-open zodra het toetsenbord
732 open is (visual viewport fors korter dan het venster) ÉN er een invoerveld
733 focus heeft. CSS verbergt dan de vaste onderbalken (bottom-tab + mini-speler)
734 zodat ze niet over het invoerveld zweven. -->
735<script>
736(function () {
737 var vv = window.visualViewport;
738 if (!vv) return;
739 function isField(el) {
740 if (!el) return false;
741 var t = el.tagName;
742 return t === 'INPUT' || t === 'TEXTAREA' || el.isContentEditable;
743 }
744 function update() {
745 var open = (window.innerHeight - vv.height) > 150 && isField(document.activeElement);
746 document.body.classList.toggle('kb-open', open);
747 }
748 vv.addEventListener('resize', update);
749 vv.addEventListener('scroll', update);
750 document.addEventListener('focusin', function () { setTimeout(update, 60); });
751 document.addEventListener('focusout', function () { setTimeout(update, 60); });
752})();
753</script>
754
755<!-- Afbeeldingen lastiger op te slaan: rechtsklik-menu + slepen blokkeren op <img>.
756 Frictie, geen echte beveiliging (DevTools/screenshot blijven kunnen). -->
757<script>
758(function () {
759 document.addEventListener('contextmenu', function (e) {
760 if (e.target && e.target.tagName === 'IMG') e.preventDefault();
761 });
762 document.addEventListener('dragstart', function (e) {
763 if (e.target && e.target.tagName === 'IMG') e.preventDefault();
764 });
765})();
766</script>
767
768<!-- Auto-resize: elk <textarea> groeit mee met de inhoud i.p.v. intern te scrollen
769 (scroll-binnen-scroll is verwarrend). Site-breed; ook na htmx-swaps. -->
770<script>
771(function () {
772 function autoSize(ta) {
773 if (!ta || ta.tagName !== 'TEXTAREA') return;
774 // Skip hidden textareas (e.g. inside a closed <details> or an unopened reply
775 // box): measuring scrollHeight there yields a bad height that sticks as inline
776 // style and makes the field open huge. They get sized on focus once visible.
777 if (ta.offsetParent === null && ta.offsetHeight === 0) return;
778 ta.style.height = 'auto';
779 var maxH = parseFloat(getComputedStyle(ta).maxHeight);
780 var sh = ta.scrollHeight;
781 var h = (maxH && !isNaN(maxH)) ? Math.min(sh, maxH) : sh; // respect a CSS max-height
782 ta.style.height = h + 'px';
783 ta.style.overflowY = sh > h ? 'auto' : 'hidden';
784 }
785 function sizeAll(root) {
786 (root || document).querySelectorAll('textarea').forEach(autoSize);
787 }
788 document.addEventListener('input', function (e) { autoSize(e.target); });
789 document.addEventListener('focusin', function (e) { autoSize(e.target); });
790 // Init + opnieuw na htmx-navigatie/partials.
791 sizeAll();
792 document.body.addEventListener('htmx:afterSettle', function () { sizeAll(); });
793 window.addEventListener('load', function () { sizeAll(); });
794})();
795</script>
796
797<!-- PWA install prompt — show button when browser fires beforeinstallprompt -->
798<script>
799(function() {
800 var btn = document.getElementById('pwa-install-btn');
801 if (!btn) return;
802 var deferred = null;
803 window.addEventListener('beforeinstallprompt', function(e) {
804 e.preventDefault();
805 deferred = e;
806 btn.hidden = false;
807 });
808 btn.addEventListener('click', async function() {
809 if (!deferred) return;
810 btn.hidden = true;
811 deferred.prompt();
812 try { await deferred.userChoice; } catch(e) {}
813 deferred = null;
814 });
815 window.addEventListener('appinstalled', function() {
816 btn.hidden = true;
817 deferred = null;
818 });
819})();
820</script>
821
822<!-- NSFW / sensitive content: click a veil/reveal to un-blur. Capture-phase so the
823 click reveals instead of following the card link or firing htmx navigation. -->
824<script>
825// Cover fade-in: a cover image that's still loading is hidden so the accent-gradient
826// placeholder behind it shows; it fades in once loaded. Cached/complete images stay
827// visible (no flash). Runs on load + htmx swaps.
828(function () {
829 if (window.__coverFadeWired) return; window.__coverFadeWired = true;
830 function scan(root) {
831 (root || document).querySelectorAll('img.grid-tile-img, .post-list-cover img, .tl-media-img img').forEach(function (img) {
832 if (img.dataset.fade) return; img.dataset.fade = '1';
833 if (img.complete && img.naturalWidth > 0) return; // already loaded → leave visible
834 img.classList.add('is-loading');
835 var done = function () { img.classList.remove('is-loading'); };
836 img.addEventListener('load', done, { once: true });
837 img.addEventListener('error', done, { once: true });
838 });
839 }
840 scan(document);
841 document.body.addEventListener('htmx:afterSettle', function (e) { scan(e.target); });
842})();
843
844// Light anti-grab friction: suppress the right-click menu on visual media (covers, images,
845// videos) so the art isn't one right-click away from "Save as". Friction, NOT protection —
846// the files are public and reachable via devtools/network. Middle/Ctrl-click (open in new
847// tab) still works; only the context menu is blocked. Delegated → covers htmx-swapped content.
848(function () {
849 if (window.__noMediaCtxWired) return; window.__noMediaCtxWired = true;
850 document.addEventListener('contextmenu', function (e) {
851 if (e.target.closest('img, video, .grid-tile, .post-list-cover, .post-cover, .tl-media-img')) {
852 e.preventDefault();
853 }
854 });
855})();
856
857// iOS animated-cover → video: an animated WebP is janky on iOS Safari, so on iOS we swap any
858// <img data-ios-mp4="…"> for a muted, looping, inline <video> (the WebP's matching MP4). Every
859// other browser keeps the crisp WebP. Runs on load + htmx swaps.
860(function () {
861 if (window.__iosVideoWired) return; window.__iosVideoWired = true;
862 var ua = navigator.userAgent || '';
863 var IS_IOS = /iP(hone|od|ad)/.test(navigator.platform || '') || /iPad|iPhone|iPod/.test(ua) ||
864 (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1); // iPadOS reports as Mac
865 if (!IS_IOS) return;
866 function swap(root) {
867 (root || document).querySelectorAll('img[data-ios-mp4]').forEach(function (img) {
868 var mp4 = img.getAttribute('data-ios-mp4');
869 if (!mp4 || img.dataset.iosSwapped) return;
870 img.dataset.iosSwapped = '1';
871 var v = document.createElement('video');
872 v.src = mp4; v.muted = true; v.loop = true; v.autoplay = true;
873 v.setAttribute('muted', ''); v.setAttribute('playsinline', ''); v.setAttribute('webkit-playsinline', '');
874 v.poster = img.getAttribute('src') || '';
875 v.className = img.className;
876 if (img.getAttribute('style')) v.setAttribute('style', img.getAttribute('style'));
877 if (img.parentNode) img.parentNode.replaceChild(v, img);
878 var p = v.play && v.play(); if (p && p.catch) p.catch(function () {});
879 });
880 }
881 swap(document);
882 document.body.addEventListener('htmx:afterSettle', function (e) { swap(e.target); });
883})();
884</script>
885
886<script>
887(function () {
888 if (window.__nsfwWired) return; window.__nsfwWired = true;
889 document.addEventListener('click', function (e) {
890 var hit = e.target.closest && e.target.closest('.nsfw-veil, .nsfw-reveal');
891 if (!hit) return;
892 e.preventDefault(); e.stopPropagation();
893 var box = hit.closest('.nsfw-media, .nsfw-gate');
894 if (box) box.classList.add('is-shown');
895 }, true);
896})();
897</script>
898
899<script>
900// Delegated replacements for inline on* handlers, so the CSP needs no
901// script-src-attr 'unsafe-inline'. Document-level → also covers htmx-swapped content.
902(function () {
903 if (window.__pcmsHandlersWired) return; window.__pcmsHandlersWired = true;
904 // Confirm before submitting a form that carries data-confirm.
905 document.addEventListener('submit', function (e) {
906 var f = e.target;
907 if (f && f.dataset && f.dataset.confirm && !window.confirm(f.dataset.confirm)) e.preventDefault();
908 });
909 // Auto-submit a form / switch language when a <select> changes.
910 document.addEventListener('change', function (e) {
911 var el = e.target;
912 if (!el || !el.dataset) return;
913 if (el.dataset.autosubmit !== undefined && el.form) el.form.submit();
914 else if (el.dataset.langSwitch !== undefined) {
915 location.href = '/lang/' + encodeURIComponent(el.value) + '?r=' + encodeURIComponent(location.pathname + location.search);
916 }
917 });
918 // Misc click helpers (select-all in a field, history-back button).
919 document.addEventListener('click', function (e) {
920 var el = e.target.closest && e.target.closest('[data-selectall],[data-back],[data-share]');
921 if (!el) return;
922 if (el.dataset.selectall !== undefined && el.select) el.select();
923 if (el.dataset.back !== undefined) { e.preventDefault(); history.back(); }
924 if (el.dataset.share !== undefined) {
925 e.preventDefault();
926 var url = location.href, title = el.dataset.shareTitle || document.title;
927 if (navigator.share) { navigator.share({ title: title, url: url }).catch(function () {}); }
928 else if (navigator.clipboard && navigator.clipboard.writeText) {
929 navigator.clipboard.writeText(url).then(function () {
930 var fb = document.getElementById('post-share-feedback');
931 if (fb) { fb.hidden = false; setTimeout(function () { fb.hidden = true; }, 2000); }
932 }).catch(function () { window.prompt('Copy link:', url); });
933 } else { window.prompt('Copy link:', url); }
934 }
935 });
936 // Image fallback (the error event doesn't bubble → capture phase).
937 document.addEventListener('error', function (e) {
938 var el = e.target;
939 if (el && el.tagName === 'IMG' && el.dataset && el.dataset.fallback !== undefined && el.parentElement) {
940 el.parentElement.innerHTML = '<span class="pl-cover-empty">⚠️</span>';
941 }
942 }, true);
943})();
944</script>
945
946<!-- Per-site custom footer HTML -->
947<% if (safeSite.custom_foot_html) { %>
948<%- safeSite.custom_foot_html %>
949<% } %>
950
951</body>
952</html>
Note: See TracBrowser for help on using the repository browser.