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

main
Last change on this file since aa189d1 was 98a1990, checked in by roboburr <roboburr@…>, 2 months ago

feat(admin-audio): replace an existing track's audio file + Playlists shortcut

  • Track editor: a "Vervang audiobestand" control uploads a new audio file for an existing track. POST /admin/audio/api/:id/replace-audio transcodes it to mp3, swaps the track's media_id + duration, and deletes the old media (file + row). The track id is unchanged, so every [[track:id]] in posts keeps pointing at the same track — now with the new audio.
  • /admin/audio header gets a Playlists button so you can jump to playlist management.
  • src/routes/admin-audio.js — POST /api/:id/replace-audio
  • src/views/partials/track-editor.ejs — replace-audio field + uploader
  • src/views/pages/admin-audio.ejs — Playlists link in the header

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

  • Property mode set to 100644
File size: 29.7 KB
RevLine 
[7bc636b]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
[834bcc3]197/* ── Credit field with © insert button ──────────────────────────── */
[459acd9]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
[7bc636b]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">
[3e86f1c]395 <span>Duur <small>(seconden — automatisch bepaald, hier te overschrijven)</small></span>
[7bc636b]396 <input type="number" id="te-duration" min="0" step="1"
397 inputmode="numeric" pattern="[0-9]*"
[3e86f1c]398 value="${track.duration || ''}" placeholder="auto">
[7bc636b]399 </label>
400
[0d7acdf]401 <div class="te-row te-row-2">
402 <label class="te-field">
403 <span>Eigenaar / credit <small>(copyright-houder)</small></span>
[459acd9]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>
[0d7acdf]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
[183875b]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
[98a1990]441 <div class="te-field">
442 <span>Audiobestand</span>
443 <div class="te-cover-btn-row">
444 <input type="file" id="te-audio-file" accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg,.aac" hidden>
445 <button type="button" class="te-cover-btn" id="te-audio-pick">${track.stream_url ? '🔁 Vervang audiobestand' : '🎵 Audiobestand kiezen'}</button>
446 <span class="te-cover-status" id="te-audio-status"></span>
447 </div>
448 </div>
449
[7bc636b]450 <div class="te-field">
451 <span>Cover</span>
452 <div class="te-cover-row">
453 <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button"
454 aria-label="Klik om cover te kiezen">
455 ${track.cover_url
456 ? `<img src="${esc(track.cover_url)}" alt="">`
457 : `<span class="te-cover-empty">🎨</span>`}
458 </div>
459 <input type="file" id="te-cover-file"
460 accept="image/jpeg,image/png,image/webp,image/gif" hidden>
461 <div class="te-cover-actions">
462 <div class="te-cover-btn-row">
463 <button type="button" class="te-cover-btn" id="te-cover-pick">
464 📷 Foto kiezen
465 </button>
466 ${track.cover_url ? `
467 <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove">
468 × Verwijder
469 </button>` : ''}
470 </div>
[b50a885]471 <input type="text" id="te-cover-url" inputmode="url"
472 placeholder="/media/… of https://…"
[7bc636b]473 autocomplete="off" autocapitalize="none" spellcheck="false"
474 value="${esc(track.cover_url || '')}">
475 <div class="te-cover-status" id="te-cover-status"></div>
476 </div>
477 </div>
478 </div>
479
480 </div>
481 </div>
482
483 <div class="te-footer">
484 <button type="button" class="te-btn" id="te-cancel">Annuleren</button>
485 <div class="te-footer-spacer"></div>
486 <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button>
487 </div>
488 </div>
489 `;
490
491 document.body.appendChild(backdrop);
492 document.body.classList.add('te-modal-open');
493
494 const $ = sel => backdrop.querySelector(sel);
495
496 // ── Inline preview player (routed through global mini-player) ──
497 // We don't build our own <audio>; instead we tell the global
498 // window.pcmsAudioPlayer to load this single track. Visual state
499 // syncs against the global audio element so toggle works correctly
500 // even if the user pauses from the mini-bar.
501 const previewBtn = $('#te-preview-play');
502 if (previewBtn) {
503 const setPreviewState = (playing) => {
504 previewBtn.textContent = playing ? '⏸' : '▶';
505 previewBtn.classList.toggle('is-playing', playing);
506 previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
507 };
508 const isOurTrack = () => {
[21522ae]509 // audio.src is a blob: URL (Spotify-style playback) — compare against
510 // the player's logical current-track URL instead.
511 const player = window.pcmsAudioPlayer;
512 const cur = player && player.currentTrack();
513 return !!(cur && track.stream_url && cur.url === track.stream_url);
[7bc636b]514 };
515 const resync = () => {
516 const audio = document.getElementById('audio-element');
517 const playing = audio && !audio.paused && !audio.ended && isOurTrack();
518 setPreviewState(!!playing);
519 };
520
521 previewBtn.addEventListener('click', () => {
522 const player = window.pcmsAudioPlayer;
523 if (!player || !track.stream_url) {
524 console.warn('preview: miniplayer or url missing');
525 return;
526 }
527 const audio = document.getElementById('audio-element');
528 if (audio && isOurTrack()) {
529 // Same track loaded — toggle
530 if (audio.paused) player.play(); else player.pause();
531 } else {
532 player.setQueue([{
533 url: track.stream_url,
534 title: track.title || '(zonder titel)',
535 artist: track.artist || '',
536 album: track.album || '',
537 cover: track.cover_url || '',
538 }], 0);
539 }
540 });
541
542 const audio = document.getElementById('audio-element');
543 if (audio) {
544 const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied'];
545 evs.forEach(ev => audio.addEventListener(ev, resync));
546 // Detach listeners on close so we don't leak them
547 backdrop._previewCleanup = () => {
548 evs.forEach(ev => audio.removeEventListener(ev, resync));
549 };
550 resync(); // initial state
551 }
552 }
553
554 function close() {
555 // Detach our resync listeners (mini-player stays running)
556 if (backdrop._previewCleanup) backdrop._previewCleanup();
557 document.body.classList.remove('te-modal-open');
558 document.removeEventListener('keydown', onEsc);
559 backdrop.remove();
560 }
561 function onEsc(e) { if (e.key === 'Escape') close(); }
562 document.addEventListener('keydown', onEsc);
563
[aa45208]564 // A click on the backdrop does NOT close the editor — too easy to lose edits by
565 // mis-clicking outside. Close deliberately via ×, Cancel or Esc.
[7bc636b]566 $('.te-close').addEventListener('click', close);
567 $('#te-cancel').addEventListener('click', close);
568
569 // ── Cover picking + URL paste + drag-drop ────────────────
570 const thumb = $('#te-cover-thumb');
571 const fileInput = $('#te-cover-file');
572 const urlInput = $('#te-cover-url');
573 const status = $('#te-cover-status');
574 const pickBtn = $('#te-cover-pick');
575 const removeBtn = $('#te-cover-remove');
576
577 function setStatus(text, kind) {
578 status.textContent = text || '';
579 status.className = 'te-cover-status' + (kind ? ' is-' + kind : '');
580 }
581 function setThumb(url) {
582 if (url) {
583 thumb.innerHTML = `<img src="${esc(url)}" alt="">`;
584 } else {
585 thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`;
586 }
587 }
588
589 pickBtn.addEventListener('click', () => fileInput.click());
590 thumb.addEventListener('click', () => fileInput.click());
591 thumb.addEventListener('keydown', e => {
592 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
593 });
594
595 async function uploadCoverFile(file) {
596 if (!file) return;
597 if (!/^image\//.test(file.type)) {
598 setStatus('Alleen afbeeldingen toegestaan', 'error'); return;
599 }
600 setStatus('Uploaden…', null);
601 const fd = new FormData();
602 fd.append('cover', file);
603 try {
604 const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', {
605 method: 'POST', body: fd, credentials: 'same-origin',
606 });
607 const j = await r.json();
608 if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
609 urlInput.value = j.cover_url || '';
610 setThumb(j.cover_url);
611 setStatus('✓ Geüpload', 'ok');
612 } catch (err) {
613 setStatus('Mislukt: ' + err.message, 'error');
614 }
615 }
616
617 fileInput.addEventListener('change', e => {
618 const f = e.target.files && e.target.files[0];
619 if (f) uploadCoverFile(f);
620 fileInput.value = '';
621 });
622
623 // Drag-drop on thumb (desktop nicety)
624 ['dragenter', 'dragover'].forEach(ev =>
625 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); }));
626 ['dragleave', 'drop'].forEach(ev =>
627 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); }));
628 thumb.addEventListener('drop', e => {
629 const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
630 if (f) uploadCoverFile(f);
631 });
632
633 // URL paste auto-preview
634 urlInput.addEventListener('input', () => {
635 const v = urlInput.value.trim();
636 setThumb(v);
637 });
638
639 if (removeBtn) {
640 removeBtn.addEventListener('click', () => {
641 urlInput.value = '';
642 setThumb('');
643 removeBtn.remove();
644 });
645 }
646
[98a1990]647 // ── Replace the audio file of this track ──────────────────
648 const aPick = $('#te-audio-pick');
649 const aFile = $('#te-audio-file');
650 const aStatus = $('#te-audio-status');
651 if (aPick && aFile) {
652 aPick.addEventListener('click', () => aFile.click());
653 aFile.addEventListener('change', async (e) => {
654 const f = e.target.files && e.target.files[0];
655 aFile.value = '';
656 if (!f) return;
657 aStatus.textContent = '⏳ Converteren… (kan even duren)'; aStatus.className = 'te-cover-status';
658 aPick.disabled = true;
659 try {
660 const fd = new FormData();
661 fd.append('audio', f);
662 const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id) + '/replace-audio', fd);
663 if (!j.ok) throw new Error(j.error || 'mislukt');
664 aStatus.textContent = '✓ Vervangen'; aStatus.className = 'te-cover-status is-ok';
665 track.stream_url = j.stream_url || track.stream_url;
666 if (j.duration) { const d = $('#te-duration'); if (d) d.value = j.duration; }
667 } catch (err) {
668 aStatus.textContent = 'Mislukt: ' + err.message; aStatus.className = 'te-cover-status is-error';
669 } finally { aPick.disabled = false; }
670 });
671 }
672
[834bcc3]673 // ── Insert © symbol into the credit field ─────────────────
[459acd9]674 const copyrBtn = $('#te-credit-copyr');
675 if (copyrBtn) {
676 copyrBtn.addEventListener('click', () => {
677 const inp = $('#te-credit');
678 if (!inp) return;
679 const sym = '© ';
680 const start = inp.selectionStart != null ? inp.selectionStart : inp.value.length;
681 const end = inp.selectionEnd != null ? inp.selectionEnd : inp.value.length;
682 inp.value = inp.value.slice(0, start) + sym + inp.value.slice(end);
683 inp.focus();
684 const pos = start + sym.length;
685 try { inp.setSelectionRange(pos, pos); } catch (e) {}
686 });
687 }
688
[7bc636b]689 // ── Save ────────────────────────────────────────────────
690 $('#te-save').addEventListener('click', async () => {
691 const titleEl = $('#te-title');
692 const title = titleEl.value.trim();
693 if (!title) {
694 titleEl.focus();
695 alert('Titel is verplicht');
696 return;
697 }
698 const saveBtn = $('#te-save');
699 saveBtn.disabled = true;
700 saveBtn.textContent = '⏳ Opslaan…';
701
702 try {
703 const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), {
704 title,
705 artist: $('#te-artist').value.trim() || null,
706 album: $('#te-album').value.trim() || null,
[0d7acdf]707 credit: $('#te-credit').value.trim() || null,
708 license: $('#te-license').value.trim() || null,
[183875b]709 link_spotify: $('#te-link-spotify').value.trim() || null,
710 link_youtube: $('#te-link-youtube').value.trim() || null,
711 link_soundcloud: $('#te-link-soundcloud').value.trim() || null,
[7bc636b]712 duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
713 cover_url: urlInput.value.trim() || null,
714 });
715 if (!j.ok) throw new Error(j.error || 'Opslaan mislukt');
716 if (typeof onSaved === 'function') onSaved(j.track || { id, title,
717 artist: $('#te-artist').value.trim() || null,
718 album: $('#te-album').value.trim() || null,
719 cover_url: urlInput.value.trim() || null });
720 close();
721 } catch (err) {
722 alert('Opslaan mislukt: ' + err.message);
723 saveBtn.disabled = false;
724 saveBtn.textContent = '💾 Opslaan';
725 }
726 });
727
[834bcc3]728 // ── Auto-duration ───────────────────────────────────────────
729 // The server already determines duration automatically on upload. This is the
730 // fallback/UX layer: if an admin opens an existing track without a duration,
731 // we read it from the audio metadata and fill the field — so you never need
732 // to type seconds manually. An existing value is never overwritten. We fetch
733 // the bytes via the same header gate as the player (X-Audio-Player).
[3e86f1c]734 (async function autoDuration() {
735 const durEl = $('#te-duration');
736 if (!durEl || !track.stream_url) return;
[834bcc3]737 if (durEl.value && Number(durEl.value) > 0) return; // already filled → leave it alone
[3e86f1c]738 let objUrl = null;
739 try {
740 const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } });
741 if (!r.ok) return;
742 objUrl = URL.createObjectURL(await r.blob());
743 const probe = new Audio();
744 probe.preload = 'metadata';
745 probe.addEventListener('loadedmetadata', () => {
746 if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) {
747 durEl.value = Math.round(probe.duration);
748 }
749 if (objUrl) URL.revokeObjectURL(objUrl);
750 });
751 probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); });
752 probe.src = objUrl;
753 } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); }
754 })();
755
[7bc636b]756 // Focus title for fast typing
757 setTimeout(() => $('#te-title').focus(), 60);
758 };
759})();
760</script>
Note: See TracBrowser for help on using the repository browser.