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

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

Playlist-modal: de teksten door i18n

Ze stonden hard in mod/playlist-editor.js -- 34 stuks, van "Titel *" tot
"Upload mislukt". Dat viel niet op omdat het toevallig de brontaal was:
een Engelse of Duitse beheerder kreeg een Nederlandse modal midden in een
verder vertaalde pagina, en daar valt niets van om.

EIGEN GEGEVENSBLOK, niet partials/page-data.ejs. pageData() pakt met
querySelector EEN blok, en deze modal hangt onder pagina's die er zelf al
een hebben (admin-playlists, post-edit). Een tweede blok zou daar
stilzwijgend genegeerd worden -- en dan was de modal alsnog Nederlands
zonder dat er een fout verscheen.

De terugval in T() is de SLEUTELNAAM en niet de Nederlandse tekst:
ontbreekt er iets, dan zie je DAT er iets ontbreekt in plaats van een
scherm dat er half vertaald uitziet en waarvan niemand merkt welke helft.

De test sluit de hele keten -- script vraagt T('x'), partial geeft 'x'
door, i18n kent 'ple.x' in alle drie de talen. Drie schakels die elk los
kunnen kloppen terwijl het geheel stuk is, en de vergeten TAAL is de
schakel die niemand ziet die in het Nederlands werkt. Hij vond meteen een
echte misser: saveBtn.textContent = 'Opslaan...' was ik overgeslagen.

Zijn eerste versie sloeg ook alarm op een COMMENTAAR dat die woorden
noemde. Dat is nu weggefilterd: een test die op uitleg valt leert je hem
negeren.

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

  • Property mode set to 100644
