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

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

Mixtape kiezen kan nu ook echt, in het scherm

De soort was er wel maar nergens te kiezen. Drie plekken, en twee ervan waren
weer dezelfde vorm die een derde soort niet afwijst maar opslokt:

  • De keuzelijst kreeg een derde optie.
  • kind: kindEl.value === 'playlist' ? 'playlist' : 'album' sloeg een mixtape op als album. Nu gaat de waarde zelf mee; normKind() op de server beslist.
  • const album = kindEl.value !== 'playlist' toonde uitgavevelden bij een mixtape, die de server vervolgens leegmaakt -- een scherm dat iets anders belooft dan het doet. Nu expliciet === 'album'.
  • De lijstweergave koos teken en woord met een ternair die er twee kende; een mixtape kreeg daardoor het albumjasje. Nu een tabel per soort.

Labels in nl/en/de, en de sleutel doorgegeven vanuit de partial -- dat laatste
ving playlist-editor-i18n.test.js meteen, precies waarvoor die test bestaat.

MOD_V naar 50.

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

  • Property mode set to 100644
File size: 19.3 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/**
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 */
20let _T = null;
21function 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 '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
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})();
Note: See TracBrowser for help on using the repository browser.