source: Klonkt/src/assets/js/mod/playlist-editor.js@ d8f7d9c

main
Last change on this file since d8f7d9c was d8f7d9c, checked in by Robin <roboburr@…>, 3 weeks ago

Playlists: uitgavedatum en MusicBrainz release-id op een album

Stap 1 van shaer-756s. Het album bestaat al als ding -- een playlist met
kind='album', met eigen id, titel, artiest, jaar, hoes en een eigen
AP-collectie. Wat ontbrak om het als Album uit te geven zijn twee velden.

release_date en niet year: die kolom blijft en wordt gewoon getoond,
maar hun AlbumSerializer leest released als een DateField. Een jaartal
als 2024-01-01 versturen is een dag verzinnen, en dat is precies wat we
bij artiesten en albums niet doen. Volledige datum of niets -- ook
2024-02-31 valt af, want Date rolt die stil door naar 2 maart en dan
bewaren we iets anders dan er ingetypt is.

mb_release_id is de tegenhanger van sites.mb_artist_id: dezelfde
verwijzing naar MusicBrainz, een niveau lager.

ALLEEN BIJ kind='album', en dat wordt in PlaylistService afgedwongen en
niet alleen in het scherm. De API ligt open -- de post-editor gebruikt
hem ook -- en een scherm is geen bewaking. Omschakelen naar afspeellijst
maakt de velden ALTIJD leeg, ook als de aanroeper er niets over zei:
anders houdt een album dat je tot mixtape ombouwt zijn uitgavedatum, en
duikt die weer op zodra iemand hem terugzet. Het leespad geeft ze ook
niet door bij een afspeellijst, zodat een oude rij niet alsnog lekt.

In de editor staan de velden onder Type en verdwijnen ze als je naar
playlist schakelt. Daar hoorde een CSS-regel bij: .pl-field is display:
flex en dat wint van het hidden-attribuut -- zonder .pl-field[hidden]
{ display: none } bleven ze gewoon staan.

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

  • Property mode set to 100644
File size: 18.0 KB
Line 
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
7import { 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 '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
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})();
Note: See TracBrowser for help on using the repository browser.