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