Index: src/assets/js/mod/admin-audio.js
===================================================================
--- src/assets/js/mod/admin-audio.js	(revision 6ee289aed635d76572556190200f28678d2d970c)
+++ src/assets/js/mod/admin-audio.js	(revision 6ee289aed635d76572556190200f28678d2d970c)
@@ -0,0 +1,442 @@
+// Audio in beheer -- verplaatst uit inline script, shaer-bqr.
+//
+// Servergegevens komen uit pageData(); interpolatie kan niet in een statisch
+// bestand. Inline script wordt bovendien geweigerd zodra deze pagina via een
+// link BINNEN de site binnenkomt (shaer-0i6).
+
+import { pageData } from './lib.js';
+
+  const T = pageData();
+
+(function() {
+
+  // ── Live filename display for custom file inputs ──────────────
+  document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
+    const nameEl = input.parentElement.querySelector('.ax-file-name');
+    if (!nameEl) return;
+    input.addEventListener('change', () => {
+      if (input.files && input.files[0]) {
+        nameEl.textContent = input.files[0].name;
+        nameEl.removeAttribute('data-empty');
+      } else {
+        nameEl.textContent = (T.no_file || '');
+        nameEl.setAttribute('data-empty', '');
+      }
+    });
+  });
+
+  // ── Inline track preview (routed through the global mini-player) ──
+  // Each .ax-track-play button is a thin wrapper around the global
+  // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
+  // .is-playing) is synced from the player's own audio element so it
+  // stays accurate even when the user uses the mini-player's controls.
+  (function setupTrackPreview() {
+    const buttons = document.querySelectorAll('.ax-track-play');
+    if (!buttons.length) return;
+
+    function setIcon(btn, playing) {
+      const icon = btn.querySelector('.ax-track-play-icon');
+      if (icon) icon.textContent = playing ? '⏸' : '▶';
+      btn.classList.toggle('is-playing', playing);
+      btn.setAttribute('aria-label', playing ? (T.pause || '') : (T.play || ''));
+    }
+
+    // Resync ALL preview buttons against the current audio element state.
+    // Called on every play/pause/ended event so admins always see the right
+    // icon — including the case where they hit pause on the mini-player
+    // bar instead of the row's button.
+    function resyncAll() {
+      const audio = document.getElementById('audio-element');
+      const player = window.pcmsAudioPlayer;
+      const playing = audio && !audio.paused && !audio.ended;
+      // audio.src is now a blob: URL (Spotify-style playback), so compare
+      // against the player's logical track URL, not the element src.
+      const cur = player && player.currentTrack();
+      const curUrl = cur ? cur.url : '';
+      buttons.forEach(b => {
+        const isThisOne = playing && curUrl === b.dataset.streamUrl;
+        setIcon(b, isThisOne);
+      });
+    }
+
+    buttons.forEach(btn => {
+      btn.addEventListener('click', e => {
+        e.preventDefault();
+        const player = window.pcmsAudioPlayer;
+        if (!player) {
+          console.warn('[admin-audio] miniplayer not available');
+          return;
+        }
+        const url = btn.dataset.streamUrl;
+        if (!url) return;
+
+        // Build track metadata from the row's DOM so the mini-player shows
+        // useful info (title/artist/album/cover) without an extra API call.
+        const row = btn.closest('.ax-track');
+        const titleEl  = row && row.querySelector('[data-cell="title"]');
+        const artistEl = row && row.querySelector('[data-cell="artist"]');
+        const albumEl  = row && row.querySelector('[data-cell="album"]');
+        const coverImg = row && row.querySelector('img[data-cover-thumb]');
+        const track = {
+          url,
+          title:  titleEl  ? titleEl.textContent.trim()  : 'Track',
+          artist: artistEl ? artistEl.textContent.trim() : '',
+          album:  albumEl  ? albumEl.textContent.trim()  : '',
+          cover:  coverImg ? coverImg.src                : '',
+        };
+
+        // If this exact track is already current, toggle pause/play instead
+        // of restarting from zero. Compare logical URLs (audio.src is a blob:).
+        const cur = player.currentTrack();
+        if (cur && cur.url === url) {
+          if (player.isPlaying()) player.pause();
+          else                    player.play();
+          return;
+        }
+
+        player.setQueue([track], 0);
+      });
+    });
+
+    // Hook the global audio element's events to keep the row buttons synced.
+    // We attach lazily after the player has built its DOM. The script in
+    // shell.ejs runs at page-load so #audio-element exists by the time
+    // this IIFE fires (script tag is below the body content).
+    const audio = document.getElementById('audio-element');
+    if (audio) {
+      ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
+        audio.addEventListener(ev, resyncAll);
+      });
+      // Initial state on page load (e.g. user navigated back while a track
+      // was already playing — buttons should reflect that).
+      resyncAll();
+    }
+  })();
+
+
+  // ── Bulk upload (drag-drop + sequential transcoding) ─────────
+  // Files dropped or picked are queued (not uploaded immediately) so the
+  // user can review the list, set shared metadata, then hit "Start upload".
+  // The loop POSTs one file at a time to /admin/audio/upload with
+  // Accept: application/json so the server returns structured per-file
+  // results instead of redirecting.
+  const dropzone   = document.getElementById('audio-dropzone');
+  const fileInput  = document.getElementById('audio-files');
+  const queueEl    = document.getElementById('upload-queue');
+  const actionsEl  = document.getElementById('upload-actions');
+  const startBtn   = document.getElementById('start-upload-btn');
+  const clearBtn   = document.getElementById('clear-queue-btn');
+  const artistInput= document.getElementById('batch-artist');
+  const albumInput = document.getElementById('batch-album');
+  const coverInput = document.getElementById('batch-cover');
+
+  /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
+  const queue = [];
+
+  function fmtBytes(n) {
+    if (n < 1024) return n + ' B';
+    if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
+    return (n / 1024 / 1024).toFixed(1) + ' MB';
+  }
+
+  function setItemStatus(item, status, label) {
+    const labels = {
+      queued:      (T.st_queued || ''),
+      uploading:   (T.st_uploading || ''),
+      transcoding: (T.st_transcoding || ''),
+      done:        '✓ ' + (T.st_done || ''),
+      error:       '✗ ' + (T.st_error || ''),
+    };
+    item.status = status;
+    const badge = item.el.querySelector('.ax-queue-status');
+    badge.className = 'ax-queue-status ax-queue-status--' + status;
+    badge.textContent = label || labels[status] || status;
+    // Restyle the row
+    item.el.classList.remove(
+      'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
+    );
+    if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
+    else if (status === 'done')  item.el.classList.add('ax-queue-item--done');
+    else if (status === 'error') item.el.classList.add('ax-queue-item--error');
+  }
+
+  function addFiles(files) {
+    let added = 0;
+    for (const file of files) {
+      if (!file.type.startsWith('audio/') &&
+          !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
+        // Silently skip non-audio drops; keeps the UX uncluttered.
+        continue;
+      }
+      const li = document.createElement('li');
+      li.className = 'ax-queue-item';
+      li.innerHTML =
+        '<div class="ax-queue-name"></div>' +
+        '<span class="ax-queue-status ax-queue-status--queued">' + (T.st_queued || '') + '</span>';
+      // Use textContent to avoid HTML-injection if a filename contains markup.
+      li.querySelector('.ax-queue-name').textContent = file.name;
+      // Append size hint inline
+      const size = document.createElement('span');
+      size.className = 'ax-queue-size';
+      size.textContent = fmtBytes(file.size);
+      li.querySelector('.ax-queue-name').appendChild(size);
+      queueEl.appendChild(li);
+      queue.push({ file, el: li, status: 'queued' });
+      added++;
+    }
+    if (added) {
+      queueEl.hidden = false;
+      actionsEl.hidden = false;
+    }
+  }
+
+  // ── Drop-zone events ─────────────────────────────────────────
+  // Page-level guard: a dropped file outside the zone would otherwise
+  // make the browser navigate to it (e.g. opening the audio inline), which
+  // discards typed metadata. We swallow drops anywhere unless the dropzone
+  // explicitly handles them.
+  ['dragover', 'drop'].forEach(ev => {
+    window.addEventListener(ev, e => {
+      // Allow drops INSIDE the dropzone — its own listener handles those.
+      if (dropzone.contains(e.target)) return;
+      e.preventDefault();
+    });
+  });
+
+  ['dragenter', 'dragover'].forEach(ev => {
+    dropzone.addEventListener(ev, e => {
+      e.preventDefault();
+      dropzone.classList.add('is-dragover');
+    });
+  });
+  ['dragleave', 'drop'].forEach(ev => {
+    dropzone.addEventListener(ev, e => {
+      e.preventDefault();
+      dropzone.classList.remove('is-dragover');
+    });
+  });
+  dropzone.addEventListener('drop', e => {
+    if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
+  });
+  fileInput.addEventListener('change', () => {
+    addFiles(fileInput.files);
+    // Reset so the same file can be picked again later if user wants
+    fileInput.value = '';
+  });
+
+  // ── Clear queue button ───────────────────────────────────────
+  clearBtn.addEventListener('click', () => {
+    // Only remove items that aren't currently uploading (anything queued
+    // or already finished). An in-progress upload finishes, then its row
+    // would also disappear once we re-render — but we keep it simple and
+    // just refuse to clear during an active run.
+    if (startBtn.disabled) return;
+    queue.length = 0;
+    queueEl.innerHTML = '';
+    queueEl.hidden = true;
+    actionsEl.hidden = true;
+  });
+
+  // ── Sequential upload loop ───────────────────────────────────
+  startBtn.addEventListener('click', async () => {
+    if (startBtn.disabled) return;
+    startBtn.disabled = true;
+    clearBtn.disabled = true;
+    dropzone.style.pointerEvents = 'none';
+    dropzone.style.opacity = '0.5';
+
+    const sharedArtist = artistInput.value.trim();
+    const sharedAlbum  = albumInput.value.trim();
+    const sharedCover  = coverInput.files && coverInput.files[0];
+
+    // Process queued items one at a time. We iterate via index so that
+    // if more files get dropped during the run they ALSO get processed
+    // (queue.push above mutates the same array we're iterating).
+    for (let i = 0; i < queue.length; i++) {
+      const item = queue[i];
+      if (item.status !== 'queued') continue;
+      try {
+        await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
+      } catch (err) {
+        console.error('upload failed for', item.file.name, err);
+        setItemStatus(item, 'error', '✗ ' + (err.message || (T.failed || '')));
+      }
+    }
+
+    startBtn.disabled = false;
+    clearBtn.disabled = false;
+    dropzone.style.pointerEvents = '';
+    dropzone.style.opacity = '';
+
+    // Reload the page so the new tracks appear in the list below.
+    // Could also fetch them and inject, but a full reload is simpler and
+    // ensures position indexes / album-grouping are correct.
+    const anyDone = queue.some(q => q.status === 'done');
+    if (anyDone) {
+      setTimeout(() => location.reload(), 700);
+    }
+  });
+
+  async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
+    setItemStatus(item, 'uploading');
+
+    const fd = new FormData();
+    fd.append('audio', item.file);
+    if (sharedArtist) fd.append('artist', sharedArtist);
+    if (sharedAlbum)  fd.append('album',  sharedAlbum);
+    if (sharedCover)  fd.append('cover',  sharedCover);
+    // Title is intentionally omitted — server uses filename fallback.
+
+    // We can't reliably distinguish "still uploading bytes" from
+    // "uploading done, ffmpeg running" without progress events, but the
+    // status flips to "Converteren…" once the request is past upload phase.
+    // We approximate this by waiting until the response arrives — by then
+    // both phases are complete on the server side. For a smoother feel we
+    // briefly show "transcoding" near the end of the request lifecycle.
+    const transcodeHint = setTimeout(() => {
+      if (item.status === 'uploading') setItemStatus(item, 'transcoding');
+    }, 1500);
+
+    try {
+      const res = await fetch('/admin/audio/upload', {
+        method: 'POST',
+        headers: { 'Accept': 'application/json' },
+        body: fd,
+        credentials: 'same-origin',
+      });
+      clearTimeout(transcodeHint);
+
+      // Server responds with JSON for our Accept header. If it didn't
+      // (e.g. session expired and got an HTML login page), surface that.
+      let data;
+      try { data = await res.json(); }
+      catch (_) { throw new Error((T.err_unexpected || '') + ' (' + res.status + ')'); }
+
+      if (!res.ok || !data.ok) {
+        throw new Error(data.error || ('HTTP ' + res.status));
+      }
+
+      setItemStatus(item, 'done', '✓ ' + (data.title || (T.st_done || '')));
+    } catch (err) {
+      clearTimeout(transcodeHint);
+      throw err;
+    }
+  }
+
+  // ── Click-to-copy embed codes ─────────────────────────────────
+  document.querySelectorAll('[data-copy]').forEach(el => {
+    el.addEventListener('click', async () => {
+      const text = el.dataset.copy;
+      try {
+        await navigator.clipboard.writeText(text);
+        el.classList.add('is-copied');
+        const original = el.textContent;
+        el.textContent = '✓ ' + (T.copied || '');
+        setTimeout(() => {
+          el.classList.remove('is-copied');
+          el.textContent = original;
+        }, 1200);
+      } catch (_) { /* fall through — selection still works */ }
+    });
+  });
+
+  // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
+  const addLinkBtn = document.getElementById('add-link-track-btn');
+  if (addLinkBtn) {
+    addLinkBtn.addEventListener('click', async () => {
+      addLinkBtn.disabled = true;
+      try {
+        const r = await fetch('/admin/audio/create-link', {
+          method: 'POST', credentials: 'same-origin',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ title: (T.new_track || '') }),
+        });
+        const j = await r.json();
+        if (!r.ok || !j.ok) throw new Error(j.error || (T.create_failed || ''));
+        if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
+        window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
+      } catch (err) {
+        alert((T.create_failed || '') + ': ' + err.message);
+      } finally {
+        addLinkBtn.disabled = false;
+      }
+    });
+  }
+
+  // ── Wire all "Edit" buttons to the track-editor modal ─────────
+  // After save we patch the row in-place rather than reloading,
+  // so the user keeps their scroll position on long lists.
+  document.querySelectorAll('[data-track-edit]').forEach(btn => {
+    btn.addEventListener('click', () => {
+      if (typeof window.openTrackEditor !== 'function') {
+        alert((T.editor_not_loaded || ''));
+        return;
+      }
+      const id = btn.dataset.id;
+      window.openTrackEditor({
+        id,
+        onSaved: (track) => {
+          const row = document.querySelector('li[data-track-id="' + id + '"]');
+          if (!row) return;
+          // Update visible cells
+          const titleEl  = row.querySelector('[data-cell="title"]');
+          const artistEl = row.querySelector('[data-cell="artist"]');
+          const albumEl  = row.querySelector('[data-cell="album"]');
+          if (titleEl)  titleEl.textContent  = track.title  || (T.untitled || '');
+          if (artistEl) artistEl.textContent = track.artist || '—';
+          if (albumEl)  {
+            albumEl.textContent = track.album || '';
+            if (track.album) albumEl.removeAttribute('hidden');
+            else albumEl.setAttribute('hidden', '');
+          }
+          // Update cover thumb (replace element if type changed)
+          const oldThumb = row.querySelector('[data-cover-thumb]');
+          if (oldThumb) {
+            const parent = oldThumb.parentElement;
+            if (track.cover_url) {
+              const img = document.createElement('img');
+              img.className = 'ax-track-cover';
+              img.src = track.cover_url;
+              img.alt = '';
+              img.dataset.coverThumb = '';
+              parent.replaceChild(img, oldThumb);
+            } else {
+              const sp = document.createElement('span');
+              sp.className = 'ax-track-cover ax-track-cover-empty';
+              sp.textContent = '♫';
+              sp.dataset.coverThumb = '';
+              parent.replaceChild(sp, oldThumb);
+            }
+          }
+        },
+      });
+    });
+  });
+
+  // Download-voor-email toggle — AJAX (geen pagina-reload meer).
+  document.querySelectorAll('[data-track-dl]').forEach(btn => {
+    btn.addEventListener('click', async () => {
+      if (btn.disabled) return;
+      const want = btn.dataset.on === '1' ? 0 : 1;
+      btn.disabled = true;
+      try {
+        const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ downloadable: !!want }),
+        });
+        if (!res.ok) throw new Error('HTTP ' + res.status);
+        btn.dataset.on = String(want);
+        btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
+        btn.style.opacity = want ? '1' : '.5';
+        btn.title = want
+          ? (T.dl_on || '')
+          : (T.dl_off || '');
+      } catch (e) {
+        alert((T.change_failed || '') + ': ' + (e.message || e));
+      } finally {
+        btn.disabled = false;
+      }
+    });
+  });
+})();
Index: src/assets/js/mod/playlist-editor.js
===================================================================
--- src/assets/js/mod/playlist-editor.js	(revision 6ee289aed635d76572556190200f28678d2d970c)
+++ src/assets/js/mod/playlist-editor.js	(revision 6ee289aed635d76572556190200f28678d2d970c)
@@ -0,0 +1,412 @@
+// De afspeellijst-editor -- verplaatst uit inline script, shaer-bqr.
+//
+// Servergegevens komen uit pageData(); interpolatie kan niet in een statisch
+// bestand. Inline script wordt bovendien geweigerd zodra deze pagina via een
+// link BINNEN de site binnenkomt (shaer-0i6).
+
+import { pageData } from './lib.js';
+
+(function() {
+  // Idempotency: if window.openPlaylistEditor already defined (multiple
+  // partial includes on a single page), skip re-binding.
+  if (typeof window.openPlaylistEditor === 'function') return;
+
+  const CSRF = pageData().csrf || '';
+
+  function esc(s) {
+    return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
+      '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
+    }[c]));
+  }
+  function fmtDur(sec) {
+    if (!sec) return '';
+    const m = Math.floor(sec / 60), s = sec % 60;
+    return `${m}:${String(s).padStart(2, '0')}`;
+  }
+
+  async function api(method, url, body) {
+    const opts = {
+      method, credentials: 'same-origin',
+      headers: { 'X-CSRF-Token': CSRF },
+    };
+    if (body !== undefined) {
+      opts.headers['Content-Type'] = 'application/json';
+      opts.body = JSON.stringify(body);
+    }
+    const r = await fetch(url, opts);
+    return r.json();
+  }
+
+  /**
+   * Public entry point: open the editor.
+   *  opts: { mode: 'create'|'edit', id?, onSaved? }
+   *  onSaved is called with { id, playlist } after a successful save.
+   */
+  window.openPlaylistEditor = async function(opts) {
+    opts = opts || {};
+    const mode = opts.mode === 'edit' ? 'edit' : 'create';
+    const isEdit = mode === 'edit';
+
+    // Load all audio tracks for the picker
+    let tracks = [];
+    try {
+      const j = await api('GET', '/admin/playlists/api/tracks');
+      if (Array.isArray(j.tracks)) tracks = j.tracks;
+    } catch (e) {
+      alert('Tracks ophalen mislukt');
+      return;
+    }
+    if (!tracks.length) {
+      alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');
+      return;
+    }
+
+    // Existing playlist data when editing
+    let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] };
+    if (isEdit && opts.id) {
+      try {
+        const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
+        if (j.ok) initial = { ...initial, ...j.playlist };
+      } catch (e) {}
+    }
+
+    // ── DOM ────────────────────────────────────────────────────────
+    const backdrop = document.createElement('div');
+    backdrop.className = 'pl-modal-backdrop';
+    backdrop.innerHTML = `
+      <div class="pl-modal" role="dialog" aria-label="Playlist editor">
+        <div class="pl-modal-header">
+          <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3>
+          <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button>
+        </div>
+        <div class="pl-modal-body">
+          <div class="pl-editor-cols">
+            <div class="pl-editor-left">
+              <div class="pl-meta-grid">
+                <label class="pl-field pl-field-full">
+                  <span>Titel *</span>
+                  <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
+                </label>
+                <label class="pl-field">
+                  <span>Artiest</span>
+                  <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
+                </label>
+                <label class="pl-field">
+                  <span>Jaar</span>
+                  <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
+                </label>
+                <label class="pl-field">
+                  <span>Type</span>
+                  <select id="pli-kind">
+                    <option value="album"    ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option>
+                    <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option>
+                  </select>
+                </label>
+                <div class="pl-field pl-field-full">
+                  <span>Cover</span>
+                  <div class="pl-cover-row">
+                    <span class="pl-cover-thumb" id="pli-cover-thumb">
+                      ${initial.cover
+                        ? `<img src="${esc(initial.cover)}" alt="">`
+                        : `<span class="pl-cover-empty">🎨</span>`}
+                    </span>
+                    <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1">
+                  </div>
+                  <div class="pl-cover-upload-row">
+                    <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
+                    <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
+                    <span class="pl-cover-status" id="pli-cover-status"></span>
+                  </div>
+                </div>
+              </div>
+              <div class="pl-section-title">
+                Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
+                <small>(sleep ⠿ om te ordenen)</small>
+              </div>
+              <div id="pli-selected" class="pl-selected-list"></div>
+            </div>
+            <div class="pl-editor-right">
+              <div class="pl-section-title">Beschikbare tracks</div>
+              <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
+              <div id="pli-available" class="pl-available-list"></div>
+            </div>
+          </div>
+        </div>
+        <div class="pl-modal-footer">
+          <button type="button" class="btn" id="pli-cancel">Annuleren</button>
+          <button type="button" class="btn btn-primary" id="pli-save" disabled>
+            ${isEdit ? 'Opslaan' : 'Aanmaken'}
+          </button>
+        </div>
+      </div>
+    `;
+    document.body.appendChild(backdrop);
+
+    const $ = sel => backdrop.querySelector(sel);
+    const close = () => backdrop.remove();
+    backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
+    $('.pl-modal-close').addEventListener('click', close);
+    $('#pli-cancel').addEventListener('click', close);
+
+    // Index tracks by id for fast lookup
+    const trackById = new Map(tracks.map(t => [t.id, t]));
+    let selected = (initial.track_ids || []).filter(id => trackById.has(id));
+
+    const titleEl = $('#pli-title');
+    const artistEl = $('#pli-artist');
+    const yearEl = $('#pli-year');
+    const kindEl = $('#pli-kind');
+    const coverEl = $('#pli-cover');
+    const coverThumb = $('#pli-cover-thumb');
+    const saveBtn = $('#pli-save');
+    const selectedEl = $('#pli-selected');
+    const availEl = $('#pli-available');
+    const searchEl = $('#pli-search');
+    const countEl = $('#pli-count');
+
+    coverEl.addEventListener('input', () => {
+      const u = coverEl.value.trim();
+      coverThumb.innerHTML = u
+        ? `<img src="${esc(u)}" alt="" data-fallback>`
+        : `<span class="pl-cover-empty">🎨</span>`;
+    });
+
+    // ── Cover file upload (werkt in create ÉN edit) ───────────
+    // We uploaden naar het generieke image-endpoint (/posts/upload-image,
+    // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
+    // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het
+    // cover-veld en wordt bij 'Aanmaken'/'Opslaan' met de playlist meegestuurd.
+    const coverFileInput = $('#pli-cover-file');
+    const coverPickBtn   = $('#pli-cover-pick');
+    const coverStatus    = $('#pli-cover-status');
+    if (coverFileInput && coverPickBtn) {
+      coverPickBtn.addEventListener('click', () => coverFileInput.click());
+      coverFileInput.addEventListener('change', async (e) => {
+        const file = e.target.files && e.target.files[0];
+        coverFileInput.value = '';
+        if (!file) return;
+        if (!/^image\//.test(file.type)) {
+          coverStatus.textContent = 'Alleen afbeeldingen';
+          coverStatus.className = 'pl-cover-status is-error';
+          return;
+        }
+        coverStatus.textContent = 'Uploaden…';
+        coverStatus.className = 'pl-cover-status';
+        const fd = new FormData();
+        fd.append('image', file);
+        try {
+          const r = await fetch('/posts/upload-image',
+            { method: 'POST', body: fd, credentials: 'same-origin' }
+          );
+          const j = await r.json();
+          if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt');
+          const url = j.url || '';
+          coverEl.value = url;
+          coverThumb.innerHTML = url
+            ? `<img src="${esc(url)}" alt="">`
+            : `<span class="pl-cover-empty">🎨</span>`;
+          coverStatus.textContent = '✓ Geüpload';
+          coverStatus.className = 'pl-cover-status is-ok';
+        } catch (err) {
+          coverStatus.textContent = 'Mislukt: ' + err.message;
+          coverStatus.className = 'pl-cover-status is-error';
+        }
+      });
+    }
+
+    function updateSaveBtn() {
+      saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
+    }
+
+    function renderSelected() {
+      countEl.textContent = selected.length;
+      if (selected.length === 0) {
+        selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
+        updateSaveBtn();
+        return;
+      }
+      selectedEl.innerHTML = selected.map((id, i) => {
+        const t = trackById.get(id);
+        if (!t) return '';
+        const cover = t.cover
+          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
+          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
+        return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
+          <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
+          <span class="pl-row-num">${i + 1}</span>
+          ${cover}
+          <span class="pl-row-info">
+            <span class="pl-row-title">${esc(t.title)}</span>
+            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
+          </span>
+          <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
+        </div>`;
+      }).join('');
+
+      selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
+        b.addEventListener('click', () => {
+          selected = selected.filter(x => x !== b.dataset.id);
+          renderSelected();
+          renderAvailable();
+        });
+      });
+      bindDrag();
+      updateSaveBtn();
+    }
+
+    function renderAvailable() {
+      const q = searchEl.value.trim().toLowerCase();
+      const matches = tracks.filter(t => {
+        if (!q) return true;
+        return (t.title || '').toLowerCase().includes(q)
+            || (t.artist || '').toLowerCase().includes(q);
+      });
+      if (matches.length === 0) {
+        availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
+        return;
+      }
+      availEl.innerHTML = matches.map(t => {
+        const isAdded = selected.includes(t.id);
+        const cls = ['pl-avail-row'];
+        if (isAdded) cls.push('is-added');
+        if (!t.playable) cls.push('is-unplayable');
+        const cover = t.cover
+          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
+          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
+        return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
+          ${cover}
+          <span class="pl-row-info">
+            <span class="pl-row-title">${esc(t.title)}</span>
+            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
+          </span>
+          <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
+        </div>`;
+      }).join('');
+
+      availEl.querySelectorAll('.pl-avail-row').forEach(row => {
+        if (row.classList.contains('is-unplayable')) return;
+        row.addEventListener('click', () => {
+          const id = row.dataset.id;
+          if (selected.includes(id)) selected = selected.filter(x => x !== id);
+          else selected.push(id);
+          renderSelected();
+          renderAvailable();
+        });
+      });
+    }
+
+    // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
+    function bindDrag() {
+      selectedEl.querySelectorAll('.pl-row').forEach(row => {
+        const handle = row.querySelector('.pl-row-handle');
+        if (!handle) return;
+        let dragging = false, originalIdx = -1;
+
+        handle.addEventListener('pointerdown', e => {
+          e.preventDefault();
+          handle.setPointerCapture(e.pointerId);
+          dragging = true;
+          originalIdx = parseInt(row.dataset.pos, 10);
+          row.classList.add('is-dragging');
+        });
+        handle.addEventListener('pointermove', e => {
+          if (!dragging) return;
+          e.preventDefault();
+          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
+          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
+          let targetIdx = -1, above = false;
+          for (let i = 0; i < rows.length; i++) {
+            const r = rows[i]; if (r === row) continue;
+            const rect = r.getBoundingClientRect();
+            const mid = rect.top + rect.height / 2;
+            if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
+            if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
+              targetIdx = i; above = e.clientY < mid; break;
+            }
+          }
+          if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
+        });
+        const finish = e => {
+          if (!dragging) return;
+          dragging = false;
+          try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
+          row.classList.remove('is-dragging');
+          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
+          let targetIdx = -1, above = false;
+          for (let i = 0; i < rows.length; i++) {
+            if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
+            if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
+          }
+          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
+          if (targetIdx === -1 || targetIdx === originalIdx) return;
+          const moved = selected[originalIdx];
+          selected.splice(originalIdx, 1);
+          let newIdx = targetIdx;
+          if (originalIdx < targetIdx) newIdx--;
+          if (!above) newIdx++;
+          newIdx = Math.max(0, Math.min(selected.length, newIdx));
+          selected.splice(newIdx, 0, moved);
+          renderSelected();
+        };
+        handle.addEventListener('pointerup', finish);
+        handle.addEventListener('pointercancel', () => {
+          dragging = false;
+          row.classList.remove('is-dragging');
+          selectedEl.querySelectorAll('.drop-above, .drop-below')
+            .forEach(r => r.classList.remove('drop-above', 'drop-below'));
+        });
+      });
+    }
+
+    titleEl.addEventListener('input', updateSaveBtn);
+    searchEl.addEventListener('input', renderAvailable);
+    renderSelected();
+    renderAvailable();
+
+    saveBtn.addEventListener('click', async () => {
+      if (saveBtn.disabled) return;
+      const orig = saveBtn.textContent;
+      saveBtn.disabled = true;
+      saveBtn.textContent = 'Opslaan...';
+
+      const payload = {
+        title:  titleEl.value.trim(),
+        artist: artistEl.value.trim(),
+        year:   parseInt(yearEl.value, 10) || 0,
+        cover:  coverEl.value.trim(),
+        kind:   kindEl.value === 'playlist' ? 'playlist' : 'album',
+        tracks: selected.slice(),
+      };
+
+      try {
+        const url = isEdit
+          ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
+          : '/admin/playlists/api';
+        const j = await api('POST', url, payload);
+        if (!j.ok) {
+          alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
+          saveBtn.disabled = false;
+          saveBtn.textContent = orig;
+          return;
+        }
+        const savedId = isEdit ? initial.id : j.id;
+        close();
+        if (typeof opts.onSaved === 'function') {
+          opts.onSaved({ id: savedId, playlist: payload });
+        }
+      } catch (err) {
+        alert('Opslaan mislukt: ' + err.message);
+        saveBtn.disabled = false;
+        saveBtn.textContent = orig;
+      }
+    });
+
+    // ESC to close
+    document.addEventListener('keydown', function onEsc(e) {
+      if (e.key === 'Escape' && document.body.contains(backdrop)) {
+        close();
+        document.removeEventListener('keydown', onEsc);
+      }
+    });
+  };
+})();
Index: src/routes/admin-playlists.js
===================================================================
--- src/routes/admin-playlists.js	(revision 156baa32c3292b1c6ba350f1fede79d7cbeeea4d)
+++ src/routes/admin-playlists.js	(revision 6ee289aed635d76572556190200f28678d2d970c)
@@ -62,5 +62,6 @@
   const playlists = PlaylistService.list(site.id);
   renderPage(req, res, 'pages/admin-playlists', {
-    pageJs: 'admin-playlists',
+    // admin-playlists neemt de playlist-editor op, dus die module hoort erbij.
+    pageJs: 'admin-playlists playlist-editor',
     pageTitleKey: 'admin.t_playlists',
     playlists,
Index: src/views/pages/admin-audio.ejs
===================================================================
--- src/views/pages/admin-audio.ejs	(revision 156baa32c3292b1c6ba350f1fede79d7cbeeea4d)
+++ src/views/pages/admin-audio.ejs	(revision 6ee289aed635d76572556190200f28678d2d970c)
@@ -573,436 +573,4 @@
 <%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
 
-<script>
-(function() {
-
-  // ── Live filename display for custom file inputs ──────────────
-  document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
-    const nameEl = input.parentElement.querySelector('.ax-file-name');
-    if (!nameEl) return;
-    input.addEventListener('change', () => {
-      if (input.files && input.files[0]) {
-        nameEl.textContent = input.files[0].name;
-        nameEl.removeAttribute('data-empty');
-      } else {
-        nameEl.textContent = '<%= t('aaud.no_file') %>';
-        nameEl.setAttribute('data-empty', '');
-      }
-    });
-  });
-
-  // ── Inline track preview (routed through the global mini-player) ──
-  // Each .ax-track-play button is a thin wrapper around the global
-  // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
-  // .is-playing) is synced from the player's own audio element so it
-  // stays accurate even when the user uses the mini-player's controls.
-  (function setupTrackPreview() {
-    const buttons = document.querySelectorAll('.ax-track-play');
-    if (!buttons.length) return;
-
-    function setIcon(btn, playing) {
-      const icon = btn.querySelector('.ax-track-play-icon');
-      if (icon) icon.textContent = playing ? '⏸' : '▶';
-      btn.classList.toggle('is-playing', playing);
-      btn.setAttribute('aria-label', playing ? '<%= t('aaud.pause') %>' : '<%= t('aaud.play') %>');
-    }
-
-    // Resync ALL preview buttons against the current audio element state.
-    // Called on every play/pause/ended event so admins always see the right
-    // icon — including the case where they hit pause on the mini-player
-    // bar instead of the row's button.
-    function resyncAll() {
-      const audio = document.getElementById('audio-element');
-      const player = window.pcmsAudioPlayer;
-      const playing = audio && !audio.paused && !audio.ended;
-      // audio.src is now a blob: URL (Spotify-style playback), so compare
-      // against the player's logical track URL, not the element src.
-      const cur = player && player.currentTrack();
-      const curUrl = cur ? cur.url : '';
-      buttons.forEach(b => {
-        const isThisOne = playing && curUrl === b.dataset.streamUrl;
-        setIcon(b, isThisOne);
-      });
-    }
-
-    buttons.forEach(btn => {
-      btn.addEventListener('click', e => {
-        e.preventDefault();
-        const player = window.pcmsAudioPlayer;
-        if (!player) {
-          console.warn('[admin-audio] miniplayer not available');
-          return;
-        }
-        const url = btn.dataset.streamUrl;
-        if (!url) return;
-
-        // Build track metadata from the row's DOM so the mini-player shows
-        // useful info (title/artist/album/cover) without an extra API call.
-        const row = btn.closest('.ax-track');
-        const titleEl  = row && row.querySelector('[data-cell="title"]');
-        const artistEl = row && row.querySelector('[data-cell="artist"]');
-        const albumEl  = row && row.querySelector('[data-cell="album"]');
-        const coverImg = row && row.querySelector('img[data-cover-thumb]');
-        const track = {
-          url,
-          title:  titleEl  ? titleEl.textContent.trim()  : 'Track',
-          artist: artistEl ? artistEl.textContent.trim() : '',
-          album:  albumEl  ? albumEl.textContent.trim()  : '',
-          cover:  coverImg ? coverImg.src                : '',
-        };
-
-        // If this exact track is already current, toggle pause/play instead
-        // of restarting from zero. Compare logical URLs (audio.src is a blob:).
-        const cur = player.currentTrack();
-        if (cur && cur.url === url) {
-          if (player.isPlaying()) player.pause();
-          else                    player.play();
-          return;
-        }
-
-        player.setQueue([track], 0);
-      });
-    });
-
-    // Hook the global audio element's events to keep the row buttons synced.
-    // We attach lazily after the player has built its DOM. The script in
-    // shell.ejs runs at page-load so #audio-element exists by the time
-    // this IIFE fires (script tag is below the body content).
-    const audio = document.getElementById('audio-element');
-    if (audio) {
-      ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
-        audio.addEventListener(ev, resyncAll);
-      });
-      // Initial state on page load (e.g. user navigated back while a track
-      // was already playing — buttons should reflect that).
-      resyncAll();
-    }
-  })();
-
-
-  // ── Bulk upload (drag-drop + sequential transcoding) ─────────
-  // Files dropped or picked are queued (not uploaded immediately) so the
-  // user can review the list, set shared metadata, then hit "Start upload".
-  // The loop POSTs one file at a time to /admin/audio/upload with
-  // Accept: application/json so the server returns structured per-file
-  // results instead of redirecting.
-  const dropzone   = document.getElementById('audio-dropzone');
-  const fileInput  = document.getElementById('audio-files');
-  const queueEl    = document.getElementById('upload-queue');
-  const actionsEl  = document.getElementById('upload-actions');
-  const startBtn   = document.getElementById('start-upload-btn');
-  const clearBtn   = document.getElementById('clear-queue-btn');
-  const artistInput= document.getElementById('batch-artist');
-  const albumInput = document.getElementById('batch-album');
-  const coverInput = document.getElementById('batch-cover');
-
-  /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
-  const queue = [];
-
-  function fmtBytes(n) {
-    if (n < 1024) return n + ' B';
-    if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
-    return (n / 1024 / 1024).toFixed(1) + ' MB';
-  }
-
-  function setItemStatus(item, status, label) {
-    const labels = {
-      queued:      '<%= t('aaud.st_queued') %>',
-      uploading:   '<%= t('aaud.st_uploading') %>',
-      transcoding: '<%= t('aaud.st_transcoding') %>',
-      done:        '✓ <%= t('aaud.st_done') %>',
-      error:       '✗ <%= t('aaud.st_error') %>',
-    };
-    item.status = status;
-    const badge = item.el.querySelector('.ax-queue-status');
-    badge.className = 'ax-queue-status ax-queue-status--' + status;
-    badge.textContent = label || labels[status] || status;
-    // Restyle the row
-    item.el.classList.remove(
-      'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
-    );
-    if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
-    else if (status === 'done')  item.el.classList.add('ax-queue-item--done');
-    else if (status === 'error') item.el.classList.add('ax-queue-item--error');
-  }
-
-  function addFiles(files) {
-    let added = 0;
-    for (const file of files) {
-      if (!file.type.startsWith('audio/') &&
-          !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
-        // Silently skip non-audio drops; keeps the UX uncluttered.
-        continue;
-      }
-      const li = document.createElement('li');
-      li.className = 'ax-queue-item';
-      li.innerHTML =
-        '<div class="ax-queue-name"></div>' +
-        '<span class="ax-queue-status ax-queue-status--queued"><%= t('aaud.st_queued') %></span>';
-      // Use textContent to avoid HTML-injection if a filename contains markup.
-      li.querySelector('.ax-queue-name').textContent = file.name;
-      // Append size hint inline
-      const size = document.createElement('span');
-      size.className = 'ax-queue-size';
-      size.textContent = fmtBytes(file.size);
-      li.querySelector('.ax-queue-name').appendChild(size);
-      queueEl.appendChild(li);
-      queue.push({ file, el: li, status: 'queued' });
-      added++;
-    }
-    if (added) {
-      queueEl.hidden = false;
-      actionsEl.hidden = false;
-    }
-  }
-
-  // ── Drop-zone events ─────────────────────────────────────────
-  // Page-level guard: a dropped file outside the zone would otherwise
-  // make the browser navigate to it (e.g. opening the audio inline), which
-  // discards typed metadata. We swallow drops anywhere unless the dropzone
-  // explicitly handles them.
-  ['dragover', 'drop'].forEach(ev => {
-    window.addEventListener(ev, e => {
-      // Allow drops INSIDE the dropzone — its own listener handles those.
-      if (dropzone.contains(e.target)) return;
-      e.preventDefault();
-    });
-  });
-
-  ['dragenter', 'dragover'].forEach(ev => {
-    dropzone.addEventListener(ev, e => {
-      e.preventDefault();
-      dropzone.classList.add('is-dragover');
-    });
-  });
-  ['dragleave', 'drop'].forEach(ev => {
-    dropzone.addEventListener(ev, e => {
-      e.preventDefault();
-      dropzone.classList.remove('is-dragover');
-    });
-  });
-  dropzone.addEventListener('drop', e => {
-    if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
-  });
-  fileInput.addEventListener('change', () => {
-    addFiles(fileInput.files);
-    // Reset so the same file can be picked again later if user wants
-    fileInput.value = '';
-  });
-
-  // ── Clear queue button ───────────────────────────────────────
-  clearBtn.addEventListener('click', () => {
-    // Only remove items that aren't currently uploading (anything queued
-    // or already finished). An in-progress upload finishes, then its row
-    // would also disappear once we re-render — but we keep it simple and
-    // just refuse to clear during an active run.
-    if (startBtn.disabled) return;
-    queue.length = 0;
-    queueEl.innerHTML = '';
-    queueEl.hidden = true;
-    actionsEl.hidden = true;
-  });
-
-  // ── Sequential upload loop ───────────────────────────────────
-  startBtn.addEventListener('click', async () => {
-    if (startBtn.disabled) return;
-    startBtn.disabled = true;
-    clearBtn.disabled = true;
-    dropzone.style.pointerEvents = 'none';
-    dropzone.style.opacity = '0.5';
-
-    const sharedArtist = artistInput.value.trim();
-    const sharedAlbum  = albumInput.value.trim();
-    const sharedCover  = coverInput.files && coverInput.files[0];
-
-    // Process queued items one at a time. We iterate via index so that
-    // if more files get dropped during the run they ALSO get processed
-    // (queue.push above mutates the same array we're iterating).
-    for (let i = 0; i < queue.length; i++) {
-      const item = queue[i];
-      if (item.status !== 'queued') continue;
-      try {
-        await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
-      } catch (err) {
-        console.error('upload failed for', item.file.name, err);
-        setItemStatus(item, 'error', '✗ ' + (err.message || '<%= t('aaud.failed') %>'));
-      }
-    }
-
-    startBtn.disabled = false;
-    clearBtn.disabled = false;
-    dropzone.style.pointerEvents = '';
-    dropzone.style.opacity = '';
-
-    // Reload the page so the new tracks appear in the list below.
-    // Could also fetch them and inject, but a full reload is simpler and
-    // ensures position indexes / album-grouping are correct.
-    const anyDone = queue.some(q => q.status === 'done');
-    if (anyDone) {
-      setTimeout(() => location.reload(), 700);
-    }
-  });
-
-  async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
-    setItemStatus(item, 'uploading');
-
-    const fd = new FormData();
-    fd.append('audio', item.file);
-    if (sharedArtist) fd.append('artist', sharedArtist);
-    if (sharedAlbum)  fd.append('album',  sharedAlbum);
-    if (sharedCover)  fd.append('cover',  sharedCover);
-    // Title is intentionally omitted — server uses filename fallback.
-
-    // We can't reliably distinguish "still uploading bytes" from
-    // "uploading done, ffmpeg running" without progress events, but the
-    // status flips to "Converteren…" once the request is past upload phase.
-    // We approximate this by waiting until the response arrives — by then
-    // both phases are complete on the server side. For a smoother feel we
-    // briefly show "transcoding" near the end of the request lifecycle.
-    const transcodeHint = setTimeout(() => {
-      if (item.status === 'uploading') setItemStatus(item, 'transcoding');
-    }, 1500);
-
-    try {
-      const res = await fetch('/admin/audio/upload', {
-        method: 'POST',
-        headers: { 'Accept': 'application/json' },
-        body: fd,
-        credentials: 'same-origin',
-      });
-      clearTimeout(transcodeHint);
-
-      // Server responds with JSON for our Accept header. If it didn't
-      // (e.g. session expired and got an HTML login page), surface that.
-      let data;
-      try { data = await res.json(); }
-      catch (_) { throw new Error('<%= t('aaud.err_unexpected') %> (' + res.status + ')'); }
-
-      if (!res.ok || !data.ok) {
-        throw new Error(data.error || ('HTTP ' + res.status));
-      }
-
-      setItemStatus(item, 'done', '✓ ' + (data.title || '<%= t('aaud.st_done') %>'));
-    } catch (err) {
-      clearTimeout(transcodeHint);
-      throw err;
-    }
-  }
-
-  // ── Click-to-copy embed codes ─────────────────────────────────
-  document.querySelectorAll('[data-copy]').forEach(el => {
-    el.addEventListener('click', async () => {
-      const text = el.dataset.copy;
-      try {
-        await navigator.clipboard.writeText(text);
-        el.classList.add('is-copied');
-        const original = el.textContent;
-        el.textContent = '✓ <%= t('aaud.copied') %>';
-        setTimeout(() => {
-          el.classList.remove('is-copied');
-          el.textContent = original;
-        }, 1200);
-      } catch (_) { /* fall through — selection still works */ }
-    });
-  });
-
-  // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
-  const addLinkBtn = document.getElementById('add-link-track-btn');
-  if (addLinkBtn) {
-    addLinkBtn.addEventListener('click', async () => {
-      addLinkBtn.disabled = true;
-      try {
-        const r = await fetch('/admin/audio/create-link', {
-          method: 'POST', credentials: 'same-origin',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ title: '<%= t('aaud.new_track') %>' }),
-        });
-        const j = await r.json();
-        if (!r.ok || !j.ok) throw new Error(j.error || '<%= t('aaud.create_failed') %>');
-        if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
-        window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
-      } catch (err) {
-        alert('<%= t('aaud.create_failed') %>: ' + err.message);
-      } finally {
-        addLinkBtn.disabled = false;
-      }
-    });
-  }
-
-  // ── Wire all "Edit" buttons to the track-editor modal ─────────
-  // After save we patch the row in-place rather than reloading,
-  // so the user keeps their scroll position on long lists.
-  document.querySelectorAll('[data-track-edit]').forEach(btn => {
-    btn.addEventListener('click', () => {
-      if (typeof window.openTrackEditor !== 'function') {
-        alert('<%= t('aaud.editor_not_loaded') %>');
-        return;
-      }
-      const id = btn.dataset.id;
-      window.openTrackEditor({
-        id,
-        onSaved: (track) => {
-          const row = document.querySelector('li[data-track-id="' + id + '"]');
-          if (!row) return;
-          // Update visible cells
-          const titleEl  = row.querySelector('[data-cell="title"]');
-          const artistEl = row.querySelector('[data-cell="artist"]');
-          const albumEl  = row.querySelector('[data-cell="album"]');
-          if (titleEl)  titleEl.textContent  = track.title  || '<%= t('aaud.untitled') %>';
-          if (artistEl) artistEl.textContent = track.artist || '—';
-          if (albumEl)  {
-            albumEl.textContent = track.album || '';
-            if (track.album) albumEl.removeAttribute('hidden');
-            else albumEl.setAttribute('hidden', '');
-          }
-          // Update cover thumb (replace element if type changed)
-          const oldThumb = row.querySelector('[data-cover-thumb]');
-          if (oldThumb) {
-            const parent = oldThumb.parentElement;
-            if (track.cover_url) {
-              const img = document.createElement('img');
-              img.className = 'ax-track-cover';
-              img.src = track.cover_url;
-              img.alt = '';
-              img.dataset.coverThumb = '';
-              parent.replaceChild(img, oldThumb);
-            } else {
-              const sp = document.createElement('span');
-              sp.className = 'ax-track-cover ax-track-cover-empty';
-              sp.textContent = '♫';
-              sp.dataset.coverThumb = '';
-              parent.replaceChild(sp, oldThumb);
-            }
-          }
-        },
-      });
-    });
-  });
-
-  // Download-voor-email toggle — AJAX (geen pagina-reload meer).
-  document.querySelectorAll('[data-track-dl]').forEach(btn => {
-    btn.addEventListener('click', async () => {
-      if (btn.disabled) return;
-      const want = btn.dataset.on === '1' ? 0 : 1;
-      btn.disabled = true;
-      try {
-        const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ downloadable: !!want }),
-        });
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        btn.dataset.on = String(want);
-        btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
-        btn.style.opacity = want ? '1' : '.5';
-        btn.title = want
-          ? '<%= t('aaud.dl_on') %>'
-          : '<%= t('aaud.dl_off') %>';
-      } catch (e) {
-        alert('<%= t('aaud.change_failed') %>: ' + (e.message || e));
-      } finally {
-        btn.disabled = false;
-      }
-    });
-  });
-})();
-</script>
+<%# Het script staat in assets/js/mod/admin-audio.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
+<%- 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') } }) %>
Index: src/views/partials/playlist-editor.ejs
===================================================================
--- src/views/partials/playlist-editor.ejs	(revision 156baa32c3292b1c6ba350f1fede79d7cbeeea4d)
+++ src/views/partials/playlist-editor.ejs	(revision 6ee289aed635d76572556190200f28678d2d970c)
@@ -280,408 +280,3 @@
 </style>
 
