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

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

feat: site player pauses when an embedded video/audio starts playing

No more double audio: when an embed iframe (.folio-embed, e.g.
YouTube/Vimeo/SoundCloud/Spotify) is clicked and gets focus -> window 'blur';
if the site player is playing, we pause it. audio-player.js v=11->v=12.

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

  • Property mode set to 100644
File size: 20.3 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
172<%- include('partials/shared-styles') %>
173
174<!-- v1 P55 — Inline the saved site accent. The base stylesheet only sets a
175 fallback (#c2410c orange) and palette blocks don't define --accent at
176 all, so without this override the saved accent never reaches the page.
177 :root + [data-palette] hits both unscoped and palette-scoped variants;
178 source-order wins on equal specificity, and this comes after style.css. -->
179<style id="pcms-site-accent">
180 :root,
181 [data-palette] {
182 --accent: <%= _e(safeAccent) %>;
183 --accent-soft: color-mix(in srgb, <%= _e(safeAccent) %> 80%, white);
184 --accent-tint: color-mix(in srgb, <%= _e(safeAccent) %> 12%, transparent);
185 }
186</style>
187
188<!-- Per-site custom CSS injection -->
189<% if (safeSite.custom_css) { %>
190<style id="pcms-custom-css"><%- safeSite.custom_css %></style>
191<% } %>
192
193<!-- Apply theme ASAP, before paint. Precedence (first match wins):
194 1. localStorage override (visitor toggled earlier on this browser)
195 2. Site default (theme_override + palette) — what new visitors see
196 3. Device prefers-color-scheme (only if site default is empty/auto)
197 4. 'dark' as last-ditch fallback
198 Note: PALETTE never has a localStorage layer anymore. There's no UI for
199 visitors to pick a palette, so any cached pcms-palette is stale data
200 from old code paths and gets cleaned up here. Site default always wins
201 for palette. -->
202<script>
203 (function() {
204 try {
205 var siteDefault = '<%= safeSite.theme_override || "" %>';
206 var sitePalette = '<%= safeSite.palette || "sage" %>';
207
208 // One-time cleanup: drop the orphan pcms-palette key set by P43-P57
209 // bootstrap. After this it never re-appears because nothing writes it.
210 try { localStorage.removeItem('pcms-palette'); } catch(_) {}
211
212 // Theme: localStorage > site override > device pref > dark
213 var storedTheme = null;
214 try { storedTheme = localStorage.getItem('pcms-theme'); } catch(_) {}
215 var deviceDark = window.matchMedia &&
216 window.matchMedia('(prefers-color-scheme: dark)').matches;
217 var t = storedTheme
218 || siteDefault
219 || (deviceDark ? 'dark' : 'light');
220
221 // Palette: site default only.
222 document.documentElement.setAttribute('data-theme', t);
223 document.documentElement.setAttribute('data-palette', sitePalette);
224 } catch(e) {
225 document.documentElement.setAttribute('data-theme', 'dark');
226 }
227 })();
228</script>
229
230<!-- HTMX — bundled locally from node_modules at boot, zero third-party requests -->
231<script src="/assets/js/htmx.min.js"></script>
232
233<!-- Per-site custom <head> HTML (analytics, verification, etc.) -->
234<% if (safeSite.custom_head_html) { %>
235<%- safeSite.custom_head_html %>
236<% } %>
237</head>
238
239<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 : '') %>">
240
241<% if (typeof isViewer !== 'undefined' && isViewer) { %>
242 <div class="viewer-banner" role="status">
243 <span class="viewer-banner-ico" aria-hidden="true">👁️</span>
244 <span class="viewer-banner-text"><strong>Kijker-modus</strong> — je kunt alles bekijken, maar niets wijzigen.</span>
245 </div>
246 <style>
247 .viewer-banner {
248 position: sticky; top: 0; z-index: 60;
249 display: flex; align-items: center; justify-content: center; gap: 0.5rem;
250 padding: 0.5rem 1rem;
251 background: linear-gradient(90deg,
252 color-mix(in srgb, var(--accent) 88%, #000) 0%,
253 var(--accent) 100%);
254 color: #fff;
255 font-size: 0.85rem; line-height: 1.3;
256 box-shadow: 0 1px 6px color-mix(in srgb, var(--accent) 45%, transparent);
257 }
258 .viewer-banner-ico { font-size: 1rem; }
259 .viewer-banner-text strong { font-weight: 700; }
260 </style>
261<% } %>
262
263<%# Geen shell-chrome (masthead/profile-header/view-switcher/bottom-tab) op:
264 - de hub-overview (on-hub): standalone landing, begint met de hero;
265 - auth-pagina's (on-auth): focus-schermen voor in-/uitloggen.
266 Die chrome hoort bij een PrutFolio en verschijnt dus op /user/-pagina's. %>
267<% var bareChrome = (typeof bodyClass === 'string' && (bodyClass.indexOf('on-hub') >= 0 || bodyClass.indexOf('on-auth') >= 0))
268 || ((typeof tenancy !== 'undefined' && tenancy === 'hub') && !siteUrlBase && !isAdminPage); %>
269
270<% if (!bareChrome) { %>
271 <%- include('partials/topnav') %>
272 <%- include('partials/profile-header') %>
273<% } %>
274
275<%# View switcher — visible on every non-admin page. On feed pages
276 (home, archive, tag, type, user) it toggles the visual layout;
277 on non-feed pages (post, account, search, auth) it navigates to /
278 in the chosen view. Click logic is in shell.ejs at the bottom. %>
279<% if (!isAdminPage && !bareChrome) { %>
280 <%- include('partials/view-switcher') %>
281<% } %>
282
283<%# hx-history-elt scopes HTMX history snapshots to #pcms-main only, so
284 when the user navigates back the switcher (which lives in the shell
285 OUTSIDE this element) keeps its place rather than being replaced
286 along with the rest of the body. %>
287<main id="pcms-main" class="pcms-main" hx-history-elt>
288 <div id="pcms-loading" class="pcms-loading" aria-hidden="true"></div>
289 <%- pageContent %>
290</main>
291
292<%- include('partials/footer') %>
293
294<!-- Mobile bottom-tab navigation (auto-hidden ≥768px) — niet op landing/auth -->
295<% if (!bareChrome) { %>
296<%- include('partials/bottom-tab') %>
297<% } %>
298
299<!-- Mobile profile sheet (auto-hidden ≥768px; only rendered when logged in) -->
300<% if (user) { %>
301<%- include('partials/profile-sheet') %>
302<% } %>
303
304<!-- Audio player: load on every page (admin + public) so window.pcmsAudioPlayer
305 is always available. The PCMS_SITE_TRACKS bootstrap is still gated on
306 enable_audio_player since it's a public-page concept (auto-discovered
307 tracks from rendered post embeds).
308
309 ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
310 Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
311<script src="/assets/js/audio-player.js?v=12"></script>
312<% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %>
313 <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
314<% } %>
315
316<!-- Install-app button: detects platform + shows install instructions modal -->
317<script src="/assets/js/install-app.js?v=2" defer></script>
318
319<!-- Service Worker registration -->
320<script>
321 if ('serviceWorker' in navigator) {
322 navigator.serviceWorker.register('/sw.js').catch(() => {});
323 }
324</script>
325
326<!-- HTMX navigation: keep body class in sync with the swapped page.
327 The server emits HX-Trigger-After-Settle: { pcmsNav: { bodyClass } } via
328 renderPage() in middleware/render.js. Without this listener the body
329 class stays whatever the initial page-load set, so the profile-header
330 never collapses/expands when navigating home → post → home via HTMX. -->
331<script>
332(function() {
333 // The set of mutually-exclusive page-context body classes. When pcmsNav
334 // fires we strip ALL of these and apply just the one the server told us.
335 var PAGE_CLASSES = ['on-home','on-post','on-special','on-archive','on-search','on-admin'];
336
337 document.body.addEventListener('pcmsNav', function(ev) {
338 var next = ev.detail && ev.detail.bodyClass;
339 if (!next) return;
340 // Server may send a multi-class string ("on-post extra"). Only the first
341 // page-class is what we care about — strip and replace.
342 var firstClass = String(next).split(/\s+/).find(function(c) {
343 return PAGE_CLASSES.indexOf(c) >= 0;
344 });
345 if (!firstClass) return;
346 PAGE_CLASSES.forEach(function(c) { document.body.classList.remove(c); });
347 document.body.classList.add(firstClass);
348 });
349})();
350</script>
351
352<!-- View switcher + grid-cols persistence (event delegation: works for switcher
353 elements rendered later by HTMX, e.g. when navigating back to home). -->
354<script>
355(function() {
356 var body = document.body;
357 // Restore feed view + grid cols from localStorage (overrides server default)
358 try {
359 var v = localStorage.getItem('pcms-feed-view');
360 if (v === 'timeline' || v === 'grid') body.dataset.feedView = v;
361 var c = parseInt(localStorage.getItem('pcms-grid-cols'), 10);
362 if (c === 2 || c === 3 || c === 4) body.dataset.gridCols = String(c);
363 } catch (e) {}
364
365 function syncAria() {
366 document.querySelectorAll('.view-switch-btn').forEach(function(b) {
367 b.setAttribute('aria-selected', b.dataset.view === body.dataset.feedView ? 'true' : 'false');
368 });
369 document.querySelectorAll('.grid-cols-btn').forEach(function(b) {
370 b.classList.toggle('is-active', b.dataset.cols === body.dataset.gridCols);
371 });
372 }
373 syncAria();
374 // Re-sync after HTMX brings in new content (e.g. navigating back to home)
375 document.body.addEventListener('htmx:afterSettle', syncAria);
376
377 // Pages where the body actually has a feed to toggle. On these the
378 // click stays put — it just flips body[data-feed-view] and CSS does
379 // the rest. Anywhere else (post detail, account, search, auth) we
380 // navigate to home in the chosen view, so the switcher is never
381 // a dead control.
382 var FEED_PAGE_CLASSES = ['on-home', 'on-archive', 'on-tag', 'on-type', 'on-user'];
383 function isFeedPage() {
384 for (var i = 0; i < FEED_PAGE_CLASSES.length; i++) {
385 if (body.classList.contains(FEED_PAGE_CLASSES[i])) return true;
386 }
387 return false;
388 }
389
390 // Event delegation — single listener handles current and future buttons.
391 document.addEventListener('click', function(e) {
392 var sw = e.target.closest('.view-switch-btn');
393 if (sw) {
394 var view = sw.dataset.view;
395 body.dataset.feedView = view;
396 try { localStorage.setItem('pcms-feed-view', view); } catch(_) {}
397 syncAria();
398 // On non-feed pages the switcher acts as a navigation: take the
399 // user back to home in the chosen view. Use HTMX if available so
400 // the page transition matches the rest of the site's nav.
401 if (!isFeedPage()) {
402 // Naar de SITE-home in de gekozen view (siteUrlBase), niet de globale '/'
403 // — in hub is '/' de hub-overview, niet de tijdlijn van deze artiest.
404 var base = body.dataset.siteBase || '';
405 if (window.htmx) {
406 window.htmx.ajax('GET', base + '/?partial=1', { target: '#pcms-main', swap: 'innerHTML' });
407 history.pushState({}, '', base + '/');
408 } else {
409 location.href = base + '/';
410 }
411 }
412 return;
413 }
414 var gc = e.target.closest('.grid-cols-btn');
415 if (gc) {
416 body.dataset.gridCols = gc.dataset.cols;
417 try { localStorage.setItem('pcms-grid-cols', gc.dataset.cols); } catch(_) {}
418 syncAria();
419 }
420 });
421})();
422</script>
423
424<!-- PWA install prompt — show button when browser fires beforeinstallprompt -->
425<script>
426(function() {
427 var btn = document.getElementById('pwa-install-btn');
428 if (!btn) return;
429 var deferred = null;
430 window.addEventListener('beforeinstallprompt', function(e) {
431 e.preventDefault();
432 deferred = e;
433 btn.hidden = false;
434 });
435 btn.addEventListener('click', async function() {
436 if (!deferred) return;
437 btn.hidden = true;
438 deferred.prompt();
439 try { await deferred.userChoice; } catch(e) {}
440 deferred = null;
441 });
442 window.addEventListener('appinstalled', function() {
443 btn.hidden = true;
444 deferred = null;
445 });
446})();
447</script>
448
449<!-- Per-site custom footer HTML -->
450<% if (safeSite.custom_foot_html) { %>
451<%- safeSite.custom_foot_html %>
452<% } %>
453
454</body>
455</html>
Note: See TracBrowser for help on using the repository browser.