Changes in / [7cfe35a:a29c8c8] in Klonkt


Ignore:
Location:
src
Files:
21 deleted
36 edited

Legend:

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

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

    r7cfe35a ra29c8c8  
    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',
    115113    pageTitleKey: 'admin.t_audio',
    116114    bodyClass: 'on-admin',
  • src/routes/admin-epk.js

    r7cfe35a ra29c8c8  
    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',
    3635    pageTitleKey: 'admin.t_epk',
    3736    bodyClass: 'on-admin',
  • src/routes/admin-media.js

    r7cfe35a ra29c8c8  
    8181    .sort((a, b) => b._mtime - a._mtime); // newest first
    8282  renderPage(req, res, 'pages/admin-media', {
    83     pageJs: 'admin-media',
    8483    pageTitleKey: 'admin.t_media',
    8584    bodyClass: 'on-admin',
     
    123122  if (!site) return res.status(404).send('Site required');
    124123  renderPage(req, res, 'pages/admin-videos', {
    125     pageJs: 'admin-videos',
    126124    pageTitleKey: 'admin.t_media',
    127125    bodyClass: 'on-admin',
  • src/routes/admin-paid.js

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

    r7cfe35a ra29c8c8  
    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',
    6664    pageTitleKey: 'admin.t_playlists',
    6765    playlists,
  • src/routes/admin-push.js

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

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

    r7cfe35a ra29c8c8  
    200200router.get('/new', requireGod, (req, res) => {
    201201  renderPage(req, res, 'pages/admin-site-edit', {
    202     pageJs: 'admin-site-edit',
    203202    pageTitleKey: 'admin.t_newsite',
    204203    bodyClass: 'on-admin',
     
    284283
    285284  renderPage(req, res, 'pages/admin-site-edit', {
    286     pageJs: 'admin-site-edit',
    287285    pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
    288286    bodyClass: 'on-admin',
  • src/routes/admin.js

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

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

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

    r7cfe35a ra29c8c8  
    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',
    7978    pageTitleKey: 'ppk.t', bodyClass: 'on-post',
    8079    optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
  • src/routes/posts.js

    r7cfe35a ra29c8c8  
    256256
    257257  renderPage(req, res, 'pages/post-edit', {
    258     // post-edit neemt de playlist-editor op.
    259     pageJs: 'post-edit playlist-editor',
    260258    post: {
    261259      id: uuid(),
     
    797795  }
    798796  renderPage(req, res, 'pages/authorize-interaction', {
    799     pageJs: 'authorize-interaction',
    800797    pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
    801798    bodyClass: 'on-special',
     
    11961193  }
    11971194  renderPage(req, res, 'pages/news', {
    1198     pageJs: 'news',
    11991195    pageTitle: 'News', bodyClass: 'on-special',
    12001196    timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
     
    13941390    const { newerPost, olderPost } = postNeighbors(site, post);
    13951391    return renderPage(req, res, 'pages/paid-gate', {
    1396     pageJs: 'paid-gate',
    13971392      pageTitle: post.title || 'Voor supporters',
    13981393      bodyClass: 'on-special',
  • src/views/pages/admin-audio.ejs

    r7cfe35a ra29c8c8  
    573573<%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    574574
    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') } }) %>
     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>
  • src/views/pages/admin-epk.ejs

    r7cfe35a ra29c8c8  
    9090  .admin-page .epk-pick-item input:disabled ~ .epk-pick-title { opacity: 0.45; }
    9191</style>
    92 <%# Het script van deze pagina staat in assets/js/mod/admin-epk.js (shaer-bqr). %>
     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>
  • src/views/pages/admin-help.ejs

    r7cfe35a ra29c8c8  
    145145  .hb-empty { color: var(--ink-muted, var(--ink-soft)); padding: 1rem; text-align: center; }
    146146</style>
    147 <%# Het script van deze pagina staat in assets/js/mod/admin-help.js (shaer-bqr). %>
     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>
  • src/views/pages/admin-media.ejs

    r7cfe35a ra29c8c8  
    4141</div>
    4242
    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') } }) %>
     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>
  • src/views/pages/admin-paid.ejs

    r7cfe35a ra29c8c8  
    7272</div>
    7373
    74 <%# Het script van deze pagina staat in assets/js/mod/admin-paid.js (shaer-bqr). %>
     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>
  • src/views/pages/admin-playlists.ejs

    r7cfe35a ra29c8c8  
    222222</style>
    223223
    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') } }) %>
     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>
  • src/views/pages/admin-push.ejs

    r7cfe35a ra29c8c8  
    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 <%# Het script van deze pagina staat in assets/js/mod/admin-push.js (shaer-bqr). %>
     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>
    61163<% } %>
  • src/views/pages/admin-settings.ejs

    r7cfe35a ra29c8c8  
    2727      <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div>
    2828    </form>
    29     <%# Het script van deze pagina staat in assets/js/mod/admin-settings.js (shaer-bqr). %>
     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>
    3041  </section>
    3142
  • src/views/pages/admin-site-edit.ejs

    r7cfe35a ra29c8c8  
    126126    <%# Live preview: selecting an accent / theme / palette re-themes this page
    127127        instantly (reverts on reload; persists on save). %>
    128     <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %>
     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>
    129149
    130150    <fieldset>
     
    255275</div>
    256276
    257 
     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>
    258377
    259378<style>
  • src/views/pages/admin-videos.ejs

    r7cfe35a ra29c8c8  
    4040</div>
    4141
    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') } }) %>
     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>
  • src/views/pages/auth-register.ejs

    r7cfe35a ra29c8c8  
    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 <%# Het script van deze pagina staat in assets/js/mod/auth-register.js (shaer-bqr). %>
     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>
  • src/views/pages/authorize-interaction.ejs

    r7cfe35a ra29c8c8  
    1212      <p class="fedi-bm-help"><%= t('fedi.bm_help') %></p>
    1313    </div>
    14     <%# Het script van deze pagina staat in assets/js/mod/authorize-interaction.js (shaer-bqr). %>
     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>
    1522    <% if (!manage.length) { %>
    1623      <p class="auth-interact-note"><%= t('fedi.manage_empty') %></p>
     
    4956        <% }); %>
    5057      </ul>
    51      
     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>
    5273    <% } %>
    5374    <p class="auth-interact-note"><a href="/"><%= t('fedi.remote_back') %></a></p>
     
    310331</style>
    311332
    312 
     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>
  • src/views/pages/download.ejs

    r7cfe35a ra29c8c8  
    2626      <p class="dl-sub"><%= _ready_sub %></p>
    2727      <p><a class="dl-go" href="<%= fileUrl %>"><%= _manual %></a></p>
    28       <%# Het script staat in assets/js/mod/download.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
     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>
    2932    <% } else { %>
    3033      <h1 class="dl-h1"><%= _download_btn %> <%= tr.title %></h1>
     
    5457  .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; }
    5558</style>
    56 <%- include('../partials/page-data', { pageData: { fileUrl: fileUrl } }) %>
  • src/views/pages/news.ejs

    r7cfe35a ra29c8c8  
    4141    .tl-paste-go { background: var(--accent); color: var(--paper); }
    4242  </style>
    43   <%# Het script staat in assets/js/mod/news.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
     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>
    4481  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
    4582  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
     
    147184  .tl-act-block:hover { background: color-mix(in srgb, #d9534f 14%, transparent); border-color: color-mix(in srgb, #d9534f 50%, transparent); }
    148185</style>
    149 <%- include('../partials/page-data', { pageData: { readMore: t('tl.read_more'), showLess: t('tl.show_less') } }) %>
     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>
  • src/views/pages/paid-gate.ejs

    r7cfe35a ra29c8c8  
    3131
    3232<script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script>
    33 <%# Het script staat in assets/js/mod/paid-gate.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     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>
    3487
    3588<style>
     
    52105  .pg-status.is-err { color: #c0392b; }
    53106</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

    r7cfe35a ra29c8c8  
    1212
    1313<script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script>
    14 <%# Het script staat in assets/js/mod/paid-passkey.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     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>
    1548
    1649<style>
     
    2558  .pk-status.is-err { color: #c0392b; }
    2659</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

    r7cfe35a ra29c8c8  
    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           <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     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>
    275280        <% } %>
    276        
     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>
    277290        <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen.
    278291           var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; }
     
    322335          </select>
    323336        </div>
    324        
     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>
    325364        <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
    326365          <label class="pe-checkbox">
     
    338377            </label>
    339378          </div>
    340          
     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>
    341383          <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
    342384          <label class="pe-checkbox" style="margin-top:8px">
     
    350392            <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
    351393          </div>
    352          
     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>
    353452        <% } %>
    354453      </div>
     
    11821281</style>
    11831282
    1184 
     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>
    11852200
    11862201<%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
     
    12142229  <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    12152230<% } %>
    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') } }) %>
     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>
  • src/views/partials/playlist-editor.ejs

    r7cfe35a ra29c8c8  
    280280</style>
    281281
    282 <%# Het script staat in assets/js/mod/playlist-editor.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     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>
  • src/views/partials/profile-header.ejs

    r7cfe35a ra29c8c8  
    160160</div>
    161161<% } %>
    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). %>
     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>
    165219<% } %>
    166220
  • src/views/partials/profile-sheet.ejs

    r7cfe35a ra29c8c8  
    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" data-dark="<%= t('nav.dark') %>" data-light="<%= t('nav.light') %>"><%= t('nav.dark') %></span>
     107          <span class="psi-meta" id="profile-sheet-theme-state"><%= t('nav.dark') %></span>
    108108        </button>
    109109      </li>
     
    326326</style>
    327327
    328 <%# Het script van het profielblad staat in assets/js/mod/chrome.js (shaer-bqr). %>
     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>
  • src/views/partials/topnav.ejs

    r7cfe35a ra29c8c8  
    176176
    177177<!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) -->
    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') }) %>">
     178<div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search">
    182179  <div class="container">
    183180    <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search">
     
    194191</div>
    195192
    196 <%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %>
     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>
    197274
    198275<style>
  • src/views/partials/track-editor.ejs

    r7cfe35a ra29c8c8  
    284284</style>
    285285
    286 <%# Het script van deze pagina staat in assets/js/mod/track-editor.js (shaer-bqr). %>
     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>
Note: See TracChangeset for help on using the changeset viewer.