| [1e172f3] | 1 | // De chrome: alles wat om elke pagina heen staat (shaer-bqr).
|
|---|
| 2 | //
|
|---|
| 3 | // Dit stond als inline <script> in de partials. Dat kan niet blijven: de
|
|---|
| 4 | // CSP-nonce rouleert per verzoek, en de chrome wordt bij ELKE htmx-navigatie
|
|---|
| 5 | // out-of-band opnieuw ingevoegd. Zo'n script draagt dan een nonce die het
|
|---|
| 6 | // document niet kent en wordt geweigerd -- dus viel de chrome-JS bij de eerste
|
|---|
| 7 | // klik binnen de site al weg (shaer-0i6).
|
|---|
| 8 | //
|
|---|
| 9 | // Nu een module, geladen door de bootstrap in shell.ejs. Die zit in het
|
|---|
| 10 | // document zelf, heeft dus wel de goede nonce, en een dynamische import vanuit
|
|---|
| 11 | // een vertrouwd script is precies waar 'strict-dynamic' voor is.
|
|---|
| 12 | //
|
|---|
| 13 | // TWEE REGELS voor alles wat hier bij komt:
|
|---|
| 14 | //
|
|---|
| 15 | // GEDELEGEERD luister op document, nooit op een element dat er nu staat. De
|
|---|
| 16 | // chrome wordt vervangen, dus een vastgehouden verwijzing is na
|
|---|
| 17 | // een navigatie een verwijzing naar iets dat weg is.
|
|---|
| 18 | // IDEMPOTENT de module kan een tweede keer geladen worden. Een slot op
|
|---|
| 19 | // window voorkomt dat er een tweede stel luisteraars bij komt --
|
|---|
| 20 | // dat is hoe de themaknop ooit twee keer vuurde en dus niets deed.
|
|---|
| 21 | //
|
|---|
| 22 | // En: GEEN servergegevens in deze code. Interpolatie hoort niet in een statisch
|
|---|
| 23 | // bestand; wat de server wil meegeven komt via een data-attribuut op een element.
|
|---|
| 24 |
|
|---|
| 25 | (function () {
|
|---|
| 26 | 'use strict';
|
|---|
| 27 | if (window.__chromeMod) return;
|
|---|
| 28 | window.__chromeMod = true;
|
|---|
| 29 |
|
|---|
| 30 | // De zoekknop in de bottom-tab opent de overlay. De overlay zelf wordt bij een
|
|---|
| 31 | // htmx-navigatie vervangen, dus hem hier vasthouden zou na een klik binnen de
|
|---|
| 32 | // site niets meer opleveren: elke keer opnieuw opzoeken.
|
|---|
| 33 | document.addEventListener('click', function (e) {
|
|---|
| 34 | if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return;
|
|---|
| 35 | var o = document.getElementById('search-overlay');
|
|---|
| 36 | if (o) {
|
|---|
| 37 | o.hidden = false;
|
|---|
| 38 | var i = o.querySelector('input');
|
|---|
| 39 | if (i) i.focus();
|
|---|
| 40 | }
|
|---|
| 41 | });
|
|---|
| 42 | })();
|
|---|
| [54ee51c] | 43 |
|
|---|
| 44 | // ── de profielkop ──────────────────────────────────────────────
|
|---|
| 45 | // Zat inline in partials/profile-header.ejs, en die partial zit IN de
|
|---|
| 46 | // OOB-chrome -- dus hij kwam bij elke navigatie opnieuw binnen.
|
|---|
| 47 | (function () {
|
|---|
| 48 | if (window.__pcmsFediFollowWired) return;
|
|---|
| 49 | window.__pcmsFediFollowWired = true;
|
|---|
| 50 | function modal() { return document.querySelector('.pf-follow'); }
|
|---|
| 51 | function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
|
|---|
| 52 | function go() {
|
|---|
| 53 | var m = modal(); if (!m) return;
|
|---|
| 54 | var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : '';
|
|---|
| 55 | // Strip a full @user@host handle (and any scheme/path) down to the server host.
|
|---|
| 56 | var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
|
|---|
| 57 | if (!server) { if (inp) inp.focus(); return; }
|
|---|
| 58 | try { localStorage.setItem('pcmsFediServer', server); } catch (e) {}
|
|---|
| 59 | var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || '');
|
|---|
| 60 | location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor);
|
|---|
| 61 | }
|
|---|
| 62 | document.addEventListener('click', function (e) {
|
|---|
| 63 | var b = e.target.closest && e.target.closest('.profile-fedi-link');
|
|---|
| 64 | if (b) { e.preventDefault();
|
|---|
| 65 | var m = modal(); if (!m) return;
|
|---|
| 66 | m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || '');
|
|---|
| 67 | var inp = m.querySelector('.pf-follow-input');
|
|---|
| 68 | try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {}
|
|---|
| 69 | m.classList.add('is-open');
|
|---|
| 70 | setTimeout(function () { if (inp) inp.focus(); }, 30);
|
|---|
| 71 | return;
|
|---|
| 72 | }
|
|---|
| 73 | if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; }
|
|---|
| 74 | if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; }
|
|---|
| 75 | var open = document.querySelector('.pf-follow.is-open');
|
|---|
| 76 | if (open && e.target === open) closeM(); /* click on backdrop */
|
|---|
| 77 | });
|
|---|
| 78 | document.addEventListener('keydown', function (e) {
|
|---|
| 79 | var m = document.querySelector('.pf-follow.is-open'); if (!m) return;
|
|---|
| 80 | if (e.key === 'Escape') { closeM(); }
|
|---|
| 81 | else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); }
|
|---|
| 82 | });
|
|---|
| 83 | })();
|
|---|
| 84 |
|
|---|
| 85 | /* Profile summary: click the avatar to open a modal with photo + details. */
|
|---|
| 86 | (function () {
|
|---|
| 87 | if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true;
|
|---|
| 88 | function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); }
|
|---|
| 89 | document.addEventListener('click', function (e) {
|
|---|
| 90 | if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; }
|
|---|
| 91 | var open = document.querySelector('.pf-summary.is-open');
|
|---|
| 92 | if (open && e.target === open) { close(); return; } /* click on backdrop */
|
|---|
| 93 | var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]');
|
|---|
| 94 | if (!t) return;
|
|---|
| 95 | e.preventDefault();
|
|---|
| 96 | var root = (t.closest && t.closest('#pcms-chrome')) || document;
|
|---|
| 97 | var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary');
|
|---|
| 98 | if (modal) modal.classList.add('is-open');
|
|---|
| 99 | });
|
|---|
| 100 | document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
|
|---|
| 101 | })();
|
|---|
| [156baa3] | 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 | }
|
|---|
| [9eb6e1b] | 163 | // Een ADRES is geen zoekterm (shaer-utpi). Dezelfde regel als lookupUri in
|
|---|
| 164 | // routes/search.js: een schema is verplicht, anders wordt zoeken naar
|
|---|
| 165 | // "soundfabrics.nl" een netwerkverzoek. De server beslist daarna nog eens,
|
|---|
| 166 | // met de rechten erbij; dit scheelt alleen een vraag die toch nee oplevert.
|
|---|
| 167 | function lookupUri(q) {
|
|---|
| 168 | if (!/^https?:\/\//i.test(q) || /\s/.test(q)) return null;
|
|---|
| 169 | try { var u = new URL(q); return u.hostname.indexOf('.') < 0 ? null : u.href; } catch (e) { return null; }
|
|---|
| 170 | }
|
|---|
| 171 | function allLink(ov, q) {
|
|---|
| 172 | var action = (ov && ov.getAttribute('data-action')) || '/search';
|
|---|
| 173 | return '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_st().all) + '</a>';
|
|---|
| 174 | }
|
|---|
| 175 | function fetchSuggest(box, q, ov, seq) {
|
|---|
| 176 | var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest';
|
|---|
| 177 | fetch(url + '?q=' + encodeURIComponent(q))
|
|---|
| 178 | .then(function(r){ return r.ok ? r.json() : null; })
|
|---|
| 179 | .then(function(d){ if (d && seq === _sSeq) renderSuggest(box, d, q, ov); })
|
|---|
| 180 | .catch(function(){});
|
|---|
| 181 | }
|
|---|
| 182 | // Elke toetsaanslag krijgt een volgnummer. Een ophaling van een andere
|
|---|
| 183 | // server duurt soms seconden; zonder nummer overschrijft een traag antwoord
|
|---|
| 184 | // op een oude vraag wat je inmiddels hebt getypt.
|
|---|
| 185 | var _sTimer, _sSeq = 0;
|
|---|
| [156baa3] | 186 | document.addEventListener('input', function(e) {
|
|---|
| 187 | var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]');
|
|---|
| 188 | if (!inp) return;
|
|---|
| 189 | var box = document.getElementById('search-suggest');
|
|---|
| 190 | if (!box) return;
|
|---|
| 191 | var q = inp.value.trim();
|
|---|
| [9eb6e1b] | 192 | var seq = ++_sSeq;
|
|---|
| [156baa3] | 193 | clearTimeout(_sTimer);
|
|---|
| 194 | if (q.length < 2) { box.innerHTML = ''; return; }
|
|---|
| [9eb6e1b] | 195 | var ov = document.getElementById('search-overlay');
|
|---|
| 196 | var address = lookupUri(q);
|
|---|
| 197 | if (!address) { _sTimer = setTimeout(function() { fetchSuggest(box, q, ov, seq); }, 200); return; }
|
|---|
| 198 | // Een geplakt adres: de post zelf, niet de tekst van de link doorzocht.
|
|---|
| 199 | box.innerHTML = '<div class="ss-remote-loading">' + esc(_st().looking) + '</div>';
|
|---|
| [156baa3] | 200 | _sTimer = setTimeout(function() {
|
|---|
| [9eb6e1b] | 201 | var url = (ov && ov.getAttribute('data-remote')) || '/search/remote';
|
|---|
| 202 | fetch(url + '?q=' + encodeURIComponent(address), { credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } })
|
|---|
| 203 | .then(function(r) {
|
|---|
| 204 | if (seq !== _sSeq) return;
|
|---|
| 205 | // 204: geen recht om op te halen (of toch geen adres). Dan is het
|
|---|
| 206 | // gewoon een zoekterm en krijg je de gewone suggesties.
|
|---|
| 207 | if (r.status === 204 || !r.ok) { fetchSuggest(box, q, ov, seq); return; }
|
|---|
| 208 | return r.text().then(function(html) { if (seq === _sSeq) box.innerHTML = html + allLink(ov, q); });
|
|---|
| 209 | })
|
|---|
| 210 | .catch(function() { if (seq === _sSeq) fetchSuggest(box, q, ov, seq); });
|
|---|
| 211 | }, 300);
|
|---|
| [156baa3] | 212 | });
|
|---|
| [9eb6e1b] | 213 | // Een klik op een suggestie sluit de overlay, want de link gaat ergens heen.
|
|---|
| 214 | // Behalve wat in een nieuw tabblad opent (het origineel, een profiel): dan
|
|---|
| 215 | // blijf je hier, en hoort de kaart er nog te staan als je terugkomt.
|
|---|
| [156baa3] | 216 | document.addEventListener('click', function(e) {
|
|---|
| [9eb6e1b] | 217 | var a = e.target.closest && e.target.closest('#search-suggest a');
|
|---|
| 218 | if (a && a.getAttribute('target') !== '_blank') { var o = overlay(); if (o) o.hidden = true; }
|
|---|
| 219 | });
|
|---|
| 220 | })();
|
|---|
| 221 |
|
|---|
| 222 | // ── De kaart van een post van een andere server ─────────────────────────
|
|---|
| 223 | // Staat hier en niet in authorize-interaction.js, want de kaart staat nu op
|
|---|
| 224 | // drie plekken: de live-preview (elke pagina, via de zoekbalk), de zoekpagina
|
|---|
| 225 | // en /authorize_interaction. Alles luistert op document, dus een kaart die
|
|---|
| 226 | // later binnenkomt (de preview, een htmx-wissel) doet vanzelf mee.
|
|---|
| 227 | (function() {
|
|---|
| 228 | if (window.__remoteCardWired) return; window.__remoteCardWired = true;
|
|---|
| 229 |
|
|---|
| 230 | // Waarderen en boosten ter plekke: POST via fetch, de knop omzetten, blijven
|
|---|
| 231 | // waar je bent. Zonder JS doet het formulier hetzelfde met een omweg.
|
|---|
| 232 | document.addEventListener('submit', function(e) {
|
|---|
| 233 | var f = e.target.closest && e.target.closest('.fedi-react-form');
|
|---|
| 234 | if (!f) return;
|
|---|
| 235 | e.preventDefault();
|
|---|
| 236 | var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
|
|---|
| 237 | btn.disabled = true;
|
|---|
| 238 | var body = new URLSearchParams();
|
|---|
| 239 | new FormData(f).forEach(function(v, k) { body.append(k, v); });
|
|---|
| 240 | fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
|
|---|
| 241 | .then(function(r) { return r.ok ? r.json() : null; })
|
|---|
| 242 | .then(function(j) {
|
|---|
| 243 | if (!j) return;
|
|---|
| 244 | var on = !!j.on;
|
|---|
| 245 | btn.classList.toggle('is-on', on);
|
|---|
| 246 | btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|---|
| 247 | var lbl = btn.querySelector('.rp-act-label');
|
|---|
| 248 | if (lbl) lbl.textContent = on ? (btn.getAttribute('data-on') || lbl.textContent) : (btn.getAttribute('data-off') || lbl.textContent);
|
|---|
| 249 | })
|
|---|
| 250 | .catch(function() {})
|
|---|
| 251 | .then(function() { btn.disabled = false; });
|
|---|
| 252 | });
|
|---|
| 253 |
|
|---|
| 254 | // Antwoorden klapt open waar de kaart staat. Staat er geen venster (de
|
|---|
| 255 | // preview), dan volgt de knop gewoon zijn link naar de volle pagina, met het
|
|---|
| 256 | // venster daar al open.
|
|---|
| 257 | document.addEventListener('click', function(e) {
|
|---|
| 258 | var open = e.target.closest && e.target.closest('[data-rp-reply]');
|
|---|
| 259 | var close = !open && e.target.closest && e.target.closest('[data-rp-reply-close]');
|
|---|
| 260 | if (!open && !close) return;
|
|---|
| 261 | var panel = document.getElementById('rp-reply');
|
|---|
| 262 | if (!panel) return;
|
|---|
| 263 | e.preventDefault();
|
|---|
| 264 | var btn = document.querySelector('[data-rp-reply][aria-controls="rp-reply"]');
|
|---|
| 265 | var show = !!open && panel.hidden;
|
|---|
| 266 | panel.hidden = !show;
|
|---|
| 267 | if (btn) btn.setAttribute('aria-expanded', show ? 'true' : 'false');
|
|---|
| 268 | if (show) {
|
|---|
| 269 | var ed = panel.querySelector('.re-editor, textarea');
|
|---|
| 270 | if (ed) ed.focus();
|
|---|
| 271 | // Op een telefoon staat het venster vaak onder de vouw.
|
|---|
| 272 | if (panel.scrollIntoView) panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|---|
| 273 | } else if (btn) {
|
|---|
| 274 | btn.focus();
|
|---|
| 275 | }
|
|---|
| [156baa3] | 276 | });
|
|---|
| 277 | })();
|
|---|
| 278 |
|
|---|
| 279 | // ── het profielblad ────────────────────────────────────────────
|
|---|
| 280 | // Zat inline in partials/profile-sheet.ejs, dat de shell opneemt.
|
|---|
| 281 | (function() {
|
|---|
| 282 | const sheet = document.getElementById('profile-sheet');
|
|---|
| 283 | if (!sheet) return;
|
|---|
| 284 |
|
|---|
| 285 | const backdrop = document.getElementById('profile-sheet-backdrop');
|
|---|
| 286 | const panel = sheet.querySelector('.profile-sheet-panel');
|
|---|
| 287 | const closeBtn = document.getElementById('profile-sheet-close');
|
|---|
| 288 | const dragZone = document.getElementById('profile-sheet-drag-zone');
|
|---|
| 289 | const themeBtn = document.getElementById('profile-sheet-theme');
|
|---|
| 290 | const themeLbl = document.getElementById('profile-sheet-theme-state');
|
|---|
| 291 |
|
|---|
| 292 | function openSheet() {
|
|---|
| 293 | sheet.classList.add('is-open');
|
|---|
| 294 | sheet.setAttribute('aria-hidden', 'false');
|
|---|
| 295 | document.body.classList.add('profile-sheet-locked');
|
|---|
| 296 | syncTheme();
|
|---|
| 297 | }
|
|---|
| 298 | function closeSheet() {
|
|---|
| 299 | sheet.classList.remove('is-open');
|
|---|
| 300 | sheet.setAttribute('aria-hidden', 'true');
|
|---|
| 301 | document.body.classList.remove('profile-sheet-locked');
|
|---|
| 302 | panel.style.removeProperty('--pcms-drag-y');
|
|---|
| 303 | }
|
|---|
| 304 |
|
|---|
| 305 | // Open: any element with [data-profile-sheet-toggle]
|
|---|
| 306 | document.addEventListener('click', function(e) {
|
|---|
| 307 | const trigger = e.target.closest('[data-profile-sheet-toggle]');
|
|---|
| 308 | if (trigger) {
|
|---|
| 309 | e.preventDefault();
|
|---|
| 310 | openSheet();
|
|---|
| 311 | }
|
|---|
| 312 | });
|
|---|
| 313 |
|
|---|
| 314 | // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item
|
|---|
| 315 | if (backdrop) backdrop.addEventListener('click', closeSheet);
|
|---|
| 316 | if (closeBtn) closeBtn.addEventListener('click', closeSheet);
|
|---|
| 317 | document.addEventListener('keydown', (e) => {
|
|---|
| 318 | if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
|
|---|
| 319 | });
|
|---|
| 320 |
|
|---|
| 321 | // Menu items that navigate: close synchronously before nav fires
|
|---|
| 322 | sheet.querySelectorAll('[data-close-sheet]').forEach((el) => {
|
|---|
| 323 | el.addEventListener('click', closeSheet);
|
|---|
| 324 | });
|
|---|
| 325 |
|
|---|
| 326 | // Drag-down-to-close on touch.
|
|---|
| 327 | // Uses --pcms-drag-y custom property rather than overwriting the panel's
|
|---|
| 328 | // transform string. This composes correctly with any horizontal centering
|
|---|
| 329 | // (currently none here, but the pattern matches audio-sheet for safety).
|
|---|
| 330 | let startY = 0, lastY = 0, dragging = false;
|
|---|
| 331 | function onDown(e) {
|
|---|
| 332 | if (e.pointerType !== 'touch') return;
|
|---|
| 333 | if (panel.scrollTop > 0) return;
|
|---|
| 334 | startY = lastY = e.clientY;
|
|---|
| 335 | dragging = true;
|
|---|
| 336 | panel.classList.add('is-dragging');
|
|---|
| 337 | }
|
|---|
| 338 | function onMove(e) {
|
|---|
| 339 | if (!dragging) return;
|
|---|
| 340 | lastY = e.clientY;
|
|---|
| 341 | const dy = Math.max(0, lastY - startY);
|
|---|
| 342 | panel.style.setProperty('--pcms-drag-y', dy + 'px');
|
|---|
| 343 | }
|
|---|
| 344 | function onUp() {
|
|---|
| 345 | if (!dragging) return;
|
|---|
| 346 | dragging = false;
|
|---|
| 347 | panel.classList.remove('is-dragging');
|
|---|
| 348 | const dy = lastY - startY;
|
|---|
| 349 | if (dy > 80) closeSheet();
|
|---|
| 350 | else panel.style.removeProperty('--pcms-drag-y');
|
|---|
| 351 | }
|
|---|
| 352 | if (window.PointerEvent && dragZone) {
|
|---|
| 353 | dragZone.addEventListener('pointerdown', onDown);
|
|---|
| 354 | document.addEventListener('pointermove', onMove);
|
|---|
| 355 | document.addEventListener('pointerup', onUp);
|
|---|
| 356 | document.addEventListener('pointercancel', onUp);
|
|---|
| 357 | }
|
|---|
| 358 |
|
|---|
| 359 | // Theme toggle inside sheet — syncs label
|
|---|
| 360 | function syncTheme() {
|
|---|
| 361 | if (!themeLbl) return;
|
|---|
| 362 | const t = document.documentElement.getAttribute('data-theme') || 'dark';
|
|---|
| 363 | // De twee labels staan op het element zelf: een module kan geen vertaling
|
|---|
| 364 | // interpoleren, en zo hoort de tekst bij het ding dat hem toont.
|
|---|
| 365 | themeLbl.textContent = themeLbl.getAttribute(t === 'dark' ? 'data-dark' : 'data-light') || themeLbl.textContent;
|
|---|
| 366 | }
|
|---|
| 367 | if (themeBtn) {
|
|---|
| 368 | themeBtn.addEventListener('click', function() {
|
|---|
| 369 | const cur = document.documentElement.getAttribute('data-theme') || 'dark';
|
|---|
| 370 | const next = cur === 'dark' ? 'light' : 'dark';
|
|---|
| 371 | document.documentElement.setAttribute('data-theme', next);
|
|---|
| 372 | try { localStorage.setItem('pcms-theme', next); } catch (e) {}
|
|---|
| 373 | syncTheme();
|
|---|
| 374 | });
|
|---|
| 375 | }
|
|---|
| 376 | })();
|
|---|