Changeset 54ee51c in Klonkt


Ignore:
Timestamp:
08/07/2026 01:38:51 PM (5 weeks ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
52fc278
Parents:
0475b13
git-author:
Robin <roboburr@…> (08/07/2026 01:07:55 PM)
git-committer:
roboburr <roboburr@…> (08/07/2026 01:38:51 PM)
Message:

De profielkop, de track-editor en het data-kanaal (shaer-bqr, stap 3c)

PROFIELKOP naar mod/chrome.js: chrome.ejs neemt die partial op, dus hij zit in
de OOB-swap en kwam bij elke navigatie opnieuw binnen. Hoort bij de chrome, niet
bij een pagina.

TRACK-EDITOR (475 regels, geen interpolatie) naar een eigen module. admin-audio
neemt hem op, dus die route vraagt om 'admin-audio track-editor' -- een pagina
mag meer dan een module willen.

HET DATA-KANAAL, want de rest heeft het nodig. Een module is een statisch
bestand, dus er kan geen EJS in; alles wat een script van de server nodig heeft
gaat via partials/page-data.ejs en wordt gelezen met pageData() uit mod/lib.js.

Dat is een script-element en toch geen CSP-probleem: een script met een ander
type dan JavaScript wordt niet uitgevoerd en niet opgehaald, dus script-src heeft
er niets over te zeggen. Het werkt daarmee ook in inhoud die via htmx binnenkomt.

Twee dingen die daar misgaan als je ze niet doet, en allebei nagemeten:

De kleiner-dan wordt ontsnapt. Zonder dat sluit een sluitende script-tag IN de
gegevens het blok voortijdig af en staat de rest als HTML op de pagina.
Getoetst met precies zo'n waarde.

pageData() leest ELKE KEER opnieuw en onthoudt niets. Bij een htmx-navigatie
wisselt de inhoud terwijl de module blijft leven, dus een blok dat je bij het
laden vasthoudt is een pagina later verouderd.

Alle templates compileren.

Location:
src
Files:
3 added
4 edited

Legend:

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

    r0475b13 r54ee51c  
    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})();
  • src/routes/admin-audio.js

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

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

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