source: Klonkt/src/assets/js/mod/track-editor.js@ 54ee51c

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

De profielkop, de track-editor en het data-kanaal (shaer-bqr, stap 3c)

PROFIELKOP naar mod/chrome.js: chrome.ejs neemt die partial op, dus hij zit in
de OOB-swap en kwam bij elke navigatie opnieuw binnen. Hoort bij de chrome, niet
bij een pagina.

TRACK-EDITOR (475 regels, geen interpolatie) naar een eigen module. admin-audio
neemt hem op, dus die route vraagt om 'admin-audio track-editor' -- een pagina
mag meer dan een module willen.

HET DATA-KANAAL, want de rest heeft het nodig. Een module is een statisch
bestand, dus er kan geen EJS in; alles wat een script van de server nodig heeft
gaat via partials/page-data.ejs en wordt gelezen met pageData() uit mod/lib.js.

Dat is een script-element en toch geen CSP-probleem: een script met een ander
type dan JavaScript wordt niet uitgevoerd en niet opgehaald, dus script-src heeft
er niets over te zeggen. Het werkt daarmee ook in inhoud die via htmx binnenkomt.

Twee dingen die daar misgaan als je ze niet doet, en allebei nagemeten:

De kleiner-dan wordt ontsnapt. Zonder dat sluit een sluitende script-tag IN de
gegevens het blok voortijdig af en staat de rest als HTML op de pagina.
Getoetst met precies zo'n waarde.

pageData() leest ELKE KEER opnieuw en onthoudt niets. Bij een htmx-navigatie
wisselt de inhoud terwijl de module blijft leven, dus een blok dat je bij het
laden vasthoudt is een pagina later verouderd.

Alle templates compileren.

  • Property mode set to 100644
