source: Klonkt/src/assets/js/mod/admin-audio.js@ 6ee289a

main
Last change on this file since 6ee289a was 6ee289a, checked in by roboburr <roboburr@…>, 5 weeks ago

De audio- en afspeellijst-editors (shaer-bqr, stap 3f)

admin-audio (435 regels, 18 vertaalsleutels) en playlist-editor (406 regels, het
csrf-token). Daarmee zijn 23 van de 24 bestanden om; alleen post-edit rest.

De achttien vertalingen van admin-audio stonden verspreid door de code, midden in
stringconcatenaties. Ze gaan nu als een tabel via pageData en worden opgezocht in
plaats van geinterpoleerd. Steekproefsgewijs nagelopen op de plekken waar de tekst
de HELE string was: daar bleef anders een leeg begin of eind over.

admin-playlists neemt de playlist-editor op, dus die route vraagt nu om
'admin-playlists playlist-editor' -- een module hoort bij het onderdeel, niet bij
de pagina die het toevallig opneemt.

Templates compileren, 22 modules schoon, suite 565/565.

  • Property mode set to 100644
File size: 17.8 KB
Line 
1// Audio in beheer -- verplaatst uit inline script, shaer-bqr.
2//
3// Servergegevens komen uit pageData(); interpolatie kan niet in een statisch
4// bestand. Inline script wordt bovendien geweigerd zodra deze pagina via een
5// link BINNEN de site binnenkomt (shaer-0i6).
6
7import { pageData } from './lib.js';
8
9 const T = pageData();
10
11(function() {
12
13 // ── Live filename display for custom file inputs ──────────────
14 document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
15 const nameEl = input.parentElement.querySelector('.ax-file-name');
16 if (!nameEl) return;
17 input.addEventListener('change', () => {
18 if (input.files && input.files[0]) {
19 nameEl.textContent = input.files[0].name;
20 nameEl.removeAttribute('data-empty');
21 } else {
22 nameEl.textContent = (T.no_file || '');
23 nameEl.setAttribute('data-empty', '');
24 }
25 });
26 });
27
28 // ── Inline track preview (routed through the global mini-player) ──
29 // Each .ax-track-play button is a thin wrapper around the global
30 // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
31 // .is-playing) is synced from the player's own audio element so it
32 // stays accurate even when the user uses the mini-player's controls.
33 (function setupTrackPreview() {
34 const buttons = document.querySelectorAll('.ax-track-play');
35 if (!buttons.length) return;
36
37 function setIcon(btn, playing) {
38 const icon = btn.querySelector('.ax-track-play-icon');
39 if (icon) icon.textContent = playing ? '⏸' : '▶';
40 btn.classList.toggle('is-playing', playing);
41 btn.setAttribute('aria-label', playing ? (T.pause || '') : (T.play || ''));
42 }
43
44 // Resync ALL preview buttons against the current audio element state.
45 // Called on every play/pause/ended event so admins always see the right
46 // icon — including the case where they hit pause on the mini-player
47 // bar instead of the row's button.
48 function resyncAll() {
49 const audio = document.getElementById('audio-element');
50 const player = window.pcmsAudioPlayer;
51 const playing = audio && !audio.paused && !audio.ended;
52 // audio.src is now a blob: URL (Spotify-style playback), so compare
53 // against the player's logical track URL, not the element src.
54 const cur = player && player.currentTrack();
55 const curUrl = cur ? cur.url : '';
56 buttons.forEach(b => {
57 const isThisOne = playing && curUrl === b.dataset.streamUrl;
58 setIcon(b, isThisOne);
59 });
60 }
61
62 buttons.forEach(btn => {
63 btn.addEventListener('click', e => {
64 e.preventDefault();
65 const player = window.pcmsAudioPlayer;
66 if (!player) {
67 console.warn('[admin-audio] miniplayer not available');
68 return;
69 }
70 const url = btn.dataset.streamUrl;
71 if (!url) return;
72
73 // Build track metadata from the row's DOM so the mini-player shows
74 // useful info (title/artist/album/cover) without an extra API call.
75 const row = btn.closest('.ax-track');
76 const titleEl = row && row.querySelector('[data-cell="title"]');
77 const artistEl = row && row.querySelector('[data-cell="artist"]');
78 const albumEl = row && row.querySelector('[data-cell="album"]');
79 const coverImg = row && row.querySelector('img[data-cover-thumb]');
80 const track = {
81 url,
82 title: titleEl ? titleEl.textContent.trim() : 'Track',
83 artist: artistEl ? artistEl.textContent.trim() : '',
84 album: albumEl ? albumEl.textContent.trim() : '',
85 cover: coverImg ? coverImg.src : '',
86 };
87
88 // If this exact track is already current, toggle pause/play instead
89 // of restarting from zero. Compare logical URLs (audio.src is a blob:).
90 const cur = player.currentTrack();
91 if (cur && cur.url === url) {
92 if (player.isPlaying()) player.pause();
93 else player.play();
94 return;
95 }
96
97 player.setQueue([track], 0);
98 });
99 });
100
101 // Hook the global audio element's events to keep the row buttons synced.
102 // We attach lazily after the player has built its DOM. The script in
103 // shell.ejs runs at page-load so #audio-element exists by the time
104 // this IIFE fires (script tag is below the body content).
105 const audio = document.getElementById('audio-element');
106 if (audio) {
107 ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
108 audio.addEventListener(ev, resyncAll);
109 });
110 // Initial state on page load (e.g. user navigated back while a track
111 // was already playing — buttons should reflect that).
112 resyncAll();
113 }
114 })();
115
116
117 // ── Bulk upload (drag-drop + sequential transcoding) ─────────
118 // Files dropped or picked are queued (not uploaded immediately) so the
119 // user can review the list, set shared metadata, then hit "Start upload".
120 // The loop POSTs one file at a time to /admin/audio/upload with
121 // Accept: application/json so the server returns structured per-file
122 // results instead of redirecting.
123 const dropzone = document.getElementById('audio-dropzone');
124 const fileInput = document.getElementById('audio-files');
125 const queueEl = document.getElementById('upload-queue');
126 const actionsEl = document.getElementById('upload-actions');
127 const startBtn = document.getElementById('start-upload-btn');
128 const clearBtn = document.getElementById('clear-queue-btn');
129 const artistInput= document.getElementById('batch-artist');
130 const albumInput = document.getElementById('batch-album');
131 const coverInput = document.getElementById('batch-cover');
132
133 /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
134 const queue = [];
135
136 function fmtBytes(n) {
137 if (n < 1024) return n + ' B';
138 if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
139 return (n / 1024 / 1024).toFixed(1) + ' MB';
140 }
141
142 function setItemStatus(item, status, label) {
143 const labels = {
144 queued: (T.st_queued || ''),
145 uploading: (T.st_uploading || ''),
146 transcoding: (T.st_transcoding || ''),
147 done: '✓ ' + (T.st_done || ''),
148 error: '✗ ' + (T.st_error || ''),
149 };
150 item.status = status;
151 const badge = item.el.querySelector('.ax-queue-status');
152 badge.className = 'ax-queue-status ax-queue-status--' + status;
153 badge.textContent = label || labels[status] || status;
154 // Restyle the row
155 item.el.classList.remove(
156 'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
157 );
158 if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
159 else if (status === 'done') item.el.classList.add('ax-queue-item--done');
160 else if (status === 'error') item.el.classList.add('ax-queue-item--error');
161 }
162
163 function addFiles(files) {
164 let added = 0;
165 for (const file of files) {
166 if (!file.type.startsWith('audio/') &&
167 !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
168 // Silently skip non-audio drops; keeps the UX uncluttered.
169 continue;
170 }
171 const li = document.createElement('li');
172 li.className = 'ax-queue-item';
173 li.innerHTML =
174 '<div class="ax-queue-name"></div>' +
175 '<span class="ax-queue-status ax-queue-status--queued">' + (T.st_queued || '') + '</span>';
176 // Use textContent to avoid HTML-injection if a filename contains markup.
177 li.querySelector('.ax-queue-name').textContent = file.name;
178 // Append size hint inline
179 const size = document.createElement('span');
180 size.className = 'ax-queue-size';
181 size.textContent = fmtBytes(file.size);
182 li.querySelector('.ax-queue-name').appendChild(size);
183 queueEl.appendChild(li);
184 queue.push({ file, el: li, status: 'queued' });
185 added++;
186 }
187 if (added) {
188 queueEl.hidden = false;
189 actionsEl.hidden = false;
190 }
191 }
192
193 // ── Drop-zone events ─────────────────────────────────────────
194 // Page-level guard: a dropped file outside the zone would otherwise
195 // make the browser navigate to it (e.g. opening the audio inline), which
196 // discards typed metadata. We swallow drops anywhere unless the dropzone
197 // explicitly handles them.
198 ['dragover', 'drop'].forEach(ev => {
199 window.addEventListener(ev, e => {
200 // Allow drops INSIDE the dropzone — its own listener handles those.
201 if (dropzone.contains(e.target)) return;
202 e.preventDefault();
203 });
204 });
205
206 ['dragenter', 'dragover'].forEach(ev => {
207 dropzone.addEventListener(ev, e => {
208 e.preventDefault();
209 dropzone.classList.add('is-dragover');
210 });
211 });
212 ['dragleave', 'drop'].forEach(ev => {
213 dropzone.addEventListener(ev, e => {
214 e.preventDefault();
215 dropzone.classList.remove('is-dragover');
216 });
217 });
218 dropzone.addEventListener('drop', e => {
219 if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
220 });
221 fileInput.addEventListener('change', () => {
222 addFiles(fileInput.files);
223 // Reset so the same file can be picked again later if user wants
224 fileInput.value = '';
225 });
226
227 // ── Clear queue button ───────────────────────────────────────
228 clearBtn.addEventListener('click', () => {
229 // Only remove items that aren't currently uploading (anything queued
230 // or already finished). An in-progress upload finishes, then its row
231 // would also disappear once we re-render — but we keep it simple and
232 // just refuse to clear during an active run.
233 if (startBtn.disabled) return;
234 queue.length = 0;
235 queueEl.innerHTML = '';
236 queueEl.hidden = true;
237 actionsEl.hidden = true;
238 });
239
240 // ── Sequential upload loop ───────────────────────────────────
241 startBtn.addEventListener('click', async () => {
242 if (startBtn.disabled) return;
243 startBtn.disabled = true;
244 clearBtn.disabled = true;
245 dropzone.style.pointerEvents = 'none';
246 dropzone.style.opacity = '0.5';
247
248 const sharedArtist = artistInput.value.trim();
249 const sharedAlbum = albumInput.value.trim();
250 const sharedCover = coverInput.files && coverInput.files[0];
251
252 // Process queued items one at a time. We iterate via index so that
253 // if more files get dropped during the run they ALSO get processed
254 // (queue.push above mutates the same array we're iterating).
255 for (let i = 0; i < queue.length; i++) {
256 const item = queue[i];
257 if (item.status !== 'queued') continue;
258 try {
259 await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
260 } catch (err) {
261 console.error('upload failed for', item.file.name, err);
262 setItemStatus(item, 'error', '✗ ' + (err.message || (T.failed || '')));
263 }
264 }
265
266 startBtn.disabled = false;
267 clearBtn.disabled = false;
268 dropzone.style.pointerEvents = '';
269 dropzone.style.opacity = '';
270
271 // Reload the page so the new tracks appear in the list below.
272 // Could also fetch them and inject, but a full reload is simpler and
273 // ensures position indexes / album-grouping are correct.
274 const anyDone = queue.some(q => q.status === 'done');
275 if (anyDone) {
276 setTimeout(() => location.reload(), 700);
277 }
278 });
279
280 async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
281 setItemStatus(item, 'uploading');
282
283 const fd = new FormData();
284 fd.append('audio', item.file);
285 if (sharedArtist) fd.append('artist', sharedArtist);
286 if (sharedAlbum) fd.append('album', sharedAlbum);
287 if (sharedCover) fd.append('cover', sharedCover);
288 // Title is intentionally omitted — server uses filename fallback.
289
290 // We can't reliably distinguish "still uploading bytes" from
291 // "uploading done, ffmpeg running" without progress events, but the
292 // status flips to "Converteren…" once the request is past upload phase.
293 // We approximate this by waiting until the response arrives — by then
294 // both phases are complete on the server side. For a smoother feel we
295 // briefly show "transcoding" near the end of the request lifecycle.
296 const transcodeHint = setTimeout(() => {
297 if (item.status === 'uploading') setItemStatus(item, 'transcoding');
298 }, 1500);
299
300 try {
301 const res = await fetch('/admin/audio/upload', {
302 method: 'POST',
303 headers: { 'Accept': 'application/json' },
304 body: fd,
305 credentials: 'same-origin',
306 });
307 clearTimeout(transcodeHint);
308
309 // Server responds with JSON for our Accept header. If it didn't
310 // (e.g. session expired and got an HTML login page), surface that.
311 let data;
312 try { data = await res.json(); }
313 catch (_) { throw new Error((T.err_unexpected || '') + ' (' + res.status + ')'); }
314
315 if (!res.ok || !data.ok) {
316 throw new Error(data.error || ('HTTP ' + res.status));
317 }
318
319 setItemStatus(item, 'done', '✓ ' + (data.title || (T.st_done || '')));
320 } catch (err) {
321 clearTimeout(transcodeHint);
322 throw err;
323 }
324 }
325
326 // ── Click-to-copy embed codes ─────────────────────────────────
327 document.querySelectorAll('[data-copy]').forEach(el => {
328 el.addEventListener('click', async () => {
329 const text = el.dataset.copy;
330 try {
331 await navigator.clipboard.writeText(text);
332 el.classList.add('is-copied');
333 const original = el.textContent;
334 el.textContent = '✓ ' + (T.copied || '');
335 setTimeout(() => {
336 el.classList.remove('is-copied');
337 el.textContent = original;
338 }, 1200);
339 } catch (_) { /* fall through — selection still works */ }
340 });
341 });
342
343 // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
344 const addLinkBtn = document.getElementById('add-link-track-btn');
345 if (addLinkBtn) {
346 addLinkBtn.addEventListener('click', async () => {
347 addLinkBtn.disabled = true;
348 try {
349 const r = await fetch('/admin/audio/create-link', {
350 method: 'POST', credentials: 'same-origin',
351 headers: { 'Content-Type': 'application/json' },
352 body: JSON.stringify({ title: (T.new_track || '') }),
353 });
354 const j = await r.json();
355 if (!r.ok || !j.ok) throw new Error(j.error || (T.create_failed || ''));
356 if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
357 window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
358 } catch (err) {
359 alert((T.create_failed || '') + ': ' + err.message);
360 } finally {
361 addLinkBtn.disabled = false;
362 }
363 });
364 }
365
366 // ── Wire all "Edit" buttons to the track-editor modal ─────────
367 // After save we patch the row in-place rather than reloading,
368 // so the user keeps their scroll position on long lists.
369 document.querySelectorAll('[data-track-edit]').forEach(btn => {
370 btn.addEventListener('click', () => {
371 if (typeof window.openTrackEditor !== 'function') {
372 alert((T.editor_not_loaded || ''));
373 return;
374 }
375 const id = btn.dataset.id;
376 window.openTrackEditor({
377 id,
378 onSaved: (track) => {
379 const row = document.querySelector('li[data-track-id="' + id + '"]');
380 if (!row) return;
381 // Update visible cells
382 const titleEl = row.querySelector('[data-cell="title"]');
383 const artistEl = row.querySelector('[data-cell="artist"]');
384 const albumEl = row.querySelector('[data-cell="album"]');
385 if (titleEl) titleEl.textContent = track.title || (T.untitled || '');
386 if (artistEl) artistEl.textContent = track.artist || '—';
387 if (albumEl) {
388 albumEl.textContent = track.album || '';
389 if (track.album) albumEl.removeAttribute('hidden');
390 else albumEl.setAttribute('hidden', '');
391 }
392 // Update cover thumb (replace element if type changed)
393 const oldThumb = row.querySelector('[data-cover-thumb]');
394 if (oldThumb) {
395 const parent = oldThumb.parentElement;
396 if (track.cover_url) {
397 const img = document.createElement('img');
398 img.className = 'ax-track-cover';
399 img.src = track.cover_url;
400 img.alt = '';
401 img.dataset.coverThumb = '';
402 parent.replaceChild(img, oldThumb);
403 } else {
404 const sp = document.createElement('span');
405 sp.className = 'ax-track-cover ax-track-cover-empty';
406 sp.textContent = '♫';
407 sp.dataset.coverThumb = '';
408 parent.replaceChild(sp, oldThumb);
409 }
410 }
411 },
412 });
413 });
414 });
415
416 // Download-voor-email toggle — AJAX (geen pagina-reload meer).
417 document.querySelectorAll('[data-track-dl]').forEach(btn => {
418 btn.addEventListener('click', async () => {
419 if (btn.disabled) return;
420 const want = btn.dataset.on === '1' ? 0 : 1;
421 btn.disabled = true;
422 try {
423 const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
424 method: 'POST',
425 headers: { 'Content-Type': 'application/json' },
426 body: JSON.stringify({ downloadable: !!want }),
427 });
428 if (!res.ok) throw new Error('HTTP ' + res.status);
429 btn.dataset.on = String(want);
430 btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
431 btn.style.opacity = want ? '1' : '.5';
432 btn.title = want
433 ? (T.dl_on || '')
434 : (T.dl_off || '');
435 } catch (e) {
436 alert((T.change_failed || '') + ': ' + (e.message || e));
437 } finally {
438 btn.disabled = false;
439 }
440 });
441 });
442})();
Note: See TracBrowser for help on using the repository browser.