Changeset 6ee289a in Klonkt
- Timestamp:
- 08/07/2026 01:38:51 PM (5 weeks ago)
- Branches:
- main
- Children:
- fb9a8ad
- Parents:
- 156baa3
- git-author:
- Robin <roboburr@…> (08/07/2026 01:33:49 PM)
- git-committer:
- roboburr <roboburr@…> (08/07/2026 01:38:51 PM)
- Location:
- src
- Files:
-
- 2 added
- 3 edited
-
assets/js/mod/admin-audio.js (added)
-
assets/js/mod/playlist-editor.js (added)
-
routes/admin-playlists.js (modified) (1 diff)
-
views/pages/admin-audio.ejs (modified) (1 diff)
-
views/partials/playlist-editor.ejs (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/admin-playlists.js
r156baa3 r6ee289a 62 62 const playlists = PlaylistService.list(site.id); 63 63 renderPage(req, res, 'pages/admin-playlists', { 64 pageJs: 'admin-playlists', 64 // admin-playlists neemt de playlist-editor op, dus die module hoort erbij. 65 pageJs: 'admin-playlists playlist-editor', 65 66 pageTitleKey: 'admin.t_playlists', 66 67 playlists, -
src/views/pages/admin-audio.ejs
r156baa3 r6ee289a 573 573 <%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %> 574 574 575 <script> 576 (function() { 577 578 // ── Live filename display for custom file inputs ────────────── 579 document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => { 580 const nameEl = input.parentElement.querySelector('.ax-file-name'); 581 if (!nameEl) return; 582 input.addEventListener('change', () => { 583 if (input.files && input.files[0]) { 584 nameEl.textContent = input.files[0].name; 585 nameEl.removeAttribute('data-empty'); 586 } else { 587 nameEl.textContent = '<%= t('aaud.no_file') %>'; 588 nameEl.setAttribute('data-empty', ''); 589 } 590 }); 591 }); 592 593 // ── Inline track preview (routed through the global mini-player) ── 594 // Each .ax-track-play button is a thin wrapper around the global 595 // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ / 596 // .is-playing) is synced from the player's own audio element so it 597 // stays accurate even when the user uses the mini-player's controls. 598 (function setupTrackPreview() { 599 const buttons = document.querySelectorAll('.ax-track-play'); 600 if (!buttons.length) return; 601 602 function setIcon(btn, playing) { 603 const icon = btn.querySelector('.ax-track-play-icon'); 604 if (icon) icon.textContent = playing ? '⏸' : '▶'; 605 btn.classList.toggle('is-playing', playing); 606 btn.setAttribute('aria-label', playing ? '<%= t('aaud.pause') %>' : '<%= t('aaud.play') %>'); 607 } 608 609 // Resync ALL preview buttons against the current audio element state. 610 // Called on every play/pause/ended event so admins always see the right 611 // icon — including the case where they hit pause on the mini-player 612 // bar instead of the row's button. 613 function resyncAll() { 614 const audio = document.getElementById('audio-element'); 615 const player = window.pcmsAudioPlayer; 616 const playing = audio && !audio.paused && !audio.ended; 617 // audio.src is now a blob: URL (Spotify-style playback), so compare 618 // against the player's logical track URL, not the element src. 619 const cur = player && player.currentTrack(); 620 const curUrl = cur ? cur.url : ''; 621 buttons.forEach(b => { 622 const isThisOne = playing && curUrl === b.dataset.streamUrl; 623 setIcon(b, isThisOne); 624 }); 625 } 626 627 buttons.forEach(btn => { 628 btn.addEventListener('click', e => { 629 e.preventDefault(); 630 const player = window.pcmsAudioPlayer; 631 if (!player) { 632 console.warn('[admin-audio] miniplayer not available'); 633 return; 634 } 635 const url = btn.dataset.streamUrl; 636 if (!url) return; 637 638 // Build track metadata from the row's DOM so the mini-player shows 639 // useful info (title/artist/album/cover) without an extra API call. 640 const row = btn.closest('.ax-track'); 641 const titleEl = row && row.querySelector('[data-cell="title"]'); 642 const artistEl = row && row.querySelector('[data-cell="artist"]'); 643 const albumEl = row && row.querySelector('[data-cell="album"]'); 644 const coverImg = row && row.querySelector('img[data-cover-thumb]'); 645 const track = { 646 url, 647 title: titleEl ? titleEl.textContent.trim() : 'Track', 648 artist: artistEl ? artistEl.textContent.trim() : '', 649 album: albumEl ? albumEl.textContent.trim() : '', 650 cover: coverImg ? coverImg.src : '', 651 }; 652 653 // If this exact track is already current, toggle pause/play instead 654 // of restarting from zero. Compare logical URLs (audio.src is a blob:). 655 const cur = player.currentTrack(); 656 if (cur && cur.url === url) { 657 if (player.isPlaying()) player.pause(); 658 else player.play(); 659 return; 660 } 661 662 player.setQueue([track], 0); 663 }); 664 }); 665 666 // Hook the global audio element's events to keep the row buttons synced. 667 // We attach lazily after the player has built its DOM. The script in 668 // shell.ejs runs at page-load so #audio-element exists by the time 669 // this IIFE fires (script tag is below the body content). 670 const audio = document.getElementById('audio-element'); 671 if (audio) { 672 ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => { 673 audio.addEventListener(ev, resyncAll); 674 }); 675 // Initial state on page load (e.g. user navigated back while a track 676 // was already playing — buttons should reflect that). 677 resyncAll(); 678 } 679 })(); 680 681 682 // ── Bulk upload (drag-drop + sequential transcoding) ───────── 683 // Files dropped or picked are queued (not uploaded immediately) so the 684 // user can review the list, set shared metadata, then hit "Start upload". 685 // The loop POSTs one file at a time to /admin/audio/upload with 686 // Accept: application/json so the server returns structured per-file 687 // results instead of redirecting. 688 const dropzone = document.getElementById('audio-dropzone'); 689 const fileInput = document.getElementById('audio-files'); 690 const queueEl = document.getElementById('upload-queue'); 691 const actionsEl = document.getElementById('upload-actions'); 692 const startBtn = document.getElementById('start-upload-btn'); 693 const clearBtn = document.getElementById('clear-queue-btn'); 694 const artistInput= document.getElementById('batch-artist'); 695 const albumInput = document.getElementById('batch-album'); 696 const coverInput = document.getElementById('batch-cover'); 697 698 /** @type {Array<{file: File, el: HTMLElement, status: string}>} */ 699 const queue = []; 700 701 function fmtBytes(n) { 702 if (n < 1024) return n + ' B'; 703 if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB'; 704 return (n / 1024 / 1024).toFixed(1) + ' MB'; 705 } 706 707 function setItemStatus(item, status, label) { 708 const labels = { 709 queued: '<%= t('aaud.st_queued') %>', 710 uploading: '<%= t('aaud.st_uploading') %>', 711 transcoding: '<%= t('aaud.st_transcoding') %>', 712 done: '✓ <%= t('aaud.st_done') %>', 713 error: '✗ <%= t('aaud.st_error') %>', 714 }; 715 item.status = status; 716 const badge = item.el.querySelector('.ax-queue-status'); 717 badge.className = 'ax-queue-status ax-queue-status--' + status; 718 badge.textContent = label || labels[status] || status; 719 // Restyle the row 720 item.el.classList.remove( 721 'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error' 722 ); 723 if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active'); 724 else if (status === 'done') item.el.classList.add('ax-queue-item--done'); 725 else if (status === 'error') item.el.classList.add('ax-queue-item--error'); 726 } 727 728 function addFiles(files) { 729 let added = 0; 730 for (const file of files) { 731 if (!file.type.startsWith('audio/') && 732 !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) { 733 // Silently skip non-audio drops; keeps the UX uncluttered. 734 continue; 735 } 736 const li = document.createElement('li'); 737 li.className = 'ax-queue-item'; 738 li.innerHTML = 739 '<div class="ax-queue-name"></div>' + 740 '<span class="ax-queue-status ax-queue-status--queued"><%= t('aaud.st_queued') %></span>'; 741 // Use textContent to avoid HTML-injection if a filename contains markup. 742 li.querySelector('.ax-queue-name').textContent = file.name; 743 // Append size hint inline 744 const size = document.createElement('span'); 745 size.className = 'ax-queue-size'; 746 size.textContent = fmtBytes(file.size); 747 li.querySelector('.ax-queue-name').appendChild(size); 748 queueEl.appendChild(li); 749 queue.push({ file, el: li, status: 'queued' }); 750 added++; 751 } 752 if (added) { 753 queueEl.hidden = false; 754 actionsEl.hidden = false; 755 } 756 } 757 758 // ── Drop-zone events ───────────────────────────────────────── 759 // Page-level guard: a dropped file outside the zone would otherwise 760 // make the browser navigate to it (e.g. opening the audio inline), which 761 // discards typed metadata. We swallow drops anywhere unless the dropzone 762 // explicitly handles them. 763 ['dragover', 'drop'].forEach(ev => { 764 window.addEventListener(ev, e => { 765 // Allow drops INSIDE the dropzone — its own listener handles those. 766 if (dropzone.contains(e.target)) return; 767 e.preventDefault(); 768 }); 769 }); 770 771 ['dragenter', 'dragover'].forEach(ev => { 772 dropzone.addEventListener(ev, e => { 773 e.preventDefault(); 774 dropzone.classList.add('is-dragover'); 775 }); 776 }); 777 ['dragleave', 'drop'].forEach(ev => { 778 dropzone.addEventListener(ev, e => { 779 e.preventDefault(); 780 dropzone.classList.remove('is-dragover'); 781 }); 782 }); 783 dropzone.addEventListener('drop', e => { 784 if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files); 785 }); 786 fileInput.addEventListener('change', () => { 787 addFiles(fileInput.files); 788 // Reset so the same file can be picked again later if user wants 789 fileInput.value = ''; 790 }); 791 792 // ── Clear queue button ─────────────────────────────────────── 793 clearBtn.addEventListener('click', () => { 794 // Only remove items that aren't currently uploading (anything queued 795 // or already finished). An in-progress upload finishes, then its row 796 // would also disappear once we re-render — but we keep it simple and 797 // just refuse to clear during an active run. 798 if (startBtn.disabled) return; 799 queue.length = 0; 800 queueEl.innerHTML = ''; 801 queueEl.hidden = true; 802 actionsEl.hidden = true; 803 }); 804 805 // ── Sequential upload loop ─────────────────────────────────── 806 startBtn.addEventListener('click', async () => { 807 if (startBtn.disabled) return; 808 startBtn.disabled = true; 809 clearBtn.disabled = true; 810 dropzone.style.pointerEvents = 'none'; 811 dropzone.style.opacity = '0.5'; 812 813 const sharedArtist = artistInput.value.trim(); 814 const sharedAlbum = albumInput.value.trim(); 815 const sharedCover = coverInput.files && coverInput.files[0]; 816 817 // Process queued items one at a time. We iterate via index so that 818 // if more files get dropped during the run they ALSO get processed 819 // (queue.push above mutates the same array we're iterating). 820 for (let i = 0; i < queue.length; i++) { 821 const item = queue[i]; 822 if (item.status !== 'queued') continue; 823 try { 824 await uploadOne(item, sharedArtist, sharedAlbum, sharedCover); 825 } catch (err) { 826 console.error('upload failed for', item.file.name, err); 827 setItemStatus(item, 'error', '✗ ' + (err.message || '<%= t('aaud.failed') %>')); 828 } 829 } 830 831 startBtn.disabled = false; 832 clearBtn.disabled = false; 833 dropzone.style.pointerEvents = ''; 834 dropzone.style.opacity = ''; 835 836 // Reload the page so the new tracks appear in the list below. 837 // Could also fetch them and inject, but a full reload is simpler and 838 // ensures position indexes / album-grouping are correct. 839 const anyDone = queue.some(q => q.status === 'done'); 840 if (anyDone) { 841 setTimeout(() => location.reload(), 700); 842 } 843 }); 844 845 async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) { 846 setItemStatus(item, 'uploading'); 847 848 const fd = new FormData(); 849 fd.append('audio', item.file); 850 if (sharedArtist) fd.append('artist', sharedArtist); 851 if (sharedAlbum) fd.append('album', sharedAlbum); 852 if (sharedCover) fd.append('cover', sharedCover); 853 // Title is intentionally omitted — server uses filename fallback. 854 855 // We can't reliably distinguish "still uploading bytes" from 856 // "uploading done, ffmpeg running" without progress events, but the 857 // status flips to "Converteren…" once the request is past upload phase. 858 // We approximate this by waiting until the response arrives — by then 859 // both phases are complete on the server side. For a smoother feel we 860 // briefly show "transcoding" near the end of the request lifecycle. 861 const transcodeHint = setTimeout(() => { 862 if (item.status === 'uploading') setItemStatus(item, 'transcoding'); 863 }, 1500); 864 865 try { 866 const res = await fetch('/admin/audio/upload', { 867 method: 'POST', 868 headers: { 'Accept': 'application/json' }, 869 body: fd, 870 credentials: 'same-origin', 871 }); 872 clearTimeout(transcodeHint); 873 874 // Server responds with JSON for our Accept header. If it didn't 875 // (e.g. session expired and got an HTML login page), surface that. 876 let data; 877 try { data = await res.json(); } 878 catch (_) { throw new Error('<%= t('aaud.err_unexpected') %> (' + res.status + ')'); } 879 880 if (!res.ok || !data.ok) { 881 throw new Error(data.error || ('HTTP ' + res.status)); 882 } 883 884 setItemStatus(item, 'done', '✓ ' + (data.title || '<%= t('aaud.st_done') %>')); 885 } catch (err) { 886 clearTimeout(transcodeHint); 887 throw err; 888 } 889 } 890 891 // ── Click-to-copy embed codes ───────────────────────────────── 892 document.querySelectorAll('[data-copy]').forEach(el => { 893 el.addEventListener('click', async () => { 894 const text = el.dataset.copy; 895 try { 896 await navigator.clipboard.writeText(text); 897 el.classList.add('is-copied'); 898 const original = el.textContent; 899 el.textContent = '✓ <%= t('aaud.copied') %>'; 900 setTimeout(() => { 901 el.classList.remove('is-copied'); 902 el.textContent = original; 903 }, 1200); 904 } catch (_) { /* fall through — selection still works */ } 905 }); 906 }); 907 908 // ── "+ Track zonder audio": maak een link-only stub + open de editor ── 909 const addLinkBtn = document.getElementById('add-link-track-btn'); 910 if (addLinkBtn) { 911 addLinkBtn.addEventListener('click', async () => { 912 addLinkBtn.disabled = true; 913 try { 914 const r = await fetch('/admin/audio/create-link', { 915 method: 'POST', credentials: 'same-origin', 916 headers: { 'Content-Type': 'application/json' }, 917 body: JSON.stringify({ title: '<%= t('aaud.new_track') %>' }), 918 }); 919 const j = await r.json(); 920 if (!r.ok || !j.ok) throw new Error(j.error || '<%= t('aaud.create_failed') %>'); 921 if (typeof window.openTrackEditor !== 'function') { location.reload(); return; } 922 window.openTrackEditor({ id: j.id, onSaved: () => location.reload() }); 923 } catch (err) { 924 alert('<%= t('aaud.create_failed') %>: ' + err.message); 925 } finally { 926 addLinkBtn.disabled = false; 927 } 928 }); 929 } 930 931 // ── Wire all "Edit" buttons to the track-editor modal ───────── 932 // After save we patch the row in-place rather than reloading, 933 // so the user keeps their scroll position on long lists. 934 document.querySelectorAll('[data-track-edit]').forEach(btn => { 935 btn.addEventListener('click', () => { 936 if (typeof window.openTrackEditor !== 'function') { 937 alert('<%= t('aaud.editor_not_loaded') %>'); 938 return; 939 } 940 const id = btn.dataset.id; 941 window.openTrackEditor({ 942 id, 943 onSaved: (track) => { 944 const row = document.querySelector('li[data-track-id="' + id + '"]'); 945 if (!row) return; 946 // Update visible cells 947 const titleEl = row.querySelector('[data-cell="title"]'); 948 const artistEl = row.querySelector('[data-cell="artist"]'); 949 const albumEl = row.querySelector('[data-cell="album"]'); 950 if (titleEl) titleEl.textContent = track.title || '<%= t('aaud.untitled') %>'; 951 if (artistEl) artistEl.textContent = track.artist || '—'; 952 if (albumEl) { 953 albumEl.textContent = track.album || ''; 954 if (track.album) albumEl.removeAttribute('hidden'); 955 else albumEl.setAttribute('hidden', ''); 956 } 957 // Update cover thumb (replace element if type changed) 958 const oldThumb = row.querySelector('[data-cover-thumb]'); 959 if (oldThumb) { 960 const parent = oldThumb.parentElement; 961 if (track.cover_url) { 962 const img = document.createElement('img'); 963 img.className = 'ax-track-cover'; 964 img.src = track.cover_url; 965 img.alt = ''; 966 img.dataset.coverThumb = ''; 967 parent.replaceChild(img, oldThumb); 968 } else { 969 const sp = document.createElement('span'); 970 sp.className = 'ax-track-cover ax-track-cover-empty'; 971 sp.textContent = '♫'; 972 sp.dataset.coverThumb = ''; 973 parent.replaceChild(sp, oldThumb); 974 } 975 } 976 }, 977 }); 978 }); 979 }); 980 981 // Download-voor-email toggle — AJAX (geen pagina-reload meer). 982 document.querySelectorAll('[data-track-dl]').forEach(btn => { 983 btn.addEventListener('click', async () => { 984 if (btn.disabled) return; 985 const want = btn.dataset.on === '1' ? 0 : 1; 986 btn.disabled = true; 987 try { 988 const res = await fetch('/admin/audio/api/' + btn.dataset.id, { 989 method: 'POST', 990 headers: { 'Content-Type': 'application/json' }, 991 body: JSON.stringify({ downloadable: !!want }), 992 }); 993 if (!res.ok) throw new Error('HTTP ' + res.status); 994 btn.dataset.on = String(want); 995 btn.style.color = want ? 'var(--accent,#6b8f71)' : ''; 996 btn.style.opacity = want ? '1' : '.5'; 997 btn.title = want 998 ? '<%= t('aaud.dl_on') %>' 999 : '<%= t('aaud.dl_off') %>'; 1000 } catch (e) { 1001 alert('<%= t('aaud.change_failed') %>: ' + (e.message || e)); 1002 } finally { 1003 btn.disabled = false; 1004 } 1005 }); 1006 }); 1007 })(); 1008 </script> 575 <%# Het script staat in assets/js/mod/admin-audio.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 576 <%- include('../partials/page-data', { pageData: { change_failed: t('aaud.change_failed'), copied: t('aaud.copied'), create_failed: t('aaud.create_failed'), dl_off: t('aaud.dl_off'), dl_on: t('aaud.dl_on'), editor_not_loaded: t('aaud.editor_not_loaded'), err_unexpected: t('aaud.err_unexpected'), failed: t('aaud.failed'), new_track: t('aaud.new_track'), no_file: t('aaud.no_file'), pause: t('aaud.pause'), play: t('aaud.play'), st_done: t('aaud.st_done'), st_error: t('aaud.st_error'), st_queued: t('aaud.st_queued'), st_transcoding: t('aaud.st_transcoding'), st_uploading: t('aaud.st_uploading'), untitled: t('aaud.untitled') } }) %> -
src/views/partials/playlist-editor.ejs
r156baa3 r6ee289a 280 280 </style> 281 281 282 <script> 283 (function() { 284 // Idempotency: if window.openPlaylistEditor already defined (multiple 285 // partial includes on a single page), skip re-binding. 286 if (typeof window.openPlaylistEditor === 'function') return; 287 288 const CSRF = '<%= _csrf %>'; 289 290 function esc(s) { 291 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ 292 '&':'&','<':'<','>':'>','"':'"',"'":''' 293 }[c])); 294 } 295 function fmtDur(sec) { 296 if (!sec) return ''; 297 const m = Math.floor(sec / 60), s = sec % 60; 298 return `${m}:${String(s).padStart(2, '0')}`; 299 } 300 301 async function api(method, url, body) { 302 const opts = { 303 method, credentials: 'same-origin', 304 headers: { 'X-CSRF-Token': CSRF }, 305 }; 306 if (body !== undefined) { 307 opts.headers['Content-Type'] = 'application/json'; 308 opts.body = JSON.stringify(body); 309 } 310 const r = await fetch(url, opts); 311 return r.json(); 312 } 313 314 /** 315 * Public entry point: open the editor. 316 * opts: { mode: 'create'|'edit', id?, onSaved? } 317 * onSaved is called with { id, playlist } after a successful save. 318 */ 319 window.openPlaylistEditor = async function(opts) { 320 opts = opts || {}; 321 const mode = opts.mode === 'edit' ? 'edit' : 'create'; 322 const isEdit = mode === 'edit'; 323 324 // Load all audio tracks for the picker 325 let tracks = []; 326 try { 327 const j = await api('GET', '/admin/playlists/api/tracks'); 328 if (Array.isArray(j.tracks)) tracks = j.tracks; 329 } catch (e) { 330 alert('Tracks ophalen mislukt'); 331 return; 332 } 333 if (!tracks.length) { 334 alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.'); 335 return; 336 } 337 338 // Existing playlist data when editing 339 let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] }; 340 if (isEdit && opts.id) { 341 try { 342 const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id)); 343 if (j.ok) initial = { ...initial, ...j.playlist }; 344 } catch (e) {} 345 } 346 347 // ── DOM ──────────────────────────────────────────────────────── 348 const backdrop = document.createElement('div'); 349 backdrop.className = 'pl-modal-backdrop'; 350 backdrop.innerHTML = ` 351 <div class="pl-modal" role="dialog" aria-label="Playlist editor"> 352 <div class="pl-modal-header"> 353 <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3> 354 <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button> 355 </div> 356 <div class="pl-modal-body"> 357 <div class="pl-editor-cols"> 358 <div class="pl-editor-left"> 359 <div class="pl-meta-grid"> 360 <label class="pl-field pl-field-full"> 361 <span>Titel *</span> 362 <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}"> 363 </label> 364 <label class="pl-field"> 365 <span>Artiest</span> 366 <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}"> 367 </label> 368 <label class="pl-field"> 369 <span>Jaar</span> 370 <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}"> 371 </label> 372 <label class="pl-field"> 373 <span>Type</span> 374 <select id="pli-kind"> 375 <option value="album" ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option> 376 <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option> 377 </select> 378 </label> 379 <div class="pl-field pl-field-full"> 380 <span>Cover</span> 381 <div class="pl-cover-row"> 382 <span class="pl-cover-thumb" id="pli-cover-thumb"> 383 ${initial.cover 384 ? `<img src="${esc(initial.cover)}" alt="">` 385 : `<span class="pl-cover-empty">🎨</span>`} 386 </span> 387 <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1"> 388 </div> 389 <div class="pl-cover-upload-row"> 390 <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden> 391 <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button> 392 <span class="pl-cover-status" id="pli-cover-status"></span> 393 </div> 394 </div> 395 </div> 396 <div class="pl-section-title"> 397 Tracks in playlist <span class="pl-track-count" id="pli-count">0</span> 398 <small>(sleep ⠿ om te ordenen)</small> 399 </div> 400 <div id="pli-selected" class="pl-selected-list"></div> 401 </div> 402 <div class="pl-editor-right"> 403 <div class="pl-section-title">Beschikbare tracks</div> 404 <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input"> 405 <div id="pli-available" class="pl-available-list"></div> 406 </div> 407 </div> 408 </div> 409 <div class="pl-modal-footer"> 410 <button type="button" class="btn" id="pli-cancel">Annuleren</button> 411 <button type="button" class="btn btn-primary" id="pli-save" disabled> 412 ${isEdit ? 'Opslaan' : 'Aanmaken'} 413 </button> 414 </div> 415 </div> 416 `; 417 document.body.appendChild(backdrop); 418 419 const $ = sel => backdrop.querySelector(sel); 420 const close = () => backdrop.remove(); 421 backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }); 422 $('.pl-modal-close').addEventListener('click', close); 423 $('#pli-cancel').addEventListener('click', close); 424 425 // Index tracks by id for fast lookup 426 const trackById = new Map(tracks.map(t => [t.id, t])); 427 let selected = (initial.track_ids || []).filter(id => trackById.has(id)); 428 429 const titleEl = $('#pli-title'); 430 const artistEl = $('#pli-artist'); 431 const yearEl = $('#pli-year'); 432 const kindEl = $('#pli-kind'); 433 const coverEl = $('#pli-cover'); 434 const coverThumb = $('#pli-cover-thumb'); 435 const saveBtn = $('#pli-save'); 436 const selectedEl = $('#pli-selected'); 437 const availEl = $('#pli-available'); 438 const searchEl = $('#pli-search'); 439 const countEl = $('#pli-count'); 440 441 coverEl.addEventListener('input', () => { 442 const u = coverEl.value.trim(); 443 coverThumb.innerHTML = u 444 ? `<img src="${esc(u)}" alt="" data-fallback>` 445 : `<span class="pl-cover-empty">🎨</span>`; 446 }); 447 448 // ── Cover file upload (werkt in create ÉN edit) ─────────── 449 // We uploaden naar het generieke image-endpoint (/posts/upload-image, 450 // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id 451 // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het 452 // cover-veld en wordt bij 'Aanmaken'/'Opslaan' met de playlist meegestuurd. 453 const coverFileInput = $('#pli-cover-file'); 454 const coverPickBtn = $('#pli-cover-pick'); 455 const coverStatus = $('#pli-cover-status'); 456 if (coverFileInput && coverPickBtn) { 457 coverPickBtn.addEventListener('click', () => coverFileInput.click()); 458 coverFileInput.addEventListener('change', async (e) => { 459 const file = e.target.files && e.target.files[0]; 460 coverFileInput.value = ''; 461 if (!file) return; 462 if (!/^image\//.test(file.type)) { 463 coverStatus.textContent = 'Alleen afbeeldingen'; 464 coverStatus.className = 'pl-cover-status is-error'; 465 return; 466 } 467 coverStatus.textContent = 'Uploaden…'; 468 coverStatus.className = 'pl-cover-status'; 469 const fd = new FormData(); 470 fd.append('image', file); 471 try { 472 const r = await fetch('/posts/upload-image', 473 { method: 'POST', body: fd, credentials: 'same-origin' } 474 ); 475 const j = await r.json(); 476 if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt'); 477 const url = j.url || ''; 478 coverEl.value = url; 479 coverThumb.innerHTML = url 480 ? `<img src="${esc(url)}" alt="">` 481 : `<span class="pl-cover-empty">🎨</span>`; 482 coverStatus.textContent = '✓ Geüpload'; 483 coverStatus.className = 'pl-cover-status is-ok'; 484 } catch (err) { 485 coverStatus.textContent = 'Mislukt: ' + err.message; 486 coverStatus.className = 'pl-cover-status is-error'; 487 } 488 }); 489 } 490 491 function updateSaveBtn() { 492 saveBtn.disabled = !titleEl.value.trim() || selected.length === 0; 493 } 494 495 function renderSelected() { 496 countEl.textContent = selected.length; 497 if (selected.length === 0) { 498 selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>'; 499 updateSaveBtn(); 500 return; 501 } 502 selectedEl.innerHTML = selected.map((id, i) => { 503 const t = trackById.get(id); 504 if (!t) return ''; 505 const cover = t.cover 506 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>` 507 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`; 508 return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}"> 509 <span class="pl-row-handle" aria-label="Verslepen">⠿</span> 510 <span class="pl-row-num">${i + 1}</span> 511 ${cover} 512 <span class="pl-row-info"> 513 <span class="pl-row-title">${esc(t.title)}</span> 514 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''} 515 </span> 516 <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button> 517 </div>`; 518 }).join(''); 519 520 selectedEl.querySelectorAll('.pl-row-x').forEach(b => { 521 b.addEventListener('click', () => { 522 selected = selected.filter(x => x !== b.dataset.id); 523 renderSelected(); 524 renderAvailable(); 525 }); 526 }); 527 bindDrag(); 528 updateSaveBtn(); 529 } 530 531 function renderAvailable() { 532 const q = searchEl.value.trim().toLowerCase(); 533 const matches = tracks.filter(t => { 534 if (!q) return true; 535 return (t.title || '').toLowerCase().includes(q) 536 || (t.artist || '').toLowerCase().includes(q); 537 }); 538 if (matches.length === 0) { 539 availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>'; 540 return; 541 } 542 availEl.innerHTML = matches.map(t => { 543 const isAdded = selected.includes(t.id); 544 const cls = ['pl-avail-row']; 545 if (isAdded) cls.push('is-added'); 546 if (!t.playable) cls.push('is-unplayable'); 547 const cover = t.cover 548 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>` 549 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`; 550 return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}> 551 ${cover} 552 <span class="pl-row-info"> 553 <span class="pl-row-title">${esc(t.title)}</span> 554 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''} 555 </span> 556 <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span> 557 </div>`; 558 }).join(''); 559 560 availEl.querySelectorAll('.pl-avail-row').forEach(row => { 561 if (row.classList.contains('is-unplayable')) return; 562 row.addEventListener('click', () => { 563 const id = row.dataset.id; 564 if (selected.includes(id)) selected = selected.filter(x => x !== id); 565 else selected.push(id); 566 renderSelected(); 567 renderAvailable(); 568 }); 569 }); 570 } 571 572 // Pointer-based drag-to-reorder. Same pattern as v9's admin.js. 573 function bindDrag() { 574 selectedEl.querySelectorAll('.pl-row').forEach(row => { 575 const handle = row.querySelector('.pl-row-handle'); 576 if (!handle) return; 577 let dragging = false, originalIdx = -1; 578 579 handle.addEventListener('pointerdown', e => { 580 e.preventDefault(); 581 handle.setPointerCapture(e.pointerId); 582 dragging = true; 583 originalIdx = parseInt(row.dataset.pos, 10); 584 row.classList.add('is-dragging'); 585 }); 586 handle.addEventListener('pointermove', e => { 587 if (!dragging) return; 588 e.preventDefault(); 589 const rows = Array.from(selectedEl.querySelectorAll('.pl-row')); 590 rows.forEach(r => r.classList.remove('drop-above', 'drop-below')); 591 let targetIdx = -1, above = false; 592 for (let i = 0; i < rows.length; i++) { 593 const r = rows[i]; if (r === row) continue; 594 const rect = r.getBoundingClientRect(); 595 const mid = rect.top + rect.height / 2; 596 if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; } 597 if (e.clientY >= rect.top && e.clientY <= rect.bottom) { 598 targetIdx = i; above = e.clientY < mid; break; 599 } 600 } 601 if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below'); 602 }); 603 const finish = e => { 604 if (!dragging) return; 605 dragging = false; 606 try { handle.releasePointerCapture(e.pointerId); } catch (_) {} 607 row.classList.remove('is-dragging'); 608 const rows = Array.from(selectedEl.querySelectorAll('.pl-row')); 609 let targetIdx = -1, above = false; 610 for (let i = 0; i < rows.length; i++) { 611 if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; } 612 if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; } 613 } 614 rows.forEach(r => r.classList.remove('drop-above', 'drop-below')); 615 if (targetIdx === -1 || targetIdx === originalIdx) return; 616 const moved = selected[originalIdx]; 617 selected.splice(originalIdx, 1); 618 let newIdx = targetIdx; 619 if (originalIdx < targetIdx) newIdx--; 620 if (!above) newIdx++; 621 newIdx = Math.max(0, Math.min(selected.length, newIdx)); 622 selected.splice(newIdx, 0, moved); 623 renderSelected(); 624 }; 625 handle.addEventListener('pointerup', finish); 626 handle.addEventListener('pointercancel', () => { 627 dragging = false; 628 row.classList.remove('is-dragging'); 629 selectedEl.querySelectorAll('.drop-above, .drop-below') 630 .forEach(r => r.classList.remove('drop-above', 'drop-below')); 631 }); 632 }); 633 } 634 635 titleEl.addEventListener('input', updateSaveBtn); 636 searchEl.addEventListener('input', renderAvailable); 637 renderSelected(); 638 renderAvailable(); 639 640 saveBtn.addEventListener('click', async () => { 641 if (saveBtn.disabled) return; 642 const orig = saveBtn.textContent; 643 saveBtn.disabled = true; 644 saveBtn.textContent = 'Opslaan...'; 645 646 const payload = { 647 title: titleEl.value.trim(), 648 artist: artistEl.value.trim(), 649 year: parseInt(yearEl.value, 10) || 0, 650 cover: coverEl.value.trim(), 651 kind: kindEl.value === 'playlist' ? 'playlist' : 'album', 652 tracks: selected.slice(), 653 }; 654 655 try { 656 const url = isEdit 657 ? '/admin/playlists/api/' + encodeURIComponent(initial.id) 658 : '/admin/playlists/api'; 659 const j = await api('POST', url, payload); 660 if (!j.ok) { 661 alert('Opslaan mislukt: ' + (j.error || 'onbekend')); 662 saveBtn.disabled = false; 663 saveBtn.textContent = orig; 664 return; 665 } 666 const savedId = isEdit ? initial.id : j.id; 667 close(); 668 if (typeof opts.onSaved === 'function') { 669 opts.onSaved({ id: savedId, playlist: payload }); 670 } 671 } catch (err) { 672 alert('Opslaan mislukt: ' + err.message); 673 saveBtn.disabled = false; 674 saveBtn.textContent = orig; 675 } 676 }); 677 678 // ESC to close 679 document.addEventListener('keydown', function onEsc(e) { 680 if (e.key === 'Escape' && document.body.contains(backdrop)) { 681 close(); 682 document.removeEventListener('keydown', onEsc); 683 } 684 }); 685 }; 686 })(); 687 </script> 282 <%# Het script staat in assets/js/mod/playlist-editor.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)