source: Klonkt/src/views/partials/track-editor.ejs@ cb3d5fc

main
Last change on this file since cb3d5fc was 834bcc3, checked in by Robin Genis <roboburr@…>, 3 months ago

i18n: translate Dutch code comments to English across src/

Comments in routes/services/views/config/middleware/assets translated to
English for the public repo. A few dev-facing throw/console message strings
were Englished too. No user-facing UI strings or i18n dictionary values changed
(src/services/i18n.js untouched). Logic unchanged.

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

  • Property mode set to 100644
File size: 27.9 KB
Line 
1<%
2// Track editor modal — mobile-first rewrite.
3//
4// Visual model:
5// Mobile (<720px): bottom-sheet that slides up from below, max 92vh,
6// rounded top corners only, drag-handle bar at top.
7// Desktop (≥720px): centered modal max-width 520px, fully-rounded.
8//
9// Self-contained styling under the .te-* namespace — no dependency on
10// the .ax-* admin primitives. Same visual language though: paper bg,
11// rule borders, accent for active states.
12//
13// Exposes window.openTrackEditor({ id, onSaved? }) — called by the
14// "✎ Bewerken" buttons on admin-audio. Fetches /admin/audio/api/:id,
15// renders the form with current values + an inline <audio> preview,
16// and PATCHes via /admin/audio/api/:id on save.
17
18const _csrf = (typeof csrfToken !== 'undefined' && csrfToken) || '';
19%>
20
21<style>
22/* ─────────────────────────────────────────────────────────────────
23 Track editor modal — namespace .te-*
24 ───────────────────────────────────────────────────────────────── */
25
26.te-backdrop {
27 position: fixed; inset: 0;
28 /* Above bottom-tab nav (z=1050) and audio-player sheet (z=1100) so the
29 editor always wins. */
30 z-index: 9000;
31 background: rgba(0, 0, 0, 0.55);
32 -webkit-backdrop-filter: blur(4px);
33 backdrop-filter: blur(4px);
34 display: flex;
35 /* Mobile: dock to bottom; desktop overrides to center via media query */
36 align-items: flex-end;
37 justify-content: center;
38 animation: te-fade 160ms ease-out;
39}
40@keyframes te-fade { from { opacity: 0; } to { opacity: 1; } }
41
42.te-modal {
43 background: var(--paper);
44 width: 100%;
45 max-height: 92vh;
46 /* Bottom-sheet shape on mobile — only top corners rounded */
47 border-radius: 16px 16px 0 0;
48 border: 1px solid var(--rule);
49 border-bottom: 0;
50 box-shadow: 0 -8px 30px rgba(0,0,0,0.4);
51 display: flex; flex-direction: column;
52 overflow: hidden;
53 animation: te-slide-up 220ms cubic-bezier(.2,.9,.3,1);
54}
55@keyframes te-slide-up {
56 from { transform: translateY(100%); }
57 to { transform: translateY(0); }
58}
59
60/* Desktop: center the modal as a card */
61@media (min-width: 720px) {
62 .te-backdrop { align-items: center; padding: 1rem; }
63 .te-modal {
64 max-width: 520px;
65 border-radius: 14px;
66 border: 1px solid var(--rule);
67 max-height: calc(100vh - 2rem);
68 box-shadow: 0 20px 60px rgba(0,0,0,0.4);
69 animation: te-fade 220ms ease-out;
70 }
71}
72
73/* Drag-handle bar at top of mobile sheet */
74.te-handle {
75 width: 100%; padding: 0.6rem 0;
76 display: flex; justify-content: center;
77 flex-shrink: 0;
78}
79.te-handle-bar {
80 width: 36px; height: 4px;
81 background: var(--rule);
82 border-radius: 99px;
83}
84@media (min-width: 720px) { .te-handle { display: none; } }
85
86.te-header {
87 display: flex; align-items: center; justify-content: space-between;
88 padding: 0.5rem 1.25rem 1rem;
89 border-bottom: 1px solid var(--rule);
90 flex-shrink: 0;
91}
92@media (min-width: 720px) { .te-header { padding: 1rem 1.25rem; } }
93.te-header h3 {
94 font-family: var(--font-display, serif);
95 margin: 0; font-size: 1.15rem; font-weight: 600;
96}
97.te-close {
98 width: 36px; height: 36px;
99 display: inline-flex; align-items: center; justify-content: center;
100 border: 0; border-radius: 8px;
101 background: transparent;
102 color: var(--ink-muted, var(--ink-soft));
103 font-size: 1.5rem; line-height: 1;
104 cursor: pointer;
105 transition: background 120ms, color 120ms;
106}
107.te-close:hover { background: var(--paper-2); color: var(--ink); }
108
109.te-body {
110 padding: 1rem 1.25rem;
111 overflow-y: auto;
112 flex: 1;
113 -webkit-overflow-scrolling: touch;
114 display: flex; flex-direction: column; gap: 1rem;
115}
116
117/* ── Inline preview player ─────────────────────────────────── */
118.te-preview {
119 display: flex; align-items: center; gap: 0.75rem;
120 padding: 0.75rem;
121 background: var(--paper-2);
122 border: 1px solid var(--rule);
123 border-radius: 10px;
124}
125.te-preview-cover {
126 width: 56px; height: 56px;
127 border-radius: 8px; overflow: hidden;
128 background: var(--paper);
129 display: inline-flex; align-items: center; justify-content: center;
130 flex-shrink: 0;
131 font-size: 1.4rem; color: var(--accent);
132}
133.te-preview-cover img { width: 100%; height: 100%; object-fit: cover; }
134.te-preview-meta {
135 flex: 1; min-width: 0;
136 display: flex; flex-direction: column; gap: 0.25rem;
137}
138.te-preview-title {
139 font-weight: 600; font-size: 0.95rem;
140 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
141}
142.te-preview-sub {
143 font-size: 0.78rem;
144 color: var(--ink-muted, var(--ink-soft));
145}
146.te-preview-play {
147 width: 44px; height: 44px;
148 border: 1px solid var(--rule);
149 border-radius: 50%;
150 background: var(--paper);
151 color: var(--ink);
152 font-size: 1.1rem;
153 cursor: pointer;
154 display: inline-flex; align-items: center; justify-content: center;
155 flex-shrink: 0;
156 transition: border-color 120ms, color 120ms, background 120ms;
157}
158.te-preview-play:hover { border-color: var(--accent); color: var(--accent); }
159.te-preview-play.is-playing {
160 background: var(--accent); color: white; border-color: var(--accent);
161}
162
163/* ── Form fields ───────────────────────────────────────────── */
164.te-form { display: flex; flex-direction: column; gap: 0.85rem; }
165.te-row { display: grid; gap: 0.85rem; }
166.te-row-2 { grid-template-columns: 1fr 1fr; }
167@media (max-width: 480px) { .te-row-2 { grid-template-columns: 1fr; } }
168
169.te-field { display: flex; flex-direction: column; gap: 0.35rem; min-width: 0; }
170.te-field > span {
171 font-size: 0.8rem; font-weight: 600;
172 color: var(--ink-soft);
173 display: flex; align-items: baseline; gap: 0.4rem;
174}
175.te-field > span small {
176 font-weight: 400; color: var(--ink-muted); font-size: 0.95em;
177}
178.te-required { color: #dc2626; }
179.te-field input[type="text"],
180.te-field input[type="number"],
181.te-field input[type="url"] {
182 width: 100%; box-sizing: border-box;
183 padding: 0.6rem 0.75rem; min-height: 44px;
184 border: 1px solid var(--rule); border-radius: 6px;
185 background: var(--paper-2); color: var(--ink);
186 font-family: var(--font-ui, system-ui), sans-serif;
187 font-size: 0.95rem;
188 -webkit-appearance: none; appearance: none;
189 transition: border-color 120ms;
190}
191.te-field input:focus {
192 outline: 2px solid var(--accent);
193 outline-offset: -1px;
194 border-color: var(--accent);
195}
196
197/* ── Credit field with © insert button ──────────────────────────── */
198.te-credit-row { display: flex; gap: 0.4rem; align-items: stretch; }
199.te-credit-row input { flex: 1; min-width: 0; }
200.te-sym-btn {
201 flex: 0 0 auto;
202 width: 44px; min-height: 44px;
203 border: 1px solid var(--rule); border-radius: 6px;
204 background: var(--paper-2); color: var(--ink);
205 font-size: 1.1rem; cursor: pointer;
206 transition: border-color 120ms, background 120ms;
207}
208.te-sym-btn:hover { border-color: var(--accent); }
209
210/* ── Cover field with thumb + button + URL ─────────────────── */
211.te-cover-row {
212 display: grid;
213 grid-template-columns: 80px 1fr;
214 gap: 0.75rem;
215 align-items: start;
216}
217.te-cover-thumb {
218 width: 80px; height: 80px;
219 border-radius: 10px;
220 border: 1px dashed var(--rule);
221 background: var(--paper-2);
222 display: flex; align-items: center; justify-content: center;
223 overflow: hidden;
224 cursor: pointer;
225 transition: border-color 120ms;
226}
227.te-cover-thumb:hover, .te-cover-thumb.is-dragover { border-color: var(--accent); border-style: solid; }
228.te-cover-thumb img { width: 100%; height: 100%; object-fit: cover; }
229.te-cover-empty {
230 font-size: 1.6rem; opacity: 0.5;
231}
232.te-cover-actions {
233 display: flex; flex-direction: column; gap: 0.5rem;
234 min-width: 0;
235}
236.te-cover-btn-row { display: flex; gap: 0.4rem; flex-wrap: wrap; }
237.te-cover-btn {
238 display: inline-flex; align-items: center; gap: 0.35rem;
239 padding: 0.45rem 0.75rem;
240 border: 1px solid var(--rule); border-radius: 6px;
241 background: var(--paper-2); color: var(--ink);
242 font-family: var(--font-ui, system-ui); font-size: 0.85rem;
243 cursor: pointer;
244 transition: border-color 120ms;
245}
246.te-cover-btn:hover { border-color: var(--accent); }
247.te-cover-btn-remove:hover { color: #dc2626; border-color: #dc2626; }
248.te-cover-status {
249 font-size: 0.78rem;
250 color: var(--ink-muted, var(--ink-soft));
251 min-height: 1em;
252}
253.te-cover-status.is-error { color: #dc2626; }
254.te-cover-status.is-ok { color: #2a9d5e; }
255
256/* ── Footer (sticky) ───────────────────────────────────────── */
257.te-footer {
258 display: flex; align-items: center; gap: 0.5rem;
259 padding: 0.85rem 1.25rem;
260 border-top: 1px solid var(--rule);
261 background: var(--paper);
262 flex-shrink: 0;
263}
264.te-footer-spacer { flex: 1; }
265.te-btn {
266 display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem;
267 padding: 0.6rem 1.1rem; min-height: 44px;
268 border: 1px solid var(--rule); border-radius: 8px;
269 background: var(--paper-2); color: var(--ink);
270 font-family: var(--font-ui, system-ui); font-size: 0.9rem; font-weight: 500;
271 cursor: pointer;
272 transition: border-color 120ms, background 120ms;
273 -webkit-tap-highlight-color: transparent;
274}
275.te-btn:hover { border-color: var(--accent); }
276.te-btn:disabled { opacity: 0.5; cursor: not-allowed; }
277.te-btn-primary {
278 background: var(--accent); color: white; border-color: var(--accent);
279}
280.te-btn-primary:hover { opacity: 0.92; }
281
282/* Lock body scroll while modal is open */
283body.te-modal-open { overflow: hidden; }
284</style>
285
286<script>
287(function() {
288
289 function esc(s) {
290 return String(s == null ? '' : s)
291 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
292 .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
293 }
294
295 async function api(method, url, body) {
296 const opts = { method, credentials: 'same-origin', headers: {} };
297 if (body !== undefined) {
298 if (body instanceof FormData) {
299 opts.body = body;
300 } else {
301 opts.headers['Content-Type'] = 'application/json';
302 opts.body = JSON.stringify(body);
303 }
304 }
305 const res = await fetch(url, opts);
306 let data;
307 try { data = await res.json(); }
308 catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); }
309 if (!res.ok) data.ok = false;
310 return data;
311 }
312
313 /**
314 * Open the track editor.
315 * @param {object} opts
316 * @param {string} opts.id — track id to edit
317 * @param {function?} opts.onSaved — called with updated track on success
318 */
319 window.openTrackEditor = async function openTrackEditor({ id, onSaved }) {
320 if (!id) return;
321 let track, albumSuggestions = [];
322 try {
323 const [trackJson, listJson] = await Promise.all([
324 api('GET', '/admin/audio/api/' + encodeURIComponent(id)),
325 api('GET', '/admin/audio/api/albums'),
326 ]);
327 if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden');
328 track = trackJson.track;
329 if (listJson.ok && Array.isArray(listJson.albums)) {
330 albumSuggestions = listJson.albums.filter(Boolean);
331 }
332 } catch (err) {
333 alert('Track ophalen mislukt: ' + err.message);
334 return;
335 }
336
337 const backdrop = document.createElement('div');
338 backdrop.className = 'te-backdrop';
339 backdrop.innerHTML = `
340 <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken">
341 <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div>
342
343 <div class="te-header">
344 <h3>✎ Track bewerken</h3>
345 <button type="button" class="te-close" aria-label="Sluiten">×</button>
346 </div>
347
348 <div class="te-body">
349
350 ${track.stream_url ? `
351 <div class="te-preview">
352 <div class="te-preview-cover">
353 ${track.cover_url
354 ? `<img src="${esc(track.cover_url)}" alt="">`
355 : `🎵`}
356 </div>
357 <div class="te-preview-meta">
358 <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div>
359 <div class="te-preview-sub">
360 ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''}
361 </div>
362 </div>
363 <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button>
364 </div>
365 ` : ''}
366
367 <div class="te-form">
368
369 <label class="te-field">
370 <span>Titel <span class="te-required" aria-hidden="true">*</span></span>
371 <input type="text" id="te-title" maxlength="200" required
372 autocomplete="off" autocapitalize="words" spellcheck="false"
373 value="${esc(track.title || '')}">
374 </label>
375
376 <div class="te-row te-row-2">
377 <label class="te-field">
378 <span>Artiest</span>
379 <input type="text" id="te-artist" maxlength="200"
380 autocomplete="off" autocapitalize="words" spellcheck="false"
381 value="${esc(track.artist || '')}">
382 </label>
383 <label class="te-field">
384 <span>Album</span>
385 <input type="text" id="te-album" maxlength="200"
386 autocomplete="off" autocapitalize="words" spellcheck="false"
387 list="te-album-list" value="${esc(track.album || '')}">
388 <datalist id="te-album-list">
389 ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')}
390 </datalist>
391 </label>
392 </div>
393
394 <label class="te-field">
395 <span>Duur <small>(seconden — automatisch bepaald, hier te overschrijven)</small></span>
396 <input type="number" id="te-duration" min="0" step="1"
397 inputmode="numeric" pattern="[0-9]*"
398 value="${track.duration || ''}" placeholder="auto">
399 </label>
400
401 <div class="te-row te-row-2">
402 <label class="te-field">
403 <span>Eigenaar / credit <small>(copyright-houder)</small></span>
404 <div class="te-credit-row">
405 <input type="text" id="te-credit" maxlength="200"
406 autocomplete="off" spellcheck="false"
407 placeholder="bv. © 2025 Mara Vos"
408 value="${esc(track.credit || '')}">
409 <button type="button" class="te-sym-btn" id="te-credit-copyr"
410 title="© invoegen" aria-label="Copyright-teken invoegen">©</button>
411 </div>
412 </label>
413 <label class="te-field">
414 <span>Licentie</span>
415 <input type="text" id="te-license" maxlength="120"
416 autocomplete="off" spellcheck="false" list="te-license-list"
417 placeholder="Alle rechten voorbehouden"
418 value="${esc(track.license || '')}">
419 <datalist id="te-license-list">
420 <option value="Alle rechten voorbehouden"></option>
421 <option value="CC BY 4.0"></option>
422 <option value="CC BY-SA 4.0"></option>
423 <option value="CC BY-NC 4.0"></option>
424 <option value="CC BY-NC-SA 4.0"></option>
425 <option value="CC BY-ND 4.0"></option>
426 <option value="CC0 1.0 (publiek domein)"></option>
427 </datalist>
428 </label>
429 </div>
430
431 <div class="te-field">
432 <span>Open in <small>(links naar dezelfde track elders)</small></span>
433 <input type="url" id="te-link-spotify" inputmode="url" autocomplete="off" spellcheck="false"
434 placeholder="Spotify-URL (https://open.spotify.com/…)" value="${esc(track.link_spotify || '')}">
435 <input type="url" id="te-link-youtube" inputmode="url" autocomplete="off" spellcheck="false"
436 placeholder="YouTube-URL (https://youtu.be/…)" value="${esc(track.link_youtube || '')}">
437 <input type="url" id="te-link-soundcloud" inputmode="url" autocomplete="off" spellcheck="false"
438 placeholder="SoundCloud-URL (https://soundcloud.com/…)" value="${esc(track.link_soundcloud || '')}">
439 </div>
440
441 <div class="te-field">
442 <span>Cover</span>
443 <div class="te-cover-row">
444 <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button"
445 aria-label="Klik om cover te kiezen">
446 ${track.cover_url
447 ? `<img src="${esc(track.cover_url)}" alt="">`
448 : `<span class="te-cover-empty">🎨</span>`}
449 </div>
450 <input type="file" id="te-cover-file"
451 accept="image/jpeg,image/png,image/webp,image/gif" hidden>
452 <div class="te-cover-actions">
453 <div class="te-cover-btn-row">
454 <button type="button" class="te-cover-btn" id="te-cover-pick">
455 📷 Foto kiezen
456 </button>
457 ${track.cover_url ? `
458 <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove">
459 × Verwijder
460 </button>` : ''}
461 </div>
462 <input type="text" id="te-cover-url" inputmode="url"
463 placeholder="/media/… of https://…"
464 autocomplete="off" autocapitalize="none" spellcheck="false"
465 value="${esc(track.cover_url || '')}">
466 <div class="te-cover-status" id="te-cover-status"></div>
467 </div>
468 </div>
469 </div>
470
471 </div>
472 </div>
473
474 <div class="te-footer">
475 <button type="button" class="te-btn" id="te-cancel">Annuleren</button>
476 <div class="te-footer-spacer"></div>
477 <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button>
478 </div>
479 </div>
480 `;
481
482 document.body.appendChild(backdrop);
483 document.body.classList.add('te-modal-open');
484
485 const $ = sel => backdrop.querySelector(sel);
486
487 // ── Inline preview player (routed through global mini-player) ──
488 // We don't build our own <audio>; instead we tell the global
489 // window.pcmsAudioPlayer to load this single track. Visual state
490 // syncs against the global audio element so toggle works correctly
491 // even if the user pauses from the mini-bar.
492 const previewBtn = $('#te-preview-play');
493 if (previewBtn) {
494 const setPreviewState = (playing) => {
495 previewBtn.textContent = playing ? '⏸' : '▶';
496 previewBtn.classList.toggle('is-playing', playing);
497 previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
498 };
499 const isOurTrack = () => {
500 // audio.src is a blob: URL (Spotify-style playback) — compare against
501 // the player's logical current-track URL instead.
502 const player = window.pcmsAudioPlayer;
503 const cur = player && player.currentTrack();
504 return !!(cur && track.stream_url && cur.url === track.stream_url);
505 };
506 const resync = () => {
507 const audio = document.getElementById('audio-element');
508 const playing = audio && !audio.paused && !audio.ended && isOurTrack();
509 setPreviewState(!!playing);
510 };
511
512 previewBtn.addEventListener('click', () => {
513 const player = window.pcmsAudioPlayer;
514 if (!player || !track.stream_url) {
515 console.warn('preview: miniplayer or url missing');
516 return;
517 }
518 const audio = document.getElementById('audio-element');
519 if (audio && isOurTrack()) {
520 // Same track loaded — toggle
521 if (audio.paused) player.play(); else player.pause();
522 } else {
523 player.setQueue([{
524 url: track.stream_url,
525 title: track.title || '(zonder titel)',
526 artist: track.artist || '',
527 album: track.album || '',
528 cover: track.cover_url || '',
529 }], 0);
530 }
531 });
532
533 const audio = document.getElementById('audio-element');
534 if (audio) {
535 const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied'];
536 evs.forEach(ev => audio.addEventListener(ev, resync));
537 // Detach listeners on close so we don't leak them
538 backdrop._previewCleanup = () => {
539 evs.forEach(ev => audio.removeEventListener(ev, resync));
540 };
541 resync(); // initial state
542 }
543 }
544
545 function close() {
546 // Detach our resync listeners (mini-player stays running)
547 if (backdrop._previewCleanup) backdrop._previewCleanup();
548 document.body.classList.remove('te-modal-open');
549 document.removeEventListener('keydown', onEsc);
550 backdrop.remove();
551 }
552 function onEsc(e) { if (e.key === 'Escape') close(); }
553 document.addEventListener('keydown', onEsc);
554
555 backdrop.addEventListener('click', e => {
556 // Click on the backdrop itself (not modal content) closes
557 if (e.target === backdrop) close();
558 });
559 $('.te-close').addEventListener('click', close);
560 $('#te-cancel').addEventListener('click', close);
561
562 // ── Cover picking + URL paste + drag-drop ────────────────
563 const thumb = $('#te-cover-thumb');
564 const fileInput = $('#te-cover-file');
565 const urlInput = $('#te-cover-url');
566 const status = $('#te-cover-status');
567 const pickBtn = $('#te-cover-pick');
568 const removeBtn = $('#te-cover-remove');
569
570 function setStatus(text, kind) {
571 status.textContent = text || '';
572 status.className = 'te-cover-status' + (kind ? ' is-' + kind : '');
573 }
574 function setThumb(url) {
575 if (url) {
576 thumb.innerHTML = `<img src="${esc(url)}" alt="">`;
577 } else {
578 thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`;
579 }
580 }
581
582 pickBtn.addEventListener('click', () => fileInput.click());
583 thumb.addEventListener('click', () => fileInput.click());
584 thumb.addEventListener('keydown', e => {
585 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
586 });
587
588 async function uploadCoverFile(file) {
589 if (!file) return;
590 if (!/^image\//.test(file.type)) {
591 setStatus('Alleen afbeeldingen toegestaan', 'error'); return;
592 }
593 setStatus('Uploaden…', null);
594 const fd = new FormData();
595 fd.append('cover', file);
596 try {
597 const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', {
598 method: 'POST', body: fd, credentials: 'same-origin',
599 });
600 const j = await r.json();
601 if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
602 urlInput.value = j.cover_url || '';
603 setThumb(j.cover_url);
604 setStatus('✓ Geüpload', 'ok');
605 } catch (err) {
606 setStatus('Mislukt: ' + err.message, 'error');
607 }
608 }
609
610 fileInput.addEventListener('change', e => {
611 const f = e.target.files && e.target.files[0];
612 if (f) uploadCoverFile(f);
613 fileInput.value = '';
614 });
615
616 // Drag-drop on thumb (desktop nicety)
617 ['dragenter', 'dragover'].forEach(ev =>
618 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); }));
619 ['dragleave', 'drop'].forEach(ev =>
620 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); }));
621 thumb.addEventListener('drop', e => {
622 const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
623 if (f) uploadCoverFile(f);
624 });
625
626 // URL paste auto-preview
627 urlInput.addEventListener('input', () => {
628 const v = urlInput.value.trim();
629 setThumb(v);
630 });
631
632 if (removeBtn) {
633 removeBtn.addEventListener('click', () => {
634 urlInput.value = '';
635 setThumb('');
636 removeBtn.remove();
637 });
638 }
639
640 // ── Insert © symbol into the credit field ─────────────────
641 const copyrBtn = $('#te-credit-copyr');
642 if (copyrBtn) {
643 copyrBtn.addEventListener('click', () => {
644 const inp = $('#te-credit');
645 if (!inp) return;
646 const sym = '© ';
647 const start = inp.selectionStart != null ? inp.selectionStart : inp.value.length;
648 const end = inp.selectionEnd != null ? inp.selectionEnd : inp.value.length;
649 inp.value = inp.value.slice(0, start) + sym + inp.value.slice(end);
650 inp.focus();
651 const pos = start + sym.length;
652 try { inp.setSelectionRange(pos, pos); } catch (e) {}
653 });
654 }
655
656 // ── Save ────────────────────────────────────────────────
657 $('#te-save').addEventListener('click', async () => {
658 const titleEl = $('#te-title');
659 const title = titleEl.value.trim();
660 if (!title) {
661 titleEl.focus();
662 alert('Titel is verplicht');
663 return;
664 }
665 const saveBtn = $('#te-save');
666 saveBtn.disabled = true;
667 saveBtn.textContent = '⏳ Opslaan…';
668
669 try {
670 const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), {
671 title,
672 artist: $('#te-artist').value.trim() || null,
673 album: $('#te-album').value.trim() || null,
674 credit: $('#te-credit').value.trim() || null,
675 license: $('#te-license').value.trim() || null,
676 link_spotify: $('#te-link-spotify').value.trim() || null,
677 link_youtube: $('#te-link-youtube').value.trim() || null,
678 link_soundcloud: $('#te-link-soundcloud').value.trim() || null,
679 duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
680 cover_url: urlInput.value.trim() || null,
681 });
682 if (!j.ok) throw new Error(j.error || 'Opslaan mislukt');
683 if (typeof onSaved === 'function') onSaved(j.track || { id, title,
684 artist: $('#te-artist').value.trim() || null,
685 album: $('#te-album').value.trim() || null,
686 cover_url: urlInput.value.trim() || null });
687 close();
688 } catch (err) {
689 alert('Opslaan mislukt: ' + err.message);
690 saveBtn.disabled = false;
691 saveBtn.textContent = '💾 Opslaan';
692 }
693 });
694
695 // ── Auto-duration ───────────────────────────────────────────
696 // The server already determines duration automatically on upload. This is the
697 // fallback/UX layer: if an admin opens an existing track without a duration,
698 // we read it from the audio metadata and fill the field — so you never need
699 // to type seconds manually. An existing value is never overwritten. We fetch
700 // the bytes via the same header gate as the player (X-Audio-Player).
701 (async function autoDuration() {
702 const durEl = $('#te-duration');
703 if (!durEl || !track.stream_url) return;
704 if (durEl.value && Number(durEl.value) > 0) return; // already filled → leave it alone
705 let objUrl = null;
706 try {
707 const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } });
708 if (!r.ok) return;
709 objUrl = URL.createObjectURL(await r.blob());
710 const probe = new Audio();
711 probe.preload = 'metadata';
712 probe.addEventListener('loadedmetadata', () => {
713 if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) {
714 durEl.value = Math.round(probe.duration);
715 }
716 if (objUrl) URL.revokeObjectURL(objUrl);
717 });
718 probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); });
719 probe.src = objUrl;
720 } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); }
721 })();
722
723 // Focus title for fast typing
724 setTimeout(() => $('#te-title').focus(), 60);
725 };
726})();
727</script>
Note: See TracBrowser for help on using the repository browser.