Changes in / [a29c8c8:7cfe35a] in Klonkt


Ignore:
Location:
src
Files:
21 added
36 edited

Legend:

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

    ra29c8c8 r7cfe35a  
    4141  });
    4242})();
     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})();
     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-audio.js

    ra29c8c8 r7cfe35a  
    111111  const embedUrl = base + (res.locals.siteUrlBase || '') + '/embed';
    112112  renderPage(req, res, 'pages/admin-audio', {
     113    // admin-audio neemt de track-editor op, dus die module hoort erbij.
     114    pageJs: 'admin-audio track-editor',
    113115    pageTitleKey: 'admin.t_audio',
    114116    bodyClass: 'on-admin',
  • src/routes/admin-epk.js

    ra29c8c8 r7cfe35a  
    3333  try { const r = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]'); if (Array.isArray(r)) chosen = r; } catch (e) {}
    3434  renderPage(req, res, 'pages/admin-epk', {
     35    pageJs: 'admin-epk',
    3536    pageTitleKey: 'admin.t_epk',
    3637    bodyClass: 'on-admin',
  • src/routes/admin-media.js

    ra29c8c8 r7cfe35a  
    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-paid.js

    ra29c8c8 r7cfe35a  
    3636  if (!gate(req, res)) return;
    3737  renderPage(req, res, 'pages/admin-paid', {
     38    pageJs: 'admin-paid',
    3839    pageTitleKey: 'apaid.t',
    3940    bodyClass: 'on-admin',
  • src/routes/admin-playlists.js

    ra29c8c8 r7cfe35a  
    6262  const playlists = PlaylistService.list(site.id);
    6363  renderPage(req, res, 'pages/admin-playlists', {
     64    // admin-playlists neemt de playlist-editor op, dus die module hoort erbij.
     65    pageJs: 'admin-playlists playlist-editor',
    6466    pageTitleKey: 'admin.t_playlists',
    6567    playlists,
  • src/routes/admin-push.js

    ra29c8c8 r7cfe35a  
    1212router.get('/', requireSiteManager, async (req, res) => {
    1313  renderPage(req, res, 'pages/admin-push', {
     14    pageJs: 'admin-push',
    1415    pageTitleKey: 'push.t',
    1516    bodyClass: 'on-admin',
  • src/routes/admin-settings.js

    ra29c8c8 r7cfe35a  
    6666router.get('/', requireGod, (req, res) => {
    6767  renderPage(req, res, 'pages/admin-settings', {
     68    pageJs: 'admin-settings',
    6869    pageTitleKey: 'admin.t_settings',
    6970    bodyClass: 'on-admin',
  • src/routes/admin-sites.js

    ra29c8c8 r7cfe35a  
    200200router.get('/new', requireGod, (req, res) => {
    201201  renderPage(req, res, 'pages/admin-site-edit', {
     202    pageJs: 'admin-site-edit',
    202203    pageTitleKey: 'admin.t_newsite',
    203204    bodyClass: 'on-admin',
     
    283284
    284285  renderPage(req, res, 'pages/admin-site-edit', {
     286    pageJs: 'admin-site-edit',
    285287    pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
    286288    bodyClass: 'on-admin',
  • src/routes/admin.js

    ra29c8c8 r7cfe35a  
    8787router.get('/handleiding', requireAuth, (req, res) => {
    8888  renderPage(req, res, 'pages/admin-help', {
     89    pageJs: 'admin-help',
    8990    pageTitleKey: 'admin.t_manual',
    9091    bodyClass: 'on-admin',
  • src/routes/auth.js

    ra29c8c8 r7cfe35a  
    115115  if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
    116116  renderPage(req, res, 'pages/auth-register', {
     117    pageJs: 'auth-register',
    117118    pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
    118119    error: null, username: '', email: '', siteName: '', next,
     
    124125  const next = safeNext(req.body.next) || '';
    125126  const renderErr = (error) => renderPage(req, res, 'pages/auth-register', {
     127    pageJs: 'auth-register',
    126128    pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
    127129    error, username: username || '', email: email || '', siteName: siteName || '', next,
  • src/routes/download.js

    ra29c8c8 r7cfe35a  
    8585  const fan = req.session && req.session.user;
    8686  renderPage(req, res, 'pages/download', {
     87    pageJs: 'download',
    8788    pageTitle: track.title + ' — download',
    8889    bodyClass: 'on-download',
  • src/routes/paid.js

    ra29c8c8 r7cfe35a  
    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

    ra29c8c8 r7cfe35a  
    256256
    257257  renderPage(req, res, 'pages/post-edit', {
     258    // post-edit neemt de playlist-editor op.
     259    pageJs: 'post-edit playlist-editor',
    258260    post: {
    259261      id: uuid(),
     
    795797  }
    796798  renderPage(req, res, 'pages/authorize-interaction', {
     799    pageJs: 'authorize-interaction',
    797800    pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
    798801    bodyClass: 'on-special',
     
    11931196  }
    11941197  renderPage(req, res, 'pages/news', {
     1198    pageJs: 'news',
    11951199    pageTitle: 'News', bodyClass: 'on-special',
    11961200    timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
     
    13901394    const { newerPost, olderPost } = postNeighbors(site, post);
    13911395    return renderPage(req, res, 'pages/paid-gate', {
     1396    pageJs: 'paid-gate',
    13921397      pageTitle: post.title || 'Voor supporters',
    13931398      bodyClass: 'on-special',
  • src/views/pages/admin-audio.ejs

    ra29c8c8 r7cfe35a  
    573573<%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    574574
    575 <script>
    576 (function() {
    577 
    578   // ── Live filename display for custom file inputs ──────────────
    579   document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
    580     const nameEl = input.parentElement.querySelector('.ax-file-name');
    581     if (!nameEl) return;
    582     input.addEventListener('change', () => {
    583       if (input.files && input.files[0]) {
    584         nameEl.textContent = input.files[0].name;
    585         nameEl.removeAttribute('data-empty');
    586       } else {
    587         nameEl.textContent = '<%= t('aaud.no_file') %>';
    588         nameEl.setAttribute('data-empty', '');
    589       }
    590     });
    591   });
    592 
    593   // ── Inline track preview (routed through the global mini-player) ──
    594   // Each .ax-track-play button is a thin wrapper around the global
    595   // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
    596   // .is-playing) is synced from the player's own audio element so it
    597   // stays accurate even when the user uses the mini-player's controls.
    598   (function setupTrackPreview() {
    599     const buttons = document.querySelectorAll('.ax-track-play');
    600     if (!buttons.length) return;
    601 
    602     function setIcon(btn, playing) {
    603       const icon = btn.querySelector('.ax-track-play-icon');
    604       if (icon) icon.textContent = playing ? '⏸' : '▶';
    605       btn.classList.toggle('is-playing', playing);
    606       btn.setAttribute('aria-label', playing ? '<%= t('aaud.pause') %>' : '<%= t('aaud.play') %>');
    607     }
    608 
    609     // Resync ALL preview buttons against the current audio element state.
    610     // Called on every play/pause/ended event so admins always see the right
    611     // icon — including the case where they hit pause on the mini-player
    612     // bar instead of the row's button.
    613     function resyncAll() {
    614       const audio = document.getElementById('audio-element');
    615       const player = window.pcmsAudioPlayer;
    616       const playing = audio && !audio.paused && !audio.ended;
    617       // audio.src is now a blob: URL (Spotify-style playback), so compare
    618       // against the player's logical track URL, not the element src.
    619       const cur = player && player.currentTrack();
    620       const curUrl = cur ? cur.url : '';
    621       buttons.forEach(b => {
    622         const isThisOne = playing && curUrl === b.dataset.streamUrl;
    623         setIcon(b, isThisOne);
    624       });
    625     }
    626 
    627     buttons.forEach(btn => {
    628       btn.addEventListener('click', e => {
    629         e.preventDefault();
    630         const player = window.pcmsAudioPlayer;
    631         if (!player) {
    632           console.warn('[admin-audio] miniplayer not available');
    633           return;
    634         }
    635         const url = btn.dataset.streamUrl;
    636         if (!url) return;
    637 
    638         // Build track metadata from the row's DOM so the mini-player shows
    639         // useful info (title/artist/album/cover) without an extra API call.
    640         const row = btn.closest('.ax-track');
    641         const titleEl  = row && row.querySelector('[data-cell="title"]');
    642         const artistEl = row && row.querySelector('[data-cell="artist"]');
    643         const albumEl  = row && row.querySelector('[data-cell="album"]');
    644         const coverImg = row && row.querySelector('img[data-cover-thumb]');
    645         const track = {
    646           url,
    647           title:  titleEl  ? titleEl.textContent.trim()  : 'Track',
    648           artist: artistEl ? artistEl.textContent.trim() : '',
    649           album:  albumEl  ? albumEl.textContent.trim()  : '',
    650           cover:  coverImg ? coverImg.src                : '',
    651         };
    652 
    653         // If this exact track is already current, toggle pause/play instead
    654         // of restarting from zero. Compare logical URLs (audio.src is a blob:).
    655         const cur = player.currentTrack();
    656         if (cur && cur.url === url) {
    657           if (player.isPlaying()) player.pause();
    658           else                    player.play();
    659           return;
    660         }
    661 
    662         player.setQueue([track], 0);
    663       });
    664     });
    665 
    666     // Hook the global audio element's events to keep the row buttons synced.
    667     // We attach lazily after the player has built its DOM. The script in
    668     // shell.ejs runs at page-load so #audio-element exists by the time
    669     // this IIFE fires (script tag is below the body content).
    670     const audio = document.getElementById('audio-element');
    671     if (audio) {
    672       ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
    673         audio.addEventListener(ev, resyncAll);
    674       });
    675       // Initial state on page load (e.g. user navigated back while a track
    676       // was already playing — buttons should reflect that).
    677       resyncAll();
    678     }
    679   })();
    680 
    681 
    682   // ── Bulk upload (drag-drop + sequential transcoding) ─────────
    683   // Files dropped or picked are queued (not uploaded immediately) so the
    684   // user can review the list, set shared metadata, then hit "Start upload".
    685   // The loop POSTs one file at a time to /admin/audio/upload with
    686   // Accept: application/json so the server returns structured per-file
    687   // results instead of redirecting.
    688   const dropzone   = document.getElementById('audio-dropzone');
    689   const fileInput  = document.getElementById('audio-files');
    690   const queueEl    = document.getElementById('upload-queue');
    691   const actionsEl  = document.getElementById('upload-actions');
    692   const startBtn   = document.getElementById('start-upload-btn');
    693   const clearBtn   = document.getElementById('clear-queue-btn');
    694   const artistInput= document.getElementById('batch-artist');
    695   const albumInput = document.getElementById('batch-album');
    696   const coverInput = document.getElementById('batch-cover');
    697 
    698   /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
    699   const queue = [];
    700 
    701   function fmtBytes(n) {
    702     if (n < 1024) return n + ' B';
    703     if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
    704     return (n / 1024 / 1024).toFixed(1) + ' MB';
    705   }
    706 
    707   function setItemStatus(item, status, label) {
    708     const labels = {
    709       queued:      '<%= t('aaud.st_queued') %>',
    710       uploading:   '<%= t('aaud.st_uploading') %>',
    711       transcoding: '<%= t('aaud.st_transcoding') %>',
    712       done:        '✓ <%= t('aaud.st_done') %>',
    713       error:       '✗ <%= t('aaud.st_error') %>',
    714     };
    715     item.status = status;
    716     const badge = item.el.querySelector('.ax-queue-status');
    717     badge.className = 'ax-queue-status ax-queue-status--' + status;
    718     badge.textContent = label || labels[status] || status;
    719     // Restyle the row
    720     item.el.classList.remove(
    721       'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
    722     );
    723     if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
    724     else if (status === 'done')  item.el.classList.add('ax-queue-item--done');
    725     else if (status === 'error') item.el.classList.add('ax-queue-item--error');
    726   }
    727 
    728   function addFiles(files) {
    729     let added = 0;
    730     for (const file of files) {
    731       if (!file.type.startsWith('audio/') &&
    732           !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
    733         // Silently skip non-audio drops; keeps the UX uncluttered.
    734         continue;
    735       }
    736       const li = document.createElement('li');
    737       li.className = 'ax-queue-item';
    738       li.innerHTML =
    739         '<div class="ax-queue-name"></div>' +
    740         '<span class="ax-queue-status ax-queue-status--queued"><%= t('aaud.st_queued') %></span>';
    741       // Use textContent to avoid HTML-injection if a filename contains markup.
    742       li.querySelector('.ax-queue-name').textContent = file.name;
    743       // Append size hint inline
    744       const size = document.createElement('span');
    745       size.className = 'ax-queue-size';
    746       size.textContent = fmtBytes(file.size);
    747       li.querySelector('.ax-queue-name').appendChild(size);
    748       queueEl.appendChild(li);
    749       queue.push({ file, el: li, status: 'queued' });
    750       added++;
    751     }
    752     if (added) {
    753       queueEl.hidden = false;
    754       actionsEl.hidden = false;
    755     }
    756   }
    757 
    758   // ── Drop-zone events ─────────────────────────────────────────
    759   // Page-level guard: a dropped file outside the zone would otherwise
    760   // make the browser navigate to it (e.g. opening the audio inline), which
    761   // discards typed metadata. We swallow drops anywhere unless the dropzone
    762   // explicitly handles them.
    763   ['dragover', 'drop'].forEach(ev => {
    764     window.addEventListener(ev, e => {
    765       // Allow drops INSIDE the dropzone — its own listener handles those.
    766       if (dropzone.contains(e.target)) return;
    767       e.preventDefault();
    768     });
    769   });
    770 
    771   ['dragenter', 'dragover'].forEach(ev => {
    772     dropzone.addEventListener(ev, e => {
    773       e.preventDefault();
    774       dropzone.classList.add('is-dragover');
    775     });
    776   });
    777   ['dragleave', 'drop'].forEach(ev => {
    778     dropzone.addEventListener(ev, e => {
    779       e.preventDefault();
    780       dropzone.classList.remove('is-dragover');
    781     });
    782   });
    783   dropzone.addEventListener('drop', e => {
    784     if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
    785   });
    786   fileInput.addEventListener('change', () => {
    787     addFiles(fileInput.files);
    788     // Reset so the same file can be picked again later if user wants
    789     fileInput.value = '';
    790   });
    791 
    792   // ── Clear queue button ───────────────────────────────────────
    793   clearBtn.addEventListener('click', () => {
    794     // Only remove items that aren't currently uploading (anything queued
    795     // or already finished). An in-progress upload finishes, then its row
    796     // would also disappear once we re-render — but we keep it simple and
    797     // just refuse to clear during an active run.
    798     if (startBtn.disabled) return;
    799     queue.length = 0;
    800     queueEl.innerHTML = '';
    801     queueEl.hidden = true;
    802     actionsEl.hidden = true;
    803   });
    804 
    805   // ── Sequential upload loop ───────────────────────────────────
    806   startBtn.addEventListener('click', async () => {
    807     if (startBtn.disabled) return;
    808     startBtn.disabled = true;
    809     clearBtn.disabled = true;
    810     dropzone.style.pointerEvents = 'none';
    811     dropzone.style.opacity = '0.5';
    812 
    813     const sharedArtist = artistInput.value.trim();
    814     const sharedAlbum  = albumInput.value.trim();
    815     const sharedCover  = coverInput.files && coverInput.files[0];
    816 
    817     // Process queued items one at a time. We iterate via index so that
    818     // if more files get dropped during the run they ALSO get processed
    819     // (queue.push above mutates the same array we're iterating).
    820     for (let i = 0; i < queue.length; i++) {
    821       const item = queue[i];
    822       if (item.status !== 'queued') continue;
    823       try {
    824         await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
    825       } catch (err) {
    826         console.error('upload failed for', item.file.name, err);
    827         setItemStatus(item, 'error', '✗ ' + (err.message || '<%= t('aaud.failed') %>'));
    828       }
    829     }
    830 
    831     startBtn.disabled = false;
    832     clearBtn.disabled = false;
    833     dropzone.style.pointerEvents = '';
    834     dropzone.style.opacity = '';
    835 
    836     // Reload the page so the new tracks appear in the list below.
    837     // Could also fetch them and inject, but a full reload is simpler and
    838     // ensures position indexes / album-grouping are correct.
    839     const anyDone = queue.some(q => q.status === 'done');
    840     if (anyDone) {
    841       setTimeout(() => location.reload(), 700);
    842     }
    843   });
    844 
    845   async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
    846     setItemStatus(item, 'uploading');
    847 
    848     const fd = new FormData();
    849     fd.append('audio', item.file);
    850     if (sharedArtist) fd.append('artist', sharedArtist);
    851     if (sharedAlbum)  fd.append('album',  sharedAlbum);
    852     if (sharedCover)  fd.append('cover',  sharedCover);
    853     // Title is intentionally omitted — server uses filename fallback.
    854 
    855     // We can't reliably distinguish "still uploading bytes" from
    856     // "uploading done, ffmpeg running" without progress events, but the
    857     // status flips to "Converteren…" once the request is past upload phase.
    858     // We approximate this by waiting until the response arrives — by then
    859     // both phases are complete on the server side. For a smoother feel we
    860     // briefly show "transcoding" near the end of the request lifecycle.
    861     const transcodeHint = setTimeout(() => {
    862       if (item.status === 'uploading') setItemStatus(item, 'transcoding');
    863     }, 1500);
    864 
    865     try {
    866       const res = await fetch('/admin/audio/upload', {
    867         method: 'POST',
    868         headers: { 'Accept': 'application/json' },
    869         body: fd,
    870         credentials: 'same-origin',
    871       });
    872       clearTimeout(transcodeHint);
    873 
    874       // Server responds with JSON for our Accept header. If it didn't
    875       // (e.g. session expired and got an HTML login page), surface that.
    876       let data;
    877       try { data = await res.json(); }
    878       catch (_) { throw new Error('<%= t('aaud.err_unexpected') %> (' + res.status + ')'); }
    879 
    880       if (!res.ok || !data.ok) {
    881         throw new Error(data.error || ('HTTP ' + res.status));
    882       }
    883 
    884       setItemStatus(item, 'done', '✓ ' + (data.title || '<%= t('aaud.st_done') %>'));
    885     } catch (err) {
    886       clearTimeout(transcodeHint);
    887       throw err;
    888     }
    889   }
    890 
    891   // ── Click-to-copy embed codes ─────────────────────────────────
    892   document.querySelectorAll('[data-copy]').forEach(el => {
    893     el.addEventListener('click', async () => {
    894       const text = el.dataset.copy;
    895       try {
    896         await navigator.clipboard.writeText(text);
    897         el.classList.add('is-copied');
    898         const original = el.textContent;
    899         el.textContent = '✓ <%= t('aaud.copied') %>';
    900         setTimeout(() => {
    901           el.classList.remove('is-copied');
    902           el.textContent = original;
    903         }, 1200);
    904       } catch (_) { /* fall through — selection still works */ }
    905     });
    906   });
    907 
    908   // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
    909   const addLinkBtn = document.getElementById('add-link-track-btn');
    910   if (addLinkBtn) {
    911     addLinkBtn.addEventListener('click', async () => {
    912       addLinkBtn.disabled = true;
    913       try {
    914         const r = await fetch('/admin/audio/create-link', {
    915           method: 'POST', credentials: 'same-origin',
    916           headers: { 'Content-Type': 'application/json' },
    917           body: JSON.stringify({ title: '<%= t('aaud.new_track') %>' }),
    918         });
    919         const j = await r.json();
    920         if (!r.ok || !j.ok) throw new Error(j.error || '<%= t('aaud.create_failed') %>');
    921         if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
    922         window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
    923       } catch (err) {
    924         alert('<%= t('aaud.create_failed') %>: ' + err.message);
    925       } finally {
    926         addLinkBtn.disabled = false;
    927       }
    928     });
    929   }
    930 
    931   // ── Wire all "Edit" buttons to the track-editor modal ─────────
    932   // After save we patch the row in-place rather than reloading,
    933   // so the user keeps their scroll position on long lists.
    934   document.querySelectorAll('[data-track-edit]').forEach(btn => {
    935     btn.addEventListener('click', () => {
    936       if (typeof window.openTrackEditor !== 'function') {
    937         alert('<%= t('aaud.editor_not_loaded') %>');
    938         return;
    939       }
    940       const id = btn.dataset.id;
    941       window.openTrackEditor({
    942         id,
    943         onSaved: (track) => {
    944           const row = document.querySelector('li[data-track-id="' + id + '"]');
    945           if (!row) return;
    946           // Update visible cells
    947           const titleEl  = row.querySelector('[data-cell="title"]');
    948           const artistEl = row.querySelector('[data-cell="artist"]');
    949           const albumEl  = row.querySelector('[data-cell="album"]');
    950           if (titleEl)  titleEl.textContent  = track.title  || '<%= t('aaud.untitled') %>';
    951           if (artistEl) artistEl.textContent = track.artist || '—';
    952           if (albumEl)  {
    953             albumEl.textContent = track.album || '';
    954             if (track.album) albumEl.removeAttribute('hidden');
    955             else albumEl.setAttribute('hidden', '');
    956           }
    957           // Update cover thumb (replace element if type changed)
    958           const oldThumb = row.querySelector('[data-cover-thumb]');
    959           if (oldThumb) {
    960             const parent = oldThumb.parentElement;
    961             if (track.cover_url) {
    962               const img = document.createElement('img');
    963               img.className = 'ax-track-cover';
    964               img.src = track.cover_url;
    965               img.alt = '';
    966               img.dataset.coverThumb = '';
    967               parent.replaceChild(img, oldThumb);
    968             } else {
    969               const sp = document.createElement('span');
    970               sp.className = 'ax-track-cover ax-track-cover-empty';
    971               sp.textContent = '♫';
    972               sp.dataset.coverThumb = '';
    973               parent.replaceChild(sp, oldThumb);
    974             }
    975           }
    976         },
    977       });
    978     });
    979   });
    980 
    981   // Download-voor-email toggle — AJAX (geen pagina-reload meer).
    982   document.querySelectorAll('[data-track-dl]').forEach(btn => {
    983     btn.addEventListener('click', async () => {
    984       if (btn.disabled) return;
    985       const want = btn.dataset.on === '1' ? 0 : 1;
    986       btn.disabled = true;
    987       try {
    988         const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
    989           method: 'POST',
    990           headers: { 'Content-Type': 'application/json' },
    991           body: JSON.stringify({ downloadable: !!want }),
    992         });
    993         if (!res.ok) throw new Error('HTTP ' + res.status);
    994         btn.dataset.on = String(want);
    995         btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
    996         btn.style.opacity = want ? '1' : '.5';
    997         btn.title = want
    998           ? '<%= t('aaud.dl_on') %>'
    999           : '<%= t('aaud.dl_off') %>';
    1000       } catch (e) {
    1001         alert('<%= t('aaud.change_failed') %>: ' + (e.message || e));
    1002       } finally {
    1003         btn.disabled = false;
    1004       }
    1005     });
    1006   });
    1007 })();
    1008 </script>
     575<%# Het script staat in assets/js/mod/admin-audio.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     576<%- include('../partials/page-data', { pageData: { change_failed: t('aaud.change_failed'), copied: t('aaud.copied'), create_failed: t('aaud.create_failed'), dl_off: t('aaud.dl_off'), dl_on: t('aaud.dl_on'), editor_not_loaded: t('aaud.editor_not_loaded'), err_unexpected: t('aaud.err_unexpected'), failed: t('aaud.failed'), new_track: t('aaud.new_track'), no_file: t('aaud.no_file'), pause: t('aaud.pause'), play: t('aaud.play'), st_done: t('aaud.st_done'), st_error: t('aaud.st_error'), st_queued: t('aaud.st_queued'), st_transcoding: t('aaud.st_transcoding'), st_uploading: t('aaud.st_uploading'), untitled: t('aaud.untitled') } }) %>
  • src/views/pages/admin-epk.ejs

    ra29c8c8 r7cfe35a  
    9090  .admin-page .epk-pick-item input:disabled ~ .epk-pick-title { opacity: 0.45; }
    9191</style>
    92 <script>
    93 (function () {
    94   var box = document.querySelector('.epk-track-pick');
    95   if (!box) return;
    96   var max = parseInt(box.dataset.max, 10) || 5;
    97   function sync() {
    98     var checks = box.querySelectorAll('input[type=checkbox]');
    99     var n = box.querySelectorAll('input[type=checkbox]:checked').length;
    100     checks.forEach(function (c) { c.disabled = (!c.checked && n >= max); });
    101   }
    102   box.addEventListener('change', sync);
    103   sync();
    104 })();
    105 </script>
     92<%# Het script van deze pagina staat in assets/js/mod/admin-epk.js (shaer-bqr). %>
  • src/views/pages/admin-help.ejs

    ra29c8c8 r7cfe35a  
    145145  .hb-empty { color: var(--ink-muted, var(--ink-soft)); padding: 1rem; text-align: center; }
    146146</style>
    147 <script>
    148 (function () {
    149   var q = document.getElementById('hb-q');
    150   var items = [].slice.call(document.querySelectorAll('.hb-item'));
    151   var empty = document.getElementById('hb-empty');
    152   var count = document.getElementById('hb-count');
    153   if (!q) return;
    154   // Bewaar de originele tekst per item (voor highlight-reset).
    155   items.forEach(function (it) { it._txt = it.textContent.toLowerCase(); });
    156   function run() {
    157     var term = q.value.trim().toLowerCase();
    158     var shown = 0;
    159     items.forEach(function (it) {
    160       var hit = !term || it._txt.indexOf(term) >= 0;
    161       it.classList.toggle('hb-hidden', !hit);
    162       if (hit) shown++;
    163     });
    164     empty.hidden = shown !== 0;
    165     count.textContent = term ? (shown + ' onderwerp' + (shown === 1 ? '' : 'en') + ' gevonden') : '';
    166   }
    167   q.addEventListener('input', run);
    168 })();
    169 </script>
     147<%# Het script van deze pagina staat in assets/js/mod/admin-help.js (shaer-bqr). %>
  • src/views/pages/admin-media.ejs

    ra29c8c8 r7cfe35a  
    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-paid.ejs

    ra29c8c8 r7cfe35a  
    7272</div>
    7373
    74 <script>
    75 (function () {
    76   var field = document.getElementById('pd-redirect');
    77   var btn = document.getElementById('pd-copy');
    78   if (!field || !btn) return;
    79   field.addEventListener('focus', function () { field.select(); });
    80   btn.addEventListener('click', function () {
    81     field.select();
    82     var done = function () { var t = btn.textContent; btn.textContent = document.getElementById('pd-copy').getAttribute('data-copied'); setTimeout(function () { btn.textContent = t; }, 1400); };
    83     if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(field.value).then(done, function () { try { document.execCommand('copy'); done(); } catch (e) {} }); }
    84     else { try { document.execCommand('copy'); done(); } catch (e) {} }
    85   });
    86 })();
    87 </script>
     74<%# Het script van deze pagina staat in assets/js/mod/admin-paid.js (shaer-bqr). %>
  • src/views/pages/admin-playlists.ejs

    ra29c8c8 r7cfe35a  
    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-push.ejs

    ra29c8c8 r7cfe35a  
    5858  i18n: { on: t('push.state_on'), off: t('push.state_off'), denied: t('push.state_denied'), unknown: t('push.state_unknown'), unsupported: t('push.state_unsupported'), failed: t('push.enable_failed') },
    5959}).replace(/</g, '\\u003c') %></script>
    60 <script>
    61 (function () {
    62   var cfg = JSON.parse(document.getElementById('np-data').textContent);
    63   var elState = document.getElementById('np-state');
    64   var btnOn = document.getElementById('np-on'), btnOff = document.getElementById('np-off'), btnTest = document.getElementById('np-test');
    65   var alertsBox = document.getElementById('np-alerts'), savedMsg = document.getElementById('np-saved');
    66   var currentEndpoint = null;
    67 
    68   var isIos = /iPad|iPhone|iPod/.test(navigator.userAgent);
    69   var standalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
    70   if (isIos && !standalone) document.getElementById('np-ios-hint').hidden = false;
    71 
    72   if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
    73     document.getElementById('np-unsupported').hidden = false;
    74     elState.textContent = cfg.i18n.unsupported;
    75     return;
    76   }
    77 
    78   function b64ToU8(s) {
    79     var pad = '='.repeat((4 - (s.length % 4)) % 4);
    80     var raw = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/'));
    81     var out = new Uint8Array(raw.length);
    82     for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
    83     return out;
    84   }
    85   function post(url, body) {
    86     return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
    87   }
    88   function deviceLabel() {
    89     var ua = navigator.userAgent;
    90     var browser = /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
    91     var os = /Android/.test(ua) ? 'Android' : /iPad|iPhone|iPod/.test(ua) ? 'iOS' : /Mac/.test(ua) ? 'macOS' : /Win/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : '';
    92     return (browser + (os ? ' op ' + os : ''));
    93   }
    94   function readAlertBoxes() {
    95     var out = {};
    96     alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { out[cb.getAttribute('data-alert')] = cb.checked ? 1 : 0; });
    97     return out;
    98   }
    99   function setAlertBoxes(alerts) {
    100     alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) {
    101       cb.checked = !!alerts[cb.getAttribute('data-alert')];
    102     });
    103   }
    104 
    105   function render(sub) {
    106     currentEndpoint = sub ? sub.endpoint : null;
    107     elState.textContent = sub ? cfg.i18n.on : (Notification.permission === 'denied' ? cfg.i18n.denied : cfg.i18n.off);
    108     btnOn.hidden = !!sub || Notification.permission === 'denied';
    109     btnOff.hidden = !sub;
    110     btnTest.hidden = !sub;
    111     alertsBox.hidden = !sub;
    112   }
    113 
    114   navigator.serviceWorker.ready.then(function (reg) {
    115     return reg.pushManager.getSubscription();
    116   }).then(function (sub) {
    117     // Show this device's SAVED prefs when we know them, defaults otherwise.
    118     setAlertBoxes((sub && cfg.saved[sub.endpoint]) ? Object.assign({}, cfg.alerts, cfg.saved[sub.endpoint]) : cfg.alerts);
    119     render(sub);
    120   }).catch(function () { elState.textContent = cfg.i18n.unknown; });
    121 
    122   btnOn.addEventListener('click', function () {
    123     btnOn.disabled = true;
    124     Notification.requestPermission().then(function (perm) {
    125       if (perm !== 'granted') { btnOn.disabled = false; render(null); return; }
    126       navigator.serviceWorker.ready.then(function (reg) {
    127         return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToU8(cfg.vapid) });
    128       }).then(function (sub) {
    129         return post('/push/subscribe', { subscription: sub.toJSON(), alerts: readAlertBoxes(), uaLabel: deviceLabel() })
    130           .then(function (r) { if (!r.ok) throw new Error('subscribe failed'); render(sub); location.reload(); });
    131       }).catch(function () { btnOn.disabled = false; elState.textContent = cfg.i18n.failed; });
    132     });
    133   });
    134 
    135   btnOff.addEventListener('click', function () {
    136     navigator.serviceWorker.ready.then(function (reg) { return reg.pushManager.getSubscription(); }).then(function (sub) {
    137       if (!sub) { render(null); return; }
    138       var ep = sub.endpoint;
    139       sub.unsubscribe().then(function () { return post('/push/unsubscribe', { endpoint: ep }); })
    140         .then(function () { location.reload(); });
    141     });
    142   });
    143 
    144   btnTest.addEventListener('click', function () {
    145     btnTest.disabled = true;
    146     post('/push/test').then(function () { setTimeout(function () { btnTest.disabled = false; }, 2000); });
    147   });
    148 
    149   alertsBox.addEventListener('change', function () {
    150     if (!currentEndpoint) return;
    151     post('/push/alerts', { endpoint: currentEndpoint, alerts: readAlertBoxes() }).then(function (r) {
    152       if (r.ok) { savedMsg.hidden = false; setTimeout(function () { savedMsg.hidden = true; }, 1500); }
    153     });
    154   });
    155 
    156   document.querySelectorAll('.np-remove').forEach(function (btn) {
    157     btn.addEventListener('click', function () {
    158       post('/push/unsubscribe', { endpoint: btn.getAttribute('data-endpoint') }).then(function () { location.reload(); });
    159     });
    160   });
    161 })();
    162 </script>
     60<%# Het script van deze pagina staat in assets/js/mod/admin-push.js (shaer-bqr). %>
    16361<% } %>
  • src/views/pages/admin-settings.ejs

    ra29c8c8 r7cfe35a  
    2727      <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div>
    2828    </form>
    29     <script>
    30     (function () {
    31       var f = document.getElementById('mode-form');
    32       if (!f || f.__age18wired) return; f.__age18wired = true;
    33       f.addEventListener('submit', function (e) {
    34         var sel = f.querySelector('input[name=mode]:checked');
    35         if (sel && sel.value === 'cirkels' && f.getAttribute('data-was-cirkels') === '0') {
    36           if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault();
    37         }
    38       });
    39     })();
    40     </script>
     29    <%# Het script van deze pagina staat in assets/js/mod/admin-settings.js (shaer-bqr). %>
    4130  </section>
    4231
  • src/views/pages/admin-site-edit.ejs

    ra29c8c8 r7cfe35a  
    126126    <%# Live preview: selecting an accent / theme / palette re-themes this page
    127127        instantly (reverts on reload; persists on save). %>
    128     <script>
    129     (function(){
    130       if (window.__themePreviewWired) return; window.__themePreviewWired = true;
    131       var html = document.documentElement;
    132       function applyAccent(hex){
    133         if(!/^#[0-9a-fA-F]{6}$/.test(hex)) return;
    134         var sa = document.getElementById('pcms-site-accent');
    135         if(!sa){ sa = document.createElement('style'); sa.id = 'pcms-site-accent'; document.head.appendChild(sa); }
    136         sa.textContent = ':root,[data-palette]{--accent:'+hex+';--accent-soft:color-mix(in srgb,'+hex+' 80%,white);--accent-tint:color-mix(in srgb,'+hex+' 12%,transparent);}';
    137       }
    138       document.addEventListener('change', function(e){
    139         var t = e.target; if(!t || !t.name) return;
    140         if(t.name === 'palette'){ html.setAttribute('data-palette', t.value); }
    141         else if(t.name === 'accent'){ applyAccent(t.value); }
    142         else if(t.name === 'theme_override'){
    143           if(t.value === 'light' || t.value === 'dark'){ html.setAttribute('data-theme', t.value); }
    144           else { var dk = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; html.setAttribute('data-theme', dk ? 'dark' : 'light'); }
    145         }
    146       });
    147     })();
    148     </script>
     128    <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %>
    149129
    150130    <fieldset>
     
    275255</div>
    276256
    277 <script>
    278 (function() {
    279   // Slug URL field: show the real host as a dimmed prefix (the slug itself is
    280   // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>.
    281   var sh = document.getElementById('slug-host');
    282   if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/');
    283 })();
    284 (function() {
    285   // Profile-links repeater: add row from <template>, remove on click.
    286   var rows = document.getElementById('profile-links-rows');
    287   var tpl  = document.getElementById('profile-link-template');
    288   var add  = document.getElementById('profile-link-add');
    289   if (!rows || !tpl || !add) return;
    290 
    291   add.addEventListener('click', function() {
    292     var clone = tpl.content.cloneNode(true);
    293     rows.appendChild(clone);
    294   });
    295   rows.addEventListener('click', function(e) {
    296     if (e.target && e.target.classList.contains('pl-remove')) {
    297       var row = e.target.closest('.profile-link-row');
    298       if (row) row.remove();
    299     }
    300   });
    301 })();
    302 
    303 // P63 — Profile photo picker: upload via /admin/sites/upload-photo,
    304 // then write the returned URL into the visible input. Live thumb preview.
    305 (function() {
    306   var picker  = document.getElementById('photo-picker');
    307   if (!picker) return;
    308   var thumb   = document.getElementById('photo-thumb');
    309   var preview = document.getElementById('photo-preview');
    310   var urlEl   = document.getElementById('photo-url');
    311   var trigger = document.getElementById('photo-upload-trigger');
    312   var clear   = document.getElementById('photo-clear');
    313   var field   = document.getElementById('photo-upload-field');
    314   var status  = document.getElementById('photo-status');
    315 
    316   function showPreview(url) {
    317     if (url) {
    318       preview.src = url;
    319       preview.hidden = false;
    320       thumb.removeAttribute('data-empty');
    321       clear.hidden = false;
    322     } else {
    323       preview.src = '';
    324       preview.hidden = true;
    325       thumb.setAttribute('data-empty', '');
    326       clear.hidden = true;
    327     }
    328   }
    329 
    330   if (urlEl) {
    331     urlEl.addEventListener('input', function() {
    332       showPreview(urlEl.value.trim());
    333     });
    334   }
    335 
    336   if (trigger && field) {
    337     trigger.addEventListener('click', function() { field.click(); });
    338     field.addEventListener('change', async function() {
    339       var file = field.files && field.files[0];
    340       if (!file) return;
    341       status.classList.remove('is-error');
    342       status.textContent = 'Uploaden…';
    343       try {
    344         var fd = new FormData();
    345         fd.append('photo', file);
    346         var r = await fetch('/admin/sites/upload-photo', {
    347           method: 'POST',
    348           body: fd,
    349           credentials: 'same-origin',
    350         });
    351         var j = await r.json();
    352         if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')'));
    353         urlEl.value = j.url;
    354         showPreview(j.url);
    355         status.textContent = 'Geüpload ✓';
    356         setTimeout(function() { status.textContent = ''; }, 2000);
    357       } catch (e) {
    358         status.classList.add('is-error');
    359         status.textContent = 'Mislukt: ' + e.message;
    360       } finally {
    361         field.value = '';
    362       }
    363     });
    364   }
    365 
    366   if (clear) {
    367     clear.addEventListener('click', function() {
    368       urlEl.value = '';
    369       showPreview('');
    370       status.textContent = '';
    371       // Note: doesn't delete the file from disk — saving the form with empty
    372       // URL leaves the file orphaned on the server. Acceptable for now.
    373     });
    374   }
    375 })();
    376 </script>
     257
    377258
    378259<style>
  • src/views/pages/admin-videos.ejs

    ra29c8c8 r7cfe35a  
    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/auth-register.ejs

    ra29c8c8 r7cfe35a  
    4141  .setup-handle-hint code { color: var(--accent); font-weight: 600; background: color-mix(in srgb, var(--accent) 12%, transparent); padding: .05rem .35rem; border-radius: 6px; white-space: nowrap; }
    4242</style>
    43 <script>
    44 (function () {
    45   var u = document.getElementById('setup-username'), out = document.getElementById('setup-handle');
    46   if (!u || !out) return;
    47   function upd() {
    48     var v = (u.value || '').toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'username';
    49     out.textContent = '@' + v + '@' + location.host;
    50   }
    51   u.addEventListener('input', upd);
    52   upd();
    53 })();
    54 </script>
     43<%# Het script van deze pagina staat in assets/js/mod/auth-register.js (shaer-bqr). %>
  • src/views/pages/authorize-interaction.ejs

    ra29c8c8 r7cfe35a  
    1212      <p class="fedi-bm-help"><%= t('fedi.bm_help') %></p>
    1313    </div>
    14     <script>
    15     (function () {
    16       if (window.__fediBmWired) return; window.__fediBmWired = true;
    17       var a = document.getElementById('fedi-bm-btn'); if (!a) return;
    18       a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
    19       a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); });
    20     })();
    21     </script>
     14    <%# Het script van deze pagina staat in assets/js/mod/authorize-interaction.js (shaer-bqr). %>
    2215    <% if (!manage.length) { %>
    2316      <p class="auth-interact-note"><%= t('fedi.manage_empty') %></p>
     
    5649        <% }); %>
    5750      </ul>
    58       <script>
    59       (function () {
    60         if (window.__fediEditWired) return; window.__fediEditWired = true;
    61         document.addEventListener('click', function (e) {
    62           var b = e.target.closest && e.target.closest('.fedi-edit-btn');
    63           if (!b) return;
    64           var li = b.closest('.fedi-manage-item'); if (!li) return;
    65           var form = li.querySelector('.fedi-edit-form'); if (!form) return;
    66           var open = form.classList.toggle('is-open');
    67           b.classList.toggle('is-open', open);
    68           b.setAttribute('aria-expanded', open ? 'true' : 'false');
    69           if (open) { var ed = form.querySelector('.re-editor') || form.querySelector('textarea'); if (ed) ed.focus(); }
    70         });
    71       })();
    72       </script>
     51     
    7352    <% } %>
    7453    <p class="auth-interact-note"><a href="/"><%= t('fedi.remote_back') %></a></p>
     
    331310</style>
    332311
    333 <script>
    334 /* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page. */
    335 (function(){
    336   if (window.__fediReactWired) return; window.__fediReactWired = true;
    337   document.addEventListener('submit', function(e){
    338     var f = e.target.closest && e.target.closest('.fedi-react-form');
    339     if (!f) return;
    340     e.preventDefault();
    341     var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
    342     btn.disabled = true;
    343     var body = new URLSearchParams();
    344     new FormData(f).forEach(function(v, k){ body.append(k, v); });
    345     fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
    346       .then(function(r){ return r.ok ? r.json() : null; })
    347       .then(function(j){
    348         if (j) {
    349           var on = !!j.on;
    350           btn.classList.toggle('is-on', on);
    351           var lbl = btn.querySelector('.fedi-bigact-label');
    352           if (lbl) lbl.textContent = on ? (btn.getAttribute('data-on') || lbl.textContent) : (btn.getAttribute('data-off') || lbl.textContent);
    353         }
    354       })
    355       .catch(function(){})
    356       .then(function(){ btn.disabled = false; });
    357   });
    358 })();
    359 </script>
     312
  • src/views/pages/download.ejs

    ra29c8c8 r7cfe35a  
    2626      <p class="dl-sub"><%= _ready_sub %></p>
    2727      <p><a class="dl-go" href="<%= fileUrl %>"><%= _manual %></a></p>
    28       <script>
    29         // Auto-start de download (zelfde-origin attachment-link).
    30         setTimeout(function(){ try { window.location.href = <%- JSON.stringify(fileUrl) %>; } catch(e){} }, 600);
    31       </script>
     28      <%# Het script staat in assets/js/mod/download.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
    3229    <% } else { %>
    3330      <h1 class="dl-h1"><%= _download_btn %> <%= tr.title %></h1>
     
    5754  .dl-go { padding: 12px 20px; border-radius: 10px; border: none; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; font-size: 15px; cursor: pointer; text-decoration: none; display: inline-block; }
    5855</style>
     56<%- include('../partials/page-data', { pageData: { fileUrl: fileUrl } }) %>
  • src/views/pages/news.ejs

    ra29c8c8 r7cfe35a  
    4141    .tl-paste-go { background: var(--accent); color: var(--paper); }
    4242  </style>
    43   <script>
    44   (function () {
    45     if (window.__tlPasteWired) return; window.__tlPasteWired = true;
    46     function modal() { return document.getElementById('tl-paste'); }
    47     function openM() {
    48       var m = modal(); if (!m) return;
    49       var inp = m.querySelector('.tl-paste-input');
    50       m.classList.add('is-open');
    51       if (inp) {
    52         inp.value = ''; inp.focus();
    53         if (navigator.clipboard && navigator.clipboard.readText) {
    54           navigator.clipboard.readText().then(function (t) {
    55             t = (t || '').trim();
    56             if (/^https?:\/\//i.test(t) && !inp.value) { inp.value = t; inp.select(); }
    57           }).catch(function () {});
    58         }
    59       }
    60     }
    61     function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
    62     function go() {
    63       var m = modal(); if (!m) return;
    64       var inp = m.querySelector('.tl-paste-input'), v = (inp ? inp.value : '').trim();
    65       if (!/^https?:\/\//i.test(v)) { if (inp) inp.focus(); return; }
    66       location.href = '/authorize_interaction?uri=' + encodeURIComponent(v);
    67     }
    68     document.addEventListener('click', function (e) {
    69       if (e.target.closest && e.target.closest('#tl-paste-btn')) { e.preventDefault(); openM(); return; }
    70       if (e.target.closest && e.target.closest('.tl-paste-go')) { e.preventDefault(); go(); return; }
    71       if (e.target.closest && (e.target.closest('.tl-paste-cancel') || e.target.closest('.tl-paste-x'))) { e.preventDefault(); closeM(); return; }
    72       var op = document.querySelector('.tl-paste.is-open'); if (op && e.target === op) closeM();
    73     });
    74     document.addEventListener('keydown', function (e) {
    75       var m = document.querySelector('.tl-paste.is-open'); if (!m) return;
    76       if (e.key === 'Escape') closeM();
    77       else if (e.key === 'Enter' && e.target.closest && e.target.closest('.tl-paste')) { e.preventDefault(); go(); }
    78     });
    79   })();
    80   </script>
     43  <%# Het script staat in assets/js/mod/news.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
    8144  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
    8245  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
     
    184147  .tl-act-block:hover { background: color-mix(in srgb, #d9534f 14%, transparent); border-color: color-mix(in srgb, #d9534f 50%, transparent); }
    185148</style>
    186 
    187 <script>
    188 (function(){
    189   if (window.__fediRemoteWired) return; window.__fediRemoteWired = true;
    190   var current = null, currentBtn = null;
    191   function close(){ if (current) { current.remove(); current = null; currentBtn = null; } }
    192   function go(raw, uri){ var d=(raw||'').trim().replace(/^@?[^@\s]*@/,'').replace(/^https?:\/\//i,'').replace(/\/.*$/,'').trim(); if(d){ try{ localStorage.setItem('pcmsFediServer', d); }catch(e){} location.href='https://'+d+'/authorize_interaction?uri='+encodeURIComponent(uri||''); } }
    193   function place(f, b){ var r=b.getBoundingClientRect(); f.style.top=(r.bottom+window.scrollY+6)+'px'; f.style.left=Math.max(8, Math.min(r.left+window.scrollX, window.scrollX+window.innerWidth-340))+'px'; }
    194   document.addEventListener('click', function(e){
    195     if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; }
    196     if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return;
    197     var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn');
    198     if (b) {
    199       e.preventDefault();
    200       if (currentBtn === b) { close(); return; }
    201       close();
    202       var f = document.createElement('form'); f.className='fedi-remote-form'; f.dataset.uri = b.getAttribute('data-fedi-uri')||'';
    203       f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false"><button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">&rarr;</button><button type="button" class="fedi-remote-cancel" aria-label="x">&times;</button>';
    204       var _inp = f.querySelector('input'); _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social';
    205       try{ var _sv = localStorage.getItem('pcmsFediServer'); if(_sv) _inp.value = _sv; }catch(e){}
    206       document.body.appendChild(f); place(f, b); current=f; currentBtn=b; _inp.focus(); _inp.select();
    207       return;
    208     }
    209     if (current) close();
    210   });
    211   document.addEventListener('submit', function(e){ var f=e.target.closest && e.target.closest('.fedi-remote-form'); if(!f) return; e.preventDefault(); go(f.querySelector('input').value, f.dataset.uri); });
    212   document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
    213   window.addEventListener('scroll', close, true);
    214 })();
    215 
    216 /* Short feed videos (gif/cover loops like an animated cover) autoplay + loop muted like a GIF;
    217    longer real videos keep their controls. Decided on the actual duration once metadata loads. */
    218 (function(){
    219   document.querySelectorAll('.tl-media-video').forEach(function(v){
    220     if (v.dataset.gifWired) return; v.dataset.gifWired = '1';
    221     var decide = function(){
    222       if (v.duration && v.duration <= 30) {
    223         v.removeAttribute('controls'); v.loop = true; v.muted = true; v.play().catch(function(){});
    224       }
    225     };
    226     if (v.readyState >= 1) decide(); else v.addEventListener('loadedmetadata', decide, { once: true });
    227   });
    228 })();
    229 
    230 /* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page (no reload, no banner). */
    231 (function(){
    232   if (window.__newsReactWired) return; window.__newsReactWired = true;
    233   document.addEventListener('submit', function(e){
    234     var f = e.target.closest && e.target.closest('.tl-react-form');
    235     if (!f) return;
    236     e.preventDefault();
    237     var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
    238     btn.disabled = true;
    239     var body = new URLSearchParams();
    240     new FormData(f).forEach(function(v, k){ body.append(k, v); });
    241     fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
    242       .then(function(r){ return r.ok ? r.json() : null; })
    243       .then(function(j){ if (j) btn.classList.toggle('is-on', !!j.on); })
    244       .catch(function(){})
    245       .then(function(){ btn.disabled = false; });
    246   });
    247 })();
    248 </script>
    249 
    250 <script>
    251 // Collapse long post bodies to a max height with a "read more" toggle — only when the content
    252 // actually overflows. Runs on every /news render (full load + htmx swap); a per-element flag
    253 // prevents double-wiring.
    254 (function(){
    255   var RM = '<%= t("tl.read_more") %>', SL = '<%= t("tl.show_less") %>';
    256   document.querySelectorAll('.tl-content:not(.nsfw-media):not([data-rm])').forEach(function(c){
    257     c.setAttribute('data-rm', '1');
    258     if (c.scrollHeight > 360) {
    259       c.classList.add('tl-clamp');
    260       var b = document.createElement('button');
    261       b.type = 'button'; b.className = 'tl-readmore'; b.textContent = RM;
    262       b.addEventListener('click', function(){
    263         b.textContent = c.classList.toggle('tl-clamp') ? RM : SL;
    264       });
    265       c.insertAdjacentElement('afterend', b);
    266     }
    267   });
    268 })();
    269 </script>
     149<%- include('../partials/page-data', { pageData: { readMore: t('tl.read_more'), showLess: t('tl.show_less') } }) %>
  • src/views/pages/paid-gate.ejs

    ra29c8c8 r7cfe35a  
    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

    ra29c8c8 r7cfe35a  
    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/pages/post-edit.ejs

    ra29c8c8 r7cfe35a  
    272272        <% } else { %>
    273273          <p id="pe-fedi-audio-warn" style="font-size:12px;color:#c0392b;margin:2px 0 0 26px" hidden>⚠️ <%= t('pedit.fedi_audio_oneway') %></p>
    274           <script>
    275           (function () {
    276             var cb = document.getElementById('pe-fedi-audio'), w = document.getElementById('pe-fedi-audio-warn');
    277             if (cb && w && !cb.__wired) { cb.__wired = true; cb.addEventListener('change', function () { w.hidden = !cb.checked; }); }
    278           })();
    279           </script>
     274          <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
    280275        <% } %>
    281         <script>
    282         (function () {
    283           var cw = document.getElementById('pe-cw'), nsfw = document.getElementById('pe-nsfw');
    284           // Typing a warning text implies the post is sensitive → auto-tick NSFW.
    285           if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true;
    286             cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; });
    287           }
    288         })();
    289         </script>
     276       
    290277        <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen.
    291278           var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; }
     
    335322          </select>
    336323        </div>
    337         <script>
    338         (function () {
    339           var box = document.getElementById('pe-poll-fields');
    340           var tog = document.getElementById('pe-poll-toggle');
    341           var opts = document.getElementById('pe-poll-opts');
    342           var add = document.getElementById('pe-poll-add');
    343           if (!box || !opts) return;
    344           if (tog && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); }
    345           var PH = opts.getAttribute('data-ph') || '', DEL = opts.getAttribute('data-del') || '';
    346           function rows() { return opts.querySelectorAll('.pe-poll-row'); }
    347           // A poll needs at least 2 options: hide the ✕ at the minimum, and cap adding at 8.
    348           function refresh() {
    349             var n = rows().length;
    350             opts.querySelectorAll('.pe-poll-del').forEach(function (b) { b.hidden = n <= 2; });
    351             if (add) add.disabled = n >= 8;
    352           }
    353           function makeRow() {
    354             var row = document.createElement('div'); row.className = 'pe-poll-row';
    355             var i = document.createElement('input'); i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100; i.placeholder = PH;
    356             var d = document.createElement('button'); d.type = 'button'; d.className = 'pe-poll-del'; d.setAttribute('aria-label', DEL); d.title = DEL; d.innerHTML = '&times;';
    357             row.appendChild(i); row.appendChild(d); return row;
    358           }
    359           if (add && !add.__wired) { add.__wired = true; add.addEventListener('click', function () { if (rows().length >= 8) return; opts.appendChild(makeRow()); refresh(); }); }
    360           if (!opts.__wired) { opts.__wired = true; opts.addEventListener('click', function (e) { var d = e.target.closest('.pe-poll-del'); if (!d || rows().length <= 2) return; d.closest('.pe-poll-row').remove(); refresh(); }); }
    361           refresh();
    362         })();
    363         </script>
     324       
    364325        <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
    365326          <label class="pe-checkbox">
     
    377338            </label>
    378339          </div>
    379           <script>
    380             (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price');
    381               if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })();
    382           </script>
     340         
    383341          <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
    384342          <label class="pe-checkbox" style="margin-top:8px">
     
    392350            <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
    393351          </div>
    394           <script>
    395             (function () {
    396               var cb = document.getElementById('pe-sched-toggle');
    397               var box = document.getElementById('pe-sched-fields');
    398               if (!cb || !box) return;
    399               var inp = document.getElementById('pe-publish-at');
    400               var SITE_TZ = '<%= timezone || '' %>'; // configured site timezone; empty = browser local
    401               var pad = function (n) { return String(n).padStart(2, '0'); };
    402               // Offset (ms) between a timezone and UTC at a given moment.
    403               function tzOffset(date, tz) {
    404                 var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
    405                 var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; });
    406                 return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime();
    407               }
    408               // datetime-local "wall time" (in the site zone) → UTC Date.
    409               function wallToUtc(wall) {
    410                 if (!SITE_TZ) return new Date(wall);
    411                 var guess = new Date(wall + ':00Z').getTime();
    412                 return new Date(guess - tzOffset(new Date(guess), SITE_TZ));
    413               }
    414               // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone.
    415               function utcToWall(iso) {
    416                 var d = new Date(iso); if (isNaN(d)) return '';
    417                 if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
    418                 var f = new Intl.DateTimeFormat('en-CA', { timeZone: SITE_TZ, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
    419                 var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; });
    420                 return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
    421               }
    422               // Prefill: stored UTC → wall time in the site zone.
    423               if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso);
    424               // "Scheduled for" in human-readable time in the site zone.
    425               var when = document.getElementById('pe-sched-when');
    426               if (when && when.dataset.iso) {
    427                 var dw = new Date(when.dataset.iso);
    428                 if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined);
    429               }
    430               function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; }
    431               cb.addEventListener('change', sync); sync();
    432               // On save: wall time in the site zone → UTC-ISO via a hidden field.
    433               var form = cb.closest('form');
    434               if (form) {
    435                 form.addEventListener('submit', function () {
    436                   if (inp) inp.removeAttribute('name');
    437                   var old = form.querySelector('input[data-pa-utc]');
    438                   if (old) old.remove();
    439                   if (cb.checked && inp && inp.value) {
    440                     var d2 = wallToUtc(inp.value);
    441                     if (!isNaN(d2)) {
    442                       var h = document.createElement('input');
    443                       h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', '');
    444                       h.value = d2.toISOString();
    445                       form.appendChild(h);
    446                     }
    447                   }
    448                 });
    449               }
    450             })();
    451           </script>
     352         
    452353        <% } %>
    453354      </div>
     
    12811182</style>
    12821183
    1283 <script>
    1284 (function() {
    1285 
    1286   // ── Cover upload ────────────────────────────────────────────────
    1287   const coverField   = document.getElementById('cover-upload-field');
    1288   const coverTrigger = document.getElementById('cover-upload-trigger');
    1289   const coverUrl     = document.getElementById('cover-url-field');
    1290   const coverVideo   = document.getElementById('cover-video-field');
    1291   const coverStatus  = document.getElementById('cover-upload-status');
    1292   const coverWrap    = document.getElementById('cover-preview-wrap');
    1293   const coverImg     = document.getElementById('cover-preview-img');
    1294 
    1295   async function uploadImage(file) {
    1296     const fd = new FormData();
    1297     fd.append('image', file);
    1298     const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
    1299     if (!res.ok) {
    1300       const j = await res.json().catch(() => ({}));
    1301       throw new Error(j.error || ('Upload failed (' + res.status + ')'));
    1302     }
    1303     return await res.json();   // {url, size, mime}
    1304   }
    1305 
    1306   // ── Image editor (rotate / crop / mirror) ──────────
    1307   // Lazy-load Cropper.js (locally vendored) on first use.
    1308   let _cropperReady = null;
    1309   function ensureCropper() {
    1310     if (window.Cropper) return Promise.resolve();
    1311     if (_cropperReady) return _cropperReady;
    1312     _cropperReady = new Promise((resolve, reject) => {
    1313       if (!document.querySelector('link[data-cropper-css]')) {
    1314         const l = document.createElement('link');
    1315         l.rel = 'stylesheet'; l.href = '/assets/vendor/cropper.min.css'; l.setAttribute('data-cropper-css', '');
    1316         document.head.appendChild(l);
    1317       }
    1318       const s = document.createElement('script');
    1319       s.src = '/assets/vendor/cropper.min.js';
    1320       s.onload = () => resolve();
    1321       s.onerror = () => reject(new Error('cropper load failed'));
    1322       document.head.appendChild(s);
    1323     });
    1324     return _cropperReady;
    1325   }
    1326 
    1327   // True for an animated WebP (VP8X chunk with the animation flag set) — like a GIF it must skip
    1328   // the canvas editor, otherwise it'd be flattened to a single static frame.
    1329   async function isAnimatedWebpFile(file) {
    1330     if (!file || file.type !== 'image/webp') return false;
    1331     try {
    1332       const b = new Uint8Array(await file.slice(0, 40).arrayBuffer());
    1333       return b.length >= 21 && String.fromCharCode(b[12], b[13], b[14], b[15]) === 'VP8X' && (b[20] & 0x02) !== 0;
    1334     } catch (_) { return false; }
    1335   }
    1336 
    1337   // Opens the editor for a chosen file; resolves with an edited File,
    1338   // or null if the user cancels. Animated images (GIF / animated WebP) are NOT sent through the
    1339   // canvas editor (they would become static) — those upload directly.
    1340   async function openImageEditor(file) {
    1341     if (!file || !file.type || !file.type.startsWith('image/')) return file;
    1342     if (file.type === 'image/gif') return file;               // preserve animation
    1343     if (await isAnimatedWebpFile(file)) return file;          // animated WebP → preserve animation
    1344     try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly
    1345 
    1346     return new Promise((resolve) => {
    1347       const back = document.createElement('div');
    1348       back.className = 'imed-backdrop';
    1349       back.innerHTML =
    1350         '<div class="imed-modal" role="dialog" aria-modal="true" aria-label="<%= t('imed.title') %>">' +
    1351           '<div class="imed-stage"><img alt=""></div>' +
    1352           '<div class="imed-tools">' +
    1353             '<button type="button" data-act="rl" title="<%= t('imed.rotate_left') %>">⟲</button>' +
    1354             '<button type="button" data-act="rr" title="<%= t('imed.rotate_right') %>">⟳</button>' +
    1355             '<button type="button" data-act="fh" title="<%= t('imed.flip_h') %>">⇆</button>' +
    1356             '<button type="button" data-act="fv" title="<%= t('imed.flip_v') %>">⇅</button>' +
    1357             '<button type="button" data-act="zi" title="<%= t('imed.zoom_in') %>">+</button>' +
    1358             '<button type="button" data-act="zo" title="<%= t('imed.zoom_out') %>">-</button>' +
    1359             '<button type="button" data-act="reset" title="<%= t('imed.reset') %>">↺</button>' +
    1360           '</div>' +
    1361           '<div class="imed-actions">' +
    1362             '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary"><%= t('imed.cancel') %></button>' +
    1363             '<button type="button" data-act="apply" class="pe-btn pe-btn-primary"><%= t('imed.apply') %></button>' +
    1364           '</div>' +
    1365         '</div>';
    1366       document.body.appendChild(back);
    1367       const img = back.querySelector('img');
    1368       const url = URL.createObjectURL(file);
    1369       let cropper = null, sx = 1, sy = 1;
    1370 
    1371       function cleanup() {
    1372         try { if (cropper) cropper.destroy(); } catch (_) {}
    1373         URL.revokeObjectURL(url);
    1374         back.remove();
    1375         document.removeEventListener('keydown', onKey);
    1376       }
    1377       function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } }
    1378       document.addEventListener('keydown', onKey);
    1379 
    1380       img.onload = () => {
    1381         cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true });
    1382       };
    1383       img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original
    1384       img.src = url;
    1385 
    1386       back.addEventListener('click', (e) => {
    1387         const btn = e.target.closest('[data-act]');
    1388         if (e.target === back) { cleanup(); resolve(null); return; }
    1389         if (!btn || !cropper) return;
    1390         const a = btn.getAttribute('data-act');
    1391         if (a === 'rl') cropper.rotate(-90);
    1392         else if (a === 'rr') cropper.rotate(90);
    1393         else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); }
    1394         else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); }
    1395         else if (a === 'zi') cropper.zoom(0.1);
    1396         else if (a === 'zo') cropper.zoom(-0.1);
    1397         else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); }
    1398         else if (a === 'cancel') { cleanup(); resolve(null); }
    1399         else if (a === 'apply') {
    1400           const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' });
    1401           const png = (file.type === 'image/png' || file.type === 'image/webp');
    1402           const mime = png ? 'image/png' : 'image/jpeg';
    1403           const ext = png ? '.png' : '.jpg';
    1404           canvas.toBlob((blob) => {
    1405             cleanup();
    1406             if (!blob) { resolve(file); return; }
    1407             const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, '');
    1408             resolve(new File([blob], base + ext, { type: mime }));
    1409           }, mime, 0.92);
    1410         }
    1411       });
    1412     });
    1413   }
    1414 
    1415   function showCoverPreview(url) {
    1416     if (!coverWrap || !coverImg) return;
    1417     if (url) {
    1418       coverImg.src = url;
    1419       coverImg.hidden = false;
    1420       coverWrap.removeAttribute('data-empty');
    1421       const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
    1422       if (emptyIcon) emptyIcon.remove();
    1423     } else {
    1424       coverImg.hidden = true;
    1425       coverImg.src = '';
    1426       coverWrap.setAttribute('data-empty', '');
    1427       if (!coverWrap.querySelector('.pe-cover-empty')) {
    1428         const span = document.createElement('span');
    1429         span.className = 'pe-cover-empty';
    1430         span.textContent = '🖼';
    1431         coverWrap.appendChild(span);
    1432       }
    1433     }
    1434   }
    1435 
    1436   if (coverTrigger && coverField) {
    1437     coverTrigger.addEventListener('click', () => coverField.click());
    1438   }
    1439   if (coverField) {
    1440     coverField.addEventListener('change', async () => {
    1441       if (!coverField.files[0]) return;
    1442       const edited = await openImageEditor(coverField.files[0]);
    1443       coverField.value = '';
    1444       if (!edited) return; // cancelled
    1445       coverStatus.classList.remove('is-error');
    1446       coverStatus.textContent = '<%= t('pedit.js_uploading') %>';
    1447       try {
    1448         const j = await uploadImage(edited);
    1449         coverUrl.value = j.url;
    1450         if (coverVideo) coverVideo.value = j.video || ''; // muted loop MP4 for an animated cover
    1451         showCoverPreview(j.url);
    1452         coverStatus.textContent = (j.video ? '🎬 ' : '') + '<%= t('pedit.js_uploaded') %> ✓';
    1453         setTimeout(() => { coverStatus.textContent = ''; }, 2000);
    1454       } catch (e) {
    1455         coverStatus.classList.add('is-error');
    1456         coverStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message;
    1457       }
    1458     });
    1459   }
    1460   // Live-update preview when user pastes a URL manually
    1461   if (coverUrl) {
    1462     coverUrl.addEventListener('input', () => {
    1463       const v = coverUrl.value.trim();
    1464       if (v) showCoverPreview(v); else showCoverPreview('');
    1465     });
    1466   }
    1467 
    1468   // ── WYSIWYG editor (P58) ────────────────────────────────────────
    1469   // Architecture:
    1470   //   - Visible <div contenteditable> (`#content-editor`) is what the user
    1471   //     types in; it shows real HTML (formatted, not raw markup).
    1472   //   - Hidden <input name="content"> (`#content-hidden`) is what submits.
    1473   //     On submit we serialize the editor's HTML into it, with shortcode
    1474   //     chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
    1475   //   - Initial content comes from a <script type="application/json"> tag
    1476   //     to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
    1477   //     and walk text nodes to render shortcode tokens as chips.
    1478   const contentField  = document.getElementById('content-upload-field');
    1479   const contentBtn    = document.getElementById('insert-image-btn');
    1480   const contentStatus = document.getElementById('content-upload-status');
    1481   const editor        = document.getElementById('content-editor');
    1482   const hiddenField   = document.getElementById('content-hidden');
    1483   const charCountEl   = document.getElementById('char-count');
    1484   const initialEl     = document.getElementById('initial-content');
    1485   const toolbar       = document.getElementById('pe-toolbar');
    1486   const form          = editor && editor.closest('form');
    1487 
    1488   if (!editor) return;
    1489 
    1490   // Auto-focus the title only on desktop (mouse/trackpad). On touch this would
    1491   // immediately open the keyboard when the editor opens — not desired.
    1492   try {
    1493     const titleInput = form && form.querySelector('input[name="title"]');
    1494     if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
    1495       titleInput.focus({ preventScroll: true });
    1496     }
    1497   } catch (_) {}
    1498 
    1499   // ── Shortcode chip rendering / serialization ────────────────────
    1500   // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
    1501   // — but we DON'T want to chipify text the user is mid-typing inside an
    1502   // HTML attribute; since chipify only walks text nodes (never attribute
    1503   // values) that's already safe.
    1504   const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g;
    1505 
    1506   const SC_ICONS = {
    1507     track:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>',
    1508     album:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>',
    1509     playlist: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>',
    1510     embed:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 9 15.5 12 10 15"/></svg>',
    1511   };
    1512 
    1513   function chipLabel(kind, value) {
    1514     if (kind === 'track') {
    1515       // UUIDs are noisy — show a 6-char prefix for visual hint
    1516       const v = String(value || '');
    1517       return '<%= t('pedit.chip_track') %> ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
    1518     }
    1519     if (kind === 'album')    return '<%= t('pedit.chip_album') %> ' + value;
    1520     if (kind === 'playlist') return '<%= t('pedit.chip_playlist') %> ' + value;
    1521     if (kind === 'embed') {
    1522       const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, '');
    1523       return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean);
    1524     }
    1525     return value;
    1526   }
    1527 
    1528   function makeChip(kind, value) {
    1529     const span = document.createElement('span');
    1530     span.className = 'sc-chip';
    1531     span.contentEditable = 'false';
    1532     span.setAttribute('data-sc', kind + ':' + value);
    1533     span.innerHTML =
    1534       '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
    1535       '<span class="sc-chip-label"></span>';
    1536     span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
    1537     return span;
    1538   }
    1539 
    1540   // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
    1541   function chipifyShortcodes(root) {
    1542     const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
    1543     const targets = [];
    1544     while (walker.nextNode()) {
    1545       const n = walker.currentNode;
    1546       // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
    1547       if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
    1548       if (SC_RE.test(n.nodeValue)) targets.push(n);
    1549       SC_RE.lastIndex = 0;
    1550     }
    1551     for (const node of targets) {
    1552       const txt = node.nodeValue;
    1553       const frag = document.createDocumentFragment();
    1554       let last = 0;
    1555       let m;
    1556       SC_RE.lastIndex = 0;
    1557       while ((m = SC_RE.exec(txt)) !== null) {
    1558         if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
    1559         frag.appendChild(makeChip(m[1], m[2].trim()));
    1560         last = m.index + m[0].length;
    1561       }
    1562       if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
    1563       node.parentNode.replaceChild(frag, node);
    1564     }
    1565   }
    1566 
    1567   // Inverse of chipify: clone the editor, replace every chip with its text.
    1568   function serializeChips(rootClone) {
    1569     const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
    1570     for (const c of chips) {
    1571       const txt = '[[' + c.getAttribute('data-sc') + ']]';
    1572       c.replaceWith(document.createTextNode(txt));
    1573     }
    1574   }
    1575 
    1576   // ── Boot: load initial content as HTML, then render shortcodes as chips
    1577   try {
    1578     const initial = JSON.parse(initialEl.textContent || '""');
    1579     editor.innerHTML = initial || '';
    1580     chipifyShortcodes(editor);
    1581   } catch (e) {
    1582     console.error('[editor] could not parse initial content', e);
    1583     editor.innerHTML = '';
    1584   }
    1585 
    1586   // ── Char counter
    1587   function updateCharCount() {
    1588     const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
    1589     if (charCountEl) charCountEl.textContent = String(text.length);
    1590   }
    1591   updateCharCount();
    1592   editor.addEventListener('input', updateCharCount);
    1593 
    1594   // ── Toolbar wiring
    1595   // Lock the scroll position around an edit command. execCommand/insert scrolls
    1596   // the caret into view by default → the view "jumps" when clicking a formatting
    1597   // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …)
    1598   // + the page and restore them — sync and over a few frames, because Chrome
    1599   // sometimes scrolls a frame later. The user scrolls themselves.
    1600   function scrollableAncestors(el) {
    1601     const list = [];
    1602     let node = el;
    1603     while (node && node !== document.body && node !== document.documentElement) {
    1604       const oy = getComputedStyle(node).overflowY;
    1605       if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node);
    1606       node = node.parentElement;
    1607     }
    1608     return list;
    1609   }
    1610   function keepScroll(fn) {
    1611     // In fullscreen the page is locked (body overflow:hidden) and the field may
    1612     // scroll to the caret freely — no page jump possible, so nothing to fix.
    1613     const frame = document.querySelector('.pe-editor-frame');
    1614     if (frame && frame.classList.contains('pe-fs')) { fn(); return; }
    1615     const wx = window.scrollX, wy = window.scrollY;
    1616     const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; });
    1617     const restore = function () {
    1618       window.scrollTo(wx, wy);
    1619       anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; });
    1620     };
    1621     fn();
    1622     restore();
    1623     requestAnimationFrame(restore);
    1624   }
    1625   function execCmd(cmd, arg) {
    1626     keepScroll(function () {
    1627       editor.focus({ preventScroll: true });
    1628       document.execCommand(cmd, false, arg);
    1629     });
    1630     updateToolbarState();
    1631     updateCharCount();
    1632   }
    1633   function wrapCode() {
    1634     const sel = window.getSelection();
    1635     if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
    1636     keepScroll(function () {
    1637       const range = sel.getRangeAt(0);
    1638       const code = document.createElement('code');
    1639       code.textContent = sel.toString();
    1640       range.deleteContents();
    1641       range.insertNode(code);
    1642       // Move caret after the new node
    1643       range.setStartAfter(code);
    1644       range.collapse(true);
    1645       sel.removeAllRanges();
    1646       sel.addRange(range);
    1647       editor.focus({ preventScroll: true });
    1648     });
    1649   }
    1650   function linkPrompt() {
    1651     const url = window.prompt('<%= t('pedit.js_link_prompt') %>');
    1652     if (!url) return;
    1653     execCmd('createLink', url);
    1654   }
    1655   // Is the current selection inside a <blockquote> within the editor? Return it.
    1656   function blockquoteAncestor() {
    1657     const sel = window.getSelection();
    1658     if (!sel || sel.rangeCount === 0) return null;
    1659     let node = sel.anchorNode;
    1660     while (node && node !== editor) {
    1661       if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node;
    1662       node = node.parentNode;
    1663     }
    1664     return null;
    1665   }
    1666   // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but
    1667   // can never turn it OFF (browser quirk). If the caret is already in a quote →
    1668   // unwrap it; otherwise apply blockquote.
    1669   function toggleBlockquote() {
    1670     keepScroll(function () {
    1671       editor.focus({ preventScroll: true });
    1672       const bq = blockquoteAncestor();
    1673       if (bq) {
    1674         const parent = bq.parentNode;
    1675         // Extract content from the quote in place, then remove the empty wrapper.
    1676         const ref = bq;
    1677         let firstMoved = null;
    1678         while (bq.firstChild) {
    1679           const child = bq.firstChild;
    1680           if (!firstMoved) firstMoved = child;
    1681           parent.insertBefore(child, ref);
    1682         }
    1683         parent.removeChild(bq);
    1684         // Restore the caret inside the unwrapped content.
    1685         if (firstMoved) {
    1686           const sel = window.getSelection();
    1687           const range = document.createRange();
    1688           range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent);
    1689           range.collapse(false);
    1690           sel.removeAllRanges();
    1691           sel.addRange(range);
    1692         }
    1693       } else {
    1694         document.execCommand('formatBlock', false, 'blockquote');
    1695       }
    1696     });
    1697     updateToolbarState();
    1698     updateCharCount();
    1699   }
    1700 
    1701   if (toolbar) {
    1702     // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection
    1703     // from the editor field. Without this the selection is lost on tap
    1704     // → execCommand operates on an empty selection (bold can no longer be toggled OFF)
    1705     // and the browser scrolls the caret back into view (the "jump down"). preventDefault
    1706     // on mousedown keeps focus in the editor; the click still fires normally.
    1707     toolbar.addEventListener('mousedown', (e) => {
    1708       if (e.target.closest('button')) e.preventDefault();
    1709     });
    1710     toolbar.addEventListener('click', (e) => {
    1711       const btn = e.target.closest('button[data-cmd]');
    1712       if (!btn) return;
    1713       e.preventDefault();
    1714       const cmd = btn.dataset.cmd;
    1715       const arg = btn.dataset.arg || null;
    1716       if (cmd === 'link-prompt') linkPrompt();
    1717       else if (cmd === 'code-wrap') wrapCode();
    1718       else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote();
    1719       else execCmd(cmd, arg);
    1720     });
    1721   }
    1722 
    1723   // ── Full-screen writing mode: the writing field fills the whole page.
    1724   const fsBtn = document.getElementById('pe-fullscreen-btn');
    1725   const editorFrame = document.querySelector('.pe-editor-frame');
    1726   const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
    1727 
    1728   // On mobile the keyboard pushes the visible (visual) viewport up while
    1729   // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar
    1730   // slides out of view. Keep the fullscreen frame aligned to the visual
    1731   // viewport (top + height) so the toolbar stays visible at the top.
    1732   function syncFsViewport() {
    1733     if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
    1734     const vv = window.visualViewport;
    1735     if (!vv) return;
    1736     editorFrame.style.top = vv.offsetTop + 'px';
    1737     editorFrame.style.height = vv.height + 'px';
    1738   }
    1739   function clearFsViewport() {
    1740     if (!editorFrame) return;
    1741     editorFrame.style.top = '';
    1742     editorFrame.style.height = '';
    1743   }
    1744   function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); }
    1745   function applyFs(on) {
    1746     if (!editorFrame) return;
    1747     editorFrame.classList.toggle('pe-fs', on);
    1748     document.body.classList.toggle('pe-fs-open', on);
    1749     document.documentElement.classList.toggle('pe-fs-open', on);
    1750     if (fsBtn) {
    1751       fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
    1752       fsBtn.title = on ? '<%= t('pedit.tb_done') %>' : '<%= t('pedit.tb_fullscreen') %>';
    1753     }
    1754     if (window.visualViewport) {
    1755       if (on) {
    1756         window.visualViewport.addEventListener('resize', syncFsViewport);
    1757         window.visualViewport.addEventListener('scroll', syncFsViewport);
    1758         syncFsViewport();
    1759       } else {
    1760         window.visualViewport.removeEventListener('resize', syncFsViewport);
    1761         window.visualViewport.removeEventListener('scroll', syncFsViewport);
    1762         clearFsViewport();
    1763       }
    1764     }
    1765     // On touch the field is NOT editable inline; only in fullscreen.
    1766     if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false');
    1767     if (on) {
    1768       editor.focus({ preventScroll: true });
    1769     } else {
    1770       if (isTouch) editor.blur();
    1771       // On close: scroll to the TOP of the content instead of staying
    1772       // somewhere at the bottom (footer).
    1773       requestAnimationFrame(function () {
    1774         try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {}
    1775       });
    1776     }
    1777   }
    1778   // The fullscreen writing "page": opening pushes a history state so the browser
    1779   // back button (and the Done button) closes it and returns you to the form — feels
    1780   // like a separate page, but all form fields remain intact (same DOM).
    1781   function openFs() {
    1782     if (isFs()) return;
    1783     try { history.pushState({ peFs: true }, ''); } catch (_) {}
    1784     applyFs(true);
    1785   }
    1786   function closeFs() {
    1787     if (!isFs()) return;
    1788     if (history.state && history.state.peFs) history.back(); // → popstate closes it
    1789     else applyFs(false);
    1790   }
    1791   function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); }
    1792   window.addEventListener('popstate', function () { if (isFs()) applyFs(false); });
    1793   if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
    1794   var fsDoneBtn = document.getElementById('pe-fs-done');
    1795   if (fsDoneBtn) fsDoneBtn.addEventListener('click', closeFs);
    1796   document.addEventListener('keydown', (e) => {
    1797     if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); }
    1798   });
    1799 
    1800   // On mobile/tablet (touch): the content field is NOT editable inline — it is
    1801   // not a text field there. One tap → fullscreen, where it becomes editable
    1802   // (toggleFullscreen toggles contenteditable). This prevents inline typing.
    1803   if (isTouch) {
    1804     editor.setAttribute('contenteditable', 'false');
    1805     editor.classList.add('pe-tap-to-edit');
    1806     editor.addEventListener('click', function () {
    1807       if (!isFs()) openFs();
    1808     });
    1809   }
    1810 
    1811   // Reflect bold/italic/list state on the toolbar buttons
    1812   function updateToolbarState() {
    1813     if (!toolbar) return;
    1814     const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
    1815     for (const cmd of cmds) {
    1816       const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
    1817       if (!btn) continue;
    1818       try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
    1819     }
    1820     // Quote button: active when the caret is inside a <blockquote> (toggle feedback).
    1821     const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
    1822     if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
    1823   }
    1824   document.addEventListener('selectionchange', () => {
    1825     if (document.activeElement === editor) updateToolbarState();
    1826   });
    1827 
    1828   // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
    1829   editor.addEventListener('keydown', (e) => {
    1830     const mod = e.ctrlKey || e.metaKey;
    1831     if (!mod) return;
    1832     const k = e.key.toLowerCase();
    1833     if (k === 'b') { e.preventDefault(); execCmd('bold'); }
    1834     else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
    1835     else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
    1836     else if (k === 'k') { e.preventDefault(); linkPrompt(); }
    1837   });
    1838 
    1839   // Paste: keep it simple — strip formatting unless user wants it. Default
    1840   // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
    1841   // styles from clipboard only when shift is held — otherwise plain text.
    1842   editor.addEventListener('paste', (e) => {
    1843     if (e.shiftKey) return; // user wants formatted paste
    1844     const text = (e.clipboardData || window.clipboardData).getData('text/plain');
    1845     if (text == null) return;
    1846     e.preventDefault();
    1847     document.execCommand('insertText', false, text);
    1848   });
    1849 
    1850   // ── Image upload (button + drag-drop into the editor)
    1851   async function uploadAndInsertImage(file) {
    1852     const edited = await openImageEditor(file);
    1853     if (!edited) return; // cancelled
    1854     contentStatus.classList.remove('is-error');
    1855     contentStatus.textContent = '<%= t('pedit.js_uploading') %>';
    1856     try {
    1857       const j = await uploadImage(edited);
    1858       const img = '<img src="' + j.url + '" alt="">';
    1859       editor.focus({ preventScroll: true });
    1860       document.execCommand('insertHTML', false, img);
    1861       contentStatus.textContent = '<%= t('pedit.js_inserted') %> ✓';
    1862       setTimeout(() => { contentStatus.textContent = ''; }, 2000);
    1863       updateCharCount();
    1864     } catch (e) {
    1865       contentStatus.classList.add('is-error');
    1866       contentStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message;
    1867     }
    1868   }
    1869 
    1870   if (contentBtn && contentField) {
    1871     contentBtn.addEventListener('click', () => contentField.click());
    1872     contentField.addEventListener('change', () => {
    1873       if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
    1874       contentField.value = '';
    1875     });
    1876 
    1877     editor.addEventListener('dragover', (e) => {
    1878       if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
    1879         e.preventDefault();
    1880         editor.classList.add('is-dragover');
    1881       }
    1882     });
    1883     editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
    1884     editor.addEventListener('drop', async (e) => {
    1885       editor.classList.remove('is-dragover');
    1886       const files = e.dataTransfer && e.dataTransfer.files;
    1887       if (!files || !files.length) return;
    1888       e.preventDefault();
    1889       for (const f of files) {
    1890         if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
    1891       }
    1892     });
    1893   }
    1894 
    1895   // ── Insert chip helpers (track / playlist)
    1896   function insertChip(kind, value) {
    1897     editor.focus({ preventScroll: true });
    1898     const chip = makeChip(kind, value);
    1899     // Insert at caret using the Selection API (execCommand insertNode)
    1900     const sel = window.getSelection();
    1901     if (sel && sel.rangeCount > 0) {
    1902       const range = sel.getRangeAt(0);
    1903       range.deleteContents();
    1904       range.insertNode(chip);
    1905       // Insert a trailing space so the user can keep typing after the chip
    1906       const space = document.createTextNode('\u00A0');
    1907       chip.after(space);
    1908       range.setStartAfter(space);
    1909       range.collapse(true);
    1910       sel.removeAllRanges();
    1911       sel.addRange(range);
    1912     } else {
    1913       editor.appendChild(chip);
    1914       editor.appendChild(document.createTextNode('\u00A0'));
    1915     }
    1916     updateCharCount();
    1917   }
    1918 
    1919   // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes
    1920   //    an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
    1921   const embedBtn = document.getElementById('insert-embed-btn');
    1922   if (embedBtn) {
    1923     embedBtn.addEventListener('click', () => {
    1924       const raw = window.prompt('<%= t('pedit.js_embed_prompt') %>');
    1925       if (!raw) return;
    1926       const url = raw.trim();
    1927       if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; }
    1928       insertChip('embed', url);
    1929     });
    1930   }
    1931 
    1932   // ── Track insert: opens the track-picker modal (P59)
    1933   const trackBtn = document.getElementById('insert-track-btn');
    1934   const trackPicker = document.getElementById('track-picker');
    1935   if (trackBtn && trackPicker) {
    1936     const tpList   = document.getElementById('tp-list');
    1937     const tpEmpty  = document.getElementById('tp-empty');
    1938     const tpSearch = document.getElementById('tp-search');
    1939     let tpCache = null;       // cached track list (fetched once per page load)
    1940     let tpLastFocus = null;   // element to restore focus to on close
    1941 
    1942     const SVG_NOTE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>';
    1943 
    1944     function fmtDur(sec) {
    1945       sec = Math.max(0, Math.floor(sec || 0));
    1946       const m = Math.floor(sec / 60), s = sec % 60;
    1947       return m + ':' + String(s).padStart(2, '0');
    1948     }
    1949     function escAttr(s) {
    1950       return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
    1951         '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
    1952       }[c]));
    1953     }
    1954 
    1955     function renderList(filter) {
    1956       if (!Array.isArray(tpCache)) return;
    1957       const q = (filter || '').trim().toLowerCase();
    1958       const filtered = q
    1959         ? tpCache.filter(t =>
    1960             (t.title  || '').toLowerCase().includes(q) ||
    1961             (t.artist || '').toLowerCase().includes(q))
    1962         : tpCache;
    1963 
    1964       if (!filtered.length) {
    1965         tpList.innerHTML = '';
    1966         tpEmpty.textContent = q ? '<%= t('pedit.js_no_tracks_found') %> ' + q : '<%= t('pedit.js_no_tracks_yet') %>';
    1967         tpList.appendChild(tpEmpty);
    1968         return;
    1969       }
    1970 
    1971       tpList.innerHTML = filtered.map(t => {
    1972         const cov = t.cover
    1973           ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
    1974           : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
    1975         const dis = t.playable ? '' : ' aria-disabled="true"';
    1976         const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
    1977         return (
    1978           '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
    1979             cov +
    1980             '<span class="tp-meta">' +
    1981               '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
    1982               sub +
    1983             '</span>' +
    1984             '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
    1985           '</button>'
    1986         );
    1987       }).join('');
    1988     }
    1989 
    1990     async function loadTracks() {
    1991       if (Array.isArray(tpCache)) return tpCache;
    1992       tpEmpty.textContent = '<%= t('pedit.js_tracks_loading') %>';
    1993       try {
    1994         const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
    1995         const j = await r.json();
    1996         tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
    1997       } catch (e) {
    1998         tpCache = [];
    1999         tpEmpty.textContent = '<%= t('pedit.js_tracks_load_fail') %>: ' + e.message;
    2000       }
    2001       return tpCache;
    2002     }
    2003 
    2004     function openPicker() {
    2005       tpLastFocus = document.activeElement;
    2006       trackPicker.hidden = false;
    2007       trackPicker.setAttribute('aria-hidden', 'false');
    2008       document.body.classList.add('tp-locked');
    2009       tpSearch.value = '';
    2010       renderList('');
    2011       // Defer focus so the open animation doesn't get jumped
    2012       setTimeout(() => tpSearch.focus(), 30);
    2013     }
    2014     function closePicker() {
    2015       trackPicker.hidden = true;
    2016       trackPicker.setAttribute('aria-hidden', 'true');
    2017       document.body.classList.remove('tp-locked');
    2018       if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
    2019         try { tpLastFocus.focus(); } catch(_) {}
    2020       }
    2021     }
    2022 
    2023     trackBtn.addEventListener('click', async () => {
    2024       openPicker();
    2025       await loadTracks();
    2026       renderList(tpSearch.value);
    2027     });
    2028 
    2029     // Close: backdrop click, [data-tp-close], or Escape
    2030     trackPicker.addEventListener('click', (e) => {
    2031       if (e.target.closest('[data-tp-close]')) {
    2032         closePicker();
    2033         return;
    2034       }
    2035       const row = e.target.closest('.tp-row[data-track-id]');
    2036       if (row) {
    2037         if (row.getAttribute('aria-disabled') === 'true') return;
    2038         const id = row.dataset.trackId;
    2039         if (id) {
    2040           insertChip('track', id);
    2041           closePicker();
    2042         }
    2043       }
    2044     });
    2045     document.addEventListener('keydown', (e) => {
    2046       if (!trackPicker.hidden && e.key === 'Escape') {
    2047         e.preventDefault();
    2048         closePicker();
    2049       }
    2050     });
    2051 
    2052     // Live filter
    2053     tpSearch.addEventListener('input', () => renderList(tpSearch.value));
    2054   }
    2055 
    2056   // ── Playlist insert (open existing or create new via modal)
    2057   const playlistBtn = document.getElementById('insert-playlist-btn');
    2058   if (playlistBtn) {
    2059     playlistBtn.addEventListener('click', async () => {
    2060       if (typeof window.openPlaylistEditor !== 'function') {
    2061         alert('<%= t('pedit.js_playlist_editor_missing') %>');
    2062         return;
    2063       }
    2064       try {
    2065         const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
    2066         const j = await r.json();
    2067         if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
    2068           const choice = prompt(
    2069             '<%= t('pedit.js_playlist_existing') %>\n\n' +
    2070             j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
    2071             '\n\n<%= t('pedit.js_playlist_choose') %>'
    2072           );
    2073           if (choice && /^\d+$/.test(choice.trim())) {
    2074             const idx = parseInt(choice.trim(), 10) - 1;
    2075             if (idx >= 0 && idx < j.playlists.length) {
    2076               insertChip('playlist', j.playlists[idx].id);
    2077               return;
    2078             }
    2079           }
    2080           if (choice === null) return;
    2081         }
    2082       } catch (_) { /* fall through to create */ }
    2083 
    2084       window.openPlaylistEditor({
    2085         mode: 'create',
    2086         onSaved: ({ id }) => insertChip('playlist', id),
    2087       });
    2088     });
    2089   }
    2090 
    2091   // ── Post type: segmented control + type-aware panels ──────────
    2092   (function () {
    2093     const typeInput = document.getElementById('pe-type-input');
    2094     const card = document.querySelector('.pe-type-card');
    2095     if (!typeInput || !card) return;
    2096     const seg = card.querySelector('.pe-typeseg');
    2097     const panels = card.querySelectorAll('.pe-type-panel');
    2098 
    2099     function applyType(tt) {
    2100       typeInput.value = tt;
    2101       seg.querySelectorAll('.pe-typeseg-btn').forEach(b => {
    2102         const on = b.dataset.type === tt;
    2103         b.classList.toggle('is-active', on);
    2104         b.setAttribute('aria-checked', on ? 'true' : 'false');
    2105       });
    2106       panels.forEach(p => { p.hidden = (p.dataset.panel !== tt); });
    2107     }
    2108     seg.addEventListener('click', (e) => {
    2109       const btn = e.target.closest('.pe-typeseg-btn');
    2110       if (btn) applyType(btn.dataset.type);
    2111     });
    2112     applyType(typeInput.value || 'post');
    2113 
    2114     // Video URL → [[embed:url]] chip
    2115     const vBtn = document.getElementById('pe-video-insert');
    2116     const vUrl = document.getElementById('pe-video-url');
    2117     if (vBtn && vUrl) {
    2118       const doInsert = () => {
    2119         const url = (vUrl.value || '').trim();
    2120         if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; }
    2121         insertChip('embed', url);
    2122         vUrl.value = '';
    2123       };
    2124       vBtn.addEventListener('click', doInsert);
    2125       vUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doInsert(); } });
    2126     }
    2127 
    2128     // Audio: inline upload → transcodes server-side → [[track:id]] chip
    2129     const drop = document.getElementById('pe-audio-drop');
    2130     const fileInput = document.getElementById('pe-audio-file');
    2131     const list = document.getElementById('pe-audio-list');
    2132     if (drop && fileInput && list) {
    2133       const pick = () => fileInput.click();
    2134       drop.addEventListener('click', pick);
    2135       drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
    2136       ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('is-drag'); }));
    2137       ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('is-drag'); }));
    2138       drop.addEventListener('drop', (e) => { if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); });
    2139       fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; });
    2140 
    2141       function clientDuration(f) {
    2142         return new Promise((resolve) => {
    2143           try {
    2144             const u = URL.createObjectURL(f);
    2145             const a = document.createElement('audio');
    2146             a.preload = 'metadata';
    2147             a.onloadedmetadata = () => { URL.revokeObjectURL(u); resolve(Number.isFinite(a.duration) ? Math.round(a.duration) : null); };
    2148             a.onerror = () => { URL.revokeObjectURL(u); resolve(null); };
    2149             a.src = u;
    2150           } catch (_) { resolve(null); }
    2151         });
    2152       }
    2153       async function handleFiles(files) {
    2154         for (const f of Array.from(files || [])) await uploadOne(f);
    2155       }
    2156       async function uploadOne(f) {
    2157         const li = document.createElement('li');
    2158         li.className = 'pe-audio-item';
    2159         const nameEl = document.createElement('span');
    2160         nameEl.className = 'pe-audio-item-name';
    2161         nameEl.textContent = f.name;
    2162         const stateEl = document.createElement('span');
    2163         stateEl.className = 'pe-audio-item-state';
    2164         stateEl.textContent = '⏳ <%= t('pedit.audio_up_busy') %>';
    2165         li.appendChild(nameEl); li.appendChild(stateEl);
    2166         list.appendChild(li);
    2167         try {
    2168           const dur = await clientDuration(f);
    2169           const fd = new FormData();
    2170           fd.append('audio', f);
    2171           if (dur) fd.append('duration', String(dur));
    2172           const res = await fetch('/admin/audio/upload', {
    2173             method: 'POST', body: fd,
    2174             headers: { 'Accept': 'application/json' },
    2175             credentials: 'same-origin',
    2176           });
    2177           const j = await res.json().catch(() => ({}));
    2178           if (!res.ok || !j.ok || !j.id) throw new Error(j.error || ('HTTP ' + res.status));
    2179           insertChip('track', j.id);
    2180           stateEl.textContent = '✓ <%= t('pedit.audio_up_done') %>';
    2181           li.classList.add('is-done');
    2182         } catch (err) {
    2183           stateEl.textContent = '✕ <%= t('pedit.audio_up_fail') %>: ' + err.message;
    2184           li.classList.add('is-fail');
    2185         }
    2186       }
    2187     }
    2188   })();
    2189 
    2190   // ── Submit: serialize editor contents into the hidden field
    2191   if (form && hiddenField) {
    2192     form.addEventListener('submit', () => {
    2193       const clone = editor.cloneNode(true);
    2194       serializeChips(clone);
    2195       hiddenField.value = clone.innerHTML;
    2196     });
    2197   }
    2198 })();
    2199 </script>
     1184
    22001185
    22011186<%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
     
    22291214  <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    22301215<% } %>
    2231 
    2232 <script>
    2233 (function () {
    2234   // Pin: checkbox toggles the hidden rank field (0 = not pinned),
    2235   // ▲▼ shifts the position, with a readable description instead of a raw number.
    2236   var toggle = document.getElementById('pin-toggle');
    2237   var rank   = document.getElementById('pin-rank');
    2238   var pos    = document.getElementById('pin-pos');
    2239   var label  = document.getElementById('pin-label');
    2240   var up     = document.getElementById('pin-up');    // higher = lower number (towards 1/top)
    2241   var down   = document.getElementById('pin-down');
    2242   if (!toggle || !rank || !pos) return;
    2243 
    2244   function descr(n) {
    2245     n = Number(n) || 0;
    2246     if (n <= 1) return '<%= t('pedit.pin_top') %>';
    2247     return n + '<%= t('pedit.pin_nth_suffix') %>';
    2248   }
    2249   function render() {
    2250     var on = toggle.checked;
    2251     pos.hidden = !on;
    2252     if (on && Number(rank.value) < 1) rank.value = 1;
    2253     if (!on) rank.value = 0;
    2254     if (label) label.textContent = on ? descr(rank.value) : '';
    2255     if (up) up.disabled = Number(rank.value) <= 1;
    2256   }
    2257   toggle.addEventListener('change', render);
    2258   if (up)   up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
    2259   if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
    2260   render();
    2261 })();
    2262 
    2263 (function () {
    2264   // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible
    2265   // obstacles by setting a dynamic bottom offset = the greater of:
    2266   //  1) the height of the keyboard area NOT covered by the layout viewport
    2267   //     (on iOS the visual viewport shifts; on Android the layout viewport shrinks
    2268   //     due to interactive-widget=resizes-content → offset ≈ 0);
    2269   //  2) the height of the playing audio player (fixed, z-index 1000).
    2270   // We stick with sticky (no fixed/top tricks → no bar floating in the middle).
    2271   var bar = document.querySelector('.pe-actions');
    2272   if (!bar) return;
    2273   var vv = window.visualViewport;
    2274   function position() {
    2275     var ap = document.querySelector('.audio-player');
    2276     var playing = document.body.classList.contains('has-audio-player') &&
    2277                   ap && getComputedStyle(ap).display !== 'none';
    2278     var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0;
    2279     var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
    2280     var offset = Math.max(audioOffset, kbCovered);
    2281     bar.style.bottom = offset ? offset + 'px' : '';
    2282   }
    2283   position();
    2284   window.addEventListener('resize', position);
    2285   if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); }
    2286   // has-audio-player is toggled via a body class → observe it.
    2287   try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
    2288 })();
    2289 </script>
     1216<%- include('../partials/page-data', { pageData: { _timezone: (typeof timezone !== 'undefined' ? timezone : ''), apply: t('imed.apply'), audio_up_busy: t('pedit.audio_up_busy'), audio_up_done: t('pedit.audio_up_done'), audio_up_fail: t('pedit.audio_up_fail'), cancel: t('imed.cancel'), chip_album: t('pedit.chip_album'), chip_playlist: t('pedit.chip_playlist'), chip_track: t('pedit.chip_track'), flip_h: t('imed.flip_h'), flip_v: t('imed.flip_v'), js_embed_invalid: t('pedit.js_embed_invalid'), js_embed_prompt: t('pedit.js_embed_prompt'), js_failed: t('pedit.js_failed'), js_inserted: t('pedit.js_inserted'), js_link_prompt: t('pedit.js_link_prompt'), js_no_tracks_found: t('pedit.js_no_tracks_found'), js_no_tracks_yet: t('pedit.js_no_tracks_yet'), js_playlist_choose: t('pedit.js_playlist_choose'), js_playlist_editor_missing: t('pedit.js_playlist_editor_missing'), js_playlist_existing: t('pedit.js_playlist_existing'), js_tracks_load_fail: t('pedit.js_tracks_load_fail'), js_tracks_loading: t('pedit.js_tracks_loading'), js_uploaded: t('pedit.js_uploaded'), js_uploading: t('pedit.js_uploading'), pin_nth_suffix: t('pedit.pin_nth_suffix'), pin_top: t('pedit.pin_top'), reset: t('imed.reset'), rotate_left: t('imed.rotate_left'), rotate_right: t('imed.rotate_right'), tb_done: t('pedit.tb_done'), tb_fullscreen: t('pedit.tb_fullscreen'), title: t('imed.title'), zoom_in: t('imed.zoom_in'), zoom_out: t('imed.zoom_out') } }) %>
  • src/views/partials/playlist-editor.ejs

    ra29c8c8 r7cfe35a  
    280280</style>
    281281
    282 <script>
    283 (function() {
    284   // Idempotency: if window.openPlaylistEditor already defined (multiple
    285   // partial includes on a single page), skip re-binding.
    286   if (typeof window.openPlaylistEditor === 'function') return;
    287 
    288   const CSRF = '<%= _csrf %>';
    289 
    290   function esc(s) {
    291     return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
    292       '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
    293     }[c]));
    294   }
    295   function fmtDur(sec) {
    296     if (!sec) return '';
    297     const m = Math.floor(sec / 60), s = sec % 60;
    298     return `${m}:${String(s).padStart(2, '0')}`;
    299   }
    300 
    301   async function api(method, url, body) {
    302     const opts = {
    303       method, credentials: 'same-origin',
    304       headers: { 'X-CSRF-Token': CSRF },
    305     };
    306     if (body !== undefined) {
    307       opts.headers['Content-Type'] = 'application/json';
    308       opts.body = JSON.stringify(body);
    309     }
    310     const r = await fetch(url, opts);
    311     return r.json();
    312   }
    313 
    314   /**
    315    * Public entry point: open the editor.
    316    *  opts: { mode: 'create'|'edit', id?, onSaved? }
    317    *  onSaved is called with { id, playlist } after a successful save.
    318    */
    319   window.openPlaylistEditor = async function(opts) {
    320     opts = opts || {};
    321     const mode = opts.mode === 'edit' ? 'edit' : 'create';
    322     const isEdit = mode === 'edit';
    323 
    324     // Load all audio tracks for the picker
    325     let tracks = [];
    326     try {
    327       const j = await api('GET', '/admin/playlists/api/tracks');
    328       if (Array.isArray(j.tracks)) tracks = j.tracks;
    329     } catch (e) {
    330       alert('Tracks ophalen mislukt');
    331       return;
    332     }
    333     if (!tracks.length) {
    334       alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');
    335       return;
    336     }
    337 
    338     // Existing playlist data when editing
    339     let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] };
    340     if (isEdit && opts.id) {
    341       try {
    342         const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
    343         if (j.ok) initial = { ...initial, ...j.playlist };
    344       } catch (e) {}
    345     }
    346 
    347     // ── DOM ────────────────────────────────────────────────────────
    348     const backdrop = document.createElement('div');
    349     backdrop.className = 'pl-modal-backdrop';
    350     backdrop.innerHTML = `
    351       <div class="pl-modal" role="dialog" aria-label="Playlist editor">
    352         <div class="pl-modal-header">
    353           <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3>
    354           <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button>
    355         </div>
    356         <div class="pl-modal-body">
    357           <div class="pl-editor-cols">
    358             <div class="pl-editor-left">
    359               <div class="pl-meta-grid">
    360                 <label class="pl-field pl-field-full">
    361                   <span>Titel *</span>
    362                   <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
    363                 </label>
    364                 <label class="pl-field">
    365                   <span>Artiest</span>
    366                   <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
    367                 </label>
    368                 <label class="pl-field">
    369                   <span>Jaar</span>
    370                   <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
    371                 </label>
    372                 <label class="pl-field">
    373                   <span>Type</span>
    374                   <select id="pli-kind">
    375                     <option value="album"    ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option>
    376                     <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option>
    377                   </select>
    378                 </label>
    379                 <div class="pl-field pl-field-full">
    380                   <span>Cover</span>
    381                   <div class="pl-cover-row">
    382                     <span class="pl-cover-thumb" id="pli-cover-thumb">
    383                       ${initial.cover
    384                         ? `<img src="${esc(initial.cover)}" alt="">`
    385                         : `<span class="pl-cover-empty">🎨</span>`}
    386                     </span>
    387                     <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1">
    388                   </div>
    389                   <div class="pl-cover-upload-row">
    390                     <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
    391                     <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
    392                     <span class="pl-cover-status" id="pli-cover-status"></span>
    393                   </div>
    394                 </div>
    395               </div>
    396               <div class="pl-section-title">
    397                 Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
    398                 <small>(sleep ⠿ om te ordenen)</small>
    399               </div>
    400               <div id="pli-selected" class="pl-selected-list"></div>
    401             </div>
    402             <div class="pl-editor-right">
    403               <div class="pl-section-title">Beschikbare tracks</div>
    404               <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
    405               <div id="pli-available" class="pl-available-list"></div>
    406             </div>
    407           </div>
    408         </div>
    409         <div class="pl-modal-footer">
    410           <button type="button" class="btn" id="pli-cancel">Annuleren</button>
    411           <button type="button" class="btn btn-primary" id="pli-save" disabled>
    412             ${isEdit ? 'Opslaan' : 'Aanmaken'}
    413           </button>
    414         </div>
    415       </div>
    416     `;
    417     document.body.appendChild(backdrop);
    418 
    419     const $ = sel => backdrop.querySelector(sel);
    420     const close = () => backdrop.remove();
    421     backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
    422     $('.pl-modal-close').addEventListener('click', close);
    423     $('#pli-cancel').addEventListener('click', close);
    424 
    425     // Index tracks by id for fast lookup
    426     const trackById = new Map(tracks.map(t => [t.id, t]));
    427     let selected = (initial.track_ids || []).filter(id => trackById.has(id));
    428 
    429     const titleEl = $('#pli-title');
    430     const artistEl = $('#pli-artist');
    431     const yearEl = $('#pli-year');
    432     const kindEl = $('#pli-kind');
    433     const coverEl = $('#pli-cover');
    434     const coverThumb = $('#pli-cover-thumb');
    435     const saveBtn = $('#pli-save');
    436     const selectedEl = $('#pli-selected');
    437     const availEl = $('#pli-available');
    438     const searchEl = $('#pli-search');
    439     const countEl = $('#pli-count');
    440 
    441     coverEl.addEventListener('input', () => {
    442       const u = coverEl.value.trim();
    443       coverThumb.innerHTML = u
    444         ? `<img src="${esc(u)}" alt="" data-fallback>`
    445         : `<span class="pl-cover-empty">🎨</span>`;
    446     });
    447 
    448     // ── Cover file upload (werkt in create ÉN edit) ───────────
    449     // We uploaden naar het generieke image-endpoint (/posts/upload-image,
    450     // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
    451     // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het
    452     // cover-veld en wordt bij 'Aanmaken'/'Opslaan' met de playlist meegestuurd.
    453     const coverFileInput = $('#pli-cover-file');
    454     const coverPickBtn   = $('#pli-cover-pick');
    455     const coverStatus    = $('#pli-cover-status');
    456     if (coverFileInput && coverPickBtn) {
    457       coverPickBtn.addEventListener('click', () => coverFileInput.click());
    458       coverFileInput.addEventListener('change', async (e) => {
    459         const file = e.target.files && e.target.files[0];
    460         coverFileInput.value = '';
    461         if (!file) return;
    462         if (!/^image\//.test(file.type)) {
    463           coverStatus.textContent = 'Alleen afbeeldingen';
    464           coverStatus.className = 'pl-cover-status is-error';
    465           return;
    466         }
    467         coverStatus.textContent = 'Uploaden…';
    468         coverStatus.className = 'pl-cover-status';
    469         const fd = new FormData();
    470         fd.append('image', file);
    471         try {
    472           const r = await fetch('/posts/upload-image',
    473             { method: 'POST', body: fd, credentials: 'same-origin' }
    474           );
    475           const j = await r.json();
    476           if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt');
    477           const url = j.url || '';
    478           coverEl.value = url;
    479           coverThumb.innerHTML = url
    480             ? `<img src="${esc(url)}" alt="">`
    481             : `<span class="pl-cover-empty">🎨</span>`;
    482           coverStatus.textContent = '✓ Geüpload';
    483           coverStatus.className = 'pl-cover-status is-ok';
    484         } catch (err) {
    485           coverStatus.textContent = 'Mislukt: ' + err.message;
    486           coverStatus.className = 'pl-cover-status is-error';
    487         }
    488       });
    489     }
    490 
    491     function updateSaveBtn() {
    492       saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
    493     }
    494 
    495     function renderSelected() {
    496       countEl.textContent = selected.length;
    497       if (selected.length === 0) {
    498         selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
    499         updateSaveBtn();
    500         return;
    501       }
    502       selectedEl.innerHTML = selected.map((id, i) => {
    503         const t = trackById.get(id);
    504         if (!t) return '';
    505         const cover = t.cover
    506           ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
    507           : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
    508         return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
    509           <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
    510           <span class="pl-row-num">${i + 1}</span>
    511           ${cover}
    512           <span class="pl-row-info">
    513             <span class="pl-row-title">${esc(t.title)}</span>
    514             ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
    515           </span>
    516           <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
    517         </div>`;
    518       }).join('');
    519 
    520       selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
    521         b.addEventListener('click', () => {
    522           selected = selected.filter(x => x !== b.dataset.id);
    523           renderSelected();
    524           renderAvailable();
    525         });
    526       });
    527       bindDrag();
    528       updateSaveBtn();
    529     }
    530 
    531     function renderAvailable() {
    532       const q = searchEl.value.trim().toLowerCase();
    533       const matches = tracks.filter(t => {
    534         if (!q) return true;
    535         return (t.title || '').toLowerCase().includes(q)
    536             || (t.artist || '').toLowerCase().includes(q);
    537       });
    538       if (matches.length === 0) {
    539         availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
    540         return;
    541       }
    542       availEl.innerHTML = matches.map(t => {
    543         const isAdded = selected.includes(t.id);
    544         const cls = ['pl-avail-row'];
    545         if (isAdded) cls.push('is-added');
    546         if (!t.playable) cls.push('is-unplayable');
    547         const cover = t.cover
    548           ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
    549           : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
    550         return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
    551           ${cover}
    552           <span class="pl-row-info">
    553             <span class="pl-row-title">${esc(t.title)}</span>
    554             ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
    555           </span>
    556           <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
    557         </div>`;
    558       }).join('');
    559 
    560       availEl.querySelectorAll('.pl-avail-row').forEach(row => {
    561         if (row.classList.contains('is-unplayable')) return;
    562         row.addEventListener('click', () => {
    563           const id = row.dataset.id;
    564           if (selected.includes(id)) selected = selected.filter(x => x !== id);
    565           else selected.push(id);
    566           renderSelected();
    567           renderAvailable();
    568         });
    569       });
    570     }
    571 
    572     // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
    573     function bindDrag() {
    574       selectedEl.querySelectorAll('.pl-row').forEach(row => {
    575         const handle = row.querySelector('.pl-row-handle');
    576         if (!handle) return;
    577         let dragging = false, originalIdx = -1;
    578 
    579         handle.addEventListener('pointerdown', e => {
    580           e.preventDefault();
    581           handle.setPointerCapture(e.pointerId);
    582           dragging = true;
    583           originalIdx = parseInt(row.dataset.pos, 10);
    584           row.classList.add('is-dragging');
    585         });
    586         handle.addEventListener('pointermove', e => {
    587           if (!dragging) return;
    588           e.preventDefault();
    589           const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
    590           rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
    591           let targetIdx = -1, above = false;
    592           for (let i = 0; i < rows.length; i++) {
    593             const r = rows[i]; if (r === row) continue;
    594             const rect = r.getBoundingClientRect();
    595             const mid = rect.top + rect.height / 2;
    596             if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
    597             if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
    598               targetIdx = i; above = e.clientY < mid; break;
    599             }
    600           }
    601           if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
    602         });
    603         const finish = e => {
    604           if (!dragging) return;
    605           dragging = false;
    606           try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
    607           row.classList.remove('is-dragging');
    608           const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
    609           let targetIdx = -1, above = false;
    610           for (let i = 0; i < rows.length; i++) {
    611             if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
    612             if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
    613           }
    614           rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
    615           if (targetIdx === -1 || targetIdx === originalIdx) return;
    616           const moved = selected[originalIdx];
    617           selected.splice(originalIdx, 1);
    618           let newIdx = targetIdx;
    619           if (originalIdx < targetIdx) newIdx--;
    620           if (!above) newIdx++;
    621           newIdx = Math.max(0, Math.min(selected.length, newIdx));
    622           selected.splice(newIdx, 0, moved);
    623           renderSelected();
    624         };
    625         handle.addEventListener('pointerup', finish);
    626         handle.addEventListener('pointercancel', () => {
    627           dragging = false;
    628           row.classList.remove('is-dragging');
    629           selectedEl.querySelectorAll('.drop-above, .drop-below')
    630             .forEach(r => r.classList.remove('drop-above', 'drop-below'));
    631         });
    632       });
    633     }
    634 
    635     titleEl.addEventListener('input', updateSaveBtn);
    636     searchEl.addEventListener('input', renderAvailable);
    637     renderSelected();
    638     renderAvailable();
    639 
    640     saveBtn.addEventListener('click', async () => {
    641       if (saveBtn.disabled) return;
    642       const orig = saveBtn.textContent;
    643       saveBtn.disabled = true;
    644       saveBtn.textContent = 'Opslaan...';
    645 
    646       const payload = {
    647         title:  titleEl.value.trim(),
    648         artist: artistEl.value.trim(),
    649         year:   parseInt(yearEl.value, 10) || 0,
    650         cover:  coverEl.value.trim(),
    651         kind:   kindEl.value === 'playlist' ? 'playlist' : 'album',
    652         tracks: selected.slice(),
    653       };
    654 
    655       try {
    656         const url = isEdit
    657           ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
    658           : '/admin/playlists/api';
    659         const j = await api('POST', url, payload);
    660         if (!j.ok) {
    661           alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
    662           saveBtn.disabled = false;
    663           saveBtn.textContent = orig;
    664           return;
    665         }
    666         const savedId = isEdit ? initial.id : j.id;
    667         close();
    668         if (typeof opts.onSaved === 'function') {
    669           opts.onSaved({ id: savedId, playlist: payload });
    670         }
    671       } catch (err) {
    672         alert('Opslaan mislukt: ' + err.message);
    673         saveBtn.disabled = false;
    674         saveBtn.textContent = orig;
    675       }
    676     });
    677 
    678     // ESC to close
    679     document.addEventListener('keydown', function onEsc(e) {
    680       if (e.key === 'Escape' && document.body.contains(backdrop)) {
    681         close();
    682         document.removeEventListener('keydown', onEsc);
    683       }
    684     });
    685   };
    686 })();
    687 </script>
     282<%# Het script staat in assets/js/mod/playlist-editor.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
  • src/views/partials/profile-header.ejs

    ra29c8c8 r7cfe35a  
    160160</div>
    161161<% } %>
    162 <script>
    163 (function () {
    164   if (window.__pcmsFediFollowWired) return;
    165   window.__pcmsFediFollowWired = true;
    166   function modal() { return document.querySelector('.pf-follow'); }
    167   function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
    168   function go() {
    169     var m = modal(); if (!m) return;
    170     var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : '';
    171     // Strip a full @user@host handle (and any scheme/path) down to the server host.
    172     var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
    173     if (!server) { if (inp) inp.focus(); return; }
    174     try { localStorage.setItem('pcmsFediServer', server); } catch (e) {}
    175     var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || '');
    176     location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor);
    177   }
    178   document.addEventListener('click', function (e) {
    179     var b = e.target.closest && e.target.closest('.profile-fedi-link');
    180     if (b) { e.preventDefault();
    181       var m = modal(); if (!m) return;
    182       m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || '');
    183       var inp = m.querySelector('.pf-follow-input');
    184       try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {}
    185       m.classList.add('is-open');
    186       setTimeout(function () { if (inp) inp.focus(); }, 30);
    187       return;
    188     }
    189     if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; }
    190     if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; }
    191     var open = document.querySelector('.pf-follow.is-open');
    192     if (open && e.target === open) closeM(); /* click on backdrop */
    193   });
    194   document.addEventListener('keydown', function (e) {
    195     var m = document.querySelector('.pf-follow.is-open'); if (!m) return;
    196     if (e.key === 'Escape') { closeM(); }
    197     else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); }
    198   });
    199 })();
    200 
    201 /* Profile summary: click the avatar to open a modal with photo + details. */
    202 (function () {
    203   if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true;
    204   function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); }
    205   document.addEventListener('click', function (e) {
    206     if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; }
    207     var open = document.querySelector('.pf-summary.is-open');
    208     if (open && e.target === open) { close(); return; } /* click on backdrop */
    209     var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]');
    210     if (!t) return;
    211     e.preventDefault();
    212     var root = (t.closest && t.closest('#pcms-chrome')) || document;
    213     var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary');
    214     if (modal) modal.classList.add('is-open');
    215   });
    216   document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
    217 })();
    218 </script>
     162<%# Het script van de profielkop staat in assets/js/mod/chrome.js: deze partial
     163    zit in de OOB-chrome en wordt bij elke htmx-navigatie opnieuw ingevoegd
     164    (shaer-bqr). %>
    219165<% } %>
    220166
  • src/views/partials/profile-sheet.ejs

    ra29c8c8 r7cfe35a  
    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

    ra29c8c8 r7cfe35a  
    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>
  • src/views/partials/track-editor.ejs

    ra29c8c8 r7cfe35a  
    284284</style>
    285285
    286 <script>
    287 (function() {
    288 
    289   function esc(s) {
    290     return String(s == null ? '' : s)
    291       .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    292       .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
    293   }
    294 
    295   async function api(method, url, body) {
    296     const opts = { method, credentials: 'same-origin', headers: {} };
    297     if (body !== undefined) {
    298       if (body instanceof FormData) {
    299         opts.body = body;
    300       } else {
    301         opts.headers['Content-Type'] = 'application/json';
    302         opts.body = JSON.stringify(body);
    303       }
    304     }
    305     const res = await fetch(url, opts);
    306     let data;
    307     try { data = await res.json(); }
    308     catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); }
    309     if (!res.ok) data.ok = false;
    310     return data;
    311   }
    312 
    313   /**
    314    * Open the track editor.
    315    * @param {object} opts
    316    * @param {string} opts.id        — track id to edit
    317    * @param {function?} opts.onSaved — called with updated track on success
    318    */
    319   window.openTrackEditor = async function openTrackEditor({ id, onSaved }) {
    320     if (!id) return;
    321     let track, albumSuggestions = [];
    322     try {
    323       const [trackJson, listJson] = await Promise.all([
    324         api('GET', '/admin/audio/api/' + encodeURIComponent(id)),
    325         api('GET', '/admin/audio/api/albums'),
    326       ]);
    327       if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden');
    328       track = trackJson.track;
    329       if (listJson.ok && Array.isArray(listJson.albums)) {
    330         albumSuggestions = listJson.albums.filter(Boolean);
    331       }
    332     } catch (err) {
    333       alert('Track ophalen mislukt: ' + err.message);
    334       return;
    335     }
    336 
    337     const backdrop = document.createElement('div');
    338     backdrop.className = 'te-backdrop';
    339     backdrop.innerHTML = `
    340       <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken">
    341         <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div>
    342 
    343         <div class="te-header">
    344           <h3>✎ Track bewerken</h3>
    345           <button type="button" class="te-close" aria-label="Sluiten">×</button>
    346         </div>
    347 
    348         <div class="te-body">
    349 
    350           ${track.stream_url ? `
    351           <div class="te-preview">
    352             <div class="te-preview-cover">
    353               ${track.cover_url
    354                 ? `<img src="${esc(track.cover_url)}" alt="">`
    355                 : `🎵`}
    356             </div>
    357             <div class="te-preview-meta">
    358               <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div>
    359               <div class="te-preview-sub">
    360                 ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''}
    361               </div>
    362             </div>
    363             <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button>
    364           </div>
    365           ` : ''}
    366 
    367           <div class="te-form">
    368 
    369             <label class="te-field">
    370               <span>Titel <span class="te-required" aria-hidden="true">*</span></span>
    371               <input type="text" id="te-title" maxlength="200" required
    372                      autocomplete="off" autocapitalize="words" spellcheck="false"
    373                      value="${esc(track.title || '')}">
    374             </label>
    375 
    376             <div class="te-row te-row-2">
    377               <label class="te-field">
    378                 <span>Artiest</span>
    379                 <input type="text" id="te-artist" maxlength="200"
    380                        autocomplete="off" autocapitalize="words" spellcheck="false"
    381                        value="${esc(track.artist || '')}">
    382               </label>
    383               <label class="te-field">
    384                 <span>Album</span>
    385                 <input type="text" id="te-album" maxlength="200"
    386                        autocomplete="off" autocapitalize="words" spellcheck="false"
    387                        list="te-album-list" value="${esc(track.album || '')}">
    388                 <datalist id="te-album-list">
    389                   ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')}
    390                 </datalist>
    391               </label>
    392             </div>
    393 
    394             <label class="te-field">
    395               <span>Duur <small>(seconden — automatisch bepaald, hier te overschrijven)</small></span>
    396               <input type="number" id="te-duration" min="0" step="1"
    397                      inputmode="numeric" pattern="[0-9]*"
    398                      value="${track.duration || ''}" placeholder="auto">
    399             </label>
    400 
    401             <div class="te-row te-row-2">
    402               <label class="te-field">
    403                 <span>Eigenaar / credit <small>(copyright-houder)</small></span>
    404                 <div class="te-credit-row">
    405                   <input type="text" id="te-credit" maxlength="200"
    406                          autocomplete="off" spellcheck="false"
    407                          placeholder="bv. © 2025 Mara Vos"
    408                          value="${esc(track.credit || '')}">
    409                   <button type="button" class="te-sym-btn" id="te-credit-copyr"
    410                           title="© invoegen" aria-label="Copyright-teken invoegen">©</button>
    411                 </div>
    412               </label>
    413               <label class="te-field">
    414                 <span>Licentie</span>
    415                 <input type="text" id="te-license" maxlength="120"
    416                        autocomplete="off" spellcheck="false" list="te-license-list"
    417                        placeholder="Alle rechten voorbehouden"
    418                        value="${esc(track.license || '')}">
    419                 <datalist id="te-license-list">
    420                   <option value="Alle rechten voorbehouden"></option>
    421                   <option value="CC BY 4.0"></option>
    422                   <option value="CC BY-SA 4.0"></option>
    423                   <option value="CC BY-NC 4.0"></option>
    424                   <option value="CC BY-NC-SA 4.0"></option>
    425                   <option value="CC BY-ND 4.0"></option>
    426                   <option value="CC0 1.0 (publiek domein)"></option>
    427                 </datalist>
    428               </label>
    429             </div>
    430 
    431             <div class="te-field">
    432               <span>Open in <small>(links naar dezelfde track elders)</small></span>
    433               <input type="url" id="te-link-spotify" inputmode="url" autocomplete="off" spellcheck="false"
    434                      placeholder="Spotify-URL (https://open.spotify.com/…)" value="${esc(track.link_spotify || '')}">
    435               <input type="url" id="te-link-youtube" inputmode="url" autocomplete="off" spellcheck="false"
    436                      placeholder="YouTube-URL (https://youtu.be/…)" value="${esc(track.link_youtube || '')}">
    437               <input type="url" id="te-link-soundcloud" inputmode="url" autocomplete="off" spellcheck="false"
    438                      placeholder="SoundCloud-URL (https://soundcloud.com/…)" value="${esc(track.link_soundcloud || '')}">
    439             </div>
    440 
    441             <div class="te-field">
    442               <span>Audiobestand</span>
    443               <div class="te-cover-btn-row">
    444                 <input type="file" id="te-audio-file" accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg,.aac" hidden>
    445                 <button type="button" class="te-cover-btn" id="te-audio-pick">${track.stream_url ? '🔁 Vervang audiobestand' : '🎵 Audiobestand kiezen'}</button>
    446                 <span class="te-cover-status" id="te-audio-status"></span>
    447               </div>
    448             </div>
    449 
    450             <div class="te-field">
    451               <span>Cover</span>
    452               <div class="te-cover-row">
    453                 <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button"
    454                      aria-label="Klik om cover te kiezen">
    455                   ${track.cover_url
    456                     ? `<img src="${esc(track.cover_url)}" alt="">`
    457                     : `<span class="te-cover-empty">🎨</span>`}
    458                 </div>
    459                 <input type="file" id="te-cover-file"
    460                        accept="image/jpeg,image/png,image/webp,image/gif" hidden>
    461                 <div class="te-cover-actions">
    462                   <div class="te-cover-btn-row">
    463                     <button type="button" class="te-cover-btn" id="te-cover-pick">
    464                       📷 Foto kiezen
    465                     </button>
    466                     ${track.cover_url ? `
    467                     <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove">
    468                       × Verwijder
    469                     </button>` : ''}
    470                   </div>
    471                   <input type="text" id="te-cover-url" inputmode="url"
    472                          placeholder="/media/… of https://…"
    473                          autocomplete="off" autocapitalize="none" spellcheck="false"
    474                          value="${esc(track.cover_url || '')}">
    475                   <div class="te-cover-status" id="te-cover-status"></div>
    476                 </div>
    477               </div>
    478             </div>
    479 
    480           </div>
    481         </div>
    482 
    483         <div class="te-footer">
    484           <button type="button" class="te-btn" id="te-cancel">Annuleren</button>
    485           <div class="te-footer-spacer"></div>
    486           <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button>
    487         </div>
    488       </div>
    489     `;
    490 
    491     document.body.appendChild(backdrop);
    492     document.body.classList.add('te-modal-open');
    493 
    494     const $ = sel => backdrop.querySelector(sel);
    495 
    496     // ── Inline preview player (routed through global mini-player) ──
    497     // We don't build our own <audio>; instead we tell the global
    498     // window.pcmsAudioPlayer to load this single track. Visual state
    499     // syncs against the global audio element so toggle works correctly
    500     // even if the user pauses from the mini-bar.
    501     const previewBtn = $('#te-preview-play');
    502     if (previewBtn) {
    503       const setPreviewState = (playing) => {
    504         previewBtn.textContent = playing ? '⏸' : '▶';
    505         previewBtn.classList.toggle('is-playing', playing);
    506         previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
    507       };
    508       const isOurTrack = () => {
    509         // audio.src is a blob: URL (Spotify-style playback) — compare against
    510         // the player's logical current-track URL instead.
    511         const player = window.pcmsAudioPlayer;
    512         const cur = player && player.currentTrack();
    513         return !!(cur && track.stream_url && cur.url === track.stream_url);
    514       };
    515       const resync = () => {
    516         const audio = document.getElementById('audio-element');
    517         const playing = audio && !audio.paused && !audio.ended && isOurTrack();
    518         setPreviewState(!!playing);
    519       };
    520 
    521       previewBtn.addEventListener('click', () => {
    522         const player = window.pcmsAudioPlayer;
    523         if (!player || !track.stream_url) {
    524           console.warn('preview: miniplayer or url missing');
    525           return;
    526         }
    527         const audio = document.getElementById('audio-element');
    528         if (audio && isOurTrack()) {
    529           // Same track loaded — toggle
    530           if (audio.paused) player.play(); else player.pause();
    531         } else {
    532           player.setQueue([{
    533             url:    track.stream_url,
    534             title:  track.title  || '(zonder titel)',
    535             artist: track.artist || '',
    536             album:  track.album  || '',
    537             cover:  track.cover_url || '',
    538           }], 0);
    539         }
    540       });
    541 
    542       const audio = document.getElementById('audio-element');
    543       if (audio) {
    544         const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied'];
    545         evs.forEach(ev => audio.addEventListener(ev, resync));
    546         // Detach listeners on close so we don't leak them
    547         backdrop._previewCleanup = () => {
    548           evs.forEach(ev => audio.removeEventListener(ev, resync));
    549         };
    550         resync();   // initial state
    551       }
    552     }
    553 
    554     function close() {
    555       // Detach our resync listeners (mini-player stays running)
    556       if (backdrop._previewCleanup) backdrop._previewCleanup();
    557       document.body.classList.remove('te-modal-open');
    558       document.removeEventListener('keydown', onEsc);
    559       backdrop.remove();
    560     }
    561     function onEsc(e) { if (e.key === 'Escape') close(); }
    562     document.addEventListener('keydown', onEsc);
    563 
    564     // A click on the backdrop does NOT close the editor — too easy to lose edits by
    565     // mis-clicking outside. Close deliberately via ×, Cancel or Esc.
    566     $('.te-close').addEventListener('click', close);
    567     $('#te-cancel').addEventListener('click', close);
    568 
    569     // ── Cover picking + URL paste + drag-drop ────────────────
    570     const thumb     = $('#te-cover-thumb');
    571     const fileInput = $('#te-cover-file');
    572     const urlInput  = $('#te-cover-url');
    573     const status    = $('#te-cover-status');
    574     const pickBtn   = $('#te-cover-pick');
    575     const removeBtn = $('#te-cover-remove');
    576 
    577     function setStatus(text, kind) {
    578       status.textContent = text || '';
    579       status.className = 'te-cover-status' + (kind ? ' is-' + kind : '');
    580     }
    581     function setThumb(url) {
    582       if (url) {
    583         thumb.innerHTML = `<img src="${esc(url)}" alt="">`;
    584       } else {
    585         thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`;
    586       }
    587     }
    588 
    589     pickBtn.addEventListener('click', () => fileInput.click());
    590     thumb.addEventListener('click', () => fileInput.click());
    591     thumb.addEventListener('keydown', e => {
    592       if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
    593     });
    594 
    595     async function uploadCoverFile(file) {
    596       if (!file) return;
    597       if (!/^image\//.test(file.type)) {
    598         setStatus('Alleen afbeeldingen toegestaan', 'error'); return;
    599       }
    600       setStatus('Uploaden…', null);
    601       const fd = new FormData();
    602       fd.append('cover', file);
    603       try {
    604         const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', {
    605           method: 'POST', body: fd, credentials: 'same-origin',
    606         });
    607         const j = await r.json();
    608         if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
    609         urlInput.value = j.cover_url || '';
    610         setThumb(j.cover_url);
    611         setStatus('✓ Geüpload', 'ok');
    612       } catch (err) {
    613         setStatus('Mislukt: ' + err.message, 'error');
    614       }
    615     }
    616 
    617     fileInput.addEventListener('change', e => {
    618       const f = e.target.files && e.target.files[0];
    619       if (f) uploadCoverFile(f);
    620       fileInput.value = '';
    621     });
    622 
    623     // Drag-drop on thumb (desktop nicety)
    624     ['dragenter', 'dragover'].forEach(ev =>
    625       thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); }));
    626     ['dragleave', 'drop'].forEach(ev =>
    627       thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); }));
    628     thumb.addEventListener('drop', e => {
    629       const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
    630       if (f) uploadCoverFile(f);
    631     });
    632 
    633     // URL paste auto-preview
    634     urlInput.addEventListener('input', () => {
    635       const v = urlInput.value.trim();
    636       setThumb(v);
    637     });
    638 
    639     if (removeBtn) {
    640       removeBtn.addEventListener('click', () => {
    641         urlInput.value = '';
    642         setThumb('');
    643         removeBtn.remove();
    644       });
    645     }
    646 
    647     // ── Replace the audio file of this track ──────────────────
    648     const aPick = $('#te-audio-pick');
    649     const aFile = $('#te-audio-file');
    650     const aStatus = $('#te-audio-status');
    651     if (aPick && aFile) {
    652       aPick.addEventListener('click', () => aFile.click());
    653       aFile.addEventListener('change', async (e) => {
    654         const f = e.target.files && e.target.files[0];
    655         aFile.value = '';
    656         if (!f) return;
    657         aStatus.textContent = '⏳ Converteren… (kan even duren)'; aStatus.className = 'te-cover-status';
    658         aPick.disabled = true;
    659         try {
    660           const fd = new FormData();
    661           fd.append('audio', f);
    662           const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id) + '/replace-audio', fd);
    663           if (!j.ok) throw new Error(j.error || 'mislukt');
    664           aStatus.textContent = '✓ Vervangen'; aStatus.className = 'te-cover-status is-ok';
    665           track.stream_url = j.stream_url || track.stream_url;
    666           if (j.duration) { const d = $('#te-duration'); if (d) d.value = j.duration; }
    667         } catch (err) {
    668           aStatus.textContent = 'Mislukt: ' + err.message; aStatus.className = 'te-cover-status is-error';
    669         } finally { aPick.disabled = false; }
    670       });
    671     }
    672 
    673     // ── Insert © symbol into the credit field ─────────────────
    674     const copyrBtn = $('#te-credit-copyr');
    675     if (copyrBtn) {
    676       copyrBtn.addEventListener('click', () => {
    677         const inp = $('#te-credit');
    678         if (!inp) return;
    679         const sym = '© ';
    680         const start = inp.selectionStart != null ? inp.selectionStart : inp.value.length;
    681         const end = inp.selectionEnd != null ? inp.selectionEnd : inp.value.length;
    682         inp.value = inp.value.slice(0, start) + sym + inp.value.slice(end);
    683         inp.focus();
    684         const pos = start + sym.length;
    685         try { inp.setSelectionRange(pos, pos); } catch (e) {}
    686       });
    687     }
    688 
    689     // ── Save ────────────────────────────────────────────────
    690     $('#te-save').addEventListener('click', async () => {
    691       const titleEl = $('#te-title');
    692       const title = titleEl.value.trim();
    693       if (!title) {
    694         titleEl.focus();
    695         alert('Titel is verplicht');
    696         return;
    697       }
    698       const saveBtn = $('#te-save');
    699       saveBtn.disabled = true;
    700       saveBtn.textContent = '⏳ Opslaan…';
    701 
    702       try {
    703         const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), {
    704           title,
    705           artist: $('#te-artist').value.trim() || null,
    706           album:  $('#te-album').value.trim() || null,
    707           credit:  $('#te-credit').value.trim() || null,
    708           license: $('#te-license').value.trim() || null,
    709           link_spotify:    $('#te-link-spotify').value.trim() || null,
    710           link_youtube:    $('#te-link-youtube').value.trim() || null,
    711           link_soundcloud: $('#te-link-soundcloud').value.trim() || null,
    712           duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
    713           cover_url: urlInput.value.trim() || null,
    714         });
    715         if (!j.ok) throw new Error(j.error || 'Opslaan mislukt');
    716         if (typeof onSaved === 'function') onSaved(j.track || { id, title,
    717           artist: $('#te-artist').value.trim() || null,
    718           album:  $('#te-album').value.trim() || null,
    719           cover_url: urlInput.value.trim() || null });
    720         close();
    721       } catch (err) {
    722         alert('Opslaan mislukt: ' + err.message);
    723         saveBtn.disabled = false;
    724         saveBtn.textContent = '💾 Opslaan';
    725       }
    726     });
    727 
    728     // ── Auto-duration ───────────────────────────────────────────
    729     // The server already determines duration automatically on upload. This is the
    730     // fallback/UX layer: if an admin opens an existing track without a duration,
    731     // we read it from the audio metadata and fill the field — so you never need
    732     // to type seconds manually. An existing value is never overwritten. We fetch
    733     // the bytes via the same header gate as the player (X-Audio-Player).
    734     (async function autoDuration() {
    735       const durEl = $('#te-duration');
    736       if (!durEl || !track.stream_url) return;
    737       if (durEl.value && Number(durEl.value) > 0) return;  // already filled → leave it alone
    738       let objUrl = null;
    739       try {
    740         const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } });
    741         if (!r.ok) return;
    742         objUrl = URL.createObjectURL(await r.blob());
    743         const probe = new Audio();
    744         probe.preload = 'metadata';
    745         probe.addEventListener('loadedmetadata', () => {
    746           if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) {
    747             durEl.value = Math.round(probe.duration);
    748           }
    749           if (objUrl) URL.revokeObjectURL(objUrl);
    750         });
    751         probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); });
    752         probe.src = objUrl;
    753       } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); }
    754     })();
    755 
    756     // Focus title for fast typing
    757     setTimeout(() => $('#te-title').focus(), 60);
    758   };
    759 })();
    760 </script>
     286<%# Het script van deze pagina staat in assets/js/mod/track-editor.js (shaer-bqr). %>
Note: See TracChangeset for help on using the changeset viewer.