source: Klonkt/src/views/shell.ejs@ 421046c

main
Last change on this file since 421046c was 421046c, checked in by roboburr <roboburr@…>, 2 months ago

fix(audio): gapless MSE playback - background auto-advance survives on Android

Root cause of "next track plays 1 second, then pauses and the media
notification closes" on backgrounded mobile Chrome/PWA: every track
change did pause() + audio.src=<new blob> + load() + play(), which the
browser treats as a NEW playback session - and Chrome's background media
policy pauses new sessions started in the background. Only a CONTINUING
session may keep playing.

The player now has two engines:

  • MSE chain (Chrome/Firefox/Android): one MediaSource + one 'audio/mpeg' SourceBuffer (every track is uniform transcoder mp3). The next track's bytes are appended into the same buffer, so the whole queue is one continuous playback session; auto-advance is just the timeline flowing past a segment boundary - no pause/src/load/play at all. Track chrome, per-track time display, seek, lock-screen position state and session save/restore all work in track coordinates via a segment table. Played data is pruned (~2 tracks buffered), ID3 tags are stripped between appends, QuotaExceeded evicts and retries.
  • Blob fallback (iOS Safari - no MSE; or runtime MSE failure): the previous per-track objectURL behaviour, now fed from shared byte fetches. An audio error while the MSE chain is active permanently falls back to blobs for the session and retries the same track.

Manual actions (play/next/prev/queue click/restore) start a fresh chain -
those happen in the foreground where a new session is allowed.

Verified in-browser (desktop Chrome, two 8s test tones): auto-advance
crosses the boundary with ZERO pause/play events (old engine: one pair
per track), per-track time display correct, manual next resets the
chain, in-track seek works, queue wrap-around is gapless, no console
errors.

  • src/assets/js/audio-player.js - the two-engine pipeline (fetchTrackBytes, MSE chain: chainStart/appendSegment/ensureNextAppended/pruneBuffer/ maybeCrossBoundary/displayTimes; applyBlobBytes fallback; updateTrackChrome extraction; MediaSession positionState)
  • src/views/shell.ejs - cache-buster v31 -> v32
  • CHANGELOG(.nl/.de).md - user-facing entry under Unreleased (3 languages)

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

  • Property mode set to 100644
