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

main
Last change on this file since 459acd9 was 459acd9, checked in by roboburr <roboburr@…>, 3 months ago

feat(editor): © insert button next to the credit field

No more looking up ©: a button inserts the copyright symbol at the cursor
position in the Owner/credit field.

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

  • Property mode set to 100644
File size: 26.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-veld met ©-invoegknop ──────────────────────────── */
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>Cover</span>
433 <div class="te-cover-row">
434 <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button"
435 aria-label="Klik om cover te kiezen">
436 ${track.cover_url
437 ? `<img src="${esc(track.cover_url)}" alt="">`
438 : `<span class="te-cover-empty">🎨</span>`}
439 </div>
440 <input type="file" id="te-cover-file"
441 accept="image/jpeg,image/png,image/webp,image/gif" hidden>
442 <div class="te-cover-actions">
443 <div class="te-cover-btn-row">
444 <button type="button" class="te-cover-btn" id="te-cover-pick">
445 📷 Foto kiezen
446 </button>
447 ${track.cover_url ? `
448 <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove">
449 × Verwijder
450 </button>` : ''}
451 </div>
452 <input type="text" id="te-cover-url" inputmode="url"
453 placeholder="/media/… of https://…"
454 autocomplete="off" autocapitalize="none" spellcheck="false"
455 value="${esc(track.cover_url || '')}">
456 <div class="te-cover-status" id="te-cover-status"></div>
457 </div>
458 </div>
459 </div>
460
461 </div>
462 </div>
463
464 <div class="te-footer">
465 <button type="button" class="te-btn" id="te-cancel">Annuleren</button>
466 <div class="te-footer-spacer"></div>
467 <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button>
468 </div>
469 </div>
470 `;
471
472 document.body.appendChild(backdrop);
473 document.body.classList.add('te-modal-open');
474
475 const $ = sel => backdrop.querySelector(sel);
476
477 // ── Inline preview player (routed through global mini-player) ──
478 // We don't build our own <audio>; instead we tell the global
479 // window.pcmsAudioPlayer to load this single track. Visual state
480 // syncs against the global audio element so toggle works correctly
481 // even if the user pauses from the mini-bar.
482 const previewBtn = $('#te-preview-play');
483 if (previewBtn) {
484 const setPreviewState = (playing) => {
485 previewBtn.textContent = playing ? '⏸' : '▶';
486 previewBtn.classList.toggle('is-playing', playing);
487 previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
488 };
489 const isOurTrack = () => {
490 // audio.src is a blob: URL (Spotify-style playback) — compare against
491 // the player's logical current-track URL instead.
492 const player = window.pcmsAudioPlayer;
493 const cur = player && player.currentTrack();
494 return !!(cur && track.stream_url && cur.url === track.stream_url);
495 };
496 const resync = () => {
497 const audio = document.getElementById('audio-element');
498 const playing = audio && !audio.paused && !audio.ended && isOurTrack();
499 setPreviewState(!!playing);
500 };
501
502 previewBtn.addEventListener('click', () => {
503 const player = window.pcmsAudioPlayer;
504 if (!player || !track.stream_url) {
505 console.warn('preview: miniplayer or url missing');
506 return;
507 }
508 const audio = document.getElementById('audio-element');
509 if (audio && isOurTrack()) {
510 // Same track loaded — toggle
511 if (audio.paused) player.play(); else player.pause();
512 } else {
513 player.setQueue([{
514 url: track.stream_url,
515 title: track.title || '(zonder titel)',
516 artist: track.artist || '',
517 album: track.album || '',
518 cover: track.cover_url || '',
519 }], 0);
520 }
521 });
522
523 const audio = document.getElementById('audio-element');
524 if (audio) {
525 const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied'];
526 evs.forEach(ev => audio.addEventListener(ev, resync));
527 // Detach listeners on close so we don't leak them
528 backdrop._previewCleanup = () => {
529 evs.forEach(ev => audio.removeEventListener(ev, resync));
530 };
531 resync(); // initial state
532 }
533 }
534
535 function close() {
536 // Detach our resync listeners (mini-player stays running)
537 if (backdrop._previewCleanup) backdrop._previewCleanup();
538 document.body.classList.remove('te-modal-open');
539 document.removeEventListener('keydown', onEsc);
540 backdrop.remove();
541 }
542 function onEsc(e) { if (e.key === 'Escape') close(); }
543 document.addEventListener('keydown', onEsc);
544
545 backdrop.addEventListener('click', e => {
546 // Click on the backdrop itself (not modal content) closes
547 if (e.target === backdrop) close();
548 });
549 $('.te-close').addEventListener('click', close);
550 $('#te-cancel').addEventListener('click', close);
551
552 // ── Cover picking + URL paste + drag-drop ────────────────
553 const thumb = $('#te-cover-thumb');
554 const fileInput = $('#te-cover-file');
555 const urlInput = $('#te-cover-url');
556 const status = $('#te-cover-status');
557 const pickBtn = $('#te-cover-pick');
558 const removeBtn = $('#te-cover-remove');
559
560 function setStatus(text, kind) {
561 status.textContent = text || '';
562 status.className = 'te-cover-status' + (kind ? ' is-' + kind : '');
563 }
564 function setThumb(url) {
565 if (url) {
566 thumb.innerHTML = `<img src="${esc(url)}" alt="">`;
567 } else {
568 thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`;
569 }
570 }
571
572 pickBtn.addEventListener('click', () => fileInput.click());
573 thumb.addEventListener('click', () => fileInput.click());
574 thumb.addEventListener('keydown', e => {
575 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
576 });
577
578 async function uploadCoverFile(file) {
579 if (!file) return;
580 if (!/^image\//.test(file.type)) {
581 setStatus('Alleen afbeeldingen toegestaan', 'error'); return;
582 }
583 setStatus('Uploaden…', null);
584 const fd = new FormData();
585 fd.append('cover', file);
586 try {
587 const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', {
588 method: 'POST', body: fd, credentials: 'same-origin',
589 });
590 const j = await r.json();
591 if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
592 urlInput.value = j.cover_url || '';
593 setThumb(j.cover_url);
594 setStatus('✓ Geüpload', 'ok');
595 } catch (err) {
596 setStatus('Mislukt: ' + err.message, 'error');
597 }
598 }
599
600 fileInput.addEventListener('change', e => {
601 const f = e.target.files && e.target.files[0];
602 if (f) uploadCoverFile(f);
603 fileInput.value = '';
604 });
605
606 // Drag-drop on thumb (desktop nicety)
607 ['dragenter', 'dragover'].forEach(ev =>
608 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); }));
609 ['dragleave', 'drop'].forEach(ev =>
610 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); }));
611 thumb.addEventListener('drop', e => {
612 const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
613 if (f) uploadCoverFile(f);
614 });
615
616 // URL paste auto-preview
617 urlInput.addEventListener('input', () => {
618 const v = urlInput.value.trim();
619 setThumb(v);
620 });
621
622 if (removeBtn) {
623 removeBtn.addEventListener('click', () => {
624 urlInput.value = '';
625 setThumb('');
626 removeBtn.remove();
627 });
628 }
629
630 // ── ©-teken invoegen in het credit-veld ─────────────────
631 const copyrBtn = $('#te-credit-copyr');
632 if (copyrBtn) {
633 copyrBtn.addEventListener('click', () => {
634 const inp = $('#te-credit');
635 if (!inp) return;
636 const sym = '© ';
637 const start = inp.selectionStart != null ? inp.selectionStart : inp.value.length;
638 const end = inp.selectionEnd != null ? inp.selectionEnd : inp.value.length;
639 inp.value = inp.value.slice(0, start) + sym + inp.value.slice(end);
640 inp.focus();
641 const pos = start + sym.length;
642 try { inp.setSelectionRange(pos, pos); } catch (e) {}
643 });
644 }
645
646 // ── Save ────────────────────────────────────────────────
647 $('#te-save').addEventListener('click', async () => {
648 const titleEl = $('#te-title');
649 const title = titleEl.value.trim();
650 if (!title) {
651 titleEl.focus();
652 alert('Titel is verplicht');
653 return;
654 }
655 const saveBtn = $('#te-save');
656 saveBtn.disabled = true;
657 saveBtn.textContent = '⏳ Opslaan…';
658
659 try {
660 const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), {
661 title,
662 artist: $('#te-artist').value.trim() || null,
663 album: $('#te-album').value.trim() || null,
664 credit: $('#te-credit').value.trim() || null,
665 license: $('#te-license').value.trim() || null,
666 duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
667 cover_url: urlInput.value.trim() || null,
668 });
669 if (!j.ok) throw new Error(j.error || 'Opslaan mislukt');
670 if (typeof onSaved === 'function') onSaved(j.track || { id, title,
671 artist: $('#te-artist').value.trim() || null,
672 album: $('#te-album').value.trim() || null,
673 cover_url: urlInput.value.trim() || null });
674 close();
675 } catch (err) {
676 alert('Opslaan mislukt: ' + err.message);
677 saveBtn.disabled = false;
678 saveBtn.textContent = '💾 Opslaan';
679 }
680 });
681
682 // ── Auto-duur ───────────────────────────────────────────
683 // De server bepaalt de duur al automatisch bij upload. Dit is de vangnet/
684 // UX-laag: opent een admin een bestaande track zónder duur, dan lezen we 'm
685 // hier uit de audio-metadata en vullen het veld — zodat je nooit seconden
686 // hoeft te typen. Bestaande waarde wordt nooit overschreven. We halen de
687 // bytes via dezelfde header-gate als de speler (X-Audio-Player).
688 (async function autoDuration() {
689 const durEl = $('#te-duration');
690 if (!durEl || !track.stream_url) return;
691 if (durEl.value && Number(durEl.value) > 0) return; // al ingevuld → met rust laten
692 let objUrl = null;
693 try {
694 const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } });
695 if (!r.ok) return;
696 objUrl = URL.createObjectURL(await r.blob());
697 const probe = new Audio();
698 probe.preload = 'metadata';
699 probe.addEventListener('loadedmetadata', () => {
700 if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) {
701 durEl.value = Math.round(probe.duration);
702 }
703 if (objUrl) URL.revokeObjectURL(objUrl);
704 });
705 probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); });
706 probe.src = objUrl;
707 } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); }
708 })();
709
710 // Focus title for fast typing
711 setTimeout(() => $('#te-title').focus(), 60);
712 };
713})();
714</script>
Note: See TracBrowser for help on using the repository browser.