Changeset 156baa3 in Klonkt


Ignore:
Timestamp:
08/07/2026 01:38:51 PM (5 weeks ago)
Author:
roboburr <roboburr@…>
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)
Message:

De chrome en vier schermen met servergegevens (shaer-bqr, stap 3e)

topnav en profile-sheet naar mod/chrome.js; admin-media, admin-videos, paid-gate,
paid-passkey en admin-playlists naar eigen modules.

TWEE MANIEREN om servergegevens bij een module te krijgen, en de keuze is niet
willekeurig:

OP HET ELEMENT waar de tekst bij EEN ding hoort. De zoekteksten staan nu op

#search-overlay, de twee themalabels op het label zelf. Dat is
ook robuuster: bij een htmx-navigatie wordt de chrome
vervangen, en dan komt de tekst gewoon mee.

VIA pageData() waar het om paginabrede gegevens gaat (csrf, slug, een blob,

een setje meldingen).

De chrome kon niet via pageData: die leeft langer dan een pagina.

DRIE FOUTEN ONDERWEG, alle drie gevangen voordat er iets stuk ging:

  1. Ik zocht de eerste </script> in het HELE bestand in plaats van die na het openende tag. paid-gate en paid-passkey hebben een <script src> erboven, dus het "blok" werd leeg en er werd een lege module geschreven. Teruggezet uit git en de zoekfunctie kreeg een offset plus een controle dat het blok niet leeg is.
  2. Een zoekterm sloot te vroeg af: de tekst van delete_failed loopt door in dezelfde JS-string (": " + err.message), dus het patroon met de sluitquote matchte niet.
  3. Een eerdere poging verving nul regels en schreef toch. Nu telt de helper de vervangingen en weigert als het er niet exact zoveel zijn als opgegeven.

Die controle op "geen enkele module bevat nog EJS" draait mee, en er is er nu een
bij die een verdacht lege module afvangt -- precies wat fout 1 opleverde.

Templates compileren, suite 565/565.

Location:
src
Files:
5 added
12 edited

Legend:

Unmodified
Added
Removed
  • src/assets/js/mod/chrome.js

    r52fc278 r156baa3  
    100100  document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
    101101})();
     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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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  
    8181    .sort((a, b) => b._mtime - a._mtime); // newest first
    8282  renderPage(req, res, 'pages/admin-media', {
     83    pageJs: 'admin-media',
    8384    pageTitleKey: 'admin.t_media',
    8485    bodyClass: 'on-admin',
     
    122123  if (!site) return res.status(404).send('Site required');
    123124  renderPage(req, res, 'pages/admin-videos', {
     125    pageJs: 'admin-videos',
    124126    pageTitleKey: 'admin.t_media',
    125127    bodyClass: 'on-admin',
  • src/routes/admin-playlists.js

    r52fc278 r156baa3  
    6262  const playlists = PlaylistService.list(site.id);
    6363  renderPage(req, res, 'pages/admin-playlists', {
     64    pageJs: 'admin-playlists',
    6465    pageTitleKey: 'admin.t_playlists',
    6566    playlists,
  • src/routes/paid.js

    r52fc278 r156baa3  
    7676  const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900);
    7777  renderPage(req, res, 'pages/paid-passkey', {
     78    pageJs: 'paid-passkey',
    7879    pageTitleKey: 'ppk.t', bodyClass: 'on-post',
    7980    optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
  • src/routes/posts.js

    r52fc278 r156baa3  
    13921392    const { newerPost, olderPost } = postNeighbors(site, post);
    13931393    return renderPage(req, res, 'pages/paid-gate', {
     1394    pageJs: 'paid-gate',
    13941395      pageTitle: post.title || 'Voor supporters',
    13951396      bodyClass: 'on-special',
  • src/views/pages/admin-media.ejs

    r52fc278 r156baa3  
    4141</div>
    4242
    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  
    222222</style>
    223223
    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  
    4040</div>
    4141
    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  
    3131
    3232<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). %>
    8734
    8835<style>
     
    10552  .pg-status.is-err { color: #c0392b; }
    10653</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  
    1212
    1313<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). %>
    4815
    4916<style>
     
    5825  .pk-status.is-err { color: #c0392b; }
    5926</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  
    105105          <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>
    106106          <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>
    108108        </button>
    109109      </li>
     
    326326</style>
    327327
    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  
    176176
    177177<!-- 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') }) %>">
    179182  <div class="container">
    180183    <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search">
     
    191194</div>
    192195
    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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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). %>
    274197
    275198<style>
Note: See TracChangeset for help on using the changeset viewer.