source: Klonkt/src/views/shell.ejs@ b4aaadd

Last change on this file since b4aaadd was 495b019, checked in by Bart <bart@…>, 2 days ago

Lay-outthema's: een pakket, de beheerder kiest, en de zijbalk als eerste

Een lay-out is nu een pakket: een map in src/themes/ met een theme.json (id,
naam, versie, contract, stylesheet) en de stylesheet zelf. De zijbalk is het
eerste ingebouwde thema; een thema van buiten installeren komt later en gaat
door dezelfde lader, zodat er één vorm is.

Dezelfde markup, een ander stelsel: een thema verandert geen template. Het
contract (1) is de afspraak over de ids en klassen waar het op mag bouwen; een
thema op een ander contract valt terug op de gewone kop in plaats van half te
werken. Elke regel staat onder html[data-layout="<id>"], en een toets bewaakt
dat, zodat een thema niets raakt zolang het niet gekozen is.

WIE KIEST: de beheerder, in het sitebeheer (sites.layout). Een bezoeker niet
-- de server zet data-layout zelf, het bezoekersscript met localStorage is
weg, en ?layout= werkt alleen als voorproefje voor wie de site beheert. Het
beheer houdt altijd de gewone kop, ook bij navigeren via htmx (de lay-out reist
mee in pcmsNav): een kapot thema mag je nooit buitensluiten van de instelling
waarmee je het uitzet.

Geserveerd op /themes/<id>/<versie>/<bestand>, met de versie in het pad als
cache-buster; alleen wat het pakket zelf noemt komt eruit. De actor noemt het
gekozen thema als shaer:theme, zodat Shaer het van zijn eigen site kan halen
in plaats van er zelf een te kiezen.

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

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