source: Klonkt/src/views/pages/admin-audio.ejs@ 255e3d3

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

Audio upload: WAV up to 100 MB (other formats remain 50 MB)

WAV is uncompressed → 50 MB was too tight. multer ceiling now 100 MB
(the highest), with a per-type check in the handler: .wav may be 100 MB,
compressed formats stay at 50 MB. Dropzone text updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

  • Property mode set to 100644
File size: 32.7 KB
RevLine 
[7bc636b]1<div class="container ax-page">
2
[8b014ef]3 <p><a href="/admin" class="btn">&larr; Beheer</a></p>
[7bc636b]4 <header class="ax-header">
5 <div>
6 <h1>Audio tracks</h1>
7 <p class="ax-tagline">Site-level MP3s. Gebruik <code>[[track:&lt;id&gt;]]</code> in een post om een play-knop in te voegen.</p>
8 </div>
9 </header>
10
11 <% if (success) { %><div class="ax-flash ax-flash-ok"><%= success %></div><% } %>
12 <% if (error) { %><div class="ax-flash ax-flash-err"><%= error %></div><% } %>
13
14 <%# ── UPLOAD ──────────────────────────────────────────────── %>
15 <section class="ax-card">
16 <div class="ax-card-title">Upload</div>
17
18 <%# Bulk drag-drop zone. Multiple files at once, transcoded sequentially.
19 Title is auto-derived from each filename. Artist/album/cover are
20 SHARED across the batch (set once, applied to every track) — same
21 pattern as iTunes/Plex bulk-add. %>
22 <div class="ax-form">
23
24 <%# Shared metadata fields (apply to ALL files in the batch). %>
25 <div class="ax-row ax-row-2">
26 <label class="ax-field">
27 <span>Artiest <small>(toegepast op alle bestanden)</small></span>
28 <input type="text" id="batch-artist" placeholder="Optioneel">
29 </label>
30 <label class="ax-field">
31 <span>Album <small>(toegepast op alle bestanden)</small></span>
32 <input type="text" id="batch-album" placeholder="Optioneel">
33 </label>
34 </div>
35 <label class="ax-field ax-file">
36 <span>Cover <small>(optioneel, toegepast op alle bestanden — jpg/png/webp/gif, max 5 MB)</small></span>
37 <span class="ax-file-control">
38 <span class="ax-btn ax-btn-secondary ax-file-btn">🖼 Kies cover</span>
39 <span class="ax-file-name" data-empty>Geen bestand gekozen</span>
40 <input type="file" id="batch-cover" accept="image/jpeg,image/png,image/webp,image/gif">
41 </span>
42 </label>
43
44 <%# The drop-zone itself. Click anywhere to open file picker. %>
45 <label class="ax-dropzone" id="audio-dropzone" tabindex="0">
46 <span class="ax-dropzone-icon">🎵</span>
47 <strong class="ax-dropzone-title">Sleep audio hierheen</strong>
[fbc8628]48 <span class="ax-dropzone-sub">of klik om bestanden te kiezen<br><small>mp3, m4a, ogg, opus, flac · max <%= maxBytesMb %> MB · wav max <%= maxWavMb %> MB</small></span>
[7bc636b]49 <input type="file" id="audio-files" accept="audio/*" multiple>
50 </label>
51
52 <%# Per-file progress list. Populated by JS as files are queued. %>
53 <ul class="ax-upload-queue" id="upload-queue" hidden></ul>
54
55 <div class="ax-form-actions" id="upload-actions" hidden>
56 <button type="button" class="ax-btn ax-btn-primary" id="start-upload-btn">⬆ Start upload</button>
57 <button type="button" class="ax-btn" id="clear-queue-btn">Wis lijst</button>
58 </div>
59 </div>
60 </section>
61
62 <%# ── TRACK LIST ─────────────────────────────────────────── %>
63 <section class="ax-card">
64 <div class="ax-card-title">
65 Tracks <span class="ax-count"><%= tracks.length %></span>
66 </div>
67
68 <% if (!tracks.length) { %>
69 <div class="ax-empty">
70 <div class="ax-empty-icon">♫</div>
71 <p>Nog geen tracks. Upload er een hierboven.</p>
72 </div>
73 <% } else { %>
74 <ul class="ax-list">
75 <% tracks.forEach(function(t) { %>
76 <li class="ax-track" data-track-id="<%= t.id %>">
77 <% if (t.cover_url) { %>
78 <img class="ax-track-cover" src="<%= t.cover_url %>" alt="" data-cover-thumb>
79 <% } else { %>
80 <span class="ax-track-cover ax-track-cover-empty" data-cover-thumb>♫</span>
81 <% } %>
82
83 <div class="ax-track-meta">
84 <div class="ax-track-title" data-cell="title"><%= t.title || '(zonder titel)' %></div>
85 <div class="ax-track-sub">
86 <span data-cell="artist"><%= t.artist || '—' %></span>
87 <% if (t.album) { %><span class="ax-track-sep">·</span><span data-cell="album"><%= t.album %></span><% } else { %><span data-cell="album" hidden></span><% } %>
88 <span class="ax-track-sep">·</span><span><%= t.size ? Math.round(t.size/1024) + ' KB' : '—' %></span>
89 </div>
90 <code class="ax-embed" data-copy="[[track:<%= t.id %>]]" title="Klik om te kopiëren">[[track:<%= t.id %>]]</code>
91 </div>
92
93 <div class="ax-track-actions">
94 <% if (t.stream_url) { %>
95 <button type="button" class="ax-icon-btn ax-track-play"
96 data-stream-url="<%= t.stream_url %>"
97 data-track-id="<%= t.id %>"
98 aria-label="Afspelen" title="Afspelen">
99 <span class="ax-track-play-icon" aria-hidden="true">▶</span>
100 </button>
101 <% } %>
102 <button type="button" class="ax-icon-btn" data-track-edit data-id="<%= t.id %>" aria-label="Bewerken" title="Bewerken">✎</button>
103 <form action="/admin/audio/<%= t.id %>/delete" method="post" onsubmit="return confirm('Track verwijderen?')" class="ax-track-delete">
104 <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="Verwijderen" title="Verwijderen">🗑</button>
105 </form>
106 </div>
107 </li>
108 <% }); %>
109 </ul>
110 <% } %>
111 </section>
112
113</div>
114
115<style>
116/* ─── Page wrapper ──────────────────────────────────────────────── */
117.ax-page {
118 max-width: 880px;
119 margin: 1.5rem auto 4rem;
120 padding: 0 1rem;
121}
122.ax-header {
123 display: flex; align-items: flex-start; gap: 0.75rem;
124 margin-bottom: 1.5rem;
125}
126.ax-header h1 {
127 font-family: var(--font-display, serif);
128 font-size: 1.75rem;
129 margin: 0 0 0.25rem;
130}
131.ax-tagline {
132 color: var(--ink-muted, var(--ink-soft));
133 margin: 0;
134 font-size: 0.9rem;
135 line-height: 1.5;
136}
137.ax-tagline code {
138 background: var(--paper-2);
139 padding: 0.1em 0.4em;
140 border-radius: 4px;
141 font-size: 0.9em;
142}
143.ax-back {
144 display: inline-flex; align-items: center; justify-content: center;
145 width: 40px; height: 40px;
146 border: 1px solid var(--rule);
147 border-radius: 8px;
148 background: var(--paper);
149 color: var(--ink);
150 text-decoration: none;
151 font-size: 1.1rem;
152 flex-shrink: 0;
153 transition: border-color 120ms;
154}
155.ax-back:hover { border-color: var(--accent); }
156
157/* ─── Flash messages ───────────────────────────────────────────── */
158.ax-flash {
159 padding: 0.75rem 1rem;
160 border-radius: 8px;
161 margin-bottom: 1rem;
162 font-size: 0.9rem;
163}
164.ax-flash-ok { background: rgba(40,160,90,.15); color: #2a9d5e; border: 1px solid rgba(40,160,90,.3); }
165.ax-flash-err { background: rgba(200,60,60,.15); color: #c33; border: 1px solid rgba(200,60,60,.3); }
166
167/* ─── Card sections ────────────────────────────────────────────── */
168.ax-card {
169 background: var(--paper);
170 border: 1px solid var(--rule);
171 border-radius: 12px;
172 padding: 1.25rem;
173 margin-bottom: 1rem;
174}
175.ax-card-title {
176 font-family: var(--font-display, serif);
177 font-size: 1.15rem;
178 font-weight: 600;
179 margin-bottom: 1rem;
180 display: flex; align-items: baseline; gap: 0.5rem;
181}
182.ax-count {
183 font-family: var(--font-ui, system-ui);
184 font-size: 0.8rem;
185 color: var(--ink-muted, var(--ink-soft));
186 background: var(--paper-2);
187 padding: 0.1em 0.55em;
188 border-radius: 999px;
189 font-weight: 500;
190}
191
192/* ─── Form fields ──────────────────────────────────────────────── */
193.ax-form {
194 display: flex; flex-direction: column;
195 gap: 0.85rem;
196}
197.ax-row {
198 display: grid; gap: 0.85rem;
199}
200.ax-row-2 { grid-template-columns: 1fr 1fr; }
201@media (max-width: 600px) {
202 .ax-row-2 { grid-template-columns: 1fr; }
203}
204.ax-field {
205 display: flex; flex-direction: column;
206 gap: 0.35rem;
207 min-width: 0;
208}
209.ax-field > span {
210 font-size: 0.8rem;
211 font-weight: 600;
212 color: var(--ink-soft);
213 display: flex; align-items: baseline; gap: 0.4rem;
214 flex-wrap: wrap;
215}
216.ax-field > span small {
217 font-weight: 400; color: var(--ink-muted);
218 font-size: 0.95em;
219}
220.ax-field input[type="text"],
221.ax-field input[type="url"],
222.ax-field input[type="email"],
223.ax-field select,
224.ax-field textarea {
225 width: 100%;
226 box-sizing: border-box;
227 padding: 0.6rem 0.75rem;
228 border: 1px solid var(--rule);
229 border-radius: 6px;
230 background: var(--paper-2);
231 color: var(--ink);
232 font-family: var(--font-ui, system-ui), sans-serif;
233 font-size: 0.95rem;
234 -webkit-appearance: none; appearance: none;
235 transition: border-color 120ms;
236}
237.ax-field input:focus,
238.ax-field select:focus,
239.ax-field textarea:focus {
240 outline: 2px solid var(--accent);
241 outline-offset: -1px;
242 border-color: var(--accent);
243}
244
245/* ─── File inputs (custom-styled wrapper) ─────────────────────── */
246.ax-file-control {
247 display: flex; align-items: center; gap: 0.5rem;
248 flex-wrap: wrap;
249 position: relative;
250 min-height: 40px;
251}
252.ax-file-control input[type="file"] {
253 position: absolute; inset: 0;
254 opacity: 0; cursor: pointer;
255 z-index: 2;
256}
257.ax-file-btn {
258 pointer-events: none; /* let clicks pass through to the file input */
259}
260.ax-file-name {
261 font-size: 0.85rem;
262 color: var(--ink);
263 word-break: break-all;
264 flex: 1;
265 min-width: 0;
266}
267.ax-file-name[data-empty] { color: var(--ink-muted, var(--ink-soft)); }
268
269.ax-form-actions {
270 display: flex; gap: 0.5rem;
271 margin-top: 0.25rem;
272}
273.ax-form-actions[hidden] { display: none; }
274
275/* ─── Buttons ──────────────────────────────────────────────────── */
276.ax-btn {
277 display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
278 padding: 0.55rem 1rem;
279 border: 1px solid var(--rule);
280 background: var(--paper-2);
281 color: var(--ink);
282 font-family: var(--font-ui, system-ui), sans-serif;
283 font-size: 0.9rem; font-weight: 500;
284 text-decoration: none;
285 border-radius: 6px;
286 cursor: pointer;
287 min-height: 40px;
288 transition: background 120ms, border-color 120ms;
289 -webkit-tap-highlight-color: transparent;
290}
291.ax-btn:hover { border-color: var(--accent); }
292.ax-btn-secondary { background: var(--paper-2); }
293.ax-btn-primary {
294 background: var(--accent); color: white;
295 border-color: var(--accent);
296}
297.ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
298
299/* Tiny icon-only button for row actions */
300.ax-icon-btn {
301 display: inline-flex; align-items: center; justify-content: center;
302 width: 40px; height: 40px;
303 border: 1px solid var(--rule);
304 background: var(--paper-2);
305 color: var(--ink);
306 border-radius: 8px;
307 cursor: pointer;
308 font-size: 1rem;
309 transition: background 120ms, border-color 120ms, color 120ms;
310}
311.ax-icon-btn:hover { border-color: var(--accent); }
312.ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }
313
314.ax-track-play.is-playing {
315 border-color: var(--accent);
316 color: var(--accent);
317 background: color-mix(in srgb, var(--accent) 12%, var(--paper-2));
318}
319.ax-track-play-icon { font-size: 0.85rem; line-height: 1; }
320
321/* ─── Empty state ──────────────────────────────────────────────── */
322.ax-empty {
323 text-align: center;
324 padding: 2rem 1rem;
325 color: var(--ink-muted, var(--ink-soft));
326}
327.ax-empty-icon {
328 font-size: 2.5rem;
329 font-family: var(--font-display, serif);
330 color: var(--ink-soft);
331 opacity: 0.4;
332 margin-bottom: 0.5rem;
333}
334.ax-empty p { margin: 0; }
335
336/* ─── Track list (card per row) ────────────────────────────────── */
337.ax-list {
338 list-style: none; padding: 0; margin: 0;
339 display: flex; flex-direction: column;
340 gap: 0.6rem;
341}
342.ax-track {
343 display: grid;
344 grid-template-columns: 56px 1fr auto;
345 gap: 0.85rem;
346 align-items: center;
347 padding: 0.75rem;
348 background: var(--paper-2);
349 border: 1px solid var(--rule);
350 border-radius: 10px;
351 transition: border-color 120ms;
352}
353.ax-track:hover { border-color: var(--accent); }
354.ax-track-cover {
355 width: 56px; height: 56px;
356 border-radius: 6px;
357 object-fit: cover;
358 background: var(--paper);
359 display: flex; align-items: center; justify-content: center;
360 font-family: var(--font-display, serif);
361 font-size: 1.5rem;
362 color: var(--accent);
363 flex-shrink: 0;
364}
365.ax-track-cover-empty { /* span variant */ }
366.ax-track-meta {
367 min-width: 0; /* allow text-overflow on long titles */
368 display: flex; flex-direction: column; gap: 0.2rem;
369}
370.ax-track-title {
371 font-weight: 600;
372 font-size: 0.95rem;
373 color: var(--ink);
374 overflow: hidden;
375 text-overflow: ellipsis;
376 white-space: nowrap;
377}
378.ax-track-sub {
379 font-size: 0.8rem;
380 color: var(--ink-muted, var(--ink-soft));
381 display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;
382}
383.ax-track-sep { opacity: 0.5; }
384.ax-embed {
385 display: inline-block;
386 background: var(--paper);
387 padding: 0.15rem 0.5rem;
388 border-radius: 4px;
389 font-family: var(--font-mono, ui-monospace, monospace);
390 font-size: 0.75rem;
391 color: var(--ink-soft);
392 cursor: pointer;
393 user-select: all;
394 margin-top: 0.15rem;
395 word-break: break-all;
396 align-self: flex-start;
397 transition: background 120ms;
398}
399.ax-embed:hover { background: var(--rule); }
400.ax-embed.is-copied { background: rgba(40,160,90,0.2); color: #2a9d5e; }
401
402.ax-track-actions {
403 display: flex; gap: 0.4rem;
404 flex-shrink: 0;
405}
406.ax-track-delete { display: inline; }
407
408/* ─── Mobile tweaks ───────────────────────────────────────────── */
409@media (max-width: 480px) {
410 .ax-track {
411 grid-template-columns: 48px 1fr;
412 grid-template-areas: "cover meta" "actions actions";
413 gap: 0.6rem;
414 }
415 .ax-track-cover { grid-area: cover; width: 48px; height: 48px; }
416 .ax-track-meta { grid-area: meta; }
417 .ax-track-actions {
418 grid-area: actions;
419 justify-content: flex-end;
420 border-top: 1px solid var(--rule);
421 padding-top: 0.5rem;
422 margin-top: 0.25rem;
423 }
424}
425
426/* ─── Bulk drop-zone ──────────────────────────────────────────── */
427.ax-dropzone {
428 display: flex; flex-direction: column; align-items: center; justify-content: center;
429 gap: 0.4rem;
430 padding: 2rem 1rem;
431 border: 2px dashed var(--rule);
432 border-radius: 12px;
433 background: var(--paper-2);
434 cursor: pointer;
435 text-align: center;
436 transition: border-color 150ms, background 150ms;
437 position: relative;
438 min-height: 160px;
439}
440.ax-dropzone:hover,
441.ax-dropzone:focus-within {
442 border-color: var(--accent);
443}
444.ax-dropzone.is-dragover {
445 border-color: var(--accent);
446 background: color-mix(in srgb, var(--accent) 8%, var(--paper-2));
447}
448.ax-dropzone input[type="file"] {
449 /* Cover the whole dropzone but invisible — clicks bubble through to it. */
450 position: absolute; inset: 0;
451 opacity: 0;
452 cursor: pointer;
453}
454.ax-dropzone-icon {
455 font-size: 2rem;
456 opacity: 0.5;
457 line-height: 1;
458}
459.ax-dropzone-title {
460 font-size: 1rem;
461 font-weight: 600;
462 color: var(--ink);
463}
464.ax-dropzone-sub {
465 font-size: 0.85rem;
466 color: var(--ink-muted, var(--ink-soft));
467 line-height: 1.4;
468}
469.ax-dropzone-sub small {
470 font-size: 0.75rem;
471 opacity: 0.8;
472}
473
474/* ─── Upload queue ────────────────────────────────────────────── */
475.ax-upload-queue {
476 list-style: none;
477 padding: 0; margin: 0.75rem 0 0;
478 display: flex; flex-direction: column;
479 gap: 0.4rem;
480}
481.ax-queue-item {
482 display: grid;
483 grid-template-columns: 1fr auto;
484 gap: 0.75rem;
485 align-items: center;
486 padding: 0.6rem 0.85rem;
487 background: var(--paper);
488 border: 1px solid var(--rule);
489 border-radius: 8px;
490 font-size: 0.875rem;
491 transition: border-color 150ms, opacity 150ms;
492}
493.ax-queue-item--active {
494 border-color: var(--accent);
495}
496.ax-queue-item--done {
497 border-color: rgba(40,160,90,0.4);
498 opacity: 0.85;
499}
500.ax-queue-item--error {
501 border-color: rgba(220,60,60,0.5);
502}
503.ax-queue-name {
504 font-weight: 500;
505 color: var(--ink);
506 overflow: hidden;
507 text-overflow: ellipsis;
508 white-space: nowrap;
509 min-width: 0;
510}
511.ax-queue-size {
512 font-size: 0.75rem;
513 color: var(--ink-muted, var(--ink-soft));
514 margin-left: 0.5rem;
515}
516.ax-queue-status {
517 font-size: 0.75rem;
518 font-weight: 500;
519 padding: 0.15em 0.6em;
520 border-radius: 999px;
521 background: var(--paper-2);
522 color: var(--ink-soft);
523 white-space: nowrap;
524 flex-shrink: 0;
525}
526.ax-queue-status--queued { color: var(--ink-muted, var(--ink-soft)); }
527.ax-queue-status--uploading,
528.ax-queue-status--transcoding {
529 color: var(--accent);
530 background: color-mix(in srgb, var(--accent) 10%, var(--paper-2));
531}
532.ax-queue-status--done {
533 color: #2a9d5e;
534 background: rgba(40,160,90,0.12);
535}
536.ax-queue-status--error {
537 color: #dc2626;
538 background: rgba(220,60,60,0.12);
539}
540</style>
541
542<%# Track editor modal (exposes window.openTrackEditor) %>
543<%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
544
545<script>
546(function() {
547
548 // ── Live filename display for custom file inputs ──────────────
549 document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
550 const nameEl = input.parentElement.querySelector('.ax-file-name');
551 if (!nameEl) return;
552 input.addEventListener('change', () => {
553 if (input.files && input.files[0]) {
554 nameEl.textContent = input.files[0].name;
555 nameEl.removeAttribute('data-empty');
556 } else {
557 nameEl.textContent = 'Geen bestand gekozen';
558 nameEl.setAttribute('data-empty', '');
559 }
560 });
561 });
562
563 // ── Inline track preview (routed through the global mini-player) ──
564 // Each .ax-track-play button is a thin wrapper around the global
565 // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
566 // .is-playing) is synced from the player's own audio element so it
567 // stays accurate even when the user uses the mini-player's controls.
568 (function setupTrackPreview() {
569 const buttons = document.querySelectorAll('.ax-track-play');
570 if (!buttons.length) return;
571
572 function setIcon(btn, playing) {
573 const icon = btn.querySelector('.ax-track-play-icon');
574 if (icon) icon.textContent = playing ? '⏸' : '▶';
575 btn.classList.toggle('is-playing', playing);
576 btn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
577 }
578
579 // Resync ALL preview buttons against the current audio element state.
580 // Called on every play/pause/ended event so admins always see the right
581 // icon — including the case where they hit pause on the mini-player
582 // bar instead of the row's button.
583 function resyncAll() {
584 const audio = document.getElementById('audio-element');
[21522ae]585 const player = window.pcmsAudioPlayer;
[7bc636b]586 const playing = audio && !audio.paused && !audio.ended;
[21522ae]587 // audio.src is now a blob: URL (Spotify-style playback), so compare
588 // against the player's logical track URL, not the element src.
589 const cur = player && player.currentTrack();
590 const curUrl = cur ? cur.url : '';
[7bc636b]591 buttons.forEach(b => {
[21522ae]592 const isThisOne = playing && curUrl === b.dataset.streamUrl;
[7bc636b]593 setIcon(b, isThisOne);
594 });
595 }
596
597 buttons.forEach(btn => {
598 btn.addEventListener('click', e => {
599 e.preventDefault();
600 const player = window.pcmsAudioPlayer;
601 if (!player) {
602 console.warn('[admin-audio] miniplayer not available');
603 return;
604 }
605 const url = btn.dataset.streamUrl;
606 if (!url) return;
607
608 // Build track metadata from the row's DOM so the mini-player shows
609 // useful info (title/artist/album/cover) without an extra API call.
610 const row = btn.closest('.ax-track');
611 const titleEl = row && row.querySelector('[data-cell="title"]');
612 const artistEl = row && row.querySelector('[data-cell="artist"]');
613 const albumEl = row && row.querySelector('[data-cell="album"]');
614 const coverImg = row && row.querySelector('img[data-cover-thumb]');
615 const track = {
616 url,
617 title: titleEl ? titleEl.textContent.trim() : 'Track',
618 artist: artistEl ? artistEl.textContent.trim() : '',
619 album: albumEl ? albumEl.textContent.trim() : '',
620 cover: coverImg ? coverImg.src : '',
621 };
622
[21522ae]623 // If this exact track is already current, toggle pause/play instead
624 // of restarting from zero. Compare logical URLs (audio.src is a blob:).
625 const cur = player.currentTrack();
626 if (cur && cur.url === url) {
627 if (player.isPlaying()) player.pause();
628 else player.play();
[7bc636b]629 return;
630 }
631
632 player.setQueue([track], 0);
633 });
634 });
635
636 // Hook the global audio element's events to keep the row buttons synced.
637 // We attach lazily after the player has built its DOM. The script in
638 // shell.ejs runs at page-load so #audio-element exists by the time
639 // this IIFE fires (script tag is below the body content).
640 const audio = document.getElementById('audio-element');
641 if (audio) {
642 ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
643 audio.addEventListener(ev, resyncAll);
644 });
645 // Initial state on page load (e.g. user navigated back while a track
646 // was already playing — buttons should reflect that).
647 resyncAll();
648 }
649 })();
650
651
652 // ── Bulk upload (drag-drop + sequential transcoding) ─────────
653 // Files dropped or picked are queued (not uploaded immediately) so the
654 // user can review the list, set shared metadata, then hit "Start upload".
655 // The loop POSTs one file at a time to /admin/audio/upload with
656 // Accept: application/json so the server returns structured per-file
657 // results instead of redirecting.
658 const dropzone = document.getElementById('audio-dropzone');
659 const fileInput = document.getElementById('audio-files');
660 const queueEl = document.getElementById('upload-queue');
661 const actionsEl = document.getElementById('upload-actions');
662 const startBtn = document.getElementById('start-upload-btn');
663 const clearBtn = document.getElementById('clear-queue-btn');
664 const artistInput= document.getElementById('batch-artist');
665 const albumInput = document.getElementById('batch-album');
666 const coverInput = document.getElementById('batch-cover');
667
668 /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
669 const queue = [];
670
671 function fmtBytes(n) {
672 if (n < 1024) return n + ' B';
673 if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
674 return (n / 1024 / 1024).toFixed(1) + ' MB';
675 }
676
677 function setItemStatus(item, status, label) {
678 const labels = {
679 queued: 'Wachten',
680 uploading: 'Uploaden…',
681 transcoding: 'Converteren…',
682 done: '✓ Klaar',
683 error: '✗ Fout',
684 };
685 item.status = status;
686 const badge = item.el.querySelector('.ax-queue-status');
687 badge.className = 'ax-queue-status ax-queue-status--' + status;
688 badge.textContent = label || labels[status] || status;
689 // Restyle the row
690 item.el.classList.remove(
691 'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
692 );
693 if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
694 else if (status === 'done') item.el.classList.add('ax-queue-item--done');
695 else if (status === 'error') item.el.classList.add('ax-queue-item--error');
696 }
697
698 function addFiles(files) {
699 let added = 0;
700 for (const file of files) {
701 if (!file.type.startsWith('audio/') &&
702 !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
703 // Silently skip non-audio drops; keeps the UX uncluttered.
704 continue;
705 }
706 const li = document.createElement('li');
707 li.className = 'ax-queue-item';
708 li.innerHTML =
709 '<div class="ax-queue-name"></div>' +
710 '<span class="ax-queue-status ax-queue-status--queued">Wachten</span>';
711 // Use textContent to avoid HTML-injection if a filename contains markup.
712 li.querySelector('.ax-queue-name').textContent = file.name;
713 // Append size hint inline
714 const size = document.createElement('span');
715 size.className = 'ax-queue-size';
716 size.textContent = fmtBytes(file.size);
717 li.querySelector('.ax-queue-name').appendChild(size);
718 queueEl.appendChild(li);
719 queue.push({ file, el: li, status: 'queued' });
720 added++;
721 }
722 if (added) {
723 queueEl.hidden = false;
724 actionsEl.hidden = false;
725 }
726 }
727
728 // ── Drop-zone events ─────────────────────────────────────────
729 // Page-level guard: a dropped file outside the zone would otherwise
730 // make the browser navigate to it (e.g. opening the audio inline), which
731 // discards typed metadata. We swallow drops anywhere unless the dropzone
732 // explicitly handles them.
733 ['dragover', 'drop'].forEach(ev => {
734 window.addEventListener(ev, e => {
735 // Allow drops INSIDE the dropzone — its own listener handles those.
736 if (dropzone.contains(e.target)) return;
737 e.preventDefault();
738 });
739 });
740
741 ['dragenter', 'dragover'].forEach(ev => {
742 dropzone.addEventListener(ev, e => {
743 e.preventDefault();
744 dropzone.classList.add('is-dragover');
745 });
746 });
747 ['dragleave', 'drop'].forEach(ev => {
748 dropzone.addEventListener(ev, e => {
749 e.preventDefault();
750 dropzone.classList.remove('is-dragover');
751 });
752 });
753 dropzone.addEventListener('drop', e => {
754 if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
755 });
756 fileInput.addEventListener('change', () => {
757 addFiles(fileInput.files);
758 // Reset so the same file can be picked again later if user wants
759 fileInput.value = '';
760 });
761
762 // ── Clear queue button ───────────────────────────────────────
763 clearBtn.addEventListener('click', () => {
764 // Only remove items that aren't currently uploading (anything queued
765 // or already finished). An in-progress upload finishes, then its row
766 // would also disappear once we re-render — but we keep it simple and
767 // just refuse to clear during an active run.
768 if (startBtn.disabled) return;
769 queue.length = 0;
770 queueEl.innerHTML = '';
771 queueEl.hidden = true;
772 actionsEl.hidden = true;
773 });
774
775 // ── Sequential upload loop ───────────────────────────────────
776 startBtn.addEventListener('click', async () => {
777 if (startBtn.disabled) return;
778 startBtn.disabled = true;
779 clearBtn.disabled = true;
780 dropzone.style.pointerEvents = 'none';
781 dropzone.style.opacity = '0.5';
782
783 const sharedArtist = artistInput.value.trim();
784 const sharedAlbum = albumInput.value.trim();
785 const sharedCover = coverInput.files && coverInput.files[0];
786
787 // Process queued items one at a time. We iterate via index so that
788 // if more files get dropped during the run they ALSO get processed
789 // (queue.push above mutates the same array we're iterating).
790 for (let i = 0; i < queue.length; i++) {
791 const item = queue[i];
792 if (item.status !== 'queued') continue;
793 try {
794 await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
795 } catch (err) {
796 console.error('upload failed for', item.file.name, err);
797 setItemStatus(item, 'error', '✗ ' + (err.message || 'Mislukt'));
798 }
799 }
800
801 startBtn.disabled = false;
802 clearBtn.disabled = false;
803 dropzone.style.pointerEvents = '';
804 dropzone.style.opacity = '';
805
806 // Reload the page so the new tracks appear in the list below.
807 // Could also fetch them and inject, but a full reload is simpler and
808 // ensures position indexes / album-grouping are correct.
809 const anyDone = queue.some(q => q.status === 'done');
810 if (anyDone) {
811 setTimeout(() => location.reload(), 700);
812 }
813 });
814
815 async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
816 setItemStatus(item, 'uploading');
817
818 const fd = new FormData();
819 fd.append('audio', item.file);
820 if (sharedArtist) fd.append('artist', sharedArtist);
821 if (sharedAlbum) fd.append('album', sharedAlbum);
822 if (sharedCover) fd.append('cover', sharedCover);
823 // Title is intentionally omitted — server uses filename fallback.
824
825 // We can't reliably distinguish "still uploading bytes" from
826 // "uploading done, ffmpeg running" without progress events, but the
827 // status flips to "Converteren…" once the request is past upload phase.
828 // We approximate this by waiting until the response arrives — by then
829 // both phases are complete on the server side. For a smoother feel we
830 // briefly show "transcoding" near the end of the request lifecycle.
831 const transcodeHint = setTimeout(() => {
832 if (item.status === 'uploading') setItemStatus(item, 'transcoding');
833 }, 1500);
834
835 try {
836 const res = await fetch('/admin/audio/upload', {
837 method: 'POST',
838 headers: { 'Accept': 'application/json' },
839 body: fd,
840 credentials: 'same-origin',
841 });
842 clearTimeout(transcodeHint);
843
844 // Server responds with JSON for our Accept header. If it didn't
845 // (e.g. session expired and got an HTML login page), surface that.
846 let data;
847 try { data = await res.json(); }
848 catch (_) { throw new Error('Onverwacht serverantwoord (' + res.status + ')'); }
849
850 if (!res.ok || !data.ok) {
851 throw new Error(data.error || ('HTTP ' + res.status));
852 }
853
854 setItemStatus(item, 'done', '✓ ' + (data.title || 'Klaar'));
855 } catch (err) {
856 clearTimeout(transcodeHint);
857 throw err;
858 }
859 }
860
861 // ── Click-to-copy embed codes ─────────────────────────────────
862 document.querySelectorAll('[data-copy]').forEach(el => {
863 el.addEventListener('click', async () => {
864 const text = el.dataset.copy;
865 try {
866 await navigator.clipboard.writeText(text);
867 el.classList.add('is-copied');
868 const original = el.textContent;
869 el.textContent = '✓ gekopieerd';
870 setTimeout(() => {
871 el.classList.remove('is-copied');
872 el.textContent = original;
873 }, 1200);
874 } catch (_) { /* fall through — selection still works */ }
875 });
876 });
877
878 // ── Wire all "Edit" buttons to the track-editor modal ─────────
879 // After save we patch the row in-place rather than reloading,
880 // so the user keeps their scroll position on long lists.
881 document.querySelectorAll('[data-track-edit]').forEach(btn => {
882 btn.addEventListener('click', () => {
883 if (typeof window.openTrackEditor !== 'function') {
884 alert('Track editor niet geladen');
885 return;
886 }
887 const id = btn.dataset.id;
888 window.openTrackEditor({
889 id,
890 onSaved: (track) => {
891 const row = document.querySelector('li[data-track-id="' + id + '"]');
892 if (!row) return;
893 // Update visible cells
894 const titleEl = row.querySelector('[data-cell="title"]');
895 const artistEl = row.querySelector('[data-cell="artist"]');
896 const albumEl = row.querySelector('[data-cell="album"]');
897 if (titleEl) titleEl.textContent = track.title || '(zonder titel)';
898 if (artistEl) artistEl.textContent = track.artist || '—';
899 if (albumEl) {
900 albumEl.textContent = track.album || '';
901 if (track.album) albumEl.removeAttribute('hidden');
902 else albumEl.setAttribute('hidden', '');
903 }
904 // Update cover thumb (replace element if type changed)
905 const oldThumb = row.querySelector('[data-cover-thumb]');
906 if (oldThumb) {
907 const parent = oldThumb.parentElement;
908 if (track.cover_url) {
909 const img = document.createElement('img');
910 img.className = 'ax-track-cover';
911 img.src = track.cover_url;
912 img.alt = '';
913 img.dataset.coverThumb = '';
914 parent.replaceChild(img, oldThumb);
915 } else {
916 const sp = document.createElement('span');
917 sp.className = 'ax-track-cover ax-track-cover-empty';
918 sp.textContent = '♫';
919 sp.dataset.coverThumb = '';
920 parent.replaceChild(sp, oldThumb);
921 }
922 }
923 },
924 });
925 });
926 });
927})();
928</script>
Note: See TracBrowser for help on using the repository browser.