source: Klonkt/src/views/shell.ejs@ 013e16c

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

Naar SEO & social, want sameAs is schema.org (Robins vraag)

Hij vroeg of het niet onder SEO en social hoort, "omdat het ook een soort
koppeling is". Dat is het, en sterker dan dat: op die pagina staat een blok
"Uitgever (JSON-LD / rich results)", en sameAs IS een schema.org-eigenschap.
Zelfde vocabulaire, zelfde plek. Het paneel staat nu direct onder Uitgever.

DAARDOOR VOEDT EEN KOPPELING NU TWEE UITGANGEN. Naast schema:sameAs op de
fediverse-actor komt hij in de JSON-LD van de pagina zelf, in _publisher. Dat
is hetzelfde feit, verteld aan de andere lezer -- en het was gratis, want het
blok stond er al.

BETERE LABELS, want de oude waren geschreven voor het audioscherm. "Ben jij dit
in MusicBrainz?" werd "MusicBrainz (dezelfde artiest elders)" als legenda, met
een hint die zegt wat het OPLEVERT in plaats van alleen wat het is: je komt als
sameAs in de JSON-LD en in je fediverse-profiel, en er verandert niets bij
MusicBrainz zelf. Dat laatste is de geruststelling die er hoort te staan. Plus
een veldlabel ("Zoek jezelf op") met de toelichting "de naam waaronder je muziek
uitbrengt" -- want dat is de vraag, niet je sitenaam. Drie talen.

Het zoekveld begint nu op publisher_name en valt terug op de sitetitel: op deze
pagina heb je die naam net ingevuld.

Meeverhuisd: het endpoint (/admin/seo/api/musicbrainz) en het clientblok, in een
nieuwe mod/admin-seo.js. Die pagina had nog geen JavaScript; opzoeken vraagt er
wel om. Het profielscherm is weer helemaal schoon.

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

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