Changeset 6ee289a in Klonkt


Ignore:
Timestamp:
08/07/2026 01:38:51 PM (5 weeks ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
fb9a8ad
Parents:
156baa3
git-author:
Robin <roboburr@…> (08/07/2026 01:33:49 PM)
git-committer:
roboburr <roboburr@…> (08/07/2026 01:38:51 PM)
Message:

De audio- en afspeellijst-editors (shaer-bqr, stap 3f)

admin-audio (435 regels, 18 vertaalsleutels) en playlist-editor (406 regels, het
csrf-token). Daarmee zijn 23 van de 24 bestanden om; alleen post-edit rest.

De achttien vertalingen van admin-audio stonden verspreid door de code, midden in
stringconcatenaties. Ze gaan nu als een tabel via pageData en worden opgezocht in
plaats van geinterpoleerd. Steekproefsgewijs nagelopen op de plekken waar de tekst
de HELE string was: daar bleef anders een leeg begin of eind over.

admin-playlists neemt de playlist-editor op, dus die route vraagt nu om
'admin-playlists playlist-editor' -- een module hoort bij het onderdeel, niet bij
de pagina die het toevallig opneemt.

Templates compileren, 22 modules schoon, suite 565/565.

Location:
src
Files:
2 added
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/admin-playlists.js

    r156baa3 r6ee289a  
    6262  const playlists = PlaylistService.list(site.id);
    6363  renderPage(req, res, 'pages/admin-playlists', {
    64     pageJs: 'admin-playlists',
     64    // admin-playlists neemt de playlist-editor op, dus die module hoort erbij.
     65    pageJs: 'admin-playlists playlist-editor',
    6566    pageTitleKey: 'admin.t_playlists',
    6667    playlists,
  • src/views/pages/admin-audio.ejs

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

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