source: Klonkt/src/views/shell.ejs@ 2d2fd8c

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

Tijdlijn | Grid | Lezen: de leesweergave krijgt een ingang

De leesweergave bestond wel maar was alleen te bereiken door /read in te tikken.
Nu staat ze als derde optie in dezelfde pil.

Een <a>, geen <button>, en met opzet NIET de klasse view-switch-btn: de
gedelegeerde klik in shell.ejs pakt alles met die klasse op, zet
body[data-feed-view] erop en springt op niet-feedpagina's terug naar home. Die
had deze link opgegeten.

Door er een link naartoe te leggen kwamen twee fouten boven die er al zaten:

  1. De route gaf ELKE htmx-aanvraag één kaal artikel terug, want de leesmodule had ?partial=1 geleend -- en dat betekent op deze site al iets anders: "de pagina zonder de schil". Een gebooste link naar /read schoof dus één artikel in #pcms-main: geen stroom, geen script. De module vraagt nu ?fragment=1, en al het andere gaat naar pages/read waar renderPage zelf over de schil beslist.
  1. PAGE_CLASSES in shell.ejs is een witte lijst, en 'on-read' stond er niet in. Bij een htmx-navigatie werd de class dus weggefilterd en sloeg ELKE regel van de leesweergave (.on-read ...) niet aan: geen schermhoge berichten, geen snappen, geen wegschuivende balken. Bij intikken ging het goed, want dan zet de shell de class zelf -- vandaar dat het onzichtbaar bleef.

Beide nagelopen in de browser: de pil navigeert, on-read staat er, min-height is
de schermhoogte, snappen staat aan, en de opgeslagen feed-view blijft ongemoeid.

MOD_V naar 3 (read.js) en style.css naar v68 (nieuwe selector).

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

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