source: Klonkt/src/views/pages/admin-audio.ejs@ 3f4049e

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

feat(i18n phase 5): all admin sub-pages translated (NL/EN/DE)

settings, site-edit, seo, google, audio, stats, playlists, users, sites,
comments, circle, shows, newsletter, updates, epk, help. ~520 new keys
per language. Loop-shadowing of t() resolved by fetching before the loop. 709
view-keys cross-checked against the dictionary (0 missing).

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

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