source: Klonkt/src/views/shell.ejs@ 7cf8144

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

Lezen is een weergave van de feed, geen eigen plek

/read was een route met een eigen shell: eigen pagina, eigen chrome-gedrag, en
op een site zonder berichten een 404 -- een dode knop in de switcher. Nu is het
wat het altijd had moeten zijn: een derde waarde van body[data-feed-view], naast
timeline en grid. Dezelfde berichten, andere vorm.

De feed stuurt ze alledrie mee en CSS kiest, precies zoals timeline/grid dat al
deden. Het lijf loopt door PostAccessService, dus een gesloten poort levert ook
hier geen tekst op. "Meer laden" vult de leesstroom OOB mee aan, net als de
tegels.

DE LEGE STAAT zit in de :has(.feed-reader) op de verbergregel. Zonder berichten
rendert de leessectie niet, en dan blijft de tijdlijn staan -- want daar staat
"nog niets geschreven" met de knop om te beginnen. Zonder die voorwaarde was
Lezen op een verse site een wit scherm. Feeds zonder leessectie (cirkel,
archief) houden om dezelfde reden gewoon hun tijdlijn.

Wat weg kon, en dat is de winst:

  • de route, pages/read.ejs, en 'on-read' als page-class;
  • mod/read.js van 222 naar 51 regels. Het ophalen van buren, het corrigeren van de scrollpositie bij invoegen en het onderbreken van het snappen zijn niet opgelost maar OVERBODIG: de feed levert de berichten al. Wat blijft is de tik.
  • read.end en read.load_error: het script plaatst geen zinnen meer.

De balken schuiven niet meer weg. Dat was mooi op een eigen scherm, maar hier
zou het de switcher verbergen waarmee je net koos -- en dan kun je niet terug.

MOD_V naar 4, style.css naar v70.

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

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