source: Klonkt/src/views/pages/admin-audio.ejs@ 11ba289

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

fix: download toggle without page reload + clear explanation page on Google admin login

1) Admin→Audio: the "download-for-email" toggle (⬇) was a form POST with redirect →

full page reload on every click (player + scroll reset). Now AJAX via the existing
/admin/audio/api/:id (accepts downloadable), button state updated in-place, no
reload. (Old POST route stays as an unused fallback.)

2) /auth/login did not show the query error (error:null) → message only appeared in

the URL. Now ?error= is rendered, and the "Google address belongs to an admin"
case gets its own clear explanation block (gerr=admin): why it is rejected + the
two paths (admin = password, fan = different Google account).

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

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