source: Klonkt/src/views/pages/admin-audio.ejs@ 91094a4

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

Download-for-email (premium feature #2)

Fan leaves their email -> receives the file; address is added to the mailing list
(source 'download', single opt-in so the download is not behind a confirm barrier).
Reuses SubscriberService (#1).

  • DB: audio_tracks.downloadable (default 0).
  • admin-audio: per-track no-JS toggle (POST /admin/audio/:id/downloadable) + ⬇ button in the admin list (premium-gated); downloadable also in the /api/:id whitelist.
  • routes/download.js (premium-gated): GET /downloads (list), GET /download/:id (capture page), POST /download/:id (save email + session grant), GET /download/:id/bestand (serves attachment from storage/audio, 15-min session window, path-traversal guards).
  • views: pages/downloads.ejs + pages/download.ejs (form/ready, auto-start download).
  • admin dashboard: ⬇ Downloads link (premium, non-hub).

node --check passed. Nothing else reuses this; #8 notify reuses subscribers.

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

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