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

main
Last change on this file since aa7f548 was 33886fb, checked in by roboburr <roboburr@…>, 3 months ago

fix: site player now also pauses fallback embeds (mutual exclusion both ways)

The registry already handled mutual exclusion for custom players (API loaded),
but fallback iframes (ad-blocker blocks the API) didn't register themselves:
the site player couldn't stop them -> double audio. A cross-origin iframe
can't be paused via API, so we now register the fallback in the registry with
a pause() that reloads the iframe WITHOUT autoplay (= stops it, remains
restartable). Result: starting an embed pauses the site player, and starting
the site player stops the embed. embed-player.js v=3 -> v=4.

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

  • Property mode set to 100644
File size: 20.7 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 || 'PrutCMS';
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">
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=5">
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<%# Geen shell-chrome (masthead/profile-header/view-switcher/bottom-tab) op:
266 - de hub-overview (on-hub): standalone landing, begint met de hero;
267 - auth-pagina's (on-auth): focus-schermen voor in-/uitloggen.
268 Die chrome hoort bij een PrutFolio en verschijnt dus op /user/-pagina's. %>
269<% var bareChrome = (typeof bodyClass === 'string' && (bodyClass.indexOf('on-hub') >= 0 || bodyClass.indexOf('on-auth') >= 0))
270 || ((typeof tenancy !== 'undefined' && tenancy === 'hub') && !siteUrlBase && !isAdminPage); %>
271
272<% if (!bareChrome) { %>
273 <%- include('partials/topnav') %>
274 <%- include('partials/profile-header') %>
275<% } %>
276
277<%# View switcher — visible on every non-admin page. On feed pages
278 (home, archive, tag, type, user) it toggles the visual layout;
279 on non-feed pages (post, account, search, auth) it navigates to /
280 in the chosen view. Click logic is in shell.ejs at the bottom. %>
281<% if (!isAdminPage && !bareChrome) { %>
282 <%- include('partials/view-switcher') %>
283<% } %>
284
285<%# hx-history-elt scopes HTMX history snapshots to #pcms-main only, so
286 when the user navigates back the switcher (which lives in the shell
287 OUTSIDE this element) keeps its place rather than being replaced
288 along with the rest of the body. %>
289<main id="pcms-main" class="pcms-main" hx-history-elt>
290 <div id="pcms-loading" class="pcms-loading" aria-hidden="true"></div>
291 <%- pageContent %>
292</main>
293
294<%- include('partials/footer') %>
295
296<!-- Mobile bottom-tab navigation (auto-hidden ≥768px) — niet op landing/auth -->
297<% if (!bareChrome) { %>
298<%- include('partials/bottom-tab') %>
299<% } %>
300
301<!-- Mobile profile sheet (auto-hidden ≥768px; only rendered when logged in) -->
302<% if (user) { %>
303<%- include('partials/profile-sheet') %>
304<% } %>
305
306<!-- Audio player: load on every page (admin + public) so window.pcmsAudioPlayer
307 is always available. The PCMS_SITE_TRACKS bootstrap is still gated on
308 enable_audio_player since it's a public-page concept (auto-discovered
309 tracks from rendered post embeds).
310
311 ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
312 Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
313<script src="/assets/js/audio-player.js?v=15"></script>
314<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) via de echte
315 player-API's + gedeelde mutual-exclusion registry met de site-speler. -->
316<script src="/assets/js/embed-player.js?v=4" defer></script>
317<% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %>
318 <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
319<% } %>
320
321<!-- Install-app button: detects platform + shows install instructions modal -->
322<script src="/assets/js/install-app.js?v=2" defer></script>
323
324<!-- Service Worker registration -->
325<script>
326 if ('serviceWorker' in navigator) {
327 navigator.serviceWorker.register('/sw.js').catch(() => {});
328 }
329</script>
330
331<!-- HTMX navigation: keep body class in sync with the swapped page.
332 The server emits HX-Trigger-After-Settle: { pcmsNav: { bodyClass } } via
333 renderPage() in middleware/render.js. Without this listener the body
334 class stays whatever the initial page-load set, so the profile-header
335 never collapses/expands when navigating home → post → home via HTMX. -->
336<script>
337(function() {
338 // The set of mutually-exclusive page-context body classes. When pcmsNav
339 // fires we strip ALL of these and apply just the one the server told us.
340 var PAGE_CLASSES = ['on-home','on-post','on-special','on-archive','on-search','on-admin'];
341
342 document.body.addEventListener('pcmsNav', function(ev) {
343 var next = ev.detail && ev.detail.bodyClass;
344 if (!next) return;
345 // Server may send a multi-class string ("on-post extra"). Only the first
346 // page-class is what we care about — strip and replace.
347 var firstClass = String(next).split(/\s+/).find(function(c) {
348 return PAGE_CLASSES.indexOf(c) >= 0;
349 });
350 if (!firstClass) return;
351 PAGE_CLASSES.forEach(function(c) { document.body.classList.remove(c); });
352 document.body.classList.add(firstClass);
353 });
354})();
355</script>
356
357<!-- View switcher + grid-cols persistence (event delegation: works for switcher
358 elements rendered later by HTMX, e.g. when navigating back to home). -->
359<script>
360(function() {
361 var body = document.body;
362 // Restore feed view + grid cols from localStorage (overrides server default)
363 try {
364 var v = localStorage.getItem('pcms-feed-view');
365 if (v === 'timeline' || v === 'grid') body.dataset.feedView = v;
366 var c = parseInt(localStorage.getItem('pcms-grid-cols'), 10);
367 if (c === 2 || c === 3 || c === 4) body.dataset.gridCols = String(c);
368 } catch (e) {}
369
370 function syncAria() {
371 document.querySelectorAll('.view-switch-btn').forEach(function(b) {
372 b.setAttribute('aria-selected', b.dataset.view === body.dataset.feedView ? 'true' : 'false');
373 });
374 document.querySelectorAll('.grid-cols-btn').forEach(function(b) {
375 b.classList.toggle('is-active', b.dataset.cols === body.dataset.gridCols);
376 });
377 }
378 syncAria();
379 // Re-sync after HTMX brings in new content (e.g. navigating back to home)
380 document.body.addEventListener('htmx:afterSettle', syncAria);
381
382 // Pages where the body actually has a feed to toggle. On these the
383 // click stays put — it just flips body[data-feed-view] and CSS does
384 // the rest. Anywhere else (post detail, account, search, auth) we
385 // navigate to home in the chosen view, so the switcher is never
386 // a dead control.
387 var FEED_PAGE_CLASSES = ['on-home', 'on-archive', 'on-tag', 'on-type', 'on-user'];
388 function isFeedPage() {
389 for (var i = 0; i < FEED_PAGE_CLASSES.length; i++) {
390 if (body.classList.contains(FEED_PAGE_CLASSES[i])) return true;
391 }
392 return false;
393 }
394
395 // Event delegation — single listener handles current and future buttons.
396 document.addEventListener('click', function(e) {
397 var sw = e.target.closest('.view-switch-btn');
398 if (sw) {
399 var view = sw.dataset.view;
400 body.dataset.feedView = view;
401 try { localStorage.setItem('pcms-feed-view', view); } catch(_) {}
402 syncAria();
403 // On non-feed pages the switcher acts as a navigation: take the
404 // user back to home in the chosen view. Use HTMX if available so
405 // the page transition matches the rest of the site's nav.
406 if (!isFeedPage()) {
407 // Naar de SITE-home in de gekozen view (siteUrlBase), niet de globale '/'
408 // — in hub is '/' de hub-overview, niet de tijdlijn van deze artiest.
409 var base = body.dataset.siteBase || '';
410 if (window.htmx) {
411 window.htmx.ajax('GET', base + '/?partial=1', { target: '#pcms-main', swap: 'innerHTML' });
412 history.pushState({}, '', base + '/');
413 } else {
414 location.href = base + '/';
415 }
416 }
417 return;
418 }
419 var gc = e.target.closest('.grid-cols-btn');
420 if (gc) {
421 body.dataset.gridCols = gc.dataset.cols;
422 try { localStorage.setItem('pcms-grid-cols', gc.dataset.cols); } catch(_) {}
423 syncAria();
424 }
425 });
426})();
427</script>
428
429<!-- PWA install prompt — show button when browser fires beforeinstallprompt -->
430<script>
431(function() {
432 var btn = document.getElementById('pwa-install-btn');
433 if (!btn) return;
434 var deferred = null;
435 window.addEventListener('beforeinstallprompt', function(e) {
436 e.preventDefault();
437 deferred = e;
438 btn.hidden = false;
439 });
440 btn.addEventListener('click', async function() {
441 if (!deferred) return;
442 btn.hidden = true;
443 deferred.prompt();
444 try { await deferred.userChoice; } catch(e) {}
445 deferred = null;
446 });
447 window.addEventListener('appinstalled', function() {
448 btn.hidden = true;
449 deferred = null;
450 });
451})();
452</script>
453
454<!-- Per-site custom footer HTML -->
455<% if (safeSite.custom_foot_html) { %>
456<%- safeSite.custom_foot_html %>
457<% } %>
458
459</body>
460</html>
Note: See TracBrowser for help on using the repository browser.