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

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

auth: read-only view accounts (view everything, modify nothing)

New users.readonly column + readonly in the session. Global guard in server.js
blocks every state-modifying method (POST/PUT/PATCH/DELETE) for read-only
accounts -> no comments, saves, settings, nothing. GET remains free, so they
can view everything (including admin panels). Sticky "read-only demo" banner in
the shell when such an account is logged in.

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

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