-<script>
-(function() {
-  // Idempotency: if window.openPlaylistEditor already defined (multiple
-  // partial includes on a single page), skip re-binding.
-  if (typeof window.openPlaylistEditor === 'function') return;
-
-  const CSRF = '<%= _csrf %>';
-
-  function esc(s) {
-    return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
-      '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
-    }[c]));
-  }
-  function fmtDur(sec) {
-    if (!sec) return '';
-    const m = Math.floor(sec / 60), s = sec % 60;
-    return `${m}:${String(s).padStart(2, '0')}`;
-  }
-
-  async function api(method, url, body) {
-    const opts = {
-      method, credentials: 'same-origin',
-      headers: { 'X-CSRF-Token': CSRF },
-    };
-    if (body !== undefined) {
-      opts.headers['Content-Type'] = 'application/json';
-      opts.body = JSON.stringify(body);
-    }
-    const r = await fetch(url, opts);
-    return r.json();
-  }
-
-  /**
-   * Public entry point: open the editor.
-   *  opts: { mode: 'create'|'edit', id?, onSaved? }
-   *  onSaved is called with { id, playlist } after a successful save.
-   */
-  window.openPlaylistEditor = async function(opts) {
-    opts = opts || {};
-    const mode = opts.mode === 'edit' ? 'edit' : 'create';
-    const isEdit = mode === 'edit';
-
-    // Load all audio tracks for the picker
-    let tracks = [];
-    try {
-      const j = await api('GET', '/admin/playlists/api/tracks');
-      if (Array.isArray(j.tracks)) tracks = j.tracks;
-    } catch (e) {
-      alert('Tracks ophalen mislukt');
-      return;
-    }
-    if (!tracks.length) {
-      alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');
-      return;
-    }
-
-    // Existing playlist data when editing
-    let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] };
-    if (isEdit && opts.id) {
-      try {
-        const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
-        if (j.ok) initial = { ...initial, ...j.playlist };
-      } catch (e) {}
-    }
-
-    // ── DOM ────────────────────────────────────────────────────────
-    const backdrop = document.createElement('div');
-    backdrop.className = 'pl-modal-backdrop';
-    backdrop.innerHTML = `
-      <div class="pl-modal" role="dialog" aria-label="Playlist editor">
-        <div class="pl-modal-header">
-          <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3>
-          <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button>
-        </div>
-        <div class="pl-modal-body">
-          <div class="pl-editor-cols">
-            <div class="pl-editor-left">
-              <div class="pl-meta-grid">
-                <label class="pl-field pl-field-full">
-                  <span>Titel *</span>
-                  <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
-                </label>
-                <label class="pl-field">
-                  <span>Artiest</span>
-                  <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
-                </label>
-                <label class="pl-field">
-                  <span>Jaar</span>
-                  <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
-                </label>
-                <label class="pl-field">
-                  <span>Type</span>
-                  <select id="pli-kind">
-                    <option value="album"    ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option>
-                    <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option>
-                  </select>
-                </label>
-                <div class="pl-field pl-field-full">
-                  <span>Cover</span>
-                  <div class="pl-cover-row">
-                    <span class="pl-cover-thumb" id="pli-cover-thumb">
-                      ${initial.cover
-                        ? `<img src="${esc(initial.cover)}" alt="">`
-                        : `<span class="pl-cover-empty">🎨</span>`}
-                    </span>
-                    <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1">
-                  </div>
-                  <div class="pl-cover-upload-row">
-                    <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
-                    <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
-                    <span class="pl-cover-status" id="pli-cover-status"></span>
-                  </div>
-                </div>
-              </div>
-              <div class="pl-section-title">
-                Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
-                <small>(sleep ⠿ om te ordenen)</small>
-              </div>
-              <div id="pli-selected" class="pl-selected-list"></div>
-            </div>
-            <div class="pl-editor-right">
-              <div class="pl-section-title">Beschikbare tracks</div>
-              <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
-              <div id="pli-available" class="pl-available-list"></div>
-            </div>
-          </div>
-        </div>
-        <div class="pl-modal-footer">
-          <button type="button" class="btn" id="pli-cancel">Annuleren</button>
-          <button type="button" class="btn btn-primary" id="pli-save" disabled>
-            ${isEdit ? 'Opslaan' : 'Aanmaken'}
-          </button>
-        </div>
-      </div>
-    `;
-    document.body.appendChild(backdrop);
-
-    const $ = sel => backdrop.querySelector(sel);
-    const close = () => backdrop.remove();
-    backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
-    $('.pl-modal-close').addEventListener('click', close);
-    $('#pli-cancel').addEventListener('click', close);
-
-    // Index tracks by id for fast lookup
-    const trackById = new Map(tracks.map(t => [t.id, t]));
-    let selected = (initial.track_ids || []).filter(id => trackById.has(id));
-
-    const titleEl = $('#pli-title');
-    const artistEl = $('#pli-artist');
-    const yearEl = $('#pli-year');
-    const kindEl = $('#pli-kind');
-    const coverEl = $('#pli-cover');
-    const coverThumb = $('#pli-cover-thumb');
-    const saveBtn = $('#pli-save');
-    const selectedEl = $('#pli-selected');
-    const availEl = $('#pli-available');
-    const searchEl = $('#pli-search');
-    const countEl = $('#pli-count');
-
-    coverEl.addEventListener('input', () => {
-      const u = coverEl.value.trim();
-      coverThumb.innerHTML = u
-        ? `<img src="${esc(u)}" alt="" data-fallback>`
-        : `<span class="pl-cover-empty">🎨</span>`;
-    });
-
-    // ── Cover file upload (werkt in create ÉN edit) ───────────
-    // We uploaden naar het generieke image-endpoint (/posts/upload-image,
-    // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
-    // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het
-    // cover-veld en wordt bij 'Aanmaken'/'Opslaan' met de playlist meegestuurd.
-    const coverFileInput = $('#pli-cover-file');
-    const coverPickBtn   = $('#pli-cover-pick');
-    const coverStatus    = $('#pli-cover-status');
-    if (coverFileInput && coverPickBtn) {
-      coverPickBtn.addEventListener('click', () => coverFileInput.click());
-      coverFileInput.addEventListener('change', async (e) => {
-        const file = e.target.files && e.target.files[0];
-        coverFileInput.value = '';
-        if (!file) return;
-        if (!/^image\//.test(file.type)) {
-          coverStatus.textContent = 'Alleen afbeeldingen';
-          coverStatus.className = 'pl-cover-status is-error';
-          return;
-        }
-        coverStatus.textContent = 'Uploaden…';
-        coverStatus.className = 'pl-cover-status';
-        const fd = new FormData();
-        fd.append('image', file);
-        try {
-          const r = await fetch('/posts/upload-image',
-            { method: 'POST', body: fd, credentials: 'same-origin' }
-          );
-          const j = await r.json();
-          if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt');
-          const url = j.url || '';
-          coverEl.value = url;
-          coverThumb.innerHTML = url
-            ? `<img src="${esc(url)}" alt="">`
-            : `<span class="pl-cover-empty">🎨</span>`;
-          coverStatus.textContent = '✓ Geüpload';
-          coverStatus.className = 'pl-cover-status is-ok';
-        } catch (err) {
-          coverStatus.textContent = 'Mislukt: ' + err.message;
-          coverStatus.className = 'pl-cover-status is-error';
-        }
-      });
-    }
-
-    function updateSaveBtn() {
-      saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
-    }
-
-    function renderSelected() {
-      countEl.textContent = selected.length;
-      if (selected.length === 0) {
-        selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
-        updateSaveBtn();
-        return;
-      }
-      selectedEl.innerHTML = selected.map((id, i) => {
-        const t = trackById.get(id);
-        if (!t) return '';
-        const cover = t.cover
-          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
-          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
-        return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
-          <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
-          <span class="pl-row-num">${i + 1}</span>
-          ${cover}
-          <span class="pl-row-info">
-            <span class="pl-row-title">${esc(t.title)}</span>
-            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
-          </span>
-          <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
-        </div>`;
-      }).join('');
-
-      selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
-        b.addEventListener('click', () => {
-          selected = selected.filter(x => x !== b.dataset.id);
-          renderSelected();
-          renderAvailable();
-        });
-      });
-      bindDrag();
-      updateSaveBtn();
-    }
-
-    function renderAvailable() {
-      const q = searchEl.value.trim().toLowerCase();
-      const matches = tracks.filter(t => {
-        if (!q) return true;
-        return (t.title || '').toLowerCase().includes(q)
-            || (t.artist || '').toLowerCase().includes(q);
-      });
-      if (matches.length === 0) {
-        availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
-        return;
-      }
-      availEl.innerHTML = matches.map(t => {
-        const isAdded = selected.includes(t.id);
-        const cls = ['pl-avail-row'];
-        if (isAdded) cls.push('is-added');
-        if (!t.playable) cls.push('is-unplayable');
-        const cover = t.cover
-          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
-          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
-        return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
-          ${cover}
-          <span class="pl-row-info">
-            <span class="pl-row-title">${esc(t.title)}</span>
-            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
-          </span>
-          <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
-        </div>`;
-      }).join('');
-
-      availEl.querySelectorAll('.pl-avail-row').forEach(row => {
-        if (row.classList.contains('is-unplayable')) return;
-        row.addEventListener('click', () => {
-          const id = row.dataset.id;
-          if (selected.includes(id)) selected = selected.filter(x => x !== id);
-          else selected.push(id);
-          renderSelected();
-          renderAvailable();
-        });
-      });
-    }
-
-    // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
-    function bindDrag() {
-      selectedEl.querySelectorAll('.pl-row').forEach(row => {
-        const handle = row.querySelector('.pl-row-handle');
-        if (!handle) return;
-        let dragging = false, originalIdx = -1;
-
-        handle.addEventListener('pointerdown', e => {
-          e.preventDefault();
-          handle.setPointerCapture(e.pointerId);
-          dragging = true;
-          originalIdx = parseInt(row.dataset.pos, 10);
-          row.classList.add('is-dragging');
-        });
-        handle.addEventListener('pointermove', e => {
-          if (!dragging) return;
-          e.preventDefault();
-          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
-          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
-          let targetIdx = -1, above = false;
-          for (let i = 0; i < rows.length; i++) {
-            const r = rows[i]; if (r === row) continue;
-            const rect = r.getBoundingClientRect();
-            const mid = rect.top + rect.height / 2;
-            if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
-            if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
-              targetIdx = i; above = e.clientY < mid; break;
-            }
-          }
-          if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
-        });
-        const finish = e => {
-          if (!dragging) return;
-          dragging = false;
-          try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
-          row.classList.remove('is-dragging');
-          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
-          let targetIdx = -1, above = false;
-          for (let i = 0; i < rows.length; i++) {
-            if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
-            if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
-          }
-          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
-          if (targetIdx === -1 || targetIdx === originalIdx) return;
-          const moved = selected[originalIdx];
-          selected.splice(originalIdx, 1);
-          let newIdx = targetIdx;
-          if (originalIdx < targetIdx) newIdx--;
-          if (!above) newIdx++;
-          newIdx = Math.max(0, Math.min(selected.length, newIdx));
-          selected.splice(newIdx, 0, moved);
-          renderSelected();
-        };
-        handle.addEventListener('pointerup', finish);
-        handle.addEventListener('pointercancel', () => {
-          dragging = false;
-          row.classList.remove('is-dragging');
-          selectedEl.querySelectorAll('.drop-above, .drop-below')
-            .forEach(r => r.classList.remove('drop-above', 'drop-below'));
-        });
-      });
-    }
-
-    titleEl.addEventListener('input', updateSaveBtn);
-    searchEl.addEventListener('input', renderAvailable);
-    renderSelected();
-    renderAvailable();
-
-    saveBtn.addEventListener('click', async () => {
-      if (saveBtn.disabled) return;
-      const orig = saveBtn.textContent;
-      saveBtn.disabled = true;
-      saveBtn.textContent = 'Opslaan...';
-
-      const payload = {
-        title:  titleEl.value.trim(),
-        artist: artistEl.value.trim(),
-        year:   parseInt(yearEl.value, 10) || 0,
-        cover:  coverEl.value.trim(),
-        kind:   kindEl.value === 'playlist' ? 'playlist' : 'album',
-        tracks: selected.slice(),
-      };
-
-      try {
-        const url = isEdit
-          ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
-          : '/admin/playlists/api';
-        const j = await api('POST', url, payload);
-        if (!j.ok) {
-          alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
-          saveBtn.disabled = false;
-          saveBtn.textContent = orig;
-          return;
-        }
-        const savedId = isEdit ? initial.id : j.id;
-        close();
-        if (typeof opts.onSaved === 'function') {
-          opts.onSaved({ id: savedId, playlist: payload });
-        }
-      } catch (err) {
-        alert('Opslaan mislukt: ' + err.message);
-        saveBtn.disabled = false;
-        saveBtn.textContent = orig;
-      }
-    });
-
-    // ESC to close
-    document.addEventListener('keydown', function onEsc(e) {
-      if (e.key === 'Escape' && document.body.contains(backdrop)) {
-        close();
-        document.removeEventListener('keydown', onEsc);
-      }
-    });
-  };
-})();
-</script>
+<%# Het script staat in assets/js/mod/playlist-editor.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
