source: Klonkt/src/views/partials/playlist-editor.ejs@ 441f5d6

main
Last change on this file since 441f5d6 was bd7f10d, checked in by roboburr <roboburr@…>, 3 months ago

fix: playlist cover upload now works on create too (not only edit)

The upload button was edit-only (id-bound route /admin/playlists/api/:id/cover).
Now uploads to the generic /posts/upload-image (no id needed) -> /media/ URL
in the cover field -> sent along on Create. Button + handler always active.

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 25.4 KB
RevLine 
[7bc636b]1<%
2// Playlist editor modal — included once per page (in admin-playlists, and in
3// post-edit when present). Exposes `window.openPlaylistEditor({ mode, id?, onSaved? })`.
4//
5// mode: 'create' or 'edit'
6// id : (edit only) playlist id to load
7// onSaved: callback receiving { id, playlist } — used by post-editor
8// to insert [[playlist:id]] at the cursor.
9//
10// The modal itself is appended on first call; subsequent calls reuse the
11// same DOM. CSS is included once. JS lives below.
12
13const _csrf = (typeof csrfToken !== 'undefined' && csrfToken) || '';
14%>
15
16<style>
17/* ─── Editor modal ─────────────────────────────────────────────── */
18.pl-modal-backdrop {
19 position: fixed; inset: 0; z-index: 9000;
20 background: color-mix(in srgb, #000 50%, transparent);
21 display: flex; align-items: center; justify-content: center;
22 padding: 1rem;
23 -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
24}
25.pl-modal {
26 background: var(--paper);
27 border: 1px solid var(--rule); border-radius: 16px;
28 width: 100%; max-width: 960px;
29 max-height: 92vh;
30 display: flex; flex-direction: column;
31 box-shadow: 0 24px 60px rgba(0, 0, 0, .35);
32 overflow: hidden;
33}
34.pl-modal-header {
35 padding: 1rem 1.25rem;
36 border-bottom: 1px solid var(--rule);
37 display: flex; align-items: center; justify-content: space-between;
38}
39.pl-modal-header h3 { margin: 0; font-size: 1.15rem; }
40.pl-modal-close {
41 background: transparent; border: 0;
42 font-size: 1.5rem; line-height: 1; cursor: pointer;
43 width: 36px; height: 36px; border-radius: 8px;
44 display: inline-flex; align-items: center; justify-content: center;
45 color: var(--ink-soft);
46}
47.pl-modal-close:hover { background: var(--paper-2); color: var(--ink); }
48.pl-modal-body {
49 flex: 1 1 auto; min-height: 0;
50 overflow: hidden;
51 padding: 1.25rem;
52 /* Become a flex container so .pl-editor-cols (grid) can flex into the
53 full available height. Without this, .pl-editor-cols had no bounded
54 parent height and inner overflow:auto never fired. */
55 display: flex; flex-direction: column;
56}
57.pl-modal-footer {
58 padding: .75rem 1.25rem;
59 border-top: 1px solid var(--rule);
60 display: flex; justify-content: flex-end; gap: .5rem;
61}
62
63.pl-editor-cols {
64 flex: 1 1 auto; min-height: 0;
65 display: grid;
66 grid-template-columns: 1fr 1fr;
67 gap: 1.5rem;
68 overflow: hidden;
69}
70@media (max-width: 720px) {
71 /* On mobile, abandon the inner-scroll-container model entirely. Two
72 scrolling boxes side-by-side don't fit, and stacked scroll boxes inside
73 a fixed modal create a scroll-trap (touch scroll easily lands on the
74 wrong layer). Instead: let the modal-body scroll the whole content,
75 and let lists flow at their natural height. */
76 .pl-editor-cols {
77 grid-template-columns: 1fr;
78 gap: 1rem;
79 height: auto;
80 overflow: visible;
81 }
82 .pl-modal-body { overflow: auto; -webkit-overflow-scrolling: touch; }
83 .pl-editor-left, .pl-editor-right { overflow: visible; min-height: 0; }
84 .pl-selected-list, .pl-available-list { overflow: visible; flex: 0 0 auto; }
85}
86
87.pl-editor-left,
88.pl-editor-right {
89 display: flex; flex-direction: column;
90 min-height: 0;
91 overflow: hidden;
92}
93
94/* Meta grid (title/artist/year/kind/cover) */
95.pl-meta-grid {
96 display: grid; gap: .5rem .75rem;
97 grid-template-columns: 1fr 1fr;
98 margin-bottom: 1rem;
99}
100.pl-field { display: flex; flex-direction: column; gap: .25rem; }
101.pl-field-full { grid-column: 1 / -1; }
102.pl-field > span {
103 font-size: .8rem; color: var(--ink-soft); font-weight: 500;
104}
105.pl-field input,
106.pl-field select {
107 width: 100%;
108 padding: .5rem .75rem;
109 border: 1px solid var(--rule); border-radius: 8px;
110 background: var(--paper); color: var(--ink);
111 font-family: var(--font-ui); font-size: .95rem;
112}
113.pl-field input:focus,
114.pl-field select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
115
116.pl-cover-row {
117 display: flex; align-items: center; gap: .5rem;
118}
119.pl-cover-thumb {
120 width: 48px; height: 48px; border-radius: 8px;
121 background: var(--paper-2);
122 border: 1px solid var(--rule);
123 display: flex; align-items: center; justify-content: center;
124 cursor: pointer; padding: 0; overflow: hidden;
125}
126.pl-cover-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
127.pl-cover-empty { font-size: 1.25rem; color: var(--ink-muted); }
128
129/* File upload row + status — only renders in edit mode */
130.pl-cover-upload-row {
131 display: flex; align-items: center; gap: .5rem;
132 margin-top: .35rem; flex-wrap: wrap;
133}
134.pl-btn-small {
135 background: var(--paper-2); color: var(--ink);
136 border: 1px solid var(--rule); border-radius: 999px;
137 padding: .35rem .75rem; font-size: .8rem;
138 cursor: pointer; transition: border-color 120ms;
139 -webkit-tap-highlight-color: transparent;
140}
141.pl-btn-small:hover { border-color: var(--accent); }
142.pl-cover-status { font-size: .8rem; color: var(--ink-muted); }
143.pl-cover-status.is-error { color: #dc2626; }
144.pl-cover-status.is-ok { color: #2a9d5e; }
145.pl-cover-note { font-size: .75rem; color: var(--ink-muted); margin-top: .25rem; display: block; }
146
147/* Section titles */
148.pl-section-title {
149 display: flex; align-items: baseline; gap: .5rem;
150 font-size: .8rem; font-weight: 600;
151 text-transform: uppercase; letter-spacing: .05em;
152 color: var(--ink-soft);
153 padding: .5rem 0;
154 margin-bottom: .5rem;
155 border-bottom: 1px solid var(--rule);
156 flex-shrink: 0;
157}
158.pl-section-title small {
159 font-size: .8em; font-weight: 400; text-transform: none; letter-spacing: 0;
160 color: var(--ink-muted);
161}
162.pl-track-count {
163 background: var(--accent); color: #fff;
164 padding: .1em .55em; border-radius: 999px; font-size: .75rem;
165 font-weight: 600; margin-left: .25rem;
166}
167
168/* Selected + available lists */
169.pl-selected-list,
170.pl-available-list {
171 flex: 1; overflow-y: auto; min-height: 0;
172 padding-right: .25rem;
173}
174.pl-empty {
175 color: var(--ink-muted); font-style: italic;
176 padding: 1.5rem; text-align: center; font-size: .9rem;
177}
178
179/* Search input on right pane */
180.pl-search-input {
181 width: 100%;
182 padding: .5rem .75rem;
183 border: 1px solid var(--rule); border-radius: 8px;
184 background: var(--paper); color: var(--ink);
185 font-family: var(--font-ui); font-size: .9rem;
186 margin-bottom: .5rem;
187 flex-shrink: 0;
188}
189
190/* Selected row (sortable) */
191.pl-row {
192 display: flex; align-items: center; gap: .5rem;
193 padding: .5rem;
194 border: 1px solid transparent;
195 border-radius: 8px;
196 margin-bottom: .25rem;
197 background: var(--paper-2);
198 user-select: none;
199}
200.pl-row.is-dragging { opacity: 0.5; }
201.pl-row.drop-above { box-shadow: 0 -2px 0 var(--accent); }
202.pl-row.drop-below { box-shadow: 0 2px 0 var(--accent); }
203.pl-row-handle {
204 cursor: grab; padding: .25rem .5rem;
205 color: var(--ink-muted);
206 touch-action: none;
207}
208.pl-row-handle:active { cursor: grabbing; }
209.pl-row-num {
210 width: 22px; text-align: right;
211 font-family: var(--font-mono, monospace);
212 font-size: .8rem; color: var(--ink-muted);
213}
214.pl-row-cover {
215 width: 32px; height: 32px; border-radius: 4px;
216 flex-shrink: 0;
217 background: var(--paper); border: 1px solid var(--rule);
218 display: inline-flex; align-items: center; justify-content: center;
219 overflow: hidden;
220}
221.pl-row-cover img { width: 100%; height: 100%; object-fit: cover; }
222.pl-row-cover-empty { color: var(--ink-muted); font-size: .8rem; }
223.pl-row-info {
224 flex: 1; min-width: 0;
225 display: flex; flex-direction: column;
226 font-size: .85rem;
227}
228.pl-row-title {
229 font-weight: 500;
230 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
231}
232.pl-row-artist {
233 font-size: .8em; color: var(--ink-soft);
234 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
235}
236.pl-row-x {
237 background: transparent; border: 0;
238 width: 28px; height: 28px;
239 border-radius: 6px;
240 cursor: pointer;
241 color: var(--ink-muted);
242 font-size: 1.1rem;
243}
244.pl-row-x:hover { background: var(--paper); color: #dc2626; }
245
246/* Available row (clickable) */
247.pl-avail-row {
248 display: flex; align-items: center; gap: .5rem;
249 padding: .5rem; border-radius: 8px;
250 cursor: pointer; margin-bottom: .25rem;
251 transition: background 120ms ease;
252}
253.pl-avail-row:hover { background: var(--paper-2); }
254.pl-avail-row.is-added { opacity: .55; }
255.pl-avail-row.is-unplayable { opacity: .4; cursor: not-allowed; }
256.pl-avail-action {
257 width: 22px; height: 22px;
258 border-radius: 50%; display: flex; align-items: center; justify-content: center;
259 font-size: .9rem; font-weight: 600;
260 background: var(--paper-2); color: var(--ink-soft);
261 flex-shrink: 0;
262}
263.pl-avail-row.is-added .pl-avail-action {
264 background: var(--accent); color: #fff;
265}
266
267/* Cover-picker grid (sub-modal) */
268.pl-cover-picker-grid {
269 display: grid;
270 grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
271 gap: .5rem; max-height: 60vh; overflow-y: auto;
272}
273.pl-cover-picker-grid button {
274 border: 1px solid var(--rule); border-radius: 8px;
275 background: var(--paper-2); padding: 0; cursor: pointer;
276 aspect-ratio: 1; overflow: hidden;
277}
278.pl-cover-picker-grid button:hover { border-color: var(--accent); }
279.pl-cover-picker-grid img { width: 100%; height: 100%; object-fit: cover; display: block; }
280</style>
281
282<script>
283(function() {
284 // Idempotency: if window.openPlaylistEditor already defined (multiple
285 // partial includes on a single page), skip re-binding.
286 if (typeof window.openPlaylistEditor === 'function') return;
287
288 const CSRF = '<%= _csrf %>';
289
290 function esc(s) {
291 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
292 '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
293 }[c]));
294 }
295 function fmtDur(sec) {
296 if (!sec) return '';
297 const m = Math.floor(sec / 60), s = sec % 60;
298 return `${m}:${String(s).padStart(2, '0')}`;
299 }
300
301 async function api(method, url, body) {
302 const opts = {
303 method, credentials: 'same-origin',
304 headers: { 'X-CSRF-Token': CSRF },
305 };
306 if (body !== undefined) {
307 opts.headers['Content-Type'] = 'application/json';
308 opts.body = JSON.stringify(body);
309 }
310 const r = await fetch(url, opts);
311 return r.json();
312 }
313
314 /**
315 * Public entry point: open the editor.
316 * opts: { mode: 'create'|'edit', id?, onSaved? }
317 * onSaved is called with { id, playlist } after a successful save.
318 */
319 window.openPlaylistEditor = async function(opts) {
320 opts = opts || {};
321 const mode = opts.mode === 'edit' ? 'edit' : 'create';
322 const isEdit = mode === 'edit';
323
324 // Load all audio tracks for the picker
325 let tracks = [];
326 try {
327 const j = await api('GET', '/admin/playlists/api/tracks');
328 if (Array.isArray(j.tracks)) tracks = j.tracks;
329 } catch (e) {
330 alert('Tracks ophalen mislukt');
331 return;
332 }
333 if (!tracks.length) {
334 alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');
335 return;
336 }
337
338 // Existing playlist data when editing
339 let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] };
340 if (isEdit && opts.id) {
341 try {
342 const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
343 if (j.ok) initial = { ...initial, ...j.playlist };
344 } catch (e) {}
345 }
346
347 // ── DOM ────────────────────────────────────────────────────────
348 const backdrop = document.createElement('div');
349 backdrop.className = 'pl-modal-backdrop';
350 backdrop.innerHTML = `
351 <div class="pl-modal" role="dialog" aria-label="Playlist editor">
352 <div class="pl-modal-header">
353 <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3>
354 <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button>
355 </div>
356 <div class="pl-modal-body">
357 <div class="pl-editor-cols">
358 <div class="pl-editor-left">
359 <div class="pl-meta-grid">
360 <label class="pl-field pl-field-full">
361 <span>Titel *</span>
362 <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
363 </label>
364 <label class="pl-field">
365 <span>Artiest</span>
366 <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
367 </label>
368 <label class="pl-field">
369 <span>Jaar</span>
370 <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
371 </label>
372 <label class="pl-field">
373 <span>Type</span>
374 <select id="pli-kind">
375 <option value="album" ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option>
376 <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option>
377 </select>
378 </label>
379 <div class="pl-field pl-field-full">
380 <span>Cover</span>
381 <div class="pl-cover-row">
382 <span class="pl-cover-thumb" id="pli-cover-thumb">
383 ${initial.cover
384 ? `<img src="${esc(initial.cover)}" alt="">`
385 : `<span class="pl-cover-empty">🎨</span>`}
386 </span>
387 <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1">
388 </div>
389 <div class="pl-cover-upload-row">
390 <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
391 <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
392 <span class="pl-cover-status" id="pli-cover-status"></span>
[bd7f10d]393 </div>
[7bc636b]394 </div>
395 </div>
396 <div class="pl-section-title">
397 Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
398 <small>(sleep ⠿ om te ordenen)</small>
399 </div>
400 <div id="pli-selected" class="pl-selected-list"></div>
401 </div>
402 <div class="pl-editor-right">
403 <div class="pl-section-title">Beschikbare tracks</div>
404 <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
405 <div id="pli-available" class="pl-available-list"></div>
406 </div>
407 </div>
408 </div>
409 <div class="pl-modal-footer">
410 <button type="button" class="btn" id="pli-cancel">Annuleren</button>
411 <button type="button" class="btn btn-primary" id="pli-save" disabled>
412 ${isEdit ? 'Opslaan' : 'Aanmaken'}
413 </button>
414 </div>
415 </div>
416 `;
417 document.body.appendChild(backdrop);
418
419 const $ = sel => backdrop.querySelector(sel);
420 const close = () => backdrop.remove();
421 backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
422 $('.pl-modal-close').addEventListener('click', close);
423 $('#pli-cancel').addEventListener('click', close);
424
425 // Index tracks by id for fast lookup
426 const trackById = new Map(tracks.map(t => [t.id, t]));
427 let selected = (initial.track_ids || []).filter(id => trackById.has(id));
428
429 const titleEl = $('#pli-title');
430 const artistEl = $('#pli-artist');
431 const yearEl = $('#pli-year');
432 const kindEl = $('#pli-kind');
433 const coverEl = $('#pli-cover');
434 const coverThumb = $('#pli-cover-thumb');
435 const saveBtn = $('#pli-save');
436 const selectedEl = $('#pli-selected');
437 const availEl = $('#pli-available');
438 const searchEl = $('#pli-search');
439 const countEl = $('#pli-count');
440
441 coverEl.addEventListener('input', () => {
442 const u = coverEl.value.trim();
443 coverThumb.innerHTML = u
444 ? `<img src="${esc(u)}" alt="" onerror="this.parentElement.innerHTML='<span class=\\'pl-cover-empty\\'>⚠️</span>'">`
445 : `<span class="pl-cover-empty">🎨</span>`;
446 });
447
[bd7f10d]448 // ── Cover file upload (werkt in create ÉN edit) ───────────
449 // We uploaden naar het generieke image-endpoint (/posts/upload-image,
450 // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
451 // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het
452 // cover-veld en wordt bij 'Aanmaken'/'Opslaan' met de playlist meegestuurd.
[7bc636b]453 const coverFileInput = $('#pli-cover-file');
454 const coverPickBtn = $('#pli-cover-pick');
455 const coverStatus = $('#pli-cover-status');
456 if (coverFileInput && coverPickBtn) {
457 coverPickBtn.addEventListener('click', () => coverFileInput.click());
458 coverFileInput.addEventListener('change', async (e) => {
459 const file = e.target.files && e.target.files[0];
460 coverFileInput.value = '';
461 if (!file) return;
462 if (!/^image\//.test(file.type)) {
463 coverStatus.textContent = 'Alleen afbeeldingen';
464 coverStatus.className = 'pl-cover-status is-error';
465 return;
466 }
467 coverStatus.textContent = 'Uploaden…';
468 coverStatus.className = 'pl-cover-status';
469 const fd = new FormData();
[bd7f10d]470 fd.append('image', file);
[7bc636b]471 try {
[bd7f10d]472 const r = await fetch('/posts/upload-image',
[7bc636b]473 { method: 'POST', body: fd, credentials: 'same-origin' }
474 );
475 const j = await r.json();
[bd7f10d]476 if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt');
477 const url = j.url || '';
[7bc636b]478 coverEl.value = url;
479 coverThumb.innerHTML = url
480 ? `<img src="${esc(url)}" alt="">`
481 : `<span class="pl-cover-empty">🎨</span>`;
482 coverStatus.textContent = '✓ Geüpload';
483 coverStatus.className = 'pl-cover-status is-ok';
484 } catch (err) {
485 coverStatus.textContent = 'Mislukt: ' + err.message;
486 coverStatus.className = 'pl-cover-status is-error';
487 }
488 });
489 }
490
491 function updateSaveBtn() {
492 saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
493 }
494
495 function renderSelected() {
496 countEl.textContent = selected.length;
497 if (selected.length === 0) {
498 selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
499 updateSaveBtn();
500 return;
501 }
502 selectedEl.innerHTML = selected.map((id, i) => {
503 const t = trackById.get(id);
504 if (!t) return '';
505 const cover = t.cover
506 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
507 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
508 return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
509 <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
510 <span class="pl-row-num">${i + 1}</span>
511 ${cover}
512 <span class="pl-row-info">
513 <span class="pl-row-title">${esc(t.title)}</span>
514 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
515 </span>
516 <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
517 </div>`;
518 }).join('');
519
520 selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
521 b.addEventListener('click', () => {
522 selected = selected.filter(x => x !== b.dataset.id);
523 renderSelected();
524 renderAvailable();
525 });
526 });
527 bindDrag();
528 updateSaveBtn();
529 }
530
531 function renderAvailable() {
532 const q = searchEl.value.trim().toLowerCase();
533 const matches = tracks.filter(t => {
534 if (!q) return true;
535 return (t.title || '').toLowerCase().includes(q)
536 || (t.artist || '').toLowerCase().includes(q);
537 });
538 if (matches.length === 0) {
539 availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
540 return;
541 }
542 availEl.innerHTML = matches.map(t => {
543 const isAdded = selected.includes(t.id);
544 const cls = ['pl-avail-row'];
545 if (isAdded) cls.push('is-added');
546 if (!t.playable) cls.push('is-unplayable');
547 const cover = t.cover
548 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
549 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
550 return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
551 ${cover}
552 <span class="pl-row-info">
553 <span class="pl-row-title">${esc(t.title)}</span>
554 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
555 </span>
556 <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
557 </div>`;
558 }).join('');
559
560 availEl.querySelectorAll('.pl-avail-row').forEach(row => {
561 if (row.classList.contains('is-unplayable')) return;
562 row.addEventListener('click', () => {
563 const id = row.dataset.id;
564 if (selected.includes(id)) selected = selected.filter(x => x !== id);
565 else selected.push(id);
566 renderSelected();
567 renderAvailable();
568 });
569 });
570 }
571
572 // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
573 function bindDrag() {
574 selectedEl.querySelectorAll('.pl-row').forEach(row => {
575 const handle = row.querySelector('.pl-row-handle');
576 if (!handle) return;
577 let dragging = false, originalIdx = -1;
578
579 handle.addEventListener('pointerdown', e => {
580 e.preventDefault();
581 handle.setPointerCapture(e.pointerId);
582 dragging = true;
583 originalIdx = parseInt(row.dataset.pos, 10);
584 row.classList.add('is-dragging');
585 });
586 handle.addEventListener('pointermove', e => {
587 if (!dragging) return;
588 e.preventDefault();
589 const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
590 rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
591 let targetIdx = -1, above = false;
592 for (let i = 0; i < rows.length; i++) {
593 const r = rows[i]; if (r === row) continue;
594 const rect = r.getBoundingClientRect();
595 const mid = rect.top + rect.height / 2;
596 if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
597 if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
598 targetIdx = i; above = e.clientY < mid; break;
599 }
600 }
601 if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
602 });
603 const finish = e => {
604 if (!dragging) return;
605 dragging = false;
606 try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
607 row.classList.remove('is-dragging');
608 const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
609 let targetIdx = -1, above = false;
610 for (let i = 0; i < rows.length; i++) {
611 if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
612 if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
613 }
614 rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
615 if (targetIdx === -1 || targetIdx === originalIdx) return;
616 const moved = selected[originalIdx];
617 selected.splice(originalIdx, 1);
618 let newIdx = targetIdx;
619 if (originalIdx < targetIdx) newIdx--;
620 if (!above) newIdx++;
621 newIdx = Math.max(0, Math.min(selected.length, newIdx));
622 selected.splice(newIdx, 0, moved);
623 renderSelected();
624 };
625 handle.addEventListener('pointerup', finish);
626 handle.addEventListener('pointercancel', () => {
627 dragging = false;
628 row.classList.remove('is-dragging');
629 selectedEl.querySelectorAll('.drop-above, .drop-below')
630 .forEach(r => r.classList.remove('drop-above', 'drop-below'));
631 });
632 });
633 }
634
635 titleEl.addEventListener('input', updateSaveBtn);
636 searchEl.addEventListener('input', renderAvailable);
637 renderSelected();
638 renderAvailable();
639
640 saveBtn.addEventListener('click', async () => {
641 if (saveBtn.disabled) return;
642 const orig = saveBtn.textContent;
643 saveBtn.disabled = true;
644 saveBtn.textContent = 'Opslaan...';
645
646 const payload = {
647 title: titleEl.value.trim(),
648 artist: artistEl.value.trim(),
649 year: parseInt(yearEl.value, 10) || 0,
650 cover: coverEl.value.trim(),
651 kind: kindEl.value === 'playlist' ? 'playlist' : 'album',
652 tracks: selected.slice(),
653 };
654
655 try {
656 const url = isEdit
657 ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
658 : '/admin/playlists/api';
659 const j = await api('POST', url, payload);
660 if (!j.ok) {
661 alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
662 saveBtn.disabled = false;
663 saveBtn.textContent = orig;
664 return;
665 }
666 const savedId = isEdit ? initial.id : j.id;
667 close();
668 if (typeof opts.onSaved === 'function') {
669 opts.onSaved({ id: savedId, playlist: payload });
670 }
671 } catch (err) {
672 alert('Opslaan mislukt: ' + err.message);
673 saveBtn.disabled = false;
674 saveBtn.textContent = orig;
675 }
676 });
677
678 // ESC to close
679 document.addEventListener('keydown', function onEsc(e) {
680 if (e.key === 'Escape' && document.body.contains(backdrop)) {
681 close();
682 document.removeEventListener('keydown', onEsc);
683 }
684 });
685 };
686})();
687</script>
Note: See TracBrowser for help on using the repository browser.