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

main
Last change on this file since b2646cc was 6ee289a, checked in by roboburr <roboburr@…>, 5 weeks ago

De audio- en afspeellijst-editors (shaer-bqr, stap 3f)

admin-audio (435 regels, 18 vertaalsleutels) en playlist-editor (406 regels, het
csrf-token). Daarmee zijn 23 van de 24 bestanden om; alleen post-edit rest.

De achttien vertalingen van admin-audio stonden verspreid door de code, midden in
stringconcatenaties. Ze gaan nu als een tabel via pageData en worden opgezocht in
plaats van geinterpoleerd. Steekproefsgewijs nagelopen op de plekken waar de tekst
de HELE string was: daar bleef anders een leeg begin of eind over.

admin-playlists neemt de playlist-editor op, dus die route vraagt nu om
'admin-playlists playlist-editor' -- een module hoort bij het onderdeel, niet bij
de pagina die het toevallig opneemt.

Templates compileren, 22 modules schoon, suite 565/565.

  • Property mode set to 100644
File size: 16.7 KB
RevLine 
[6ee289a]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', 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 <div class="pl-field pl-field-full">
106 <span>Cover</span>
107 <div class="pl-cover-row">
108 <span class="pl-cover-thumb" id="pli-cover-thumb">
109 ${initial.cover
110 ? `<img src="${esc(initial.cover)}" alt="">`
111 : `<span class="pl-cover-empty">🎨</span>`}
112 </span>
113 <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1">
114 </div>
115 <div class="pl-cover-upload-row">
116 <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
117 <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
118 <span class="pl-cover-status" id="pli-cover-status"></span>
119 </div>
120 </div>
121 </div>
122 <div class="pl-section-title">
123 Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
124 <small>(sleep ⠿ om te ordenen)</small>
125 </div>
126 <div id="pli-selected" class="pl-selected-list"></div>
127 </div>
128 <div class="pl-editor-right">
129 <div class="pl-section-title">Beschikbare tracks</div>
130 <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
131 <div id="pli-available" class="pl-available-list"></div>
132 </div>
133 </div>
134 </div>
135 <div class="pl-modal-footer">
136 <button type="button" class="btn" id="pli-cancel">Annuleren</button>
137 <button type="button" class="btn btn-primary" id="pli-save" disabled>
138 ${isEdit ? 'Opslaan' : 'Aanmaken'}
139 </button>
140 </div>
141 </div>
142 `;
143 document.body.appendChild(backdrop);
144
145 const $ = sel => backdrop.querySelector(sel);
146 const close = () => backdrop.remove();
147 backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
148 $('.pl-modal-close').addEventListener('click', close);
149 $('#pli-cancel').addEventListener('click', close);
150
151 // Index tracks by id for fast lookup
152 const trackById = new Map(tracks.map(t => [t.id, t]));
153 let selected = (initial.track_ids || []).filter(id => trackById.has(id));
154
155 const titleEl = $('#pli-title');
156 const artistEl = $('#pli-artist');
157 const yearEl = $('#pli-year');
158 const kindEl = $('#pli-kind');
159 const coverEl = $('#pli-cover');
160 const coverThumb = $('#pli-cover-thumb');
161 const saveBtn = $('#pli-save');
162 const selectedEl = $('#pli-selected');
163 const availEl = $('#pli-available');
164 const searchEl = $('#pli-search');
165 const countEl = $('#pli-count');
166
167 coverEl.addEventListener('input', () => {
168 const u = coverEl.value.trim();
169 coverThumb.innerHTML = u
170 ? `<img src="${esc(u)}" alt="" data-fallback>`
171 : `<span class="pl-cover-empty">🎨</span>`;
172 });
173
174 // ── Cover file upload (werkt in create ÉN edit) ───────────
175 // We uploaden naar het generieke image-endpoint (/posts/upload-image,
176 // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
177 // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het
178 // cover-veld en wordt bij 'Aanmaken'/'Opslaan' met de playlist meegestuurd.
179 const coverFileInput = $('#pli-cover-file');
180 const coverPickBtn = $('#pli-cover-pick');
181 const coverStatus = $('#pli-cover-status');
182 if (coverFileInput && coverPickBtn) {
183 coverPickBtn.addEventListener('click', () => coverFileInput.click());
184 coverFileInput.addEventListener('change', async (e) => {
185 const file = e.target.files && e.target.files[0];
186 coverFileInput.value = '';
187 if (!file) return;
188 if (!/^image\//.test(file.type)) {
189 coverStatus.textContent = 'Alleen afbeeldingen';
190 coverStatus.className = 'pl-cover-status is-error';
191 return;
192 }
193 coverStatus.textContent = 'Uploaden…';
194 coverStatus.className = 'pl-cover-status';
195 const fd = new FormData();
196 fd.append('image', file);
197 try {
198 const r = await fetch('/posts/upload-image',
199 { method: 'POST', body: fd, credentials: 'same-origin' }
200 );
201 const j = await r.json();
202 if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt');
203 const url = j.url || '';
204 coverEl.value = url;
205 coverThumb.innerHTML = url
206 ? `<img src="${esc(url)}" alt="">`
207 : `<span class="pl-cover-empty">🎨</span>`;
208 coverStatus.textContent = '✓ Geüpload';
209 coverStatus.className = 'pl-cover-status is-ok';
210 } catch (err) {
211 coverStatus.textContent = 'Mislukt: ' + err.message;
212 coverStatus.className = 'pl-cover-status is-error';
213 }
214 });
215 }
216
217 function updateSaveBtn() {
218 saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
219 }
220
221 function renderSelected() {
222 countEl.textContent = selected.length;
223 if (selected.length === 0) {
224 selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
225 updateSaveBtn();
226 return;
227 }
228 selectedEl.innerHTML = selected.map((id, i) => {
229 const t = trackById.get(id);
230 if (!t) return '';
231 const cover = t.cover
232 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
233 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
234 return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
235 <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
236 <span class="pl-row-num">${i + 1}</span>
237 ${cover}
238 <span class="pl-row-info">
239 <span class="pl-row-title">${esc(t.title)}</span>
240 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
241 </span>
242 <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
243 </div>`;
244 }).join('');
245
246 selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
247 b.addEventListener('click', () => {
248 selected = selected.filter(x => x !== b.dataset.id);
249 renderSelected();
250 renderAvailable();
251 });
252 });
253 bindDrag();
254 updateSaveBtn();
255 }
256
257 function renderAvailable() {
258 const q = searchEl.value.trim().toLowerCase();
259 const matches = tracks.filter(t => {
260 if (!q) return true;
261 return (t.title || '').toLowerCase().includes(q)
262 || (t.artist || '').toLowerCase().includes(q);
263 });
264 if (matches.length === 0) {
265 availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
266 return;
267 }
268 availEl.innerHTML = matches.map(t => {
269 const isAdded = selected.includes(t.id);
270 const cls = ['pl-avail-row'];
271 if (isAdded) cls.push('is-added');
272 if (!t.playable) cls.push('is-unplayable');
273 const cover = t.cover
274 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
275 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
276 return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
277 ${cover}
278 <span class="pl-row-info">
279 <span class="pl-row-title">${esc(t.title)}</span>
280 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
281 </span>
282 <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
283 </div>`;
284 }).join('');
285
286 availEl.querySelectorAll('.pl-avail-row').forEach(row => {
287 if (row.classList.contains('is-unplayable')) return;
288 row.addEventListener('click', () => {
289 const id = row.dataset.id;
290 if (selected.includes(id)) selected = selected.filter(x => x !== id);
291 else selected.push(id);
292 renderSelected();
293 renderAvailable();
294 });
295 });
296 }
297
298 // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
299 function bindDrag() {
300 selectedEl.querySelectorAll('.pl-row').forEach(row => {
301 const handle = row.querySelector('.pl-row-handle');
302 if (!handle) return;
303 let dragging = false, originalIdx = -1;
304
305 handle.addEventListener('pointerdown', e => {
306 e.preventDefault();
307 handle.setPointerCapture(e.pointerId);
308 dragging = true;
309 originalIdx = parseInt(row.dataset.pos, 10);
310 row.classList.add('is-dragging');
311 });
312 handle.addEventListener('pointermove', e => {
313 if (!dragging) return;
314 e.preventDefault();
315 const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
316 rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
317 let targetIdx = -1, above = false;
318 for (let i = 0; i < rows.length; i++) {
319 const r = rows[i]; if (r === row) continue;
320 const rect = r.getBoundingClientRect();
321 const mid = rect.top + rect.height / 2;
322 if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
323 if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
324 targetIdx = i; above = e.clientY < mid; break;
325 }
326 }
327 if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
328 });
329 const finish = e => {
330 if (!dragging) return;
331 dragging = false;
332 try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
333 row.classList.remove('is-dragging');
334 const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
335 let targetIdx = -1, above = false;
336 for (let i = 0; i < rows.length; i++) {
337 if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
338 if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
339 }
340 rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
341 if (targetIdx === -1 || targetIdx === originalIdx) return;
342 const moved = selected[originalIdx];
343 selected.splice(originalIdx, 1);
344 let newIdx = targetIdx;
345 if (originalIdx < targetIdx) newIdx--;
346 if (!above) newIdx++;
347 newIdx = Math.max(0, Math.min(selected.length, newIdx));
348 selected.splice(newIdx, 0, moved);
349 renderSelected();
350 };
351 handle.addEventListener('pointerup', finish);
352 handle.addEventListener('pointercancel', () => {
353 dragging = false;
354 row.classList.remove('is-dragging');
355 selectedEl.querySelectorAll('.drop-above, .drop-below')
356 .forEach(r => r.classList.remove('drop-above', 'drop-below'));
357 });
358 });
359 }
360
361 titleEl.addEventListener('input', updateSaveBtn);
362 searchEl.addEventListener('input', renderAvailable);
363 renderSelected();
364 renderAvailable();
365
366 saveBtn.addEventListener('click', async () => {
367 if (saveBtn.disabled) return;
368 const orig = saveBtn.textContent;
369 saveBtn.disabled = true;
370 saveBtn.textContent = 'Opslaan...';
371
372 const payload = {
373 title: titleEl.value.trim(),
374 artist: artistEl.value.trim(),
375 year: parseInt(yearEl.value, 10) || 0,
376 cover: coverEl.value.trim(),
377 kind: kindEl.value === 'playlist' ? 'playlist' : 'album',
378 tracks: selected.slice(),
379 };
380
381 try {
382 const url = isEdit
383 ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
384 : '/admin/playlists/api';
385 const j = await api('POST', url, payload);
386 if (!j.ok) {
387 alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
388 saveBtn.disabled = false;
389 saveBtn.textContent = orig;
390 return;
391 }
392 const savedId = isEdit ? initial.id : j.id;
393 close();
394 if (typeof opts.onSaved === 'function') {
395 opts.onSaved({ id: savedId, playlist: payload });
396 }
397 } catch (err) {
398 alert('Opslaan mislukt: ' + err.message);
399 saveBtn.disabled = false;
400 saveBtn.textContent = orig;
401 }
402 });
403
404 // ESC to close
405 document.addEventListener('keydown', function onEsc(e) {
406 if (e.key === 'Escape' && document.body.contains(backdrop)) {
407 close();
408 document.removeEventListener('keydown', onEsc);
409 }
410 });
411 };
412})();
Note: See TracBrowser for help on using the repository browser.