File size: 18.7 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 </select>
126 </label>
127 <label class="pl-field pl-uitgave">
128 <span>${T('uitgave')}</span>
129 <input type="date" id="pli-release-date" value="${esc(initial.release_date || '')}">
130 </label>
131 <label class="pl-field pl-uitgave">
132 <span>${T('mb_release')}</span>
133 <input type="text" id="pli-mb-release" maxlength="36" spellcheck="false"
134 placeholder="00000000-0000-0000-0000-000000000000"
135 value="${esc(initial.mb_release_id || '')}">
136 </label>
137 <div class="pl-field pl-field-full">
138 <span>${T('cover')}</span>
139 <div class="pl-cover-row">
140 <span class="pl-cover-thumb" id="pli-cover-thumb">
141 ${initial.cover
142 ? `<img src="${esc(initial.cover)}" alt="">`
143 : `<span class="pl-cover-empty">🎨</span>`}
144 </span>
145 <input type="text" id="pli-cover" placeholder="${T('cover_url')}" value="${esc(initial.cover)}" style="flex:1">
146 </div>
147 <div class="pl-cover-upload-row">
148 <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
149 <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 ${T('cover_kies')}</button>
150 <span class="pl-cover-status" id="pli-cover-status"></span>
151 </div>
152 </div>
153 </div>
154 <div class="pl-section-title">
155 ${T('tracks_in')} <span class="pl-track-count" id="pli-count">0</span>
156 <small>${T('sleep_hint')}</small>
157 </div>
158 <div id="pli-selected" class="pl-selected-list"></div>
159 </div>
160 <div class="pl-editor-right">
161 <div class="pl-section-title">${T('beschikbaar')}</div>
162 <input type="search" id="pli-search" placeholder="${T('zoek')}" class="pl-search-input">
163 <div id="pli-available" class="pl-available-list"></div>
164 </div>
165 </div>
166 </div>
167 <div class="pl-modal-footer">
168 <button type="button" class="btn" id="pli-cancel">${T('annuleren')}</button>
169 <button type="button" class="btn btn-primary" id="pli-save" disabled>
170 ${isEdit ? T('opslaan') : T('aanmaken')}
171 </button>
172 </div>
173 </div>
174 `;
175 document.body.appendChild(backdrop);
176
177 const $ = sel => backdrop.querySelector(sel);
178 const close = () => backdrop.remove();
179 backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
180 $('.pl-modal-close').addEventListener('click', close);
181 $('#pli-cancel').addEventListener('click', close);
182
183 // Index tracks by id for fast lookup
184 const trackById = new Map(tracks.map(t => [t.id, t]));
185 let selected = (initial.track_ids || []).filter(id => trackById.has(id));
186
187 const titleEl = $('#pli-title');
188 const artistEl = $('#pli-artist');
189 const yearEl = $('#pli-year');
190 const kindEl = $('#pli-kind');
191 const releaseEl = $('#pli-release-date');
192 const mbReleaseEl = $('#pli-mb-release');
193 const coverEl = $('#pli-cover');
194
195 // Uitgavedatum en release-id horen bij een ALBUM, niet bij een
196 // afspeellijst -- dat is wat de keuze album/playlist betekent. Ze
197 // verdwijnen dus als je omschakelt, en de server maakt ze dan ook leeg;
198 // dit scherm is de uitleg, niet de bewaking.
199 const toonUitgave = () => {
200 const album = kindEl.value !== 'playlist';
201 for (const el of backdrop.querySelectorAll('.pl-uitgave')) el.hidden = !album;
202 };
203 kindEl.addEventListener('change', toonUitgave);
204 toonUitgave();
205 const coverThumb = $('#pli-cover-thumb');
206 const saveBtn = $('#pli-save');
207 const selectedEl = $('#pli-selected');
208 const availEl = $('#pli-available');
209 const searchEl = $('#pli-search');
210 const countEl = $('#pli-count');
211
212 coverEl.addEventListener('input', () => {
213 const u = coverEl.value.trim();
214 coverThumb.innerHTML = u
215 ? `<img src="${esc(u)}" alt="" data-fallback>`
216 : `<span class="pl-cover-empty">🎨</span>`;
217 });
218
219 // ── Cover file upload (werkt in create ÉN edit) ───────────
220 // We uploaden naar het generieke image-endpoint (/posts/upload-image,
221 // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
222 // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het
223 // cover-veld en wordt bij het opslaan met de playlist meegestuurd.
224 const coverFileInput = $('#pli-cover-file');
225 const coverPickBtn = $('#pli-cover-pick');
226 const coverStatus = $('#pli-cover-status');
227 if (coverFileInput && coverPickBtn) {
228 coverPickBtn.addEventListener('click', () => coverFileInput.click());
229 coverFileInput.addEventListener('change', async (e) => {
230 const file = e.target.files && e.target.files[0];
231 coverFileInput.value = '';
232 if (!file) return;
233 if (!/^image\//.test(file.type)) {
234 coverStatus.textContent = T('e_alleen_afb');
235 coverStatus.className = 'pl-cover-status is-error';
236 return;
237 }
238 coverStatus.textContent = T('bezig');
239 coverStatus.className = 'pl-cover-status';
240 const fd = new FormData();
241 fd.append('image', file);
242 try {
243 const r = await fetch('/posts/upload-image',
244 { method: 'POST', body: fd, credentials: 'same-origin' }
245 );
246 const j = await r.json();
247 if (!r.ok || !j.url) throw new Error(j.error || T('e_upload'));
248 const url = j.url || '';
249 coverEl.value = url;
250 coverThumb.innerHTML = url
251 ? `<img src="${esc(url)}" alt="">`
252 : `<span class="pl-cover-empty">🎨</span>`;
253 coverStatus.textContent = '✓ Geüpload';
254 coverStatus.className = 'pl-cover-status is-ok';
255 } catch (err) {
256 coverStatus.textContent = T('e_mislukt') + err.message;
257 coverStatus.className = 'pl-cover-status is-error';
258 }
259 });
260 }
261
262 function updateSaveBtn() {
263 saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
264 }
265
266 function renderSelected() {
267 countEl.textContent = selected.length;
268 if (selected.length === 0) {
269 selectedEl.innerHTML = '<div class="pl-empty">' + T('leeg_sel') + '</div>';
270 updateSaveBtn();
271 return;
272 }
273 selectedEl.innerHTML = selected.map((id, i) => {
274 const t = trackById.get(id);
275 if (!t) return '';
276 const cover = t.cover
277 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
278 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
279 return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
280 <span class="pl-row-handle" aria-label="${T('versleep')}">⠿</span>
281 <span class="pl-row-num">${i + 1}</span>
282 ${cover}
283 <span class="pl-row-info">
284 <span class="pl-row-title">${esc(t.title)}</span>
285 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
286 </span>
287 <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="${T('verwijder')}">×</button>
288 </div>`;
289 }).join('');
290
291 selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
292 b.addEventListener('click', () => {
293 selected = selected.filter(x => x !== b.dataset.id);
294 renderSelected();
295 renderAvailable();
296 });
297 });
298 bindDrag();
299 updateSaveBtn();
300 }
301
302 function renderAvailable() {
303 const q = searchEl.value.trim().toLowerCase();
304 const matches = tracks.filter(t => {
305 if (!q) return true;
306 return (t.title || '').toLowerCase().includes(q)
307 || (t.artist || '').toLowerCase().includes(q);
308 });
309 if (matches.length === 0) {
310 availEl.innerHTML = '<div class="pl-empty">' + T('geen_res') + '</div>';
311 return;
312 }
313 availEl.innerHTML = matches.map(t => {
314 const isAdded = selected.includes(t.id);
315 const cls = ['pl-avail-row'];
316 if (isAdded) cls.push('is-added');
317 if (!t.playable) cls.push('is-unplayable');
318 const cover = t.cover
319 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
320 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
321 return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : `title="${T('geen_audio')}"`}>
322 ${cover}
323 <span class="pl-row-info">
324 <span class="pl-row-title">${esc(t.title)}</span>
325 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
326 </span>
327 <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
328 </div>`;
329 }).join('');
330
331 availEl.querySelectorAll('.pl-avail-row').forEach(row => {
332 if (row.classList.contains('is-unplayable')) return;
333 row.addEventListener('click', () => {
334 const id = row.dataset.id;
335 if (selected.includes(id)) selected = selected.filter(x => x !== id);
336 else selected.push(id);
337 renderSelected();
338 renderAvailable();
339 });
340 });
341 }
342
343 // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
344 function bindDrag() {
345 selectedEl.querySelectorAll('.pl-row').forEach(row => {
346 const handle = row.querySelector('.pl-row-handle');
347 if (!handle) return;
348 let dragging = false, originalIdx = -1;
349
350 handle.addEventListener('pointerdown', e => {
351 e.preventDefault();
352 handle.setPointerCapture(e.pointerId);
353 dragging = true;
354 originalIdx = parseInt(row.dataset.pos, 10);
355 row.classList.add('is-dragging');
356 });
357 handle.addEventListener('pointermove', e => {
358 if (!dragging) return;
359 e.preventDefault();
360 const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
361 rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
362 let targetIdx = -1, above = false;
363 for (let i = 0; i < rows.length; i++) {
364 const r = rows[i]; if (r === row) continue;
365 const rect = r.getBoundingClientRect();
366 const mid = rect.top + rect.height / 2;
367 if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
368 if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
369 targetIdx = i; above = e.clientY < mid; break;
370 }
371 }
372 if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
373 });
374 const finish = e => {
375 if (!dragging) return;
376 dragging = false;
377 try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
378 row.classList.remove('is-dragging');
379 const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
380 let targetIdx = -1, above = false;
381 for (let i = 0; i < rows.length; i++) {
382 if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
383 if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
384 }
385 rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
386 if (targetIdx === -1 || targetIdx === originalIdx) return;
387 const moved = selected[originalIdx];
388 selected.splice(originalIdx, 1);
389 let newIdx = targetIdx;
390 if (originalIdx < targetIdx) newIdx--;
391 if (!above) newIdx++;
392 newIdx = Math.max(0, Math.min(selected.length, newIdx));
393 selected.splice(newIdx, 0, moved);
394 renderSelected();
395 };
396 handle.addEventListener('pointerup', finish);
397 handle.addEventListener('pointercancel', () => {
398 dragging = false;
399 row.classList.remove('is-dragging');
400 selectedEl.querySelectorAll('.drop-above, .drop-below')
401 .forEach(r => r.classList.remove('drop-above', 'drop-below'));
402 });
403 });
404 }
405
406 titleEl.addEventListener('input', updateSaveBtn);
407 searchEl.addEventListener('input', renderAvailable);
408 renderSelected();
409 renderAvailable();
410
411 saveBtn.addEventListener('click', async () => {
412 if (saveBtn.disabled) return;
413 const orig = saveBtn.textContent;
414 saveBtn.disabled = true;
415 saveBtn.textContent = T('bezig_opslaan');
416
417 const payload = {
418 title: titleEl.value.trim(),
419 artist: artistEl.value.trim(),
420 year: parseInt(yearEl.value, 10) || 0,
421 cover: coverEl.value.trim(),
422 kind: kindEl.value === 'playlist' ? 'playlist' : 'album',
423 release_date: releaseEl.value.trim(),
424 mb_release_id: mbReleaseEl.value.trim(),
425 tracks: selected.slice(),
426 };
427
428 try {
429 const url = isEdit
430 ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
431 : '/admin/playlists/api';
432 const j = await api('POST', url, payload);
433 if (!j.ok) {
434 alert(T('e_opslaan') + (j.error || 'onbekend'));
435 saveBtn.disabled = false;
436 saveBtn.textContent = orig;
437 return;
438 }
439 const savedId = isEdit ? initial.id : j.id;
440 close();
441 if (typeof opts.onSaved === 'function') {
442 opts.onSaved({ id: savedId, playlist: payload });
443 }
444 } catch (err) {
445 alert(T('e_opslaan') + err.message);
446 saveBtn.disabled = false;
447 saveBtn.textContent = orig;
448 }
449 });
450
451 // ESC to close
452 document.addEventListener('keydown', function onEsc(e) {
453 if (e.key === 'Escape' && document.body.contains(backdrop)) {
454 close();
455 document.removeEventListener('keydown', onEsc);
456 }
457 });
458 };
459})();
Note: See TracBrowser for help on using the repository browser.