source: Klonkt/src/views/pages/admin-audio.ejs@ 084d1c0

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

fix(admin-audio): fediverse-share toggle is now AJAX (no page jump)

The new per-track fedi-share toggle used a form POST + redirect, so clicking it reloaded the
page and jumped to the top. It now toggles via AJAX in place, mirroring the download toggle.

  • src/views/pages/admin-audio.ejs — fedi button -> data-track-fedi + an AJAX click handler
  • src/routes/admin-audio.js — the /api/:id field-update route now accepts fedi_open

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

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