source: Klonkt/src/views/partials/playlist-editor.ejs@ 5b22ea9

main
Last change on this file since 5b22ea9 was 7bc636b, checked in by Robin <robin@…>, 4 months ago

Initial commit — PrutFolio v1 source (pulled from Hetzner /srv/prutfolio)

  • Property mode set to 100644
File size: 25.6 KB
Line 
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 ${isEdit ? `
390 <div class="pl-cover-upload-row">
391 <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
392 <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
393 <span class="pl-cover-status" id="pli-cover-status"></span>
394 </div>` : `
395 <small class="pl-cover-note">Upload van een afbeelding kan na het aanmaken van de playlist.</small>`}
396 </div>
397 </div>
398 <div class="pl-section-title">
399 Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
400 <small>(sleep ⠿ om te ordenen)</small>
401 </div>
402 <div id="pli-selected" class="pl-selected-list"></div>
403 </div>
404 <div class="pl-editor-right">
405 <div class="pl-section-title">Beschikbare tracks</div>
406 <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
407 <div id="pli-available" class="pl-available-list"></div>
408 </div>
409 </div>
410 </div>
411 <div class="pl-modal-footer">
412 <button type="button" class="btn" id="pli-cancel">Annuleren</button>
413 <button type="button" class="btn btn-primary" id="pli-save" disabled>
414 ${isEdit ? 'Opslaan' : 'Aanmaken'}
415 </button>
416 </div>
417 </div>
418 `;
419 document.body.appendChild(backdrop);
420
421 const $ = sel => backdrop.querySelector(sel);
422 const close = () => backdrop.remove();
423 backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
424 $('.pl-modal-close').addEventListener('click', close);
425 $('#pli-cancel').addEventListener('click', close);
426
427 // Index tracks by id for fast lookup
428 const trackById = new Map(tracks.map(t => [t.id, t]));
429 let selected = (initial.track_ids || []).filter(id => trackById.has(id));
430
431 const titleEl = $('#pli-title');
432 const artistEl = $('#pli-artist');
433 const yearEl = $('#pli-year');
434 const kindEl = $('#pli-kind');
435 const coverEl = $('#pli-cover');
436 const coverThumb = $('#pli-cover-thumb');
437 const saveBtn = $('#pli-save');
438 const selectedEl = $('#pli-selected');
439 const availEl = $('#pli-available');
440 const searchEl = $('#pli-search');
441 const countEl = $('#pli-count');
442
443 coverEl.addEventListener('input', () => {
444 const u = coverEl.value.trim();
445 coverThumb.innerHTML = u
446 ? `<img src="${esc(u)}" alt="" onerror="this.parentElement.innerHTML='<span class=\\'pl-cover-empty\\'>⚠️</span>'">`
447 : `<span class="pl-cover-empty">🎨</span>`;
448 });
449
450 // ── Cover file upload (edit mode only) ────────────────────
451 // The upload widget only exists once a playlist has an id to attach to.
452 // In create mode the user types/pastes a URL, saves the playlist, then
453 // re-opens for upload. The route is the same as for tracks: returns
454 // { ok, url, cover_url }.
455 const coverFileInput = $('#pli-cover-file');
456 const coverPickBtn = $('#pli-cover-pick');
457 const coverStatus = $('#pli-cover-status');
458 if (coverFileInput && coverPickBtn) {
459 coverPickBtn.addEventListener('click', () => coverFileInput.click());
460 coverFileInput.addEventListener('change', async (e) => {
461 const file = e.target.files && e.target.files[0];
462 coverFileInput.value = '';
463 if (!file) return;
464 if (!/^image\//.test(file.type)) {
465 coverStatus.textContent = 'Alleen afbeeldingen';
466 coverStatus.className = 'pl-cover-status is-error';
467 return;
468 }
469 coverStatus.textContent = 'Uploaden…';
470 coverStatus.className = 'pl-cover-status';
471 const fd = new FormData();
472 fd.append('cover', file);
473 try {
474 const r = await fetch(
475 `/admin/playlists/api/${encodeURIComponent(opts.id)}/cover`,
476 { method: 'POST', body: fd, credentials: 'same-origin' }
477 );
478 const j = await r.json();
479 if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
480 const url = j.cover_url || j.url || '';
481 coverEl.value = url;
482 coverThumb.innerHTML = url
483 ? `<img src="${esc(url)}" alt="">`
484 : `<span class="pl-cover-empty">🎨</span>`;
485 coverStatus.textContent = '✓ Geüpload';
486 coverStatus.className = 'pl-cover-status is-ok';
487 } catch (err) {
488 coverStatus.textContent = 'Mislukt: ' + err.message;
489 coverStatus.className = 'pl-cover-status is-error';
490 }
491 });
492 }
493
494 function updateSaveBtn() {
495 saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
496 }
497
498 function renderSelected() {
499 countEl.textContent = selected.length;
500 if (selected.length === 0) {
501 selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
502 updateSaveBtn();
503 return;
504 }
505 selectedEl.innerHTML = selected.map((id, i) => {
506 const t = trackById.get(id);
507 if (!t) return '';
508 const cover = t.cover
509 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
510 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
511 return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
512 <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
513 <span class="pl-row-num">${i + 1}</span>
514 ${cover}
515 <span class="pl-row-info">
516 <span class="pl-row-title">${esc(t.title)}</span>
517 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
518 </span>
519 <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
520 </div>`;
521 }).join('');
522
523 selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
524 b.addEventListener('click', () => {
525 selected = selected.filter(x => x !== b.dataset.id);
526 renderSelected();
527 renderAvailable();
528 });
529 });
530 bindDrag();
531 updateSaveBtn();
532 }
533
534 function renderAvailable() {
535 const q = searchEl.value.trim().toLowerCase();
536 const matches = tracks.filter(t => {
537 if (!q) return true;
538 return (t.title || '').toLowerCase().includes(q)
539 || (t.artist || '').toLowerCase().includes(q);
540 });
541 if (matches.length === 0) {
542 availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
543 return;
544 }
545 availEl.innerHTML = matches.map(t => {
546 const isAdded = selected.includes(t.id);
547 const cls = ['pl-avail-row'];
548 if (isAdded) cls.push('is-added');
549 if (!t.playable) cls.push('is-unplayable');
550 const cover = t.cover
551 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
552 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
553 return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
554 ${cover}
555 <span class="pl-row-info">
556 <span class="pl-row-title">${esc(t.title)}</span>
557 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
558 </span>
559 <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
560 </div>`;
561 }).join('');
562
563 availEl.querySelectorAll('.pl-avail-row').forEach(row => {
564 if (row.classList.contains('is-unplayable')) return;
565 row.addEventListener('click', () => {
566 const id = row.dataset.id;
567 if (selected.includes(id)) selected = selected.filter(x => x !== id);
568 else selected.push(id);
569 renderSelected();
570 renderAvailable();
571 });
572 });
573 }
574
575 // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
576 function bindDrag() {
577 selectedEl.querySelectorAll('.pl-row').forEach(row => {
578 const handle = row.querySelector('.pl-row-handle');
579 if (!handle) return;
580 let dragging = false, originalIdx = -1;
581
582 handle.addEventListener('pointerdown', e => {
583 e.preventDefault();
584 handle.setPointerCapture(e.pointerId);
585 dragging = true;
586 originalIdx = parseInt(row.dataset.pos, 10);
587 row.classList.add('is-dragging');
588 });
589 handle.addEventListener('pointermove', e => {
590 if (!dragging) return;
591 e.preventDefault();
592 const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
593 rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
594 let targetIdx = -1, above = false;
595 for (let i = 0; i < rows.length; i++) {
596 const r = rows[i]; if (r === row) continue;
597 const rect = r.getBoundingClientRect();
598 const mid = rect.top + rect.height / 2;
599 if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
600 if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
601 targetIdx = i; above = e.clientY < mid; break;
602 }
603 }
604 if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
605 });
606 const finish = e => {
607 if (!dragging) return;
608 dragging = false;
609 try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
610 row.classList.remove('is-dragging');
611 const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
612 let targetIdx = -1, above = false;
613 for (let i = 0; i < rows.length; i++) {
614 if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
615 if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
616 }
617 rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
618 if (targetIdx === -1 || targetIdx === originalIdx) return;
619 const moved = selected[originalIdx];
620 selected.splice(originalIdx, 1);
621 let newIdx = targetIdx;
622 if (originalIdx < targetIdx) newIdx--;
623 if (!above) newIdx++;
624 newIdx = Math.max(0, Math.min(selected.length, newIdx));
625 selected.splice(newIdx, 0, moved);
626 renderSelected();
627 };
628 handle.addEventListener('pointerup', finish);
629 handle.addEventListener('pointercancel', () => {
630 dragging = false;
631 row.classList.remove('is-dragging');
632 selectedEl.querySelectorAll('.drop-above, .drop-below')
633 .forEach(r => r.classList.remove('drop-above', 'drop-below'));
634 });
635 });
636 }
637
638 titleEl.addEventListener('input', updateSaveBtn);
639 searchEl.addEventListener('input', renderAvailable);
640 renderSelected();
641 renderAvailable();
642
643 saveBtn.addEventListener('click', async () => {
644 if (saveBtn.disabled) return;
645 const orig = saveBtn.textContent;
646 saveBtn.disabled = true;
647 saveBtn.textContent = 'Opslaan...';
648
649 const payload = {
650 title: titleEl.value.trim(),
651 artist: artistEl.value.trim(),
652 year: parseInt(yearEl.value, 10) || 0,
653 cover: coverEl.value.trim(),
654 kind: kindEl.value === 'playlist' ? 'playlist' : 'album',
655 tracks: selected.slice(),
656 };
657
658 try {
659 const url = isEdit
660 ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
661 : '/admin/playlists/api';
662 const j = await api('POST', url, payload);
663 if (!j.ok) {
664 alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
665 saveBtn.disabled = false;
666 saveBtn.textContent = orig;
667 return;
668 }
669 const savedId = isEdit ? initial.id : j.id;
670 close();
671 if (typeof opts.onSaved === 'function') {
672 opts.onSaved({ id: savedId, playlist: payload });
673 }
674 } catch (err) {
675 alert('Opslaan mislukt: ' + err.message);
676 saveBtn.disabled = false;
677 saveBtn.textContent = orig;
678 }
679 });
680
681 // ESC to close
682 document.addEventListener('keydown', function onEsc(e) {
683 if (e.key === 'Escape' && document.body.contains(backdrop)) {
684 close();
685 document.removeEventListener('keydown', onEsc);
686 }
687 });
688 };
689})();
690</script>
Note: See TracBrowser for help on using the repository browser.