source: Klonkt/src/views/pages/admin-audio.ejs@ 742ba7e

main
Last change on this file since 742ba7e was 2ab99c0, checked in by roboburr <roboburr@…>, 2 months ago

feat(media): shared Media tab bar across Images / Audio / Playlists

A new partials/media-tabs.ejs renders the Afbeeldingen | Audio | Playlists tabs (active one
highlighted) and is included on /admin/media, /admin/audio and /admin/playlists — so you can move
between all three from any of them. Playlists is now a tab too.

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

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