File size: 42.5 KB
Line 
1<%
2// ── Helpers used inside this template ─────────────────────────────
3// Escape for double-quoted HTML attributes. IMPORTANT: emit this with the RAW EJS output
4// tag, never the escaping one — escaping it a second time turned og:title "Jason's" into the
5// double-escaped "Jason&amp;#39;s", and naive OG scrapers (Signal/WhatsApp) show that literally.
6// We deliberately do NOT escape the apostrophe: it is safe inside a double-quoted attribute and
7// a literal apostrophe is what link-preview scrapers expect.
8function _e(s) {
9 return String(s == null ? '' : s)
10 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
11 .replace(/"/g, '&quot;');
12}
13
14const safeSite = site || {};
15const safeUrlBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
16const safeAccent = safeSite.accent && /^#[0-9a-fA-F]{6}$/.test(safeSite.accent) ? safeSite.accent : '#e8b04b';
17const lang = safeSite.language || 'nl';
18const ogLocale = safeSite.og_locale || (lang === 'nl' ? 'nl_NL' : (lang.length === 2 ? lang + '_' + lang.toUpperCase() : 'en_US'));
19const homePath = safeUrlBase + '/';
20const isPostPage = bodyClass && bodyClass.indexOf('on-post') >= 0;
21const isHomePage = bodyClass && bodyClass.indexOf('on-home') >= 0;
22const isSpecialPg = bodyClass && bodyClass.indexOf('on-special') >= 0;
23const isAdminPage = bodyClass && bodyClass.indexOf('on-admin') >= 0;
24
25// ── <title> via site.title_template ──────────────────────────────
26// Template: '{title} — {site}'. If pageTitle equals site.title (homepage) we
27// just use the site title alone, otherwise apply the template.
28const _siteTitle = safeSite.title || 'Klonkt';
29const _rawTitle = pageTitle || _siteTitle;
30const _tpl = safeSite.title_template || '{title} — {site}';
31const _finalTitle = (_rawTitle === _siteTitle)
32 ? _siteTitle
33 : _tpl.replace('{title}', _rawTitle).replace('{site}', _siteTitle);
34
35// ── Robots: noindex on listing pages and on per-post override ─────
36let _shouldIndex = safeSite.robots_index !== 0;
37if (typeof post !== 'undefined' && post && post.noindex) _shouldIndex = false;
38// Listing pages (search/tag/type/archive) shouldn't be indexed (dupe content)
39if (currentPath) {
40 if (/^\/(?:search|tag|type|archive|users|account|admin)(?:$|\/)/.test(currentPath)) {
41 _shouldIndex = false;
42 }
43}
44// Special-flagged views from routes opt out too
45if (isSpecialPg && (currentPath === '/search' || /^\/(tag|type|archive|users)\//.test(currentPath))) {
46 _shouldIndex = false;
47}
48
49// ── Canonical URL: per-site override (admin SEO), else the .env base
50// (PUBLIC_BASE_URL, via ogOrigin → falls back to the request host) ──
51let _canonical = null;
52const _canonBase = safeSite.canonical || (typeof ogOrigin !== 'undefined' ? ogOrigin : '');
53if (_canonBase) {
54 const _base = _canonBase.replace(/\/+$/, '');
55 let _path = '/';
56 if (typeof post !== 'undefined' && post && post.slug) _path = '/' + post.slug;
57 else if (currentPath) _path = currentPath;
58 _canonical = _base + _path;
59}
60
61// ── Social bits (OG/Twitter) ──────────────────────────────────────
62const _socialTitle = (typeof post !== 'undefined' && post && post.title) ? post.title : _siteTitle;
63const _socialDescr = (typeof socialDescr !== 'undefined' && socialDescr)
64 ? socialDescr
65 : (safeSite.default_description || safeSite.description || '');
66// og:image — custom (post/site) first; otherwise the auto-generated themed card
67// (/og/<slug>.png), so every site has a branded social preview by default.
68let _socialImage = '', _ogGenerated = false;
69if (typeof socialImage !== 'undefined' && socialImage) _socialImage = socialImage;
70else if (safeSite.og_image_default) _socialImage = safeSite.og_image_default;
71else if (safeSite.default_cover) _socialImage = safeSite.default_cover;
72else if (safeSite.slug && typeof ogOrigin !== 'undefined' && ogOrigin) {
73 _socialImage = ogOrigin + '/og/' + encodeURIComponent(safeSite.slug) + '.png';
74 _ogGenerated = true;
75}
76// og:image / twitter:image / JSON-LD image MUST be absolute (OGP spec). A post cover arrives as
77// a relative /media/... path; strict scrapers (WhatsApp/Signal/some fediverse clients) won't
78// resolve it against the page URL → no preview image. Absolutize against the canonical origin.
79if (_socialImage && _socialImage.charAt(0) === '/' && _socialImage.charAt(1) !== '/' && typeof ogOrigin !== 'undefined' && ogOrigin) {
80 _socialImage = ogOrigin + _socialImage;
81}
82const _ogType = isPostPage ? 'article' : 'website';
83
84// ── JSON-LD ───────────────────────────────────────────────────────
85const _publisher = {
86 '@type': safeSite.schema_type === 'Organization' ? 'Organization' : 'Person',
87 name: safeSite.publisher_name || _siteTitle,
88 url: safeSite.publisher_url || (_canonical ? _canonical.split(/(?<=^[^/]*\/\/[^/]+)\//)[0] + '/' : null),
89};
90if (safeSite.publisher_logo) {
91 _publisher.logo = { '@type': 'ImageObject', url: safeSite.publisher_logo };
92}
93let _jsonLd = null;
94if (typeof post !== 'undefined' && post && post.slug) {
95 _jsonLd = {
96 '@context': 'https://schema.org',
97 '@type': (post.type === 'foto' || post.type === 'video') ? 'CreativeWork' : 'Article',
98 headline: post.title || _siteTitle,
99 description: _socialDescr,
100 datePublished: post.published_at || post.created_at || new Date().toISOString(),
101 dateModified: post.updated_at || post.published_at || new Date().toISOString(),
102 publisher: _publisher,
103 };
104 if (_socialImage) _jsonLd.image = _socialImage;
105 if (post.author_username) _jsonLd.author = { '@type': 'Person', name: post.author_username };
106 if (Array.isArray(post.tags) && post.tags.length) _jsonLd.keywords = post.tags.join(', ');
107} else if (isHomePage) {
108 _jsonLd = {
109 '@context': 'https://schema.org',
110 '@type': 'WebSite',
111 name: _siteTitle,
112 description: safeSite.description || safeSite.default_description || '',
113 publisher: _publisher,
114 };
115}
116%><!DOCTYPE html>
117<html lang="<%- _e(lang) %>" data-palette="<%- _e((typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt')) %>">
118<head>
119<meta charset="utf-8">
120<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
121<meta name="color-scheme" content="dark light">
122
123<title><%= _finalTitle %></title>
124<meta name="description" content="<%- _e(_socialDescr) %>">
125<meta name="theme-color" content="<%- _e(safeAccent) %>">
126<meta name="robots" content="<%= _shouldIndex ? 'index,follow' : 'noindex,nofollow' %>">
127<% if (safeSite.author) { %><meta name="author" content="<%- _e(safeSite.author) %>"><% } %>
128<% if (_canonical) { %><link rel="canonical" href="<%- _e(_canonical) %>"><% } %>
129
130<!-- Search-engine verification -->
131<% if (safeSite.google_verification) { %><meta name="google-site-verification" content="<%- _e(safeSite.google_verification) %>"><% } %>
132<% if (safeSite.bing_verification) { %><meta name="msvalidate.01" content="<%- _e(safeSite.bing_verification) %>"><% } %>
133<% if (safeSite.pinterest_verification) { %><meta name="p:domain_verify" content="<%- _e(safeSite.pinterest_verification) %>"><% } %>
134<% if (safeSite.yandex_verification) { %><meta name="yandex-verification" content="<%- _e(safeSite.yandex_verification) %>"><% } %>
135
136<!-- Feed autodiscovery -->
137<% if (site) { %>
138<link rel="alternate" type="application/rss+xml" title="<%- _e(_siteTitle) %> — RSS" href="<%- _e(safeUrlBase + '/feed.xml') %>">
139<link rel="alternate" type="application/atom+xml" title="<%- _e(_siteTitle) %> — Atom" href="<%- _e(safeUrlBase + '/atom.xml') %>">
140<% } %>
141
142<!-- PWA -->
143<link rel="manifest" href="<%- _e(safeUrlBase + '/manifest.webmanifest') %>">
144<meta name="mobile-web-app-capable" content="yes">
145<meta name="apple-mobile-web-app-capable" content="yes">
146<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
147<meta name="apple-mobile-web-app-title" content="<%- _e(_siteTitle.slice(0, 16)) %>">
148<link rel="apple-touch-icon" href="<%- _e(safeSite.profile_photo || '/favicon.ico') %>">
149<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=sf">
150<link rel="alternate icon" href="/favicon.ico?v=sf">
151
152<!-- OpenGraph -->
153<meta property="og:type" content="<%= _ogType %>">
154<meta property="og:title" content="<%- _e(_socialTitle) %>">
155<meta property="og:description" content="<%- _e(_socialDescr) %>">
156<meta property="og:site_name" content="<%- _e(_siteTitle) %>">
157<meta property="og:locale" content="<%- _e(ogLocale) %>">
158<% if (_socialImage) { %>
159<meta property="og:image" content="<%- _e(_socialImage) %>">
160<meta property="og:image:alt" content="<%- _e(_socialTitle) %>">
161<% if (_ogGenerated) { %>
162<meta property="og:image:width" content="1200">
163<meta property="og:image:height" content="630">
164<meta property="og:image:type" content="image/png">
165<% } %>
166<% } %>
167<% if (_canonical) { %><meta property="og:url" content="<%- _e(_canonical) %>"><% } %>
168<%
169// Fediverse/social PLAYER card for posts with audio: instead of shipping the raw
170// mp3, point at our embeddable player (/embed?post=slug) so Mastodon shows an
171// inline player that streams via the gated /audio/stream (no downloadable file).
172const _postAudio = !!(typeof post !== 'undefined' && post
173 && typeof postHasPlayableAudio !== 'undefined' && postHasPlayableAudio
174 && typeof premiumUnlocked !== 'undefined' && premiumUnlocked);
175const _embedUrl = _postAudio
176 ? ((typeof ogOrigin !== 'undefined' && ogOrigin ? ogOrigin : '') + safeUrlBase + '/embed?post=' + encodeURIComponent(post.slug))
177 : '';
178%>
179<% if (_postAudio) { %>
180<meta property="og:video" content="<%- _e(_embedUrl) %>">
181<meta property="og:video:secure_url" content="<%- _e(_embedUrl) %>">
182<meta property="og:video:type" content="text/html">
183<meta property="og:video:width" content="480">
184<meta property="og:video:height" content="480">
185<% } %>
186<% if (typeof post !== 'undefined' && post && post.published_at) { %>
187<meta property="article:published_time" content="<%- _e(post.published_at) %>">
188<% if (post.author_username) { %><meta property="article:author" content="<%- _e(post.author_username) %>"><% } %>
189<% } %>
190<% if (safeSite.facebook_app_id) { %><meta property="fb:app_id" content="<%- _e(safeSite.facebook_app_id) %>"><% } %>
191
192<!-- Twitter Cards -->
193<meta name="twitter:card" content="<%= _postAudio ? 'player' : (_socialImage ? 'summary_large_image' : 'summary') %>">
194<% if (_postAudio) { %>
195<meta name="twitter:player" content="<%- _e(_embedUrl) %>">
196<meta name="twitter:player:width" content="480">
197<meta name="twitter:player:height" content="480">
198<% } %>
199<meta name="twitter:title" content="<%- _e(_socialTitle) %>">
200<meta name="twitter:description" content="<%- _e(_socialDescr) %>">
201<% if (_socialImage) { %><meta name="twitter:image" content="<%- _e(_socialImage) %>"><% } %>
202<% if (safeSite.twitter) { %><meta name="twitter:creator" content="<%- _e(safeSite.twitter) %>"><meta name="twitter:site" content="<%- _e(safeSite.twitter) %>"><% } %>
203
204<% if (_jsonLd) { %>
205<script type="application/ld+json"><%- JSON.stringify(_jsonLd) %></script>
206<% } %>
207<% if (typeof musicLd !== 'undefined' && musicLd) { %>
208<%# Music posts also carry standard schema.org MusicRecording/MusicAlbum data (Phase 1 of
209 music federation): real web standard, read by search engines + generic consumers. %>
210<script type="application/ld+json"><%- JSON.stringify(musicLd) %></script>
211<% } %>
212
213<!-- Self-hosted fonts (privacy-first) -->
214<link rel="preload" href="/assets/fonts/literata-latin-opsz-normal.woff2" as="font" type="font/woff2" crossorigin>
215<link rel="preload" href="/assets/fonts/fraunces-latin-full-normal.woff2" as="font" type="font/woff2" crossorigin>
216
217<!-- v9 stylesheet (full palette system) -->
218<link rel="stylesheet" href="/assets/css/style.css?v=67">
219<script>
220/* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
221 BOTH Safari and the installed PWA (standalone), even with viewport-fit=cover, so the
222 masthead can't clear the camera. We measure env directly and, when it comes back empty
223 on a notched iPhone, fall back to a fixed inset (a bit larger in standalone for the
224 Dynamic Island). We set --ios-safe-top AND force the masthead padding inline — the
225 latter survives a stale cached stylesheet that lacks the var. Re-applied after htmx
226 chrome swaps (the top-nav is out-of-band swapped on navigation). */
227(function(){
228 var ua = navigator.userAgent || '';
229 var isIOS = /iP(hone|od|ad)/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
230 if (!isIOS) return;
231 var standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || navigator.standalone === true;
232 function apply(){
233 if (!document.body) return;
234 var p = document.createElement('div');
235 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';
236 document.body.appendChild(p);
237 var rawEnv = parseFloat(getComputedStyle(p).paddingTop) || 0;
238 p.remove();
239 var i = rawEnv;
240 if (i < 20 && Math.max(screen.width, screen.height) >= 812) {
241 // Portrait: the island sits at the TOP → full inset (UNCHANGED: standalone?59:47).
242 // Landscape: the island moves to the SIDE, so the top inset is ~0 → no top padding
243 // (only the masthead's own base padding remains). env() can't tell us (returns 0),
244 // so we key off orientation directly.
245 i = (window.innerWidth > window.innerHeight) ? 0 : (standalone ? 59 : 47);
246 }
247 document.documentElement.style.setProperty('--ios-safe-top', i + 'px');
248 var mh = document.querySelector('.masthead');
249 var mhHidden = !mh || getComputedStyle(mh).display === 'none' || mh.offsetHeight === 0;
250 if (mhHidden) {
251 /* Mobile: the masthead is hidden (bottom-tab replaces it ≤767px) so it can't carry
252 the inset. Pad the body instead → the profile-header clears the island/notch. */
253 document.body.style.paddingTop = i + 'px';
254 if (mh) mh.style.paddingTop = '';
255 } else {
256 /* Desktop/landscape: the sticky masthead carries the inset in its own padding. */
257 document.body.style.paddingTop = '';
258 mh.style.paddingTop = 'calc(.55rem + ' + i + 'px)';
259 }
260 }
261 if (document.body) apply(); else document.addEventListener('DOMContentLoaded', apply);
262 document.addEventListener('htmx:afterSettle', apply);
263 window.addEventListener('orientationchange', function(){ setTimeout(apply, 250); });
264})();
265</script>
266
267<!-- Audio player styles: loaded on every page so the mini-player works
268 anywhere (admin previews, post embeds, etc). The player itself is
269 a singleton — see the script tag near </body>. -->
270<link rel="stylesheet" href="/assets/css/audio.css?v=11">
271<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) in huisstijl. -->
272<link rel="stylesheet" href="/assets/css/embed.css?v=9">
273
274<%- include('partials/shared-styles') %>
275
276<!-- v1 P55 — Inline the saved site accent. The base stylesheet only sets a
277 fallback (#c2410c orange) and palette blocks don't define --accent at
278 all, so without this override the saved accent never reaches the page.
279 :root + [data-palette] hits both unscoped and palette-scoped variants;
280 source-order wins on equal specificity, and this comes after style.css. -->
281<style id="pcms-site-accent">
282 :root,
283 [data-palette] {
284 --accent: <%- _e(safeAccent) %>;
285 --accent-soft: color-mix(in srgb, <%- _e(safeAccent) %> 80%, white);
286 --accent-tint: color-mix(in srgb, <%- _e(safeAccent) %> 12%, transparent);
287 }
288</style>
289
290<!-- Per-site custom CSS injection -->
291<% if (safeSite.custom_css) { %>
292<style id="pcms-custom-css"><%- safeSite.custom_css %></style>
293<% } %>
294
295<!-- Apply theme ASAP, before paint. Precedence (first match wins):
296 1. localStorage override (visitor toggled earlier on this browser)
297 2. Site default (theme_override + palette) — what new visitors see
298 3. Device prefers-color-scheme (only if site default is empty/auto)
299 4. 'dark' as last-ditch fallback
300 Note: PALETTE never has a localStorage layer anymore. There's no UI for
301 visitors to pick a palette, so any cached pcms-palette is stale data
302 from old code paths and gets cleaned up here. Site default always wins
303 for palette. -->
304<script>
305 (function() {
306 try {
307 var siteDefault = '<%= safeSite.theme_override || "" %>';
308 var sitePalette = '<%= (typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt') %>';
309
310 // One-time cleanup: drop the orphan pcms-palette key set by P43-P57
311 // bootstrap. After this it never re-appears because nothing writes it.
312 try { localStorage.removeItem('pcms-palette'); } catch(_) {}
313
314 // Theme: localStorage > site override > device pref > dark
315 var storedTheme = null;
316 try { storedTheme = localStorage.getItem('pcms-theme'); } catch(_) {}
317 var deviceDark = window.matchMedia &&
318 window.matchMedia('(prefers-color-scheme: dark)').matches;
319 var t = storedTheme
320 || siteDefault
321 || (deviceDark ? 'dark' : 'light');
322
323 // Palette: site default only.
324 document.documentElement.setAttribute('data-theme', t);
325 document.documentElement.setAttribute('data-palette', sitePalette);
326 } catch(e) {
327 document.documentElement.setAttribute('data-theme', 'dark');
328 }
329 })();
330</script>
331
332<!-- HTMX — bundled locally from node_modules at boot, zero third-party requests -->
333<script src="/assets/js/htmx.min.js"></script>
334
335<!-- Per-site custom <head> HTML (analytics, verification, etc.) -->
336<% if (safeSite.custom_head_html) { %>
337<%- safeSite.custom_head_html %>
338<% } %>
339</head>
340
341<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 : '') %>">
342
343<% if (typeof isViewer !== 'undefined' && isViewer) { %>
344 <div class="viewer-banner" role="status">
345 <span class="viewer-banner-ico" aria-hidden="true">👁️</span>
346 <span class="viewer-banner-text"><strong>Kijker-modus</strong> — je kunt alles bekijken, maar niets wijzigen.</span>
347 </div>
348 <style>
349 .viewer-banner {
350 position: sticky; top: 0; z-index: 60;
351 display: flex; align-items: center; justify-content: center; gap: 0.5rem;
352 padding: 0.5rem 1rem;
353 background: linear-gradient(90deg,
354 color-mix(in srgb, var(--accent) 88%, #000) 0%,
355 var(--accent) 100%);
356 color: #fff;
357 font-size: 0.85rem; line-height: 1.3;
358 box-shadow: 0 1px 6px color-mix(in srgb, var(--accent) 45%, transparent);
359 }
360 .viewer-banner-ico { font-size: 1rem; }
361 .viewer-banner-text strong { font-weight: 700; }
362 </style>
363<% } %>
364
365<%# Site-chrome (topnav + profielkop + view-switcher) in één vaste slot #pcms-chrome.
366 Bij htmx-navigatie wordt dit slot out-of-band ververst (zie chrome.ejs +
367 render.js), zodat de kop ALTIJD bij de nieuwe pagina/artiest hoort terwijl de
368 audioplayer (los in document.body) blijft leven → geen verspringen. Op de
369 hub-landing is het slot leeg: de hero is daar de header. %>
370<%- include('partials/chrome') %>
371
372<%# Geen hx-history-elt: htmx' eigen history staat uit (zie de link-boost).
373 Back/forward wordt door onze popstate-listener gedaan, die de partial
374 her-fetcht (incl. correcte OOB-chrome). Met hx-history-elt + htmx-history
375 aan dumpte htmx de partial ongefilterd hier → dubbele kop. %>
376<main id="pcms-main" class="pcms-main">
377 <div id="pcms-loading" class="pcms-loading" aria-hidden="true"></div>
378 <%- pageContent %>
379</main>
380
381<%- include('partials/footer') %>
382
383<!-- Mobile bottom-tab navigation (auto-hidden ≥768px). Toont overal — óók op de
384 hub-landing (zodat mobiel altijd Home/Zoek/Inloggen heeft) — behalve op de
385 auth-focusschermen. -->
386<% if (!(typeof bodyClass === 'string' && bodyClass.indexOf('on-auth') >= 0)) { %>
387<%- include('partials/bottom-tab') %>
388<% } %>
389
390<!-- Mobile profile sheet (auto-hidden ≥768px; only rendered when logged in) -->
391<% if (user) { %>
392<%- include('partials/profile-sheet') %>
393<% } %>
394
395<!-- Audio player: load on every page (admin + public) so window.pcmsAudioPlayer
396 is always available. The PCMS_SITE_TRACKS bootstrap is still gated on
397 enable_audio_player since it's a public-page concept (auto-discovered
398 tracks from rendered post embeds).
399
400 ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
401 Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
402<script src="/assets/js/audio-player.js?v=32"></script>
403<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) via de echte
404 player-API's + gedeelde mutual-exclusion registry met de site-speler. -->
405<script src="/assets/js/embed-player.js?v=17" defer></script>
406<% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %>
407 <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
408<% } %>
409
410<!-- Install-app button: detects platform + shows install instructions modal -->
411<script src="/assets/js/install-app.js?v=2" defer></script>
412
413<!-- Service Worker registration -->
414<script>
415 if ('serviceWorker' in navigator) {
416 navigator.serviceWorker.register('/sw.js').catch(() => {});
417 }
418</script>
419
420<!-- HTMX navigation: keep body class in sync with the swapped page.
421 The server emits HX-Trigger-After-Settle: { pcmsNav: { bodyClass } } via
422 renderPage() in middleware/render.js. Without this listener the body
423 class stays whatever the initial page-load set, so the profile-header
424 never collapses/expands when navigating home → post → home via HTMX. -->
425<script>
426(function() {
427 // De page-context body-classes. Bij pcmsNav strippen we ze allemaal en zetten
428 // we opnieuw wat de server stuurde. 'on-auth' staat erbij zodat het login-
429 // focusscherm óók via htmx z'n styling krijgt (geen chrome, geen tab, audio op 0).
430 var PAGE_CLASSES = ['on-home','on-post','on-special','on-archive','on-search','on-admin','on-auth',
431 'on-shows','on-downloads','on-download','on-epk','on-linkbio','on-newsletter',
432 'on-tag','on-type','on-user','on-cirkel','on-hub','on-chat'];
433
434 document.body.addEventListener('pcmsNav', function(ev) {
435 var d = ev.detail || {};
436 // Per-site accent + palette bijwerken (head wordt niet mee-geswapt bij htmx-nav,
437 // dus zonder dit erft een artiest de kleuren van de vorige pagina).
438 if (d.accent && /^#[0-9a-fA-F]{6}$/.test(d.accent)) {
439 var sa = document.getElementById('pcms-site-accent');
440 if (sa) {
441 sa.textContent =
442 ':root,[data-palette]{--accent:' + d.accent +
443 ';--accent-soft:color-mix(in srgb,' + d.accent + ' 80%,white)' +
444 ';--accent-tint:color-mix(in srgb,' + d.accent + ' 12%,transparent);}';
445 }
446 }
447 if (d.palette && /^[a-z0-9-]+$/i.test(d.palette)) {
448 document.documentElement.setAttribute('data-palette', d.palette);
449 }
450
451 var next = d.bodyClass;
452 if (!next) return;
453 // De server kan meerdere page-classes sturen ("on-special on-auth"). Strip
454 // alle bekende en zet ALLE meegestuurde page-classes terug (niet alleen de
455 // eerste) — anders mist 'on-auth' bij htmx-nav en blijft het login-scherm
456 // de chrome/tab van de vorige pagina houden.
457 var matched = String(next).split(/\s+/).filter(function(c) {
458 return PAGE_CLASSES.indexOf(c) >= 0;
459 });
460 if (!matched.length) return;
461 PAGE_CLASSES.forEach(function(c) { document.body.classList.remove(c); });
462 matched.forEach(function(c) { document.body.classList.add(c); });
463 });
464
465 // (Back/forward wordt afgehandeld door de popstate-listener in de link-boost
466 // hieronder — die her-fetcht de partial via htmx.ajax, wat de OOB-chrome +
467 // de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere
468 // htmx:historyRestore-handler is vervallen nu htmx-history uitstaat.)
469})();
470</script>
471
472<!-- View switcher + grid-cols persistence (event delegation: works for switcher
473 elements rendered later by HTMX, e.g. when navigating back to home). -->
474<script>
475(function() {
476 var body = document.body;
477 // Restore feed view + grid cols from localStorage (overrides server default)
478 try {
479 var v = localStorage.getItem('pcms-feed-view');
480 if (v === 'timeline' || v === 'grid') body.dataset.feedView = v;
481 var c = parseInt(localStorage.getItem('pcms-grid-cols'), 10);
482 if (c === 2 || c === 3 || c === 4) body.dataset.gridCols = String(c);
483 } catch (e) {}
484
485 function syncAria() {
486 // Alleen op een feed-pagina hoort Tijdlijn/Grid 'actief' (wit) te zijn; op
487 // agenda/downloads/post/etc. beide grijs. Inline feed-check (FEED_PAGE_CLASSES
488 // staat verderop, maar deze functie draait al bij init).
489 var _feedC = ['on-home','on-tag','on-type','on-user','on-cirkel'];
490 var _onFeed = _feedC.some(function(c){ return body.classList.contains(c); });
491 document.querySelectorAll('.view-switch-btn').forEach(function(b) {
492 b.setAttribute('aria-selected', (_onFeed && b.dataset.view === body.dataset.feedView) ? 'true' : 'false');
493 });
494 document.querySelectorAll('.grid-cols-btn').forEach(function(b) {
495 b.classList.toggle('is-active', b.dataset.cols === body.dataset.gridCols);
496 });
497 }
498 syncAria();
499 // Re-sync after HTMX brings in new content (e.g. navigating back to home).
500 // pcmsNav vuurt ná de body-class-update (zie de pcmsNav-listener hierboven), dus
501 // dáár weet syncAria de juiste pagina-class — los van de afterSettle-timing.
502 // Een vertraagde herhaling wint eventuele resterende races (OOB-chrome-swap).
503 document.body.addEventListener('htmx:afterSettle', function(){ syncAria(); setTimeout(syncAria, 60); });
504 document.body.addEventListener('pcmsNav', function(){ syncAria(); setTimeout(syncAria, 60); });
505
506 // Pages where the body actually has a feed to toggle. On these the
507 // click stays put — it just flips body[data-feed-view] and CSS does
508 // the rest. Anywhere else (post detail, account, search, auth) we
509 // navigate to home in the chosen view, so the switcher is never
510 // a dead control.
511 // NB: 'on-archive' staat hier bewust NIET tussen — op het archief is er geen
512 // in-place timeline/grid-toggle; een klik op de switcher springt terug naar de
513 // feed in de gekozen weergave (zie de !isFeedPage()-tak hieronder).
514 var FEED_PAGE_CLASSES = ['on-home', 'on-tag', 'on-type', 'on-user', 'on-cirkel'];
515 function isFeedPage() {
516 for (var i = 0; i < FEED_PAGE_CLASSES.length; i++) {
517 if (body.classList.contains(FEED_PAGE_CLASSES[i])) return true;
518 }
519 return false;
520 }
521
522 // Event delegation — single listener handles current and future buttons.
523 document.addEventListener('click', function(e) {
524 var sw = e.target.closest('.view-switch-btn');
525 if (sw) {
526 var view = sw.dataset.view;
527 body.dataset.feedView = view;
528 try { localStorage.setItem('pcms-feed-view', view); } catch(_) {}
529 syncAria();
530 // On non-feed pages the switcher acts as a navigation: take the
531 // user back to home in the chosen view. Use HTMX if available so
532 // the page transition matches the rest of the site's nav.
533 if (!isFeedPage()) {
534 // Naar de SITE-home in de gekozen view (siteUrlBase), niet de globale '/'
535 // — in hub is '/' de hub-overview, niet de tijdlijn van deze artiest.
536 var base = body.dataset.siteBase || '';
537 if (window.htmx) {
538 window.htmx.ajax('GET', base + '/?partial=1', { target: '#pcms-main', swap: 'innerHTML' });
539 history.pushState({}, '', base + '/');
540 } else {
541 location.href = base + '/';
542 }
543 }
544 return;
545 }
546 var gc = e.target.closest('.grid-cols-btn');
547 if (gc) {
548 body.dataset.gridCols = gc.dataset.cols;
549 try { localStorage.setItem('pcms-grid-cols', gc.dataset.cols); } catch(_) {}
550 syncAria();
551 }
552 });
553})();
554</script>
555
556<!-- Globale link-boost: alle interne navigatie-links lopen via htmx in #pcms-main,
557 zodat de audioplayer (los in document.body) blijft spelen i.p.v. te verspringen
558 bij een full page-load. Werkt overal — Beheer, Account, posts, sites — zonder
559 elke link los htmx te maken. Links die écht een volledige load nodig hebben
560 (uitloggen/auth, downloads, feeds, media, assets, bestanden) worden overgeslagen,
561 net als links die al hun eigen hx-* hebben. -->
562<script>
563(function () {
564 if (!window.htmx) return;
565 // htmx' EIGEN history-afhandeling volledig uitzetten. We doen back/forward zelf
566 // via de popstate-listener hieronder (htmx.ajax → verwerkt de OOB-chrome netjes).
567 // Lieten we htmx z'n gang gaan, dan herstelde 'ie #pcms-main door de partial
568 // (inclusief de <div id=pcms-chrome hx-swap-oob>) ONGEFILTERD in #pcms-main te
569 // dumpen → een tweede, geneste kop = de pagina dubbel. Eén mechanisme nu.
570 try { window.htmx.config.historyEnabled = false; } catch (_) {}
571
572 function fullLoad(a, url) {
573 if (a.hasAttribute('download') || a.hasAttribute('data-full-load')) return true;
574 if (a.hasAttribute('hx-get') || a.hasAttribute('hx-post') || a.hasAttribute('hx-boost')) return true;
575 if (a.target && a.target !== '_self') return true;
576 if (a.getAttribute('rel') === 'external') return true;
577 var p = url.pathname;
578 // Sessie/redirect-acties → volledige navigatie (cookies, Google-redirect).
579 // Maar de auth-FORMULIERpagina's (/auth/admin, /auth/login, /auth/register,
580 // /auth/reset…) mogen wél via htmx, zodat de audiospeler blijft doorspelen
581 // i.p.v. te herstarten/verspringen bij een volledige page-load.
582 if (/^\/(logout|oauth)(?:\/|$)/.test(p)) return true;
583 if (/^\/auth\/(logout|google)(?:\/|$)/.test(p)) return true;
584 // Feeds, PWA, service-worker, statics, media-streams, downloads.
585 if (/^\/(feed|atom|sitemap|manifest|robots|sw\.js|assets|media|audio|uploads)(?:\/|\.|$)/.test(p)) return true;
586 if (/\.[a-z0-9]{2,5}$/i.test(p)) return true; // bestandsextensie → laat de browser 't halen
587 return false;
588 }
589
590 var lastPath = location.pathname + location.search;
591 var navTimer = null;
592
593 // Eén plek voor alle programmatische navigatie-swaps. Annuleert eerst een nog
594 // lopende request op #pcms-main (anti-race: bij snel klikken/terug-gaan kan een
595 // trage oude response anders een nieuwe pagina overschrijven → "kale content").
596 function doNav(dest) {
597 try { window.htmx.trigger('#pcms-main', 'htmx:abort'); } catch (_) {}
598 window.htmx.ajax('GET', dest, { target: '#pcms-main', swap: 'innerHTML' });
599 }
600
601 document.addEventListener('click', function (e) {
602 if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
603 var a = e.target.closest('a[href]');
604 if (!a) return;
605 var href = a.getAttribute('href');
606 if (!href || href.charAt(0) === '#') return;
607 var url; try { url = new URL(a.href, location.href); } catch (_) { return; }
608 if (url.origin !== location.origin) return;
609 if (fullLoad(a, url)) return;
610 e.preventDefault();
611 var dest = url.pathname + url.search;
612 if (dest !== lastPath) history.pushState({ b: 1 }, '', dest);
613 lastPath = dest;
614 if (navTimer) { clearTimeout(navTimer); navTimer = null; }
615 doNav(dest);
616 try { window.scrollTo(0, 0); } catch (_) {}
617 });
618
619 window.addEventListener('popstate', function () {
620 var here = location.pathname + location.search;
621 if (here === lastPath) return;
622 lastPath = here;
623 // Debounce: bij heel snel/herhaald terug-vooruit niet elke tussenpagina ophalen,
624 // alleen de LAATSTE bestemming. Voorkomt overlappende swaps ("kale content").
625 if (navTimer) clearTimeout(navTimer);
626 navTimer = setTimeout(function () {
627 navTimer = null;
628 doNav(location.pathname + location.search);
629 }, 90);
630 });
631
632 // Links MÉT eigen hx-get + hx-push-url (post-card/post-tile/topnav/…) lopen NIET
633 // via de boost hierboven, en hx-push-url is een no-op nu htmx-history uitstaat.
634 // Doe daarom de adresbalk-update hier zelf zodra htmx swapt. Programmatische
635 // htmx.ajax-calls (boost/popstate) hebben geen elt met hx-push-url → geen dubbel.
636 document.body.addEventListener('htmx:beforeRequest', function (evt) {
637 try {
638 var elt = evt.detail && evt.detail.elt; // het TRIGGERENDE element (de link)
639 if (!elt || !elt.closest) return;
640 var node = elt.closest('[hx-push-url]');
641 if (!node) return;
642 var u = node.getAttribute('hx-push-url');
643 if (!u || u === 'false') return;
644 if (u !== (location.pathname + location.search)) history.pushState({ b: 1 }, '', u);
645 lastPath = u;
646 } catch (_) {}
647 });
648
649 // Spring naar boven na ELKE navigatie-swap van #pcms-main. De boost hierboven
650 // scrollt al, maar links met hun eigen hx-get (post-nav Newer/Older, post-kaarten)
651 // lopen NIET via de boost → zonder dit blijf je op de oude scrollpositie hangen
652 // bij het openen van een gerelateerde/volgende post. Alleen #pcms-main, zodat
653 // in-page swaps (comments e.d.) en de OOB-chrome-swap niet meescrollen.
654 document.body.addEventListener('htmx:afterSwap', function (evt) {
655 var t = evt.detail && evt.detail.target;
656 if (t && t.id === 'pcms-main') { try { window.scrollTo(0, 0); } catch (_) {} }
657 });
658})();
659</script>
660
661<!-- Mobiel toetsenbord vs. site-layout: zet body.kb-open zodra het toetsenbord
662 open is (visual viewport fors korter dan het venster) ÉN er een invoerveld
663 focus heeft. CSS verbergt dan de vaste onderbalken (bottom-tab + mini-speler)
664 zodat ze niet over het invoerveld zweven. -->
665<script>
666(function () {
667 var vv = window.visualViewport;
668 if (!vv) return;
669 function isField(el) {
670 if (!el) return false;
671 var t = el.tagName;
672 return t === 'INPUT' || t === 'TEXTAREA' || el.isContentEditable;
673 }
674 function update() {
675 var open = (window.innerHeight - vv.height) > 150 && isField(document.activeElement);
676 document.body.classList.toggle('kb-open', open);
677 }
678 vv.addEventListener('resize', update);
679 vv.addEventListener('scroll', update);
680 document.addEventListener('focusin', function () { setTimeout(update, 60); });
681 document.addEventListener('focusout', function () { setTimeout(update, 60); });
682})();
683</script>
684
685<!-- Afbeeldingen lastiger op te slaan: rechtsklik-menu + slepen blokkeren op <img>.
686 Frictie, geen echte beveiliging (DevTools/screenshot blijven kunnen). -->
687<script>
688(function () {
689 document.addEventListener('contextmenu', function (e) {
690 if (e.target && e.target.tagName === 'IMG') e.preventDefault();
691 });
692 document.addEventListener('dragstart', function (e) {
693 if (e.target && e.target.tagName === 'IMG') e.preventDefault();
694 });
695})();
696</script>
697
698<!-- Auto-resize: elk <textarea> groeit mee met de inhoud i.p.v. intern te scrollen
699 (scroll-binnen-scroll is verwarrend). Site-breed; ook na htmx-swaps. -->
700<script>
701(function () {
702 function autoSize(ta) {
703 if (!ta || ta.tagName !== 'TEXTAREA') return;
704 // Skip hidden textareas (e.g. inside a closed <details> or an unopened reply
705 // box): measuring scrollHeight there yields a bad height that sticks as inline
706 // style and makes the field open huge. They get sized on focus once visible.
707 if (ta.offsetParent === null && ta.offsetHeight === 0) return;
708 ta.style.height = 'auto';
709 var maxH = parseFloat(getComputedStyle(ta).maxHeight);
710 var sh = ta.scrollHeight;
711 var h = (maxH && !isNaN(maxH)) ? Math.min(sh, maxH) : sh; // respect a CSS max-height
712 ta.style.height = h + 'px';
713 ta.style.overflowY = sh > h ? 'auto' : 'hidden';
714 }
715 function sizeAll(root) {
716 (root || document).querySelectorAll('textarea').forEach(autoSize);
717 }
718 document.addEventListener('input', function (e) { autoSize(e.target); });
719 document.addEventListener('focusin', function (e) { autoSize(e.target); });
720 // Init + opnieuw na htmx-navigatie/partials.
721 sizeAll();
722 document.body.addEventListener('htmx:afterSettle', function () { sizeAll(); });
723 window.addEventListener('load', function () { sizeAll(); });
724})();
725</script>
726
727<!-- PWA install prompt — show button when browser fires beforeinstallprompt -->
728<script>
729(function() {
730 var btn = document.getElementById('pwa-install-btn');
731 if (!btn) return;
732 var deferred = null;
733 window.addEventListener('beforeinstallprompt', function(e) {
734 e.preventDefault();
735 deferred = e;
736 btn.hidden = false;
737 });
738 btn.addEventListener('click', async function() {
739 if (!deferred) return;
740 btn.hidden = true;
741 deferred.prompt();
742 try { await deferred.userChoice; } catch(e) {}
743 deferred = null;
744 });
745 window.addEventListener('appinstalled', function() {
746 btn.hidden = true;
747 deferred = null;
748 });
749})();
750</script>
751
752<!-- NSFW / sensitive content: click a veil/reveal to un-blur. Capture-phase so the
753 click reveals instead of following the card link or firing htmx navigation. -->
754<script>
755// Cover fade-in: a cover image that's still loading is hidden so the accent-gradient
756// placeholder behind it shows; it fades in once loaded. Cached/complete images stay
757// visible (no flash). Runs on load + htmx swaps.
758(function () {
759 if (window.__coverFadeWired) return; window.__coverFadeWired = true;
760 function scan(root) {
761 (root || document).querySelectorAll('img.grid-tile-img, .post-list-cover img, .tl-media-img img').forEach(function (img) {
762 if (img.dataset.fade) return; img.dataset.fade = '1';
763 if (img.complete && img.naturalWidth > 0) return; // already loaded → leave visible
764 img.classList.add('is-loading');
765 var done = function () { img.classList.remove('is-loading'); };
766 img.addEventListener('load', done, { once: true });
767 img.addEventListener('error', done, { once: true });
768 });
769 }
770 scan(document);
771 document.body.addEventListener('htmx:afterSettle', function (e) { scan(e.target); });
772})();
773
774// Light anti-grab friction: suppress the right-click menu on visual media (covers, images,
775// videos) so the art isn't one right-click away from "Save as". Friction, NOT protection —
776// the files are public and reachable via devtools/network. Middle/Ctrl-click (open in new
777// tab) still works; only the context menu is blocked. Delegated → covers htmx-swapped content.
778(function () {
779 if (window.__noMediaCtxWired) return; window.__noMediaCtxWired = true;
780 document.addEventListener('contextmenu', function (e) {
781 if (e.target.closest('img, video, .grid-tile, .post-list-cover, .post-cover, .tl-media-img')) {
782 e.preventDefault();
783 }
784 });
785})();
786
787// iOS animated-cover → video: an animated WebP is janky on iOS Safari, so on iOS we swap any
788// <img data-ios-mp4="…"> for a muted, looping, inline <video> (the WebP's matching MP4). Every
789// other browser keeps the crisp WebP. Runs on load + htmx swaps.
790(function () {
791 if (window.__iosVideoWired) return; window.__iosVideoWired = true;
792 var ua = navigator.userAgent || '';
793 var IS_IOS = /iP(hone|od|ad)/.test(navigator.platform || '') || /iPad|iPhone|iPod/.test(ua) ||
794 (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1); // iPadOS reports as Mac
795 if (!IS_IOS) return;
796 function swap(root) {
797 (root || document).querySelectorAll('img[data-ios-mp4]').forEach(function (img) {
798 var mp4 = img.getAttribute('data-ios-mp4');
799 if (!mp4 || img.dataset.iosSwapped) return;
800 img.dataset.iosSwapped = '1';
801 var v = document.createElement('video');
802 v.src = mp4; v.muted = true; v.loop = true; v.autoplay = true;
803 v.setAttribute('muted', ''); v.setAttribute('playsinline', ''); v.setAttribute('webkit-playsinline', '');
804 v.poster = img.getAttribute('src') || '';
805 v.className = img.className;
806 if (img.getAttribute('style')) v.setAttribute('style', img.getAttribute('style'));
807 if (img.parentNode) img.parentNode.replaceChild(v, img);
808 var p = v.play && v.play(); if (p && p.catch) p.catch(function () {});
809 });
810 }
811 swap(document);
812 document.body.addEventListener('htmx:afterSettle', function (e) { swap(e.target); });
813})();
814</script>
815
816<script>
817(function () {
818 if (window.__nsfwWired) return; window.__nsfwWired = true;
819 document.addEventListener('click', function (e) {
820 var hit = e.target.closest && e.target.closest('.nsfw-veil, .nsfw-reveal');
821 if (!hit) return;
822 e.preventDefault(); e.stopPropagation();
823 var box = hit.closest('.nsfw-media, .nsfw-gate');
824 if (box) box.classList.add('is-shown');
825 }, true);
826})();
827</script>
828
829<script>
830// Delegated replacements for inline on* handlers, so the CSP needs no
831// script-src-attr 'unsafe-inline'. Document-level → also covers htmx-swapped content.
832(function () {
833 if (window.__pcmsHandlersWired) return; window.__pcmsHandlersWired = true;
834 // Confirm before submitting a form that carries data-confirm.
835 document.addEventListener('submit', function (e) {
836 var f = e.target;
837 if (f && f.dataset && f.dataset.confirm && !window.confirm(f.dataset.confirm)) e.preventDefault();
838 });
839 // Auto-submit a form / switch language when a <select> changes.
840 document.addEventListener('change', function (e) {
841 var el = e.target;
842 if (!el || !el.dataset) return;
843 if (el.dataset.autosubmit !== undefined && el.form) el.form.submit();
844 else if (el.dataset.langSwitch !== undefined) {
845 location.href = '/lang/' + encodeURIComponent(el.value) + '?r=' + encodeURIComponent(location.pathname + location.search);
846 }
847 });
848 // Misc click helpers (select-all in a field, history-back button).
849 document.addEventListener('click', function (e) {
850 var el = e.target.closest && e.target.closest('[data-selectall],[data-back],[data-share]');
851 if (!el) return;
852 if (el.dataset.selectall !== undefined && el.select) el.select();
853 if (el.dataset.back !== undefined) { e.preventDefault(); history.back(); }
854 if (el.dataset.share !== undefined) {
855 e.preventDefault();
856 var url = location.href, title = el.dataset.shareTitle || document.title;
857 if (navigator.share) { navigator.share({ title: title, url: url }).catch(function () {}); }
858 else if (navigator.clipboard && navigator.clipboard.writeText) {
859 navigator.clipboard.writeText(url).then(function () {
860 var fb = document.getElementById('post-share-feedback');
861 if (fb) { fb.hidden = false; setTimeout(function () { fb.hidden = true; }, 2000); }
862 }).catch(function () { window.prompt('Copy link:', url); });
863 } else { window.prompt('Copy link:', url); }
864 }
865 });
866 // Image fallback (the error event doesn't bubble → capture phase).
867 document.addEventListener('error', function (e) {
868 var el = e.target;
869 if (el && el.tagName === 'IMG' && el.dataset && el.dataset.fallback !== undefined && el.parentElement) {
870 el.parentElement.innerHTML = '<span class="pl-cover-empty">⚠️</span>';
871 }
872 }, true);
873})();
874</script>
875
876<!-- Per-site custom footer HTML -->
877<% if (safeSite.custom_foot_html) { %>
878<%- safeSite.custom_foot_html %>
879<% } %>
880
881</body>
882</html>
Note: See TracBrowser for help on using the repository browser.