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

main
Last change on this file since b08b5bc was 0dbb5c1, checked in by roboburr <roboburr@…>, 3 months ago

Mini player: clickable on desktop → navigates to the associated post

When playing from a post embed, the current post URL is stored on the track(s)
(survives the sessionStorage resume). The expand trigger (cover+title) navigates
on desktop (≥768px) via htmx to that post — audio keeps playing. Mobile (or no
post known, e.g. site tracks) keeps the current behaviour: open the player sheet.
audio-player.js cache-buster v16.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

  • Property mode set to 100644
File size: 25.4 KB
Line 
1<%
2// ── Helpers used inside this template ─────────────────────────────
3function _e(s) {
4 return String(s == null ? '' : s)
5 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
6 .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
7}
8
9const safeSite = site || {};
10const safeUrlBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
11const safeAccent = safeSite.accent && /^#[0-9a-fA-F]{6}$/.test(safeSite.accent) ? safeSite.accent : '#c2410c';
12const lang = safeSite.language || 'nl';
13const ogLocale = safeSite.og_locale || (lang === 'nl' ? 'nl_NL' : (lang.length === 2 ? lang + '_' + lang.toUpperCase() : 'en_US'));
14const homePath = safeUrlBase + '/';
15const isPostPage = bodyClass && bodyClass.indexOf('on-post') >= 0;
16const isHomePage = bodyClass && bodyClass.indexOf('on-home') >= 0;
17const isSpecialPg = bodyClass && bodyClass.indexOf('on-special') >= 0;
18const isAdminPage = bodyClass && bodyClass.indexOf('on-admin') >= 0;
19
20// ── <title> via site.title_template ──────────────────────────────
21// Template: '{title} — {site}'. If pageTitle equals site.title (homepage) we
22// just use the site title alone, otherwise apply the template.
23const _siteTitle = safeSite.title || 'Klonkt';
24const _rawTitle = pageTitle || _siteTitle;
25const _tpl = safeSite.title_template || '{title} — {site}';
26const _finalTitle = (_rawTitle === _siteTitle)
27 ? _siteTitle
28 : _tpl.replace('{title}', _rawTitle).replace('{site}', _siteTitle);
29
30// ── Robots: noindex on listing pages and on per-post override ─────
31let _shouldIndex = safeSite.robots_index !== 0;
32if (typeof post !== 'undefined' && post && post.noindex) _shouldIndex = false;
33// Listing pages (search/tag/type/archive) shouldn't be indexed (dupe content)
34if (currentPath) {
35 if (/^\/(?:search|tag|type|archive|users|prutter|account|admin)(?:$|\/)/.test(currentPath)) {
36 _shouldIndex = false;
37 }
38}
39// Special-flagged views from routes opt out too
40if (isSpecialPg && (currentPath === '/search' || /^\/(tag|type|archive|users)\//.test(currentPath))) {
41 _shouldIndex = false;
42}
43
44// ── Canonical URL (only when we have one configured) ─────────────
45let _canonical = null;
46if (safeSite.canonical) {
47 const _base = safeSite.canonical.replace(/\/+$/, '');
48 let _path = '/';
49 if (typeof post !== 'undefined' && post && post.slug) _path = '/' + post.slug;
50 else if (currentPath) _path = currentPath;
51 _canonical = _base + _path;
52}
53
54// ── Social bits (OG/Twitter) ──────────────────────────────────────
55const _socialTitle = (typeof post !== 'undefined' && post && post.title) ? post.title : _siteTitle;
56const _socialDescr = (typeof socialDescr !== 'undefined' && socialDescr)
57 ? socialDescr
58 : (safeSite.default_description || safeSite.description || '');
59const _socialImage = (typeof socialImage !== 'undefined' && socialImage)
60 ? socialImage
61 : (safeSite.og_image_default || safeSite.default_cover || '');
62const _ogType = isPostPage ? 'article' : 'website';
63
64// ── JSON-LD ───────────────────────────────────────────────────────
65const _publisher = {
66 '@type': safeSite.schema_type === 'Organization' ? 'Organization' : 'Person',
67 name: safeSite.publisher_name || _siteTitle,
68 url: safeSite.publisher_url || (_canonical ? _canonical.split(/(?<=^[^/]*\/\/[^/]+)\//)[0] + '/' : null),
69};
70if (safeSite.publisher_logo) {
71 _publisher.logo = { '@type': 'ImageObject', url: safeSite.publisher_logo };
72}
73let _jsonLd = null;
74if (typeof post !== 'undefined' && post && post.slug) {
75 _jsonLd = {
76 '@context': 'https://schema.org',
77 '@type': (post.type === 'foto' || post.type === 'video') ? 'CreativeWork' : 'Article',
78 headline: post.title || _siteTitle,
79 description: _socialDescr,
80 datePublished: post.published_at || post.created_at || new Date().toISOString(),
81 dateModified: post.updated_at || post.published_at || new Date().toISOString(),
82 publisher: _publisher,
83 };
84 if (_socialImage) _jsonLd.image = _socialImage;
85 if (post.author_username) _jsonLd.author = { '@type': 'Person', name: post.author_username };
86 if (Array.isArray(post.tags) && post.tags.length) _jsonLd.keywords = post.tags.join(', ');
87} else if (isHomePage) {
88 _jsonLd = {
89 '@context': 'https://schema.org',
90 '@type': 'WebSite',
91 name: _siteTitle,
92 description: safeSite.description || safeSite.default_description || '',
93 publisher: _publisher,
94 };
95}
96%><!DOCTYPE html>
97<html lang="<%= _e(lang) %>" data-palette="<%= _e(safeSite.palette || 'sage') %>">
98<head>
99<meta charset="utf-8">
100<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
101<meta name="color-scheme" content="dark light">
102
103<title><%= _finalTitle %></title>
104<meta name="description" content="<%= _e(_socialDescr) %>">
105<meta name="theme-color" content="<%= _e(safeAccent) %>">
106<meta name="robots" content="<%= _shouldIndex ? 'index,follow' : 'noindex,nofollow' %>">
107<% if (safeSite.author) { %><meta name="author" content="<%= _e(safeSite.author) %>"><% } %>
108<% if (_canonical) { %><link rel="canonical" href="<%= _e(_canonical) %>"><% } %>
109
110<!-- Search-engine verification -->
111<% if (safeSite.google_verification) { %><meta name="google-site-verification" content="<%= _e(safeSite.google_verification) %>"><% } %>
112<% if (safeSite.bing_verification) { %><meta name="msvalidate.01" content="<%= _e(safeSite.bing_verification) %>"><% } %>
113<% if (safeSite.pinterest_verification) { %><meta name="p:domain_verify" content="<%= _e(safeSite.pinterest_verification) %>"><% } %>
114<% if (safeSite.yandex_verification) { %><meta name="yandex-verification" content="<%= _e(safeSite.yandex_verification) %>"><% } %>
115
116<!-- Feed autodiscovery -->
117<% if (site) { %>
118<link rel="alternate" type="application/rss+xml" title="<%= _e(_siteTitle) %> — RSS" href="<%= _e(safeUrlBase + '/feed.xml') %>">
119<link rel="alternate" type="application/atom+xml" title="<%= _e(_siteTitle) %> — Atom" href="<%= _e(safeUrlBase + '/atom.xml') %>">
120<% } %>
121
122<!-- PWA -->
123<link rel="manifest" href="<%= _e(safeUrlBase + '/manifest.webmanifest') %>">
124<meta name="mobile-web-app-capable" content="yes">
125<meta name="apple-mobile-web-app-capable" content="yes">
126<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
127<meta name="apple-mobile-web-app-title" content="<%= _e(_siteTitle.slice(0, 16)) %>">
128<link rel="apple-touch-icon" href="<%= _e(safeSite.profile_photo || '/favicon.ico') %>">
129<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=sf">
130<link rel="alternate icon" href="/favicon.ico?v=sf">
131
132<!-- OpenGraph -->
133<meta property="og:type" content="<%= _ogType %>">
134<meta property="og:title" content="<%= _e(_socialTitle) %>">
135<meta property="og:description" content="<%= _e(_socialDescr) %>">
136<meta property="og:site_name" content="<%= _e(_siteTitle) %>">
137<meta property="og:locale" content="<%= _e(ogLocale) %>">
138<% if (_socialImage) { %>
139<meta property="og:image" content="<%= _e(_socialImage) %>">
140<meta property="og:image:alt" content="<%= _e(_socialTitle) %>">
141<% } %>
142<% if (_canonical) { %><meta property="og:url" content="<%= _e(_canonical) %>"><% } %>
143<% if (typeof post !== 'undefined' && post && post.published_at) { %>
144<meta property="article:published_time" content="<%= _e(post.published_at) %>">
145<% if (post.author_username) { %><meta property="article:author" content="<%= _e(post.author_username) %>"><% } %>
146<% } %>
147<% if (safeSite.facebook_app_id) { %><meta property="fb:app_id" content="<%= _e(safeSite.facebook_app_id) %>"><% } %>
148
149<!-- Twitter Cards -->
150<meta name="twitter:card" content="<%= _socialImage ? 'summary_large_image' : 'summary' %>">
151<meta name="twitter:title" content="<%= _e(_socialTitle) %>">
152<meta name="twitter:description" content="<%= _e(_socialDescr) %>">
153<% if (_socialImage) { %><meta name="twitter:image" content="<%= _e(_socialImage) %>"><% } %>
154<% if (safeSite.twitter) { %><meta name="twitter:creator" content="<%= _e(safeSite.twitter) %>"><meta name="twitter:site" content="<%= _e(safeSite.twitter) %>"><% } %>
155
156<% if (_jsonLd) { %>
157<script type="application/ld+json"><%- JSON.stringify(_jsonLd) %></script>
158<% } %>
159
160<!-- Self-hosted fonts (privacy-first) -->
161<link rel="preload" href="/assets/fonts/literata-latin-opsz-normal.woff2" as="font" type="font/woff2" crossorigin>
162<link rel="preload" href="/assets/fonts/fraunces-latin-full-normal.woff2" as="font" type="font/woff2" crossorigin>
163
164<!-- v9 stylesheet (full palette system) -->
165<link rel="stylesheet" href="/assets/css/style.css?v=9">
166
167<!-- Audio player styles: loaded on every page so the mini-player works
168 anywhere (admin previews, post embeds, etc). The player itself is
169 a singleton — see the script tag near </body>. -->
170<link rel="stylesheet" href="/assets/css/audio.css?v=6">
171<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) in huisstijl. -->
172<link rel="stylesheet" href="/assets/css/embed.css?v=3">
173
174<%- include('partials/shared-styles') %>
175
176<!-- v1 P55 — Inline the saved site accent. The base stylesheet only sets a
177 fallback (#c2410c orange) and palette blocks don't define --accent at
178 all, so without this override the saved accent never reaches the page.
179 :root + [data-palette] hits both unscoped and palette-scoped variants;
180 source-order wins on equal specificity, and this comes after style.css. -->
181<style id="pcms-site-accent">
182 :root,
183 [data-palette] {
184 --accent: <%= _e(safeAccent) %>;
185 --accent-soft: color-mix(in srgb, <%= _e(safeAccent) %> 80%, white);
186 --accent-tint: color-mix(in srgb, <%= _e(safeAccent) %> 12%, transparent);
187 }
188</style>
189
190<!-- Per-site custom CSS injection -->
191<% if (safeSite.custom_css) { %>
192<style id="pcms-custom-css"><%- safeSite.custom_css %></style>
193<% } %>
194
195<!-- Apply theme ASAP, before paint. Precedence (first match wins):
196 1. localStorage override (visitor toggled earlier on this browser)
197 2. Site default (theme_override + palette) — what new visitors see
198 3. Device prefers-color-scheme (only if site default is empty/auto)
199 4. 'dark' as last-ditch fallback
200 Note: PALETTE never has a localStorage layer anymore. There's no UI for
201 visitors to pick a palette, so any cached pcms-palette is stale data
202 from old code paths and gets cleaned up here. Site default always wins
203 for palette. -->
204<script>
205 (function() {
206 try {
207 var siteDefault = '<%= safeSite.theme_override || "" %>';
208 var sitePalette = '<%= safeSite.palette || "sage" %>';
209
210 // One-time cleanup: drop the orphan pcms-palette key set by P43-P57
211 // bootstrap. After this it never re-appears because nothing writes it.
212 try { localStorage.removeItem('pcms-palette'); } catch(_) {}
213
214 // Theme: localStorage > site override > device pref > dark
215 var storedTheme = null;
216 try { storedTheme = localStorage.getItem('pcms-theme'); } catch(_) {}
217 var deviceDark = window.matchMedia &&
218 window.matchMedia('(prefers-color-scheme: dark)').matches;
219 var t = storedTheme
220 || siteDefault
221 || (deviceDark ? 'dark' : 'light');
222
223 // Palette: site default only.
224 document.documentElement.setAttribute('data-theme', t);
225 document.documentElement.setAttribute('data-palette', sitePalette);
226 } catch(e) {
227 document.documentElement.setAttribute('data-theme', 'dark');
228 }
229 })();
230</script>
231
232<!-- HTMX — bundled locally from node_modules at boot, zero third-party requests -->
233<script src="/assets/js/htmx.min.js"></script>
234
235<!-- Per-site custom <head> HTML (analytics, verification, etc.) -->
236<% if (safeSite.custom_head_html) { %>
237<%- safeSite.custom_head_html %>
238<% } %>
239</head>
240
241<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-feed-view="<%= _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%= _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
242
243<% if (typeof isViewer !== 'undefined' && isViewer) { %>
244 <div class="viewer-banner" role="status">
245 <span class="viewer-banner-ico" aria-hidden="true">👁️</span>
246 <span class="viewer-banner-text"><strong>Kijker-modus</strong> — je kunt alles bekijken, maar niets wijzigen.</span>
247 </div>
248 <style>
249 .viewer-banner {
250 position: sticky; top: 0; z-index: 60;
251 display: flex; align-items: center; justify-content: center; gap: 0.5rem;
252 padding: 0.5rem 1rem;
253 background: linear-gradient(90deg,
254 color-mix(in srgb, var(--accent) 88%, #000) 0%,
255 var(--accent) 100%);
256 color: #fff;
257 font-size: 0.85rem; line-height: 1.3;
258 box-shadow: 0 1px 6px color-mix(in srgb, var(--accent) 45%, transparent);
259 }
260 .viewer-banner-ico { font-size: 1rem; }
261 .viewer-banner-text strong { font-weight: 700; }
262 </style>
263<% } %>
264
265<%# Site-chrome (topnav + profielkop + view-switcher) in één vaste slot #pcms-chrome.
266 Bij htmx-navigatie wordt dit slot out-of-band ververst (zie chrome.ejs +
267 render.js), zodat de kop ALTIJD bij de nieuwe pagina/artiest hoort terwijl de
268 audioplayer (los in document.body) blijft leven → geen verspringen. Op de
269 hub-landing is het slot leeg: de hero is daar de header. %>
270<%- include('partials/chrome') %>
271
272<%# hx-history-elt scopes HTMX history snapshots to #pcms-main only, so
273 when the user navigates back the switcher (which lives in the shell
274 OUTSIDE this element) keeps its place rather than being replaced
275 along with the rest of the body. %>
276<main id="pcms-main" class="pcms-main" hx-history-elt>
277 <div id="pcms-loading" class="pcms-loading" aria-hidden="true"></div>
278 <%- pageContent %>
279</main>
280
281<%- include('partials/footer') %>
282
283<!-- Mobile bottom-tab navigation (auto-hidden ≥768px). Toont overal — óók op de
284 hub-landing (zodat mobiel altijd Home/Zoek/Inloggen heeft) — behalve op de
285 auth-focusschermen. -->
286<% if (!(typeof bodyClass === 'string' && bodyClass.indexOf('on-auth') >= 0)) { %>
287<%- include('partials/bottom-tab') %>
288<% } %>
289
290<!-- Mobile profile sheet (auto-hidden ≥768px; only rendered when logged in) -->
291<% if (user) { %>
292<%- include('partials/profile-sheet') %>
293<% } %>
294
295<!-- Audio player: load on every page (admin + public) so window.pcmsAudioPlayer
296 is always available. The PCMS_SITE_TRACKS bootstrap is still gated on
297 enable_audio_player since it's a public-page concept (auto-discovered
298 tracks from rendered post embeds).
299
300 ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
301 Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
302<script src="/assets/js/audio-player.js?v=16"></script>
303<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) via de echte
304 player-API's + gedeelde mutual-exclusion registry met de site-speler. -->
305<script src="/assets/js/embed-player.js?v=4" defer></script>
306<% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %>
307 <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
308<% } %>
309
310<!-- Install-app button: detects platform + shows install instructions modal -->
311<script src="/assets/js/install-app.js?v=2" defer></script>
312
313<!-- Service Worker registration -->
314<script>
315 if ('serviceWorker' in navigator) {
316 navigator.serviceWorker.register('/sw.js').catch(() => {});
317 }
318</script>
319
320<!-- HTMX navigation: keep body class in sync with the swapped page.
321 The server emits HX-Trigger-After-Settle: { pcmsNav: { bodyClass } } via
322 renderPage() in middleware/render.js. Without this listener the body
323 class stays whatever the initial page-load set, so the profile-header
324 never collapses/expands when navigating home → post → home via HTMX. -->
325<script>
326(function() {
327 // De page-context body-classes. Bij pcmsNav strippen we ze allemaal en zetten
328 // we opnieuw wat de server stuurde. 'on-auth' staat erbij zodat het login-
329 // focusscherm óók via htmx z'n styling krijgt (geen chrome, geen tab, audio op 0).
330 var PAGE_CLASSES = ['on-home','on-post','on-special','on-archive','on-search','on-admin','on-auth'];
331
332 document.body.addEventListener('pcmsNav', function(ev) {
333 var d = ev.detail || {};
334 // Per-site accent + palette bijwerken (head wordt niet mee-geswapt bij htmx-nav,
335 // dus zonder dit erft een artiest de kleuren van de vorige pagina).
336 if (d.accent && /^#[0-9a-fA-F]{6}$/.test(d.accent)) {
337 var sa = document.getElementById('pcms-site-accent');
338 if (sa) {
339 sa.textContent =
340 ':root,[data-palette]{--accent:' + d.accent +
341 ';--accent-soft:color-mix(in srgb,' + d.accent + ' 80%,white)' +
342 ';--accent-tint:color-mix(in srgb,' + d.accent + ' 12%,transparent);}';
343 }
344 }
345 if (d.palette && /^[a-z0-9-]+$/i.test(d.palette)) {
346 document.documentElement.setAttribute('data-palette', d.palette);
347 }
348
349 var next = d.bodyClass;
350 if (!next) return;
351 // De server kan meerdere page-classes sturen ("on-special on-auth"). Strip
352 // alle bekende en zet ALLE meegestuurde page-classes terug (niet alleen de
353 // eerste) — anders mist 'on-auth' bij htmx-nav en blijft het login-scherm
354 // de chrome/tab van de vorige pagina houden.
355 var matched = String(next).split(/\s+/).filter(function(c) {
356 return PAGE_CLASSES.indexOf(c) >= 0;
357 });
358 if (!matched.length) return;
359 PAGE_CLASSES.forEach(function(c) { document.body.classList.remove(c); });
360 matched.forEach(function(c) { document.body.classList.add(c); });
361 });
362
363 // Back/forward: htmx herstelt alleen #pcms-main, maar NIET de OOB-chrome of de
364 // per-site accent/palette (die komen via de HX-Trigger-header, en een history-
365 // restore vuurt die niet). Zonder dit blijft de kleur van de vorige artiest
366 // hangen. We halen de partial opnieuw op en passen alleen die twee toe —
367 // #pcms-main is al hersteld, dus die laten we met rust.
368 document.body.addEventListener('htmx:historyRestore', function () {
369 var url = location.pathname + location.search;
370 var sep = url.indexOf('?') >= 0 ? '&' : '?';
371 fetch(url + sep + 'partial=1', { headers: { 'HX-Request': 'true' }, credentials: 'same-origin' })
372 .then(function (r) {
373 var trig = r.headers.get('HX-Trigger-After-Settle');
374 if (trig) {
375 try {
376 var nav = JSON.parse(trig).pcmsNav;
377 if (nav) document.body.dispatchEvent(new CustomEvent('pcmsNav', { detail: nav }));
378 } catch (e) { /* geen/ongeldige trigger */ }
379 }
380 return r.text();
381 })
382 .then(function (html) {
383 try {
384 var fresh = new DOMParser().parseFromString(html, 'text/html').querySelector('#pcms-chrome');
385 var cur = document.getElementById('pcms-chrome');
386 if (fresh && cur) cur.replaceWith(fresh);
387 } catch (e) { /* chrome-update overslaan */ }
388 })
389 .catch(function () { /* netwerkfout: laat staan */ });
390 });
391})();
392</script>
393
394<!-- View switcher + grid-cols persistence (event delegation: works for switcher
395 elements rendered later by HTMX, e.g. when navigating back to home). -->
396<script>
397(function() {
398 var body = document.body;
399 // Restore feed view + grid cols from localStorage (overrides server default)
400 try {
401 var v = localStorage.getItem('pcms-feed-view');
402 if (v === 'timeline' || v === 'grid') body.dataset.feedView = v;
403 var c = parseInt(localStorage.getItem('pcms-grid-cols'), 10);
404 if (c === 2 || c === 3 || c === 4) body.dataset.gridCols = String(c);
405 } catch (e) {}
406
407 function syncAria() {
408 document.querySelectorAll('.view-switch-btn').forEach(function(b) {
409 b.setAttribute('aria-selected', b.dataset.view === body.dataset.feedView ? 'true' : 'false');
410 });
411 document.querySelectorAll('.grid-cols-btn').forEach(function(b) {
412 b.classList.toggle('is-active', b.dataset.cols === body.dataset.gridCols);
413 });
414 }
415 syncAria();
416 // Re-sync after HTMX brings in new content (e.g. navigating back to home)
417 document.body.addEventListener('htmx:afterSettle', syncAria);
418
419 // Pages where the body actually has a feed to toggle. On these the
420 // click stays put — it just flips body[data-feed-view] and CSS does
421 // the rest. Anywhere else (post detail, account, search, auth) we
422 // navigate to home in the chosen view, so the switcher is never
423 // a dead control.
424 // NB: 'on-archive' staat hier bewust NIET tussen — op het archief is er geen
425 // in-place timeline/grid-toggle; een klik op de switcher springt terug naar de
426 // feed in de gekozen weergave (zie de !isFeedPage()-tak hieronder).
427 var FEED_PAGE_CLASSES = ['on-home', 'on-tag', 'on-type', 'on-user', 'on-cirkel'];
428 function isFeedPage() {
429 for (var i = 0; i < FEED_PAGE_CLASSES.length; i++) {
430 if (body.classList.contains(FEED_PAGE_CLASSES[i])) return true;
431 }
432 return false;
433 }
434
435 // Event delegation — single listener handles current and future buttons.
436 document.addEventListener('click', function(e) {
437 var sw = e.target.closest('.view-switch-btn');
438 if (sw) {
439 var view = sw.dataset.view;
440 body.dataset.feedView = view;
441 try { localStorage.setItem('pcms-feed-view', view); } catch(_) {}
442 syncAria();
443 // On non-feed pages the switcher acts as a navigation: take the
444 // user back to home in the chosen view. Use HTMX if available so
445 // the page transition matches the rest of the site's nav.
446 if (!isFeedPage()) {
447 // Naar de SITE-home in de gekozen view (siteUrlBase), niet de globale '/'
448 // — in hub is '/' de hub-overview, niet de tijdlijn van deze artiest.
449 var base = body.dataset.siteBase || '';
450 if (window.htmx) {
451 window.htmx.ajax('GET', base + '/?partial=1', { target: '#pcms-main', swap: 'innerHTML' });
452 history.pushState({}, '', base + '/');
453 } else {
454 location.href = base + '/';
455 }
456 }
457 return;
458 }
459 var gc = e.target.closest('.grid-cols-btn');
460 if (gc) {
461 body.dataset.gridCols = gc.dataset.cols;
462 try { localStorage.setItem('pcms-grid-cols', gc.dataset.cols); } catch(_) {}
463 syncAria();
464 }
465 });
466})();
467</script>
468
469<!-- Globale link-boost: alle interne navigatie-links lopen via htmx in #pcms-main,
470 zodat de audioplayer (los in document.body) blijft spelen i.p.v. te verspringen
471 bij een full page-load. Werkt overal — Beheer, Account, posts, sites — zonder
472 elke link los htmx te maken. Links die écht een volledige load nodig hebben
473 (uitloggen/auth, downloads, feeds, media, assets, bestanden) worden overgeslagen,
474 net als links die al hun eigen hx-* hebben. -->
475<script>
476(function () {
477 if (!window.htmx) return;
478 // Back/forward moet her-ophalen, zodat de OOB-site-chrome (kop) meekomt met de
479 // herstelde pagina — htmx' snapshot-cache zou anders een stale kop tonen.
480 try { window.htmx.config.historyCacheSize = 0; } catch (_) {}
481
482 function fullLoad(a, url) {
483 if (a.hasAttribute('download') || a.hasAttribute('data-full-load')) return true;
484 if (a.hasAttribute('hx-get') || a.hasAttribute('hx-post') || a.hasAttribute('hx-boost')) return true;
485 if (a.target && a.target !== '_self') return true;
486 if (a.getAttribute('rel') === 'external') return true;
487 var p = url.pathname;
488 // Sessie/security/oauth → volledige navigatie (cookies, redirects).
489 if (/^\/(logout|login|auth|oauth)(?:\/|$)/.test(p)) return true;
490 // Feeds, PWA, service-worker, statics, media-streams, downloads.
491 if (/^\/(feed|atom|sitemap|manifest|robots|sw\.js|assets|media|audio|uploads)(?:\/|\.|$)/.test(p)) return true;
492 if (/\.[a-z0-9]{2,5}$/i.test(p)) return true; // bestandsextensie → laat de browser 't halen
493 return false;
494 }
495
496 var lastPath = location.pathname + location.search;
497
498 document.addEventListener('click', function (e) {
499 if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
500 var a = e.target.closest('a[href]');
501 if (!a) return;
502 var href = a.getAttribute('href');
503 if (!href || href.charAt(0) === '#') return;
504 var url; try { url = new URL(a.href, location.href); } catch (_) { return; }
505 if (url.origin !== location.origin) return;
506 if (fullLoad(a, url)) return;
507 e.preventDefault();
508 var dest = url.pathname + url.search;
509 if (dest !== lastPath) history.pushState({ b: 1 }, '', dest);
510 lastPath = dest;
511 window.htmx.ajax('GET', dest, { target: '#pcms-main', swap: 'innerHTML' });
512 try { window.scrollTo(0, 0); } catch (_) {}
513 });
514
515 window.addEventListener('popstate', function () {
516 var here = location.pathname + location.search;
517 if (here === lastPath) return;
518 lastPath = here;
519 window.htmx.ajax('GET', here, { target: '#pcms-main', swap: 'innerHTML' });
520 });
521})();
522</script>
523
524<!-- PWA install prompt — show button when browser fires beforeinstallprompt -->
525<script>
526(function() {
527 var btn = document.getElementById('pwa-install-btn');
528 if (!btn) return;
529 var deferred = null;
530 window.addEventListener('beforeinstallprompt', function(e) {
531 e.preventDefault();
532 deferred = e;
533 btn.hidden = false;
534 });
535 btn.addEventListener('click', async function() {
536 if (!deferred) return;
537 btn.hidden = true;
538 deferred.prompt();
539 try { await deferred.userChoice; } catch(e) {}
540 deferred = null;
541 });
542 window.addEventListener('appinstalled', function() {
543 btn.hidden = true;
544 deferred = null;
545 });
546})();
547</script>
548
549<!-- Per-site custom footer HTML -->
550<% if (safeSite.custom_foot_html) { %>
551<%- safeSite.custom_foot_html %>
552<% } %>
553
554</body>
555</html>
Note: See TracBrowser for help on using the repository browser.