| 1 | // De afspeellijst-editor -- verplaatst uit inline script, shaer-bqr.
|
|---|
| 2 | //
|
|---|
| 3 | // Servergegevens komen uit pageData(); interpolatie kan niet in een statisch
|
|---|
| 4 | // bestand. Inline script wordt bovendien geweigerd zodra deze pagina via een
|
|---|
| 5 | // link BINNEN de site binnenkomt (shaer-0i6).
|
|---|
| 6 |
|
|---|
| 7 | import { pageData } from './lib.js';
|
|---|
| 8 |
|
|---|
| 9 | /**
|
|---|
| 10 | * De teksten van deze modal, uit het gegevensblok van partials/playlist-editor.ejs.
|
|---|
| 11 | *
|
|---|
| 12 | * EIGEN BLOK en niet pageData(): die pakt met querySelector er EEN, en deze
|
|---|
| 13 | * modal hangt onder pagina's die er zelf al een hebben. Een tweede blok zou
|
|---|
| 14 | * daar genegeerd worden.
|
|---|
| 15 | *
|
|---|
| 16 | * De terugval is bewust de sleutelnaam en niet de Nederlandse tekst: ontbreekt
|
|---|
| 17 | * er iets, dan zie je DAT er iets ontbreekt in plaats van een pagina die er
|
|---|
| 18 | * half vertaald uitziet en waarvan niemand merkt welke helft.
|
|---|
| 19 | */
|
|---|
| 20 | let _T = null;
|
|---|
| 21 | function T(sleutel) {
|
|---|
| 22 | if (_T === null) {
|
|---|
| 23 | try {
|
|---|
| 24 | const el = document.querySelector('script[type="application/json"][data-playlist-editor-i18n]');
|
|---|
| 25 | _T = el ? (JSON.parse(el.textContent) || {}) : {};
|
|---|
| 26 | } catch (e) { _T = {}; }
|
|---|
| 27 | }
|
|---|
| 28 | return _T[sleutel] != null ? _T[sleutel] : sleutel;
|
|---|
| 29 | }
|
|---|
| 30 |
|
|---|
| 31 | (function() {
|
|---|
| 32 | // Idempotency: if window.openPlaylistEditor already defined (multiple
|
|---|
| 33 | // partial includes on a single page), skip re-binding.
|
|---|
| 34 | if (typeof window.openPlaylistEditor === 'function') return;
|
|---|
| 35 |
|
|---|
| 36 | const CSRF = pageData().csrf || '';
|
|---|
| 37 |
|
|---|
| 38 | function esc(s) {
|
|---|
| 39 | return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
|
|---|
| 40 | '&':'&','<':'<','>':'>','"':'"',"'":'''
|
|---|
| 41 | }[c]));
|
|---|
| 42 | }
|
|---|
| 43 | function fmtDur(sec) {
|
|---|
| 44 | if (!sec) return '';
|
|---|
| 45 | const m = Math.floor(sec / 60), s = sec % 60;
|
|---|
| 46 | return `${m}:${String(s).padStart(2, '0')}`;
|
|---|
| 47 | }
|
|---|
| 48 |
|
|---|
| 49 | async function api(method, url, body) {
|
|---|
| 50 | const opts = {
|
|---|
| 51 | method, credentials: 'same-origin',
|
|---|
| 52 | headers: { 'X-CSRF-Token': CSRF },
|
|---|
| 53 | };
|
|---|
| 54 | if (body !== undefined) {
|
|---|
| 55 | opts.headers['Content-Type'] = 'application/json';
|
|---|
| 56 | opts.body = JSON.stringify(body);
|
|---|
| 57 | }
|
|---|
| 58 | const r = await fetch(url, opts);
|
|---|
| 59 | return r.json();
|
|---|
| 60 | }
|
|---|
| 61 |
|
|---|
| 62 | /**
|
|---|
| 63 | * Public entry point: open the editor.
|
|---|
| 64 | * opts: { mode: 'create'|'edit', id?, onSaved? }
|
|---|
| 65 | * onSaved is called with { id, playlist } after a successful save.
|
|---|
| 66 | */
|
|---|
| 67 | window.openPlaylistEditor = async function(opts) {
|
|---|
| 68 | opts = opts || {};
|
|---|
| 69 | const mode = opts.mode === 'edit' ? 'edit' : 'create';
|
|---|
| 70 | const isEdit = mode === 'edit';
|
|---|
| 71 |
|
|---|
| 72 | // Load all audio tracks for the picker
|
|---|
| 73 | let tracks = [];
|
|---|
| 74 | try {
|
|---|
| 75 | const j = await api('GET', '/admin/playlists/api/tracks');
|
|---|
| 76 | if (Array.isArray(j.tracks)) tracks = j.tracks;
|
|---|
| 77 | } catch (e) {
|
|---|
| 78 | alert(T('e_tracks'));
|
|---|
| 79 | return;
|
|---|
| 80 | }
|
|---|
| 81 | if (!tracks.length) {
|
|---|
| 82 | alert(T('e_geen_tracks'));
|
|---|
| 83 | return;
|
|---|
| 84 | }
|
|---|
| 85 |
|
|---|
| 86 | // Existing playlist data when editing
|
|---|
| 87 | let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', release_date: '', mb_release_id: '', track_ids: [] };
|
|---|
| 88 | if (isEdit && opts.id) {
|
|---|
| 89 | try {
|
|---|
| 90 | const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
|
|---|
| 91 | if (j.ok) initial = { ...initial, ...j.playlist };
|
|---|
| 92 | } catch (e) {}
|
|---|
| 93 | }
|
|---|
| 94 |
|
|---|
| 95 | // ── DOM ────────────────────────────────────────────────────────
|
|---|
| 96 | const backdrop = document.createElement('div');
|
|---|
| 97 | backdrop.className = 'pl-modal-backdrop';
|
|---|
| 98 | backdrop.innerHTML = `
|
|---|
| 99 | <div class="pl-modal" role="dialog" aria-label="${T('dialoog')}">
|
|---|
| 100 | <div class="pl-modal-header">
|
|---|
| 101 | <h3>${isEdit ? '✎ ' + T('t_edit') : '+ ' + T('t_new')}</h3>
|
|---|
| 102 | <button type="button" class="pl-modal-close" aria-label="${T('sluiten')}">×</button>
|
|---|
| 103 | </div>
|
|---|
| 104 | <div class="pl-modal-body">
|
|---|
| 105 | <div class="pl-editor-cols">
|
|---|
| 106 | <div class="pl-editor-left">
|
|---|
| 107 | <div class="pl-meta-grid">
|
|---|
| 108 | <label class="pl-field pl-field-full">
|
|---|
| 109 | <span>${T('titel')}</span>
|
|---|
| 110 | <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
|
|---|
| 111 | </label>
|
|---|
| 112 | <label class="pl-field">
|
|---|
| 113 | <span>${T('artiest')}</span>
|
|---|
| 114 | <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
|
|---|
| 115 | </label>
|
|---|
| 116 | <label class="pl-field">
|
|---|
| 117 | <span>${T('jaar')}</span>
|
|---|
| 118 | <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
|
|---|
| 119 | </label>
|
|---|
| 120 | <label class="pl-field">
|
|---|
| 121 | <span>${T('type')}</span>
|
|---|
| 122 | <select id="pli-kind">
|
|---|
| 123 | <option value="album" ${initial.kind === 'album' ? 'selected' : ''}>💿 ${T('k_album')}</option>
|
|---|
| 124 | <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 ${T('k_playlist')}</option>
|
|---|
| 125 | <option value="mixtape" ${initial.kind === 'mixtape' ? 'selected' : ''}>📼 ${T('k_mixtape')}</option>
|
|---|
| 126 | </select>
|
|---|
| 127 | </label>
|
|---|
| 128 | <label class="pl-field pl-uitgave">
|
|---|
| 129 | <span>${T('uitgave')}</span>
|
|---|
| 130 | <input type="date" id="pli-release-date" value="${esc(initial.release_date || '')}">
|
|---|
| 131 | </label>
|
|---|
| 132 | <label class="pl-field pl-uitgave">
|
|---|
| 133 | <span>${T('mb_release')}</span>
|
|---|
| 134 | <input type="text" id="pli-mb-release" maxlength="36" spellcheck="false"
|
|---|
| 135 | placeholder="00000000-0000-0000-0000-000000000000"
|
|---|
| 136 | value="${esc(initial.mb_release_id || '')}">
|
|---|
| 137 | </label>
|
|---|
| 138 | <div class="pl-field pl-field-full">
|
|---|
| 139 | <span>${T('cover')}</span>
|
|---|
| 140 | <div class="pl-cover-row">
|
|---|
| 141 | <span class="pl-cover-thumb" id="pli-cover-thumb">
|
|---|
| 142 | ${initial.cover
|
|---|
| 143 | ? `<img src="${esc(initial.cover)}" alt="">`
|
|---|
| 144 | : `<span class="pl-cover-empty">🎨</span>`}
|
|---|
| 145 | </span>
|
|---|
| 146 | <input type="text" id="pli-cover" placeholder="${T('cover_url')}" value="${esc(initial.cover)}" style="flex:1">
|
|---|
| 147 | </div>
|
|---|
| 148 | <div class="pl-cover-upload-row">
|
|---|
| 149 | <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
|
|---|
| 150 | <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 ${T('cover_kies')}</button>
|
|---|
| 151 | <span class="pl-cover-status" id="pli-cover-status"></span>
|
|---|
| 152 | </div>
|
|---|
| 153 | </div>
|
|---|
| 154 | </div>
|
|---|
| 155 | <div class="pl-section-title">
|
|---|
| 156 | ${T('tracks_in')} <span class="pl-track-count" id="pli-count">0</span>
|
|---|
| 157 | <small>${T('sleep_hint')}</small>
|
|---|
| 158 | </div>
|
|---|
| 159 | <div id="pli-selected" class="pl-selected-list"></div>
|
|---|
| 160 | </div>
|
|---|
| 161 | <div class="pl-editor-right">
|
|---|
| 162 | <div class="pl-section-title">${T('beschikbaar')}</div>
|
|---|
| 163 | <input type="search" id="pli-search" placeholder="${T('zoek')}" class="pl-search-input">
|
|---|
| 164 | <div id="pli-available" class="pl-available-list"></div>
|
|---|
| 165 | </div>
|
|---|
| 166 | </div>
|
|---|
| 167 | </div>
|
|---|
| 168 | <div class="pl-modal-footer">
|
|---|
| 169 | <button type="button" class="btn" id="pli-cancel">${T('annuleren')}</button>
|
|---|
| 170 | <button type="button" class="btn btn-primary" id="pli-save" disabled>
|
|---|
| 171 | ${isEdit ? T('opslaan') : T('aanmaken')}
|
|---|
| 172 | </button>
|
|---|
| 173 | </div>
|
|---|
| 174 | </div>
|
|---|
| 175 | `;
|
|---|
| 176 | document.body.appendChild(backdrop);
|
|---|
| 177 |
|
|---|
| 178 | const $ = sel => backdrop.querySelector(sel);
|
|---|
| 179 | const close = () => backdrop.remove();
|
|---|
| 180 | backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
|
|---|
| 181 | $('.pl-modal-close').addEventListener('click', close);
|
|---|
| 182 | $('#pli-cancel').addEventListener('click', close);
|
|---|
| 183 |
|
|---|
| 184 | // Index tracks by id for fast lookup
|
|---|
| 185 | const trackById = new Map(tracks.map(t => [t.id, t]));
|
|---|
| 186 | let selected = (initial.track_ids || []).filter(id => trackById.has(id));
|
|---|
| 187 |
|
|---|
| 188 | const titleEl = $('#pli-title');
|
|---|
| 189 | const artistEl = $('#pli-artist');
|
|---|
| 190 | const yearEl = $('#pli-year');
|
|---|
| 191 | const kindEl = $('#pli-kind');
|
|---|
| 192 | const releaseEl = $('#pli-release-date');
|
|---|
| 193 | const mbReleaseEl = $('#pli-mb-release');
|
|---|
| 194 | const coverEl = $('#pli-cover');
|
|---|
| 195 |
|
|---|
| 196 | // Uitgavedatum en release-id horen bij een ALBUM, niet bij een
|
|---|
| 197 | // afspeellijst -- dat is wat de keuze album/playlist betekent. Ze
|
|---|
| 198 | // verdwijnen dus als je omschakelt, en de server maakt ze dan ook leeg;
|
|---|
| 199 | // dit scherm is de uitleg, niet de bewaking.
|
|---|
| 200 | // ALLEEN een album, en niet "alles behalve playlist". Dat stond er eerst, en
|
|---|
| 201 | // met een derde soort erbij zou een mixtape ineens uitgavevelden tonen die
|
|---|
| 202 | // de server toch weggooit -- een scherm dat iets anders belooft dan het doet.
|
|---|
| 203 | const toonUitgave = () => {
|
|---|
| 204 | const album = kindEl.value === 'album';
|
|---|
| 205 | for (const el of backdrop.querySelectorAll('.pl-uitgave')) el.hidden = !album;
|
|---|
| 206 | };
|
|---|
| 207 | kindEl.addEventListener('change', toonUitgave);
|
|---|
| 208 | toonUitgave();
|
|---|
| 209 | const coverThumb = $('#pli-cover-thumb');
|
|---|
| 210 | const saveBtn = $('#pli-save');
|
|---|
| 211 | const selectedEl = $('#pli-selected');
|
|---|
| 212 | const availEl = $('#pli-available');
|
|---|
| 213 | const searchEl = $('#pli-search');
|
|---|
| 214 | const countEl = $('#pli-count');
|
|---|
| 215 |
|
|---|
| 216 | coverEl.addEventListener('input', () => {
|
|---|
| 217 | const u = coverEl.value.trim();
|
|---|
| 218 | coverThumb.innerHTML = u
|
|---|
| 219 | ? `<img src="${esc(u)}" alt="" data-fallback>`
|
|---|
| 220 | : `<span class="pl-cover-empty">🎨</span>`;
|
|---|
| 221 | });
|
|---|
| 222 |
|
|---|
| 223 | // ── Cover file upload (werkt in create ÉN edit) ───────────
|
|---|
| 224 | // We uploaden naar het generieke image-endpoint (/posts/upload-image,
|
|---|
| 225 | // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
|
|---|
| 226 | // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het
|
|---|
| 227 | // cover-veld en wordt bij het opslaan met de playlist meegestuurd.
|
|---|
| 228 | const coverFileInput = $('#pli-cover-file');
|
|---|
| 229 | const coverPickBtn = $('#pli-cover-pick');
|
|---|
| 230 | const coverStatus = $('#pli-cover-status');
|
|---|
| 231 | if (coverFileInput && coverPickBtn) {
|
|---|
| 232 | coverPickBtn.addEventListener('click', () => coverFileInput.click());
|
|---|
| 233 | coverFileInput.addEventListener('change', async (e) => {
|
|---|
| 234 | const file = e.target.files && e.target.files[0];
|
|---|
| 235 | coverFileInput.value = '';
|
|---|
| 236 | if (!file) return;
|
|---|
| 237 | if (!/^image\//.test(file.type)) {
|
|---|
| 238 | coverStatus.textContent = T('e_alleen_afb');
|
|---|
| 239 | coverStatus.className = 'pl-cover-status is-error';
|
|---|
| 240 | return;
|
|---|
| 241 | }
|
|---|
| 242 | coverStatus.textContent = T('bezig');
|
|---|
| 243 | coverStatus.className = 'pl-cover-status';
|
|---|
| 244 | const fd = new FormData();
|
|---|
| 245 | fd.append('image', file);
|
|---|
| 246 | try {
|
|---|
| 247 | const r = await fetch('/posts/upload-image',
|
|---|
| 248 | { method: 'POST', body: fd, credentials: 'same-origin' }
|
|---|
| 249 | );
|
|---|
| 250 | const j = await r.json();
|
|---|
| 251 | if (!r.ok || !j.url) throw new Error(j.error || T('e_upload'));
|
|---|
| 252 | const url = j.url || '';
|
|---|
| 253 | coverEl.value = url;
|
|---|
| 254 | coverThumb.innerHTML = url
|
|---|
| 255 | ? `<img src="${esc(url)}" alt="">`
|
|---|
| 256 | : `<span class="pl-cover-empty">🎨</span>`;
|
|---|
| 257 | coverStatus.textContent = '✓ Geüpload';
|
|---|
| 258 | coverStatus.className = 'pl-cover-status is-ok';
|
|---|
| 259 | } catch (err) {
|
|---|
| 260 | coverStatus.textContent = T('e_mislukt') + err.message;
|
|---|
| 261 | coverStatus.className = 'pl-cover-status is-error';
|
|---|
| 262 | }
|
|---|
| 263 | });
|
|---|
| 264 | }
|
|---|
| 265 |
|
|---|
| 266 | function updateSaveBtn() {
|
|---|
| 267 | saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
|
|---|
| 268 | }
|
|---|
| 269 |
|
|---|
| 270 | function renderSelected() {
|
|---|
| 271 | countEl.textContent = selected.length;
|
|---|
| 272 | if (selected.length === 0) {
|
|---|
| 273 | selectedEl.innerHTML = '<div class="pl-empty">' + T('leeg_sel') + '</div>';
|
|---|
| 274 | updateSaveBtn();
|
|---|
| 275 | return;
|
|---|
| 276 | }
|
|---|
| 277 | selectedEl.innerHTML = selected.map((id, i) => {
|
|---|
| 278 | const t = trackById.get(id);
|
|---|
| 279 | if (!t) return '';
|
|---|
| 280 | const cover = t.cover
|
|---|
| 281 | ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
|
|---|
| 282 | : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
|
|---|
| 283 | return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
|
|---|
| 284 | <span class="pl-row-handle" aria-label="${T('versleep')}">⠿</span>
|
|---|
| 285 | <span class="pl-row-num">${i + 1}</span>
|
|---|
| 286 | ${cover}
|
|---|
| 287 | <span class="pl-row-info">
|
|---|
| 288 | <span class="pl-row-title">${esc(t.title)}</span>
|
|---|
| 289 | ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
|
|---|
| 290 | </span>
|
|---|
| 291 | <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="${T('verwijder')}">×</button>
|
|---|
| 292 | </div>`;
|
|---|
| 293 | }).join('');
|
|---|
| 294 |
|
|---|
| 295 | selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
|
|---|
| 296 | b.addEventListener('click', () => {
|
|---|
| 297 | selected = selected.filter(x => x !== b.dataset.id);
|
|---|
| 298 | renderSelected();
|
|---|
| 299 | renderAvailable();
|
|---|
| 300 | });
|
|---|
| 301 | });
|
|---|
| 302 | bindDrag();
|
|---|
| 303 | updateSaveBtn();
|
|---|
| 304 | }
|
|---|
| 305 |
|
|---|
| 306 | function renderAvailable() {
|
|---|
| 307 | const q = searchEl.value.trim().toLowerCase();
|
|---|
| 308 | const matches = tracks.filter(t => {
|
|---|
| 309 | if (!q) return true;
|
|---|
| 310 | return (t.title || '').toLowerCase().includes(q)
|
|---|
| 311 | || (t.artist || '').toLowerCase().includes(q);
|
|---|
| 312 | });
|
|---|
| 313 | if (matches.length === 0) {
|
|---|
| 314 | availEl.innerHTML = '<div class="pl-empty">' + T('geen_res') + '</div>';
|
|---|
| 315 | return;
|
|---|
| 316 | }
|
|---|
| 317 | availEl.innerHTML = matches.map(t => {
|
|---|
| 318 | const isAdded = selected.includes(t.id);
|
|---|
| 319 | const cls = ['pl-avail-row'];
|
|---|
| 320 | if (isAdded) cls.push('is-added');
|
|---|
| 321 | if (!t.playable) cls.push('is-unplayable');
|
|---|
| 322 | const cover = t.cover
|
|---|
| 323 | ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
|
|---|
| 324 | : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
|
|---|
| 325 | return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : `title="${T('geen_audio')}"`}>
|
|---|
| 326 | ${cover}
|
|---|
| 327 | <span class="pl-row-info">
|
|---|
| 328 | <span class="pl-row-title">${esc(t.title)}</span>
|
|---|
| 329 | ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
|
|---|
| 330 | </span>
|
|---|
| 331 | <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
|
|---|
| 332 | </div>`;
|
|---|
| 333 | }).join('');
|
|---|
| 334 |
|
|---|
| 335 | availEl.querySelectorAll('.pl-avail-row').forEach(row => {
|
|---|
| 336 | if (row.classList.contains('is-unplayable')) return;
|
|---|
| 337 | row.addEventListener('click', () => {
|
|---|
| 338 | const id = row.dataset.id;
|
|---|
| 339 | if (selected.includes(id)) selected = selected.filter(x => x !== id);
|
|---|
| 340 | else selected.push(id);
|
|---|
| 341 | renderSelected();
|
|---|
| 342 | renderAvailable();
|
|---|
| 343 | });
|
|---|
| 344 | });
|
|---|
| 345 | }
|
|---|
| 346 |
|
|---|
| 347 | // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
|
|---|
| 348 | function bindDrag() {
|
|---|
| 349 | selectedEl.querySelectorAll('.pl-row').forEach(row => {
|
|---|
| 350 | const handle = row.querySelector('.pl-row-handle');
|
|---|
| 351 | if (!handle) return;
|
|---|
| 352 | let dragging = false, originalIdx = -1;
|
|---|
| 353 |
|
|---|
| 354 | handle.addEventListener('pointerdown', e => {
|
|---|
| 355 | e.preventDefault();
|
|---|
| 356 | handle.setPointerCapture(e.pointerId);
|
|---|
| 357 | dragging = true;
|
|---|
| 358 | originalIdx = parseInt(row.dataset.pos, 10);
|
|---|
| 359 | row.classList.add('is-dragging');
|
|---|
| 360 | });
|
|---|
| 361 | handle.addEventListener('pointermove', e => {
|
|---|
| 362 | if (!dragging) return;
|
|---|
| 363 | e.preventDefault();
|
|---|
| 364 | const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
|
|---|
| 365 | rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
|
|---|
| 366 | let targetIdx = -1, above = false;
|
|---|
| 367 | for (let i = 0; i < rows.length; i++) {
|
|---|
| 368 | const r = rows[i]; if (r === row) continue;
|
|---|
| 369 | const rect = r.getBoundingClientRect();
|
|---|
| 370 | const mid = rect.top + rect.height / 2;
|
|---|
| 371 | if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
|
|---|
| 372 | if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
|
|---|
| 373 | targetIdx = i; above = e.clientY < mid; break;
|
|---|
| 374 | }
|
|---|
| 375 | }
|
|---|
| 376 | if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
|
|---|
| 377 | });
|
|---|
| 378 | const finish = e => {
|
|---|
| 379 | if (!dragging) return;
|
|---|
| 380 | dragging = false;
|
|---|
| 381 | try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
|
|---|
| 382 | row.classList.remove('is-dragging');
|
|---|
| 383 | const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
|
|---|
| 384 | let targetIdx = -1, above = false;
|
|---|
| 385 | for (let i = 0; i < rows.length; i++) {
|
|---|
| 386 | if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
|
|---|
| 387 | if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
|
|---|
| 388 | }
|
|---|
| 389 | rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
|
|---|
| 390 | if (targetIdx === -1 || targetIdx === originalIdx) return;
|
|---|
| 391 | const moved = selected[originalIdx];
|
|---|
| 392 | selected.splice(originalIdx, 1);
|
|---|
| 393 | let newIdx = targetIdx;
|
|---|
| 394 | if (originalIdx < targetIdx) newIdx--;
|
|---|
| 395 | if (!above) newIdx++;
|
|---|
| 396 | newIdx = Math.max(0, Math.min(selected.length, newIdx));
|
|---|
| 397 | selected.splice(newIdx, 0, moved);
|
|---|
| 398 | renderSelected();
|
|---|
| 399 | };
|
|---|
| 400 | handle.addEventListener('pointerup', finish);
|
|---|
| 401 | handle.addEventListener('pointercancel', () => {
|
|---|
| 402 | dragging = false;
|
|---|
| 403 | row.classList.remove('is-dragging');
|
|---|
| 404 | selectedEl.querySelectorAll('.drop-above, .drop-below')
|
|---|
| 405 | .forEach(r => r.classList.remove('drop-above', 'drop-below'));
|
|---|
| 406 | });
|
|---|
| 407 | });
|
|---|
| 408 | }
|
|---|
| 409 |
|
|---|
| 410 | titleEl.addEventListener('input', updateSaveBtn);
|
|---|
| 411 | searchEl.addEventListener('input', renderAvailable);
|
|---|
| 412 | renderSelected();
|
|---|
| 413 | renderAvailable();
|
|---|
| 414 |
|
|---|
| 415 | saveBtn.addEventListener('click', async () => {
|
|---|
| 416 | if (saveBtn.disabled) return;
|
|---|
| 417 | const orig = saveBtn.textContent;
|
|---|
| 418 | saveBtn.disabled = true;
|
|---|
| 419 | saveBtn.textContent = T('bezig_opslaan');
|
|---|
| 420 |
|
|---|
| 421 | const payload = {
|
|---|
| 422 | title: titleEl.value.trim(),
|
|---|
| 423 | artist: artistEl.value.trim(),
|
|---|
| 424 | year: parseInt(yearEl.value, 10) || 0,
|
|---|
| 425 | cover: coverEl.value.trim(),
|
|---|
| 426 | // De waarde zelf. Deze regel was `=== 'playlist' ? 'playlist' : 'album'`,
|
|---|
| 427 | // en dat is precies de vorm die een nieuwe soort niet afwijst maar
|
|---|
| 428 | // opslokt: een mixtape werd hier stil een album. De server heeft het
|
|---|
| 429 | // laatste woord via normKind().
|
|---|
| 430 | kind: kindEl.value,
|
|---|
| 431 | release_date: releaseEl.value.trim(),
|
|---|
| 432 | mb_release_id: mbReleaseEl.value.trim(),
|
|---|
| 433 | tracks: selected.slice(),
|
|---|
| 434 | };
|
|---|
| 435 |
|
|---|
| 436 | try {
|
|---|
| 437 | const url = isEdit
|
|---|
| 438 | ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
|
|---|
| 439 | : '/admin/playlists/api';
|
|---|
| 440 | const j = await api('POST', url, payload);
|
|---|
| 441 | if (!j.ok) {
|
|---|
| 442 | alert(T('e_opslaan') + (j.error || 'onbekend'));
|
|---|
| 443 | saveBtn.disabled = false;
|
|---|
| 444 | saveBtn.textContent = orig;
|
|---|
| 445 | return;
|
|---|
| 446 | }
|
|---|
| 447 | const savedId = isEdit ? initial.id : j.id;
|
|---|
| 448 | close();
|
|---|
| 449 | if (typeof opts.onSaved === 'function') {
|
|---|
| 450 | opts.onSaved({ id: savedId, playlist: payload });
|
|---|
| 451 | }
|
|---|
| 452 | } catch (err) {
|
|---|
| 453 | alert(T('e_opslaan') + err.message);
|
|---|
| 454 | saveBtn.disabled = false;
|
|---|
| 455 | saveBtn.textContent = orig;
|
|---|
| 456 | }
|
|---|
| 457 | });
|
|---|
| 458 |
|
|---|
| 459 | // ESC to close
|
|---|
| 460 | document.addEventListener('keydown', function onEsc(e) {
|
|---|
| 461 | if (e.key === 'Escape' && document.body.contains(backdrop)) {
|
|---|
| 462 | close();
|
|---|
| 463 | document.removeEventListener('keydown', onEsc);
|
|---|
| 464 | }
|
|---|
| 465 | });
|
|---|
| 466 | };
|
|---|
| 467 | })();
|
|---|