Changeset 156baa3 in Klonkt
- Timestamp:
- 08/07/2026 01:38:51 PM (5 weeks ago)
- Branches:
- main
- Children:
- 6ee289a
- Parents:
- 52fc278
- git-author:
- Robin <roboburr@…> (08/07/2026 01:26:06 PM)
- git-committer:
- roboburr <roboburr@…> (08/07/2026 01:38:51 PM)
- Location:
- src
- Files:
-
- 5 added
- 12 edited
-
assets/js/mod/admin-media.js (added)
-
assets/js/mod/admin-playlists.js (added)
-
assets/js/mod/admin-videos.js (added)
-
assets/js/mod/chrome.js (modified) (1 diff)
-
assets/js/mod/paid-gate.js (added)
-
assets/js/mod/paid-passkey.js (added)
-
routes/admin-media.js (modified) (2 diffs)
-
routes/admin-playlists.js (modified) (1 diff)
-
routes/paid.js (modified) (1 diff)
-
routes/posts.js (modified) (1 diff)
-
views/pages/admin-media.ejs (modified) (1 diff)
-
views/pages/admin-playlists.ejs (modified) (1 diff)
-
views/pages/admin-videos.ejs (modified) (1 diff)
-
views/pages/paid-gate.ejs (modified) (2 diffs)
-
views/pages/paid-passkey.ejs (modified) (2 diffs)
-
views/partials/profile-sheet.ejs (modified) (2 diffs)
-
views/partials/topnav.ejs (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/js/mod/chrome.js
r52fc278 r156baa3 100 100 document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); }); 101 101 })(); 102 103 // ── de topnav ────────────────────────────────────────────────── 104 // Zat inline in partials/topnav.ejs. De zoekteksten komen nu van het 105 // overlay-element (data-i18n) in plaats van uit interpolatie. 106 (function() { 107 // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation 108 // → without this guard the script would stack EXTRA listeners on every navigation, 109 // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working"). 110 // Everything below uses event delegation on body/document, so it also works for 111 // buttons that appear after this run (OOB). 112 if (window.__pcmsChromeWired) return; 113 window.__pcmsChromeWired = true; 114 115 function toggleTheme() { 116 var cur = document.documentElement.getAttribute('data-theme') || 'dark'; 117 var next = cur === 'dark' ? 'light' : 'dark'; 118 document.documentElement.setAttribute('data-theme', next); 119 try { localStorage.setItem('pcms-theme', next); } catch (e) {} 120 } 121 function overlay() { return document.getElementById('search-overlay'); } 122 function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } } 123 function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } } 124 125 document.body.addEventListener('click', function(e) { 126 if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; } 127 if (e.target.closest('#search-toggle')) { openSearch(); return; } 128 if (e.target.closest('#search-close')) { closeSearch(); return; } 129 // Close open dropdowns (user menu + language picker) on click outside. 130 document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) { 131 if (!d.contains(e.target)) d.removeAttribute('open'); 132 }); 133 }); 134 135 document.addEventListener('keydown', function(e) { 136 if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); } 137 }); 138 139 // ── Live results while typing ─────────────────────────────────── 140 // De teksten komen van de overlay zelf, elke keer opnieuw: bij een 141 // htmx-navigatie wordt die vervangen en kan de taal gewisseld zijn. 142 function _st() { 143 var o = document.getElementById('search-overlay'); 144 try { return JSON.parse((o && o.getAttribute('data-i18n')) || '{}'); } catch (e) { return {}; } 145 } 146 function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); } 147 function renderSuggest(box, d, q, ov) { 148 var html = ''; 149 function grp(title, items, fmt) { 150 if (!items || !items.length) return; 151 html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>'; 152 } 153 grp(_st().posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; }); 154 grp(_st().tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; }); 155 grp(_st().events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; }); 156 grp(_st().pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; }); 157 var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length); 158 if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_st().none) + '</div>'; return; } 159 var action = (ov && ov.getAttribute('data-action')) || '/search'; 160 html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_st().all) + '</a>'; 161 box.innerHTML = html; 162 } 163 var _sTimer; 164 document.addEventListener('input', function(e) { 165 var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]'); 166 if (!inp) return; 167 var box = document.getElementById('search-suggest'); 168 if (!box) return; 169 var q = inp.value.trim(); 170 clearTimeout(_sTimer); 171 if (q.length < 2) { box.innerHTML = ''; return; } 172 _sTimer = setTimeout(function() { 173 var ov = document.getElementById('search-overlay'); 174 var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest'; 175 fetch(url + '?q=' + encodeURIComponent(q)) 176 .then(function(r){ return r.ok ? r.json() : null; }) 177 .then(function(d){ if (d) renderSuggest(box, d, q, ov); }) 178 .catch(function(){}); 179 }, 200); 180 }); 181 // Click on a suggestion → close the overlay (the link/boost handles navigation). 182 document.addEventListener('click', function(e) { 183 if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; } 184 }); 185 })(); 186 187 // ── het profielblad ──────────────────────────────────────────── 188 // Zat inline in partials/profile-sheet.ejs, dat de shell opneemt. 189 (function() { 190 const sheet = document.getElementById('profile-sheet'); 191 if (!sheet) return; 192 193 const backdrop = document.getElementById('profile-sheet-backdrop'); 194 const panel = sheet.querySelector('.profile-sheet-panel'); 195 const closeBtn = document.getElementById('profile-sheet-close'); 196 const dragZone = document.getElementById('profile-sheet-drag-zone'); 197 const themeBtn = document.getElementById('profile-sheet-theme'); 198 const themeLbl = document.getElementById('profile-sheet-theme-state'); 199 200 function openSheet() { 201 sheet.classList.add('is-open'); 202 sheet.setAttribute('aria-hidden', 'false'); 203 document.body.classList.add('profile-sheet-locked'); 204 syncTheme(); 205 } 206 function closeSheet() { 207 sheet.classList.remove('is-open'); 208 sheet.setAttribute('aria-hidden', 'true'); 209 document.body.classList.remove('profile-sheet-locked'); 210 panel.style.removeProperty('--pcms-drag-y'); 211 } 212 213 // Open: any element with [data-profile-sheet-toggle] 214 document.addEventListener('click', function(e) { 215 const trigger = e.target.closest('[data-profile-sheet-toggle]'); 216 if (trigger) { 217 e.preventDefault(); 218 openSheet(); 219 } 220 }); 221 222 // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item 223 if (backdrop) backdrop.addEventListener('click', closeSheet); 224 if (closeBtn) closeBtn.addEventListener('click', closeSheet); 225 document.addEventListener('keydown', (e) => { 226 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet(); 227 }); 228 229 // Menu items that navigate: close synchronously before nav fires 230 sheet.querySelectorAll('[data-close-sheet]').forEach((el) => { 231 el.addEventListener('click', closeSheet); 232 }); 233 234 // Drag-down-to-close on touch. 235 // Uses --pcms-drag-y custom property rather than overwriting the panel's 236 // transform string. This composes correctly with any horizontal centering 237 // (currently none here, but the pattern matches audio-sheet for safety). 238 let startY = 0, lastY = 0, dragging = false; 239 function onDown(e) { 240 if (e.pointerType !== 'touch') return; 241 if (panel.scrollTop > 0) return; 242 startY = lastY = e.clientY; 243 dragging = true; 244 panel.classList.add('is-dragging'); 245 } 246 function onMove(e) { 247 if (!dragging) return; 248 lastY = e.clientY; 249 const dy = Math.max(0, lastY - startY); 250 panel.style.setProperty('--pcms-drag-y', dy + 'px'); 251 } 252 function onUp() { 253 if (!dragging) return; 254 dragging = false; 255 panel.classList.remove('is-dragging'); 256 const dy = lastY - startY; 257 if (dy > 80) closeSheet(); 258 else panel.style.removeProperty('--pcms-drag-y'); 259 } 260 if (window.PointerEvent && dragZone) { 261 dragZone.addEventListener('pointerdown', onDown); 262 document.addEventListener('pointermove', onMove); 263 document.addEventListener('pointerup', onUp); 264 document.addEventListener('pointercancel', onUp); 265 } 266 267 // Theme toggle inside sheet — syncs label 268 function syncTheme() { 269 if (!themeLbl) return; 270 const t = document.documentElement.getAttribute('data-theme') || 'dark'; 271 // De twee labels staan op het element zelf: een module kan geen vertaling 272 // interpoleren, en zo hoort de tekst bij het ding dat hem toont. 273 themeLbl.textContent = themeLbl.getAttribute(t === 'dark' ? 'data-dark' : 'data-light') || themeLbl.textContent; 274 } 275 if (themeBtn) { 276 themeBtn.addEventListener('click', function() { 277 const cur = document.documentElement.getAttribute('data-theme') || 'dark'; 278 const next = cur === 'dark' ? 'light' : 'dark'; 279 document.documentElement.setAttribute('data-theme', next); 280 try { localStorage.setItem('pcms-theme', next); } catch (e) {} 281 syncTheme(); 282 }); 283 } 284 })(); -
src/routes/admin-media.js
r52fc278 r156baa3 81 81 .sort((a, b) => b._mtime - a._mtime); // newest first 82 82 renderPage(req, res, 'pages/admin-media', { 83 pageJs: 'admin-media', 83 84 pageTitleKey: 'admin.t_media', 84 85 bodyClass: 'on-admin', … … 122 123 if (!site) return res.status(404).send('Site required'); 123 124 renderPage(req, res, 'pages/admin-videos', { 125 pageJs: 'admin-videos', 124 126 pageTitleKey: 'admin.t_media', 125 127 bodyClass: 'on-admin', -
src/routes/admin-playlists.js
r52fc278 r156baa3 62 62 const playlists = PlaylistService.list(site.id); 63 63 renderPage(req, res, 'pages/admin-playlists', { 64 pageJs: 'admin-playlists', 64 65 pageTitleKey: 'admin.t_playlists', 65 66 playlists, -
src/routes/paid.js
r52fc278 r156baa3 76 76 const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900); 77 77 renderPage(req, res, 'pages/paid-passkey', { 78 pageJs: 'paid-passkey', 78 79 pageTitleKey: 'ppk.t', bodyClass: 'on-post', 79 80 optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post, -
src/routes/posts.js
r52fc278 r156baa3 1392 1392 const { newerPost, olderPost } = postNeighbors(site, post); 1393 1393 return renderPage(req, res, 'pages/paid-gate', { 1394 pageJs: 'paid-gate', 1394 1395 pageTitle: post.title || 'Voor supporters', 1395 1396 bodyClass: 'on-special', -
src/views/pages/admin-media.ejs
r52fc278 r156baa3 41 41 </div> 42 42 43 <script> 44 (function () { 45 if (window.__mediaWired) return; window.__mediaWired = true; 46 var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') }) %>; 47 document.addEventListener('click', function (e) { 48 var c = e.target.closest('[data-copy]'); 49 if (c) { 50 var u = location.origin + c.getAttribute('data-copy'); 51 var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); }; 52 if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); }); 53 else window.prompt('URL', u); 54 return; 55 } 56 var d = e.target.closest('[data-del]'); 57 if (d) { 58 if (!window.confirm(T.delC)) return; 59 fetch('/admin/media/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) }) 60 .then(function (r) { return r.json(); }) 61 .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } else window.alert((j && j.error) || 'Error'); }) 62 .catch(function () { window.alert('Error'); }); 63 return; 64 } 65 if (e.target.closest('#media-cleanup')) { 66 if (!window.confirm(T.cleanC)) return; 67 fetch('/admin/media/cleanup', { method: 'POST', credentials: 'same-origin' }) 68 .then(function (r) { return r.json(); }) 69 .then(function (j) { location.href = '/admin/media?success=' + encodeURIComponent(((j && j.removed) || 0) + ' file(s) removed'); }) 70 .catch(function () { window.alert('Error'); }); 71 } 72 }); 73 })(); 74 </script> 43 <%# Het script staat in assets/js/mod/admin-media.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 44 <%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') } }) %> -
src/views/pages/admin-playlists.ejs
r52fc278 r156baa3 222 222 </style> 223 223 224 <script> 225 (function() { 226 const csrf = '<%= _csrf %>'; 227 228 document.getElementById('pl-new-btn')?.addEventListener('click', () => { 229 if (typeof window.openPlaylistEditor === 'function') { 230 window.openPlaylistEditor({ mode: 'create', onSaved: () => location.reload() }); 231 } 232 }); 233 234 // Click-to-copy on shortcodes 235 document.querySelectorAll('[data-copy]').forEach(el => { 236 el.addEventListener('click', async () => { 237 try { 238 await navigator.clipboard.writeText(el.dataset.copy); 239 el.classList.add('is-copied'); 240 const original = el.textContent; 241 el.textContent = '✓ <%= t('apl.copied') %>'; 242 setTimeout(() => { el.classList.remove('is-copied'); el.textContent = original; }, 1200); 243 } catch (_) { /* fall back to selection */ } 244 }); 245 }); 246 247 document.querySelectorAll('[data-pl-edit]').forEach(btn => { 248 btn.addEventListener('click', () => { 249 if (typeof window.openPlaylistEditor === 'function') { 250 window.openPlaylistEditor({ mode: 'edit', id: btn.dataset.id, onSaved: () => location.reload() }); 251 } 252 }); 253 }); 254 255 document.querySelectorAll('[data-pl-delete]').forEach(btn => { 256 btn.addEventListener('click', async () => { 257 const id = btn.dataset.id; 258 const title = btn.dataset.title || id; 259 if (!confirm('<%= t('apl.delete_confirm') %>'.replace('{title}', title))) return; 260 try { 261 const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, { 262 method: 'POST', 263 headers: { 'X-CSRF-Token': csrf }, 264 credentials: 'same-origin', 265 }); 266 const j = await r.json(); 267 if (j.ok) location.reload(); 268 else alert('<%= t('apl.delete_failed') %>: ' + (j.error || '')); 269 } catch (err) { 270 alert('<%= t('apl.delete_failed') %>: ' + err.message); 271 } 272 }); 273 }); 274 275 // P52 — deep-link from playlist embed (?edit=<id>) auto-opens the editor. 276 // openPlaylistEditor is defined synchronously by the included partial, so 277 // it's available by the time this IIFE runs. 278 (function deepLinkEdit() { 279 const params = new URLSearchParams(location.search); 280 const editId = params.get('edit'); 281 if (!editId) return; 282 if (typeof window.openPlaylistEditor !== 'function') return; 283 // Strip the query param immediately so reload after save doesn't re-open. 284 history.replaceState({}, '', location.pathname); 285 window.openPlaylistEditor({ 286 mode: 'edit', 287 id: editId, 288 onSaved: () => location.reload(), 289 }); 290 })(); 291 })(); 292 </script> 224 <%# Het script staat in assets/js/mod/admin-playlists.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 225 <%- include('../partials/page-data', { pageData: { csrf: _csrf, copied: t('apl.copied'), delConfirm: t('apl.delete_confirm'), delFailed: t('apl.delete_failed') } }) %> -
src/views/pages/admin-videos.ejs
r52fc278 r156baa3 40 40 </div> 41 41 42 <script> 43 (function () { 44 if (window.__videosWired) return; window.__videosWired = true; 45 var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') }) %>; 46 document.addEventListener('click', function (e) { 47 var c = e.target.closest('[data-copy]'); 48 if (c) { 49 var u = location.origin + c.getAttribute('data-copy'); 50 var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); }; 51 if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); }); 52 else window.prompt('URL', u); 53 return; 54 } 55 var d = e.target.closest('[data-del]'); 56 if (d) { 57 if (!window.confirm(T.delC)) return; 58 fetch('/admin/media/videos/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) }) 59 .then(function (r) { return r.json(); }) 60 .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } }) 61 .catch(function () {}); 62 } 63 }); 64 })(); 65 </script> 42 <%# Het script staat in assets/js/mod/admin-videos.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 43 <%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') } }) %> -
src/views/pages/paid-gate.ejs
r52fc278 r156baa3 31 31 32 32 <script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script> 33 <script> 34 (function () { 35 var base = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>"; 36 var slug = "<%= pgSlug %>"; 37 var hasPatron = <%= _hasPatron ? 'true' : 'false' %>; 38 var I = { join: "<%= t('pgate.join_short') %>", confirm: "<%= t('pgate.confirm') %>", failed: "<%= t('pgate.failed') %>", error: "<%= t('pgate.error') %>" }; 39 var btn = document.getElementById('pg-unlock'); 40 var status = document.getElementById('pg-status'); 41 function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); } 42 function toLink() { location.href = base + '/paid/link?post=' + encodeURIComponent(slug); } 43 44 // No WebAuthn here: an assertion is impossible. With a Patreon page there is 45 // already a "Word supporter" button, so hide the (dead) unlock button rather 46 // than turn it into a second "Word supporter". Without one, this IS the button. 47 if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) { 48 if (hasPatron) { btn.style.display = 'none'; } 49 else { btn.textContent = I.join; btn.addEventListener('click', toLink); } 50 return; 51 } 52 53 btn.addEventListener('click', function () { 54 btn.disabled = true; 55 say(I.confirm); 56 fetch(base + '/paid/challenge?post=' + encodeURIComponent(slug)) 57 .then(function (r) { if (!r.ok) throw { link: true }; return r.json(); }) 58 .then(function (data) { 59 return window.SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: data.options }) 60 .then(function (response) { 61 return fetch(base + '/paid/unlock', { 62 method: 'POST', headers: { 'Content-Type': 'application/json' }, 63 body: JSON.stringify({ response: response, blob: data.blob }), 64 }); 65 }); 66 }) 67 .then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); }) 68 .then(function (res) { 69 if (res.j && res.j.ok && res.j.redirect) { 70 // Reload the real post page via the one-shot unlock capability, so it 71 // renders through its normal template (layout, styles, audio). 72 location.href = res.j.redirect; 73 } else if (res.status === 403) { 74 toLink(); // no valid passkey yet (or lapsed tier): link via Patreon 75 } else { 76 btn.disabled = false; say(I.failed, true); 77 } 78 }) 79 .catch(function (e) { 80 if (e && e.link) { toLink(); return; } 81 if (e && e.name === 'NotAllowedError') { toLink(); return; } // cancelled / no passkey -> link 82 btn.disabled = false; say(I.error, true); 83 }); 84 }); 85 })(); 86 </script> 33 <%# Het script staat in assets/js/mod/paid-gate.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 87 34 88 35 <style> … … 105 52 .pg-status.is-err { color: #c0392b; } 106 53 </style> 54 <%- include('../partials/page-data', { pageData: { base: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : ''), slug: pgSlug, hasPatron: !!_hasPatron, i18n: { join: t('pgate.join_short'), confirm: t('pgate.confirm'), failed: t('pgate.failed'), error: t('pgate.error') } } }) %> -
src/views/pages/paid-passkey.ejs
r52fc278 r156baa3 12 12 13 13 <script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script> 14 <script> 15 (function () { 16 var options = <%- optionsJson %>; 17 var blob = "<%= regBlob %>"; 18 var I = { unsupported: "<%= t('ppk.unsupported') %>", follow: "<%= t('ppk.follow') %>", done: "<%= t('ppk.done') %>", failed: "<%= t('ppk.failed') %>", cancelled: "<%= t('ppk.cancelled') %>", error: "<%= t('pgate.error') %>" }; 19 var postUrl = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>"; 20 var btn = document.getElementById('pk-go'); 21 var status = document.getElementById('pk-status'); 22 function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); } 23 24 if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) { 25 btn.disabled = true; 26 say(I.unsupported, true); 27 return; 28 } 29 btn.addEventListener('click', function () { 30 btn.disabled = true; 31 say(I.follow); 32 window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: options }) 33 .then(function (response) { 34 return fetch('/paid/register', { 35 method: 'POST', headers: { 'Content-Type': 'application/json' }, 36 body: JSON.stringify({ response: response, blob: blob }), 37 }); 38 }) 39 .then(function (r) { return r.json(); }) 40 .then(function (j) { 41 if (j && j.ok) { say(I.done); setTimeout(function () { location.href = postUrl; }, 900); } 42 else { btn.disabled = false; say(I.failed.replace('{err}', (j && j.error) || '?'), true); } 43 }) 44 .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? I.cancelled : I.error, true); }); 45 }); 46 })(); 47 </script> 14 <%# Het script staat in assets/js/mod/paid-passkey.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 48 15 49 16 <style> … … 58 25 .pk-status.is-err { color: #c0392b; } 59 26 </style> 27 <%- include('../partials/page-data', { pageData: { options: JSON.parse(optionsJson), blob: regBlob, i18n: { unsupported: t('ppk.unsupported'), follow: t('ppk.follow'), done: t('ppk.done'), failed: t('ppk.failed'), cancelled: t('ppk.cancelled'), error: t('pgate.error') }, postUrl: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') + '/' + postSlug } }) %> -
src/views/partials/profile-sheet.ejs
r52fc278 r156baa3 105 105 <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg> 106 106 <span class="psi-label"><%= t('nav.theme_label') %></span> 107 <span class="psi-meta" id="profile-sheet-theme-state" ><%= t('nav.dark') %></span>107 <span class="psi-meta" id="profile-sheet-theme-state" data-dark="<%= t('nav.dark') %>" data-light="<%= t('nav.light') %>"><%= t('nav.dark') %></span> 108 108 </button> 109 109 </li> … … 326 326 </style> 327 327 328 <script> 329 (function() { 330 const sheet = document.getElementById('profile-sheet'); 331 if (!sheet) return; 332 333 const backdrop = document.getElementById('profile-sheet-backdrop'); 334 const panel = sheet.querySelector('.profile-sheet-panel'); 335 const closeBtn = document.getElementById('profile-sheet-close'); 336 const dragZone = document.getElementById('profile-sheet-drag-zone'); 337 const themeBtn = document.getElementById('profile-sheet-theme'); 338 const themeLbl = document.getElementById('profile-sheet-theme-state'); 339 340 function openSheet() { 341 sheet.classList.add('is-open'); 342 sheet.setAttribute('aria-hidden', 'false'); 343 document.body.classList.add('profile-sheet-locked'); 344 syncTheme(); 345 } 346 function closeSheet() { 347 sheet.classList.remove('is-open'); 348 sheet.setAttribute('aria-hidden', 'true'); 349 document.body.classList.remove('profile-sheet-locked'); 350 panel.style.removeProperty('--pcms-drag-y'); 351 } 352 353 // Open: any element with [data-profile-sheet-toggle] 354 document.addEventListener('click', function(e) { 355 const trigger = e.target.closest('[data-profile-sheet-toggle]'); 356 if (trigger) { 357 e.preventDefault(); 358 openSheet(); 359 } 360 }); 361 362 // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item 363 if (backdrop) backdrop.addEventListener('click', closeSheet); 364 if (closeBtn) closeBtn.addEventListener('click', closeSheet); 365 document.addEventListener('keydown', (e) => { 366 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet(); 367 }); 368 369 // Menu items that navigate: close synchronously before nav fires 370 sheet.querySelectorAll('[data-close-sheet]').forEach((el) => { 371 el.addEventListener('click', closeSheet); 372 }); 373 374 // Drag-down-to-close on touch. 375 // Uses --pcms-drag-y custom property rather than overwriting the panel's 376 // transform string. This composes correctly with any horizontal centering 377 // (currently none here, but the pattern matches audio-sheet for safety). 378 let startY = 0, lastY = 0, dragging = false; 379 function onDown(e) { 380 if (e.pointerType !== 'touch') return; 381 if (panel.scrollTop > 0) return; 382 startY = lastY = e.clientY; 383 dragging = true; 384 panel.classList.add('is-dragging'); 385 } 386 function onMove(e) { 387 if (!dragging) return; 388 lastY = e.clientY; 389 const dy = Math.max(0, lastY - startY); 390 panel.style.setProperty('--pcms-drag-y', dy + 'px'); 391 } 392 function onUp() { 393 if (!dragging) return; 394 dragging = false; 395 panel.classList.remove('is-dragging'); 396 const dy = lastY - startY; 397 if (dy > 80) closeSheet(); 398 else panel.style.removeProperty('--pcms-drag-y'); 399 } 400 if (window.PointerEvent && dragZone) { 401 dragZone.addEventListener('pointerdown', onDown); 402 document.addEventListener('pointermove', onMove); 403 document.addEventListener('pointerup', onUp); 404 document.addEventListener('pointercancel', onUp); 405 } 406 407 // Theme toggle inside sheet — syncs label 408 function syncTheme() { 409 if (!themeLbl) return; 410 const t = document.documentElement.getAttribute('data-theme') || 'dark'; 411 themeLbl.textContent = t === 'dark' ? '<%= t('nav.dark') %>' : '<%= t('nav.light') %>'; 412 } 413 if (themeBtn) { 414 themeBtn.addEventListener('click', function() { 415 const cur = document.documentElement.getAttribute('data-theme') || 'dark'; 416 const next = cur === 'dark' ? 'light' : 'dark'; 417 document.documentElement.setAttribute('data-theme', next); 418 try { localStorage.setItem('pcms-theme', next); } catch (e) {} 419 syncTheme(); 420 }); 421 } 422 })(); 423 </script> 328 <%# Het script van het profielblad staat in assets/js/mod/chrome.js (shaer-bqr). %> -
src/views/partials/topnav.ejs
r52fc278 r156baa3 176 176 177 177 <!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) --> 178 <div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search"> 178 <%# De teksten staan op het element dat ze gebruikt, niet in het script: een 179 module is een statisch bestand en kan geen vertaling interpoleren. %> 180 <div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search" 181 data-i18n="<%= JSON.stringify({ posts: t('search.section_posts'), tracks: t('search.section_tracks'), events: t('search.section_events'), pages: t('search.section_pages'), all: t('search.suggest_all'), none: t('search.suggest_empty') }) %>"> 179 182 <div class="container"> 180 183 <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search"> … … 191 194 </div> 192 195 193 <script> 194 (function() { 195 // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation 196 // → without this guard the script would stack EXTRA listeners on every navigation, 197 // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working"). 198 // Everything below uses event delegation on body/document, so it also works for 199 // buttons that appear after this run (OOB). 200 if (window.__pcmsChromeWired) return; 201 window.__pcmsChromeWired = true; 202 203 function toggleTheme() { 204 var cur = document.documentElement.getAttribute('data-theme') || 'dark'; 205 var next = cur === 'dark' ? 'light' : 'dark'; 206 document.documentElement.setAttribute('data-theme', next); 207 try { localStorage.setItem('pcms-theme', next); } catch (e) {} 208 } 209 function overlay() { return document.getElementById('search-overlay'); } 210 function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } } 211 function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } } 212 213 document.body.addEventListener('click', function(e) { 214 if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; } 215 if (e.target.closest('#search-toggle')) { openSearch(); return; } 216 if (e.target.closest('#search-close')) { closeSearch(); return; } 217 // Close open dropdowns (user menu + language picker) on click outside. 218 document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) { 219 if (!d.contains(e.target)) d.removeAttribute('open'); 220 }); 221 }); 222 223 document.addEventListener('keydown', function(e) { 224 if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); } 225 }); 226 227 // ── Live results while typing ─────────────────────────────────── 228 var _ST = { 229 posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>', 230 events: '<%= t('search.section_events') %>', pages: '<%= t('search.section_pages') %>', 231 all: '<%= t('search.suggest_all') %>', none: '<%= t('search.suggest_empty') %>' 232 }; 233 function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); } 234 function renderSuggest(box, d, q, ov) { 235 var html = ''; 236 function grp(title, items, fmt) { 237 if (!items || !items.length) return; 238 html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>'; 239 } 240 grp(_ST.posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; }); 241 grp(_ST.tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; }); 242 grp(_ST.events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; }); 243 grp(_ST.pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; }); 244 var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length); 245 if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_ST.none) + '</div>'; return; } 246 var action = (ov && ov.getAttribute('data-action')) || '/search'; 247 html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_ST.all) + '</a>'; 248 box.innerHTML = html; 249 } 250 var _sTimer; 251 document.addEventListener('input', function(e) { 252 var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]'); 253 if (!inp) return; 254 var box = document.getElementById('search-suggest'); 255 if (!box) return; 256 var q = inp.value.trim(); 257 clearTimeout(_sTimer); 258 if (q.length < 2) { box.innerHTML = ''; return; } 259 _sTimer = setTimeout(function() { 260 var ov = document.getElementById('search-overlay'); 261 var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest'; 262 fetch(url + '?q=' + encodeURIComponent(q)) 263 .then(function(r){ return r.ok ? r.json() : null; }) 264 .then(function(d){ if (d) renderSuggest(box, d, q, ov); }) 265 .catch(function(){}); 266 }, 200); 267 }); 268 // Click on a suggestion → close the overlay (the link/boost handles navigation). 269 document.addEventListener('click', function(e) { 270 if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; } 271 }); 272 })(); 273 </script> 196 <%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %> 274 197 275 198 <style>
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)