source: Klonkt/src/views/shell.ejs@ 847775c

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

Leesweergave: tikken op een bericht opent het bericht

De stroom toont de tekst, maar het gesprek staat op de berichtpagina: ⭐,
πŸ”, reacties, en het antwoordveld voor bezoekers van elders. Een leesweergave
waar je niet uit kunt naar dat gesprek is een doodlopende weg.

Drie ingangen, met opzet:

  • de titel is een echte <a>, dus toetsenbord, schermlezer en cmd-klik werken;
  • een voetlink zegt waar je heen gaat ("Reacties en waarderingen"), want een tik die je niet ziet, bestaat niet;
  • en een tik ergens op het bericht, voor een duim.

Die tik kent vier uitzonderingen, want een tik die je niet bedoelde is erger dan
geen tik: links, knoppen en velden houden hun eigen werking, een geselecteerde
tekst is geen tik, een verschoven vinger is scrollen, en cmd/ctrl-klik laat je
aan de browser. Alle vier nagemeten in de browser, plus dat een link Γ­n een
bericht wint van de tik.

MOD_V naar 2: mod/read.js is gewijzigd.

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 = 2;
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.