source: Klonkt/src/views/shell.ejs@ 1e172f3

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

Modules laden vanuit de shell in plaats van inline script (shaer-bqr, stap 1)

Het mechanisme uit optie C, met de bottom-tab als eerste geval zodat het ook
te bewijzen is.

WAAROM. De CSP-nonce rouleert per verzoek (shaer-0i6). Een script dat via htmx
binnenkomt draagt dus een nonce die het document niet kent en wordt geweigerd.
De chrome komt bij ELKE navigatie out-of-band opnieuw binnen, dus daar valt de
JS bij de eerste klik binnen de site al weg.

HOE. Een bootstrap in shell.ejs -- die komt alleen bij een volledige laadbeurt
binnen en heeft dus wel de goede nonce. Hij leest body[data-js], een lijst
modulenamen, en importeert ze uit /assets/js/mod/. Een dynamische import vanuit
een vertrouwd script is precies waar strict-dynamic voor bedoeld is, dus de
module zelf heeft geen nonce nodig.

Bij een htmx-navigatie zet de pcmsNav-trigger data-js opnieuw en haalt de
bootstrap op wat er nieuw bij staat. 'chrome' staat er altijd bij.

De naam wordt een PAD, dus hij moet door /[a-z0-9-]+$/ -- geen punt, geen
schuine streep.

EERSTE GEVAL: de zoekknop van de bottom-tab. Geen servergegevens erin, al
gedelegeerd, al voorzien van een slot -- dus de verhuizing verandert niets aan de
logica en het mechanisme is er echt mee te toetsen.

WAT DIT BLOOTLEGT VOOR DE VOLGENDE STAP: het topnav-script interpoleert
vertalingen (<%= t('search.section_posts') %>) en kan dus niet zomaar een
statisch bestand worden. Servergegevens horen via een data-attribuut naar een
module, niet via interpolatie in de code. Dat is een eigen stap en staat als
zodanig in mod/chrome.js opgeschreven.

Templates compileren, suite 551/551. Het echte bewijs is een klik BINNEN de site:
na een herlading werkt alles toch al.

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