File size: 20.6 KB
Line 
1// De track-editor (beheer > audio) (partials/track-editor.ejs) -- verplaatst uit inline script, shaer-bqr.
2//
3// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
4// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
5// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
6//
7// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
8// er nu staat) en tegen een tweede aanroep te kunnen.
9
10(function() {
11
12 function esc(s) {
13 return String(s == null ? '' : s)
14 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
15 .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
16 }
17
18 async function api(method, url, body) {
19 const opts = { method, credentials: 'same-origin', headers: {} };
20 if (body !== undefined) {
21 if (body instanceof FormData) {
22 opts.body = body;
23 } else {
24 opts.headers['Content-Type'] = 'application/json';
25 opts.body = JSON.stringify(body);
26 }
27 }
28 const res = await fetch(url, opts);
29 let data;
30 try { data = await res.json(); }
31 catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); }
32 if (!res.ok) data.ok = false;
33 return data;
34 }
35
36 /**
37 * Open the track editor.
38 * @param {object} opts
39 * @param {string} opts.id — track id to edit
40 * @param {function?} opts.onSaved — called with updated track on success
41 */
42 window.openTrackEditor = async function openTrackEditor({ id, onSaved }) {
43 if (!id) return;
44 let track, albumSuggestions = [];
45 try {
46 const [trackJson, listJson] = await Promise.all([
47 api('GET', '/admin/audio/api/' + encodeURIComponent(id)),
48 api('GET', '/admin/audio/api/albums'),
49 ]);
50 if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden');
51 track = trackJson.track;
52 if (listJson.ok && Array.isArray(listJson.albums)) {
53 albumSuggestions = listJson.albums.filter(Boolean);
54 }
55 } catch (err) {
56 alert('Track ophalen mislukt: ' + err.message);
57 return;
58 }
59
60 const backdrop = document.createElement('div');
61 backdrop.className = 'te-backdrop';
62 backdrop.innerHTML = `
63 <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken">
64 <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div>
65
66 <div class="te-header">
67 <h3>✎ Track bewerken</h3>
68 <button type="button" class="te-close" aria-label="Sluiten">×</button>
69 </div>
70
71 <div class="te-body">
72
73 ${track.stream_url ? `
74 <div class="te-preview">
75 <div class="te-preview-cover">
76 ${track.cover_url
77 ? `<img src="${esc(track.cover_url)}" alt="">`
78 : `🎵`}
79 </div>
80 <div class="te-preview-meta">
81 <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div>
82 <div class="te-preview-sub">
83 ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''}
84 </div>
85 </div>
86 <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button>
87 </div>
88 ` : ''}
89
90 <div class="te-form">
91
92 <label class="te-field">
93 <span>Titel <span class="te-required" aria-hidden="true">*</span></span>
94 <input type="text" id="te-title" maxlength="200" required
95 autocomplete="off" autocapitalize="words" spellcheck="false"
96 value="${esc(track.title || '')}">
97 </label>
98
99 <div class="te-row te-row-2">
100 <label class="te-field">
101 <span>Artiest</span>
102 <input type="text" id="te-artist" maxlength="200"
103 autocomplete="off" autocapitalize="words" spellcheck="false"
104 value="${esc(track.artist || '')}">
105 </label>
106 <label class="te-field">
107 <span>Album</span>
108 <input type="text" id="te-album" maxlength="200"
109 autocomplete="off" autocapitalize="words" spellcheck="false"
110 list="te-album-list" value="${esc(track.album || '')}">
111 <datalist id="te-album-list">
112 ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')}
113 </datalist>
114 </label>
115 </div>
116
117 <label class="te-field">
118 <span>Duur <small>(seconden — automatisch bepaald, hier te overschrijven)</small></span>
119 <input type="number" id="te-duration" min="0" step="1"
120 inputmode="numeric" pattern="[0-9]*"
121 value="${track.duration || ''}" placeholder="auto">
122 </label>
123
124 <div class="te-row te-row-2">
125 <label class="te-field">
126 <span>Eigenaar / credit <small>(copyright-houder)</small></span>
127 <div class="te-credit-row">
128 <input type="text" id="te-credit" maxlength="200"
129 autocomplete="off" spellcheck="false"
130 placeholder="bv. © 2025 Mara Vos"
131 value="${esc(track.credit || '')}">
132 <button type="button" class="te-sym-btn" id="te-credit-copyr"
133 title="© invoegen" aria-label="Copyright-teken invoegen">©</button>
134 </div>
135 </label>
136 <label class="te-field">
137 <span>Licentie</span>
138 <input type="text" id="te-license" maxlength="120"
139 autocomplete="off" spellcheck="false" list="te-license-list"
140 placeholder="Alle rechten voorbehouden"
141 value="${esc(track.license || '')}">
142 <datalist id="te-license-list">
143 <option value="Alle rechten voorbehouden"></option>
144 <option value="CC BY 4.0"></option>
145 <option value="CC BY-SA 4.0"></option>
146 <option value="CC BY-NC 4.0"></option>
147 <option value="CC BY-NC-SA 4.0"></option>
148 <option value="CC BY-ND 4.0"></option>
149 <option value="CC0 1.0 (publiek domein)"></option>
150 </datalist>
151 </label>
152 </div>
153
154 <div class="te-field">
155 <span>Open in <small>(links naar dezelfde track elders)</small></span>
156 <input type="url" id="te-link-spotify" inputmode="url" autocomplete="off" spellcheck="false"
157 placeholder="Spotify-URL (https://open.spotify.com/…)" value="${esc(track.link_spotify || '')}">
158 <input type="url" id="te-link-youtube" inputmode="url" autocomplete="off" spellcheck="false"
159 placeholder="YouTube-URL (https://youtu.be/…)" value="${esc(track.link_youtube || '')}">
160 <input type="url" id="te-link-soundcloud" inputmode="url" autocomplete="off" spellcheck="false"
161 placeholder="SoundCloud-URL (https://soundcloud.com/…)" value="${esc(track.link_soundcloud || '')}">
162 </div>
163
164 <div class="te-field">
165 <span>Audiobestand</span>
166 <div class="te-cover-btn-row">
167 <input type="file" id="te-audio-file" accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg,.aac" hidden>
168 <button type="button" class="te-cover-btn" id="te-audio-pick">${track.stream_url ? '🔁 Vervang audiobestand' : '🎵 Audiobestand kiezen'}</button>
169 <span class="te-cover-status" id="te-audio-status"></span>
170 </div>
171 </div>
172
173 <div class="te-field">
174 <span>Cover</span>
175 <div class="te-cover-row">
176 <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button"
177 aria-label="Klik om cover te kiezen">
178 ${track.cover_url
179 ? `<img src="${esc(track.cover_url)}" alt="">`
180 : `<span class="te-cover-empty">🎨</span>`}
181 </div>
182 <input type="file" id="te-cover-file"
183 accept="image/jpeg,image/png,image/webp,image/gif" hidden>
184 <div class="te-cover-actions">
185 <div class="te-cover-btn-row">
186 <button type="button" class="te-cover-btn" id="te-cover-pick">
187 📷 Foto kiezen
188 </button>
189 ${track.cover_url ? `
190 <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove">
191 × Verwijder
192 </button>` : ''}
193 </div>
194 <input type="text" id="te-cover-url" inputmode="url"
195 placeholder="/media/… of https://…"
196 autocomplete="off" autocapitalize="none" spellcheck="false"
197 value="${esc(track.cover_url || '')}">
198 <div class="te-cover-status" id="te-cover-status"></div>
199 </div>
200 </div>
201 </div>
202
203 </div>
204 </div>
205
206 <div class="te-footer">
207 <button type="button" class="te-btn" id="te-cancel">Annuleren</button>
208 <div class="te-footer-spacer"></div>
209 <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button>
210 </div>
211 </div>
212 `;
213
214 document.body.appendChild(backdrop);
215 document.body.classList.add('te-modal-open');
216
217 const $ = sel => backdrop.querySelector(sel);
218
219 // ── Inline preview player (routed through global mini-player) ──
220 // We don't build our own <audio>; instead we tell the global
221 // window.pcmsAudioPlayer to load this single track. Visual state
222 // syncs against the global audio element so toggle works correctly
223 // even if the user pauses from the mini-bar.
224 const previewBtn = $('#te-preview-play');
225 if (previewBtn) {
226 const setPreviewState = (playing) => {
227 previewBtn.textContent = playing ? '⏸' : '▶';
228 previewBtn.classList.toggle('is-playing', playing);
229 previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
230 };
231 const isOurTrack = () => {
232 // audio.src is a blob: URL (Spotify-style playback) — compare against
233 // the player's logical current-track URL instead.
234 const player = window.pcmsAudioPlayer;
235 const cur = player && player.currentTrack();
236 return !!(cur && track.stream_url && cur.url === track.stream_url);
237 };
238 const resync = () => {
239 const audio = document.getElementById('audio-element');
240 const playing = audio && !audio.paused && !audio.ended && isOurTrack();
241 setPreviewState(!!playing);
242 };
243
244 previewBtn.addEventListener('click', () => {
245 const player = window.pcmsAudioPlayer;
246 if (!player || !track.stream_url) {
247 console.warn('preview: miniplayer or url missing');
248 return;
249 }
250 const audio = document.getElementById('audio-element');
251 if (audio && isOurTrack()) {
252 // Same track loaded — toggle
253 if (audio.paused) player.play(); else player.pause();
254 } else {
255 player.setQueue([{
256 url: track.stream_url,
257 title: track.title || '(zonder titel)',
258 artist: track.artist || '',
259 album: track.album || '',
260 cover: track.cover_url || '',
261 }], 0);
262 }
263 });
264
265 const audio = document.getElementById('audio-element');
266 if (audio) {
267 const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied'];
268 evs.forEach(ev => audio.addEventListener(ev, resync));
269 // Detach listeners on close so we don't leak them
270 backdrop._previewCleanup = () => {
271 evs.forEach(ev => audio.removeEventListener(ev, resync));
272 };
273 resync(); // initial state
274 }
275 }
276
277 function close() {
278 // Detach our resync listeners (mini-player stays running)
279 if (backdrop._previewCleanup) backdrop._previewCleanup();
280 document.body.classList.remove('te-modal-open');
281 document.removeEventListener('keydown', onEsc);
282 backdrop.remove();
283 }
284 function onEsc(e) { if (e.key === 'Escape') close(); }
285 document.addEventListener('keydown', onEsc);
286
287 // A click on the backdrop does NOT close the editor — too easy to lose edits by
288 // mis-clicking outside. Close deliberately via ×, Cancel or Esc.
289 $('.te-close').addEventListener('click', close);
290 $('#te-cancel').addEventListener('click', close);
291
292 // ── Cover picking + URL paste + drag-drop ────────────────
293 const thumb = $('#te-cover-thumb');
294 const fileInput = $('#te-cover-file');
295 const urlInput = $('#te-cover-url');
296 const status = $('#te-cover-status');
297 const pickBtn = $('#te-cover-pick');
298 const removeBtn = $('#te-cover-remove');
299
300 function setStatus(text, kind) {
301 status.textContent = text || '';
302 status.className = 'te-cover-status' + (kind ? ' is-' + kind : '');
303 }
304 function setThumb(url) {
305 if (url) {
306 thumb.innerHTML = `<img src="${esc(url)}" alt="">`;
307 } else {
308 thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`;
309 }
310 }
311
312 pickBtn.addEventListener('click', () => fileInput.click());
313 thumb.addEventListener('click', () => fileInput.click());
314 thumb.addEventListener('keydown', e => {
315 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
316 });
317
318 async function uploadCoverFile(file) {
319 if (!file) return;
320 if (!/^image\//.test(file.type)) {
321 setStatus('Alleen afbeeldingen toegestaan', 'error'); return;
322 }
323 setStatus('Uploaden…', null);
324 const fd = new FormData();
325 fd.append('cover', file);
326 try {
327 const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', {
328 method: 'POST', body: fd, credentials: 'same-origin',
329 });
330 const j = await r.json();
331 if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
332 urlInput.value = j.cover_url || '';
333 setThumb(j.cover_url);
334 setStatus('✓ Geüpload', 'ok');
335 } catch (err) {
336 setStatus('Mislukt: ' + err.message, 'error');
337 }
338 }
339
340 fileInput.addEventListener('change', e => {
341 const f = e.target.files && e.target.files[0];
342 if (f) uploadCoverFile(f);
343 fileInput.value = '';
344 });
345
346 // Drag-drop on thumb (desktop nicety)
347 ['dragenter', 'dragover'].forEach(ev =>
348 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); }));
349 ['dragleave', 'drop'].forEach(ev =>
350 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); }));
351 thumb.addEventListener('drop', e => {
352 const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
353 if (f) uploadCoverFile(f);
354 });
355
356 // URL paste auto-preview
357 urlInput.addEventListener('input', () => {
358 const v = urlInput.value.trim();
359 setThumb(v);
360 });
361
362 if (removeBtn) {
363 removeBtn.addEventListener('click', () => {
364 urlInput.value = '';
365 setThumb('');
366 removeBtn.remove();
367 });
368 }
369
370 // ── Replace the audio file of this track ──────────────────
371 const aPick = $('#te-audio-pick');
372 const aFile = $('#te-audio-file');
373 const aStatus = $('#te-audio-status');
374 if (aPick && aFile) {
375 aPick.addEventListener('click', () => aFile.click());
376 aFile.addEventListener('change', async (e) => {
377 const f = e.target.files && e.target.files[0];
378 aFile.value = '';
379 if (!f) return;
380 aStatus.textContent = '⏳ Converteren… (kan even duren)'; aStatus.className = 'te-cover-status';
381 aPick.disabled = true;
382 try {
383 const fd = new FormData();
384 fd.append('audio', f);
385 const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id) + '/replace-audio', fd);
386 if (!j.ok) throw new Error(j.error || 'mislukt');
387 aStatus.textContent = '✓ Vervangen'; aStatus.className = 'te-cover-status is-ok';
388 track.stream_url = j.stream_url || track.stream_url;
389 if (j.duration) { const d = $('#te-duration'); if (d) d.value = j.duration; }
390 } catch (err) {
391 aStatus.textContent = 'Mislukt: ' + err.message; aStatus.className = 'te-cover-status is-error';
392 } finally { aPick.disabled = false; }
393 });
394 }
395
396 // ── Insert © symbol into the credit field ─────────────────
397 const copyrBtn = $('#te-credit-copyr');
398 if (copyrBtn) {
399 copyrBtn.addEventListener('click', () => {
400 const inp = $('#te-credit');
401 if (!inp) return;
402 const sym = '© ';
403 const start = inp.selectionStart != null ? inp.selectionStart : inp.value.length;
404 const end = inp.selectionEnd != null ? inp.selectionEnd : inp.value.length;
405 inp.value = inp.value.slice(0, start) + sym + inp.value.slice(end);
406 inp.focus();
407 const pos = start + sym.length;
408 try { inp.setSelectionRange(pos, pos); } catch (e) {}
409 });
410 }
411
412 // ── Save ────────────────────────────────────────────────
413 $('#te-save').addEventListener('click', async () => {
414 const titleEl = $('#te-title');
415 const title = titleEl.value.trim();
416 if (!title) {
417 titleEl.focus();
418 alert('Titel is verplicht');
419 return;
420 }
421 const saveBtn = $('#te-save');
422 saveBtn.disabled = true;
423 saveBtn.textContent = '⏳ Opslaan…';
424
425 try {
426 const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), {
427 title,
428 artist: $('#te-artist').value.trim() || null,
429 album: $('#te-album').value.trim() || null,
430 credit: $('#te-credit').value.trim() || null,
431 license: $('#te-license').value.trim() || null,
432 link_spotify: $('#te-link-spotify').value.trim() || null,
433 link_youtube: $('#te-link-youtube').value.trim() || null,
434 link_soundcloud: $('#te-link-soundcloud').value.trim() || null,
435 duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
436 cover_url: urlInput.value.trim() || null,
437 });
438 if (!j.ok) throw new Error(j.error || 'Opslaan mislukt');
439 if (typeof onSaved === 'function') onSaved(j.track || { id, title,
440 artist: $('#te-artist').value.trim() || null,
441 album: $('#te-album').value.trim() || null,
442 cover_url: urlInput.value.trim() || null });
443 close();
444 } catch (err) {
445 alert('Opslaan mislukt: ' + err.message);
446 saveBtn.disabled = false;
447 saveBtn.textContent = '💾 Opslaan';
448 }
449 });
450
451 // ── Auto-duration ───────────────────────────────────────────
452 // The server already determines duration automatically on upload. This is the
453 // fallback/UX layer: if an admin opens an existing track without a duration,
454 // we read it from the audio metadata and fill the field — so you never need
455 // to type seconds manually. An existing value is never overwritten. We fetch
456 // the bytes via the same header gate as the player (X-Audio-Player).
457 (async function autoDuration() {
458 const durEl = $('#te-duration');
459 if (!durEl || !track.stream_url) return;
460 if (durEl.value && Number(durEl.value) > 0) return; // already filled → leave it alone
461 let objUrl = null;
462 try {
463 const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } });
464 if (!r.ok) return;
465 objUrl = URL.createObjectURL(await r.blob());
466 const probe = new Audio();
467 probe.preload = 'metadata';
468 probe.addEventListener('loadedmetadata', () => {
469 if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) {
470 durEl.value = Math.round(probe.duration);
471 }
472 if (objUrl) URL.revokeObjectURL(objUrl);
473 });
474 probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); });
475 probe.src = objUrl;
476 } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); }
477 })();
478
479 // Focus title for fast typing
480 setTimeout(() => $('#te-title').focus(), 60);
481 };
482})();
Note: See TracBrowser for help on using the repository browser.