source: Klonkt/src/views/shell.ejs@ 4b5db37

main
Last change on this file since 4b5db37 was 4b5db37, checked in by Robin <roboburr@…>, 5 weeks ago

Modules herstarten per paginawissel: de editor leeft weer na een terugkeer (shaer-5s1)

Drie klachten, een wortel. De oude inline scripts draaiden bij ELKE render;
een ES-module draait zijn top-level EEN keer per sessie, en de bootstrap
importeert een geladen module nooit opnieuw. Dus:

  • /posts/new na een htmx-terugkeer: geen toolbar, geen serialisatie -- het verborgen veld kreeg de chips nooit terug (shortcodes 'opgegeten')
  • /admin/audio na een terugkeer: de eigen kopieerhandler dood, en de klik viel door naar de GEDELEGEERDE handler van admin-media, die voor mediapaden terecht location.origin voorplakt -- vandaar https://site[[track:uuid]] op het klembord

De afspraak is nu: een module die per render moet draaien exporteert init(),
en de bootstrap roept die aan bij elke wissel waarop de module actief is.
Modules zonder init houden hun oude gedrag. Listeners op document/window
overleven de swap met closures naar dode elementen; makeSweeper in lib.js
veegt bij elke init de vorige lichting weg. Gedelegeerde handlers die
bewust blijven leven (media/videos) krijgen een paginawacht, want data-copy
betekent daar een PAD en elders een shortcode.

Omgezet: post-edit, admin-audio, admin-playlists. Bewaakt: admin-media,
admin-videos. playlist-editor en track-editor waren al swap-bestendig
(globale functie, gedelegeerd met eigen guards).

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

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