- Timestamp:
- 08/07/2026 05:15:52 PM (5 weeks ago)
- Branches:
- main
- Children:
- ba76bf5
- Parents:
- f85b2c3 (diff), 0d5bd2c (diff)
Note: this is a merge changeset, the changes displayed below correspond to the merge itself.
Use the(diff)links above to see all the changes relative to each parent. - Location:
- src/views
- Files:
-
- 1 added
- 27 edited
-
pages/admin-audio.ejs (modified) (1 diff)
-
pages/admin-epk.ejs (modified) (1 diff)
-
pages/admin-help.ejs (modified) (1 diff)
-
pages/admin-media.ejs (modified) (1 diff)
-
pages/admin-paid.ejs (modified) (1 diff)
-
pages/admin-playlists.ejs (modified) (1 diff)
-
pages/admin-push.ejs (modified) (1 diff)
-
pages/admin-settings.ejs (modified) (1 diff)
-
pages/admin-site-edit.ejs (modified) (2 diffs)
-
pages/admin-videos.ejs (modified) (1 diff)
-
pages/auth-register.ejs (modified) (1 diff)
-
pages/authorize-interaction.ejs (modified) (3 diffs)
-
pages/download.ejs (modified) (2 diffs)
-
pages/messages.ejs (modified) (5 diffs)
-
pages/news.ejs (modified) (2 diffs)
-
pages/paid-gate.ejs (modified) (2 diffs)
-
pages/paid-passkey.ejs (modified) (2 diffs)
-
pages/post-edit.ejs (modified) (6 diffs)
-
pages/post.ejs (modified) (1 diff)
-
partials/bottom-tab.ejs (modified) (1 diff)
-
partials/msg-item.ejs (modified) (7 diffs)
-
partials/page-data.ejs (added)
-
partials/playlist-editor.ejs (modified) (1 diff)
-
partials/profile-header.ejs (modified) (1 diff)
-
partials/profile-sheet.ejs (modified) (2 diffs)
-
partials/topnav.ejs (modified) (2 diffs)
-
partials/track-editor.ejs (modified) (1 diff)
-
shell.ejs (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/views/pages/admin-audio.ejs
rf85b2c3 r952baf3 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/pages/admin-epk.ejs
rf85b2c3 r952baf3 90 90 .admin-page .epk-pick-item input:disabled ~ .epk-pick-title { opacity: 0.45; } 91 91 </style> 92 <script> 93 (function () { 94 var box = document.querySelector('.epk-track-pick'); 95 if (!box) return; 96 var max = parseInt(box.dataset.max, 10) || 5; 97 function sync() { 98 var checks = box.querySelectorAll('input[type=checkbox]'); 99 var n = box.querySelectorAll('input[type=checkbox]:checked').length; 100 checks.forEach(function (c) { c.disabled = (!c.checked && n >= max); }); 101 } 102 box.addEventListener('change', sync); 103 sync(); 104 })(); 105 </script> 92 <%# Het script van deze pagina staat in assets/js/mod/admin-epk.js (shaer-bqr). %> -
src/views/pages/admin-help.ejs
rf85b2c3 r952baf3 145 145 .hb-empty { color: var(--ink-muted, var(--ink-soft)); padding: 1rem; text-align: center; } 146 146 </style> 147 <script> 148 (function () { 149 var q = document.getElementById('hb-q'); 150 var items = [].slice.call(document.querySelectorAll('.hb-item')); 151 var empty = document.getElementById('hb-empty'); 152 var count = document.getElementById('hb-count'); 153 if (!q) return; 154 // Bewaar de originele tekst per item (voor highlight-reset). 155 items.forEach(function (it) { it._txt = it.textContent.toLowerCase(); }); 156 function run() { 157 var term = q.value.trim().toLowerCase(); 158 var shown = 0; 159 items.forEach(function (it) { 160 var hit = !term || it._txt.indexOf(term) >= 0; 161 it.classList.toggle('hb-hidden', !hit); 162 if (hit) shown++; 163 }); 164 empty.hidden = shown !== 0; 165 count.textContent = term ? (shown + ' onderwerp' + (shown === 1 ? '' : 'en') + ' gevonden') : ''; 166 } 167 q.addEventListener('input', run); 168 })(); 169 </script> 147 <%# Het script van deze pagina staat in assets/js/mod/admin-help.js (shaer-bqr). %> -
src/views/pages/admin-media.ejs
rf85b2c3 r952baf3 41 41 </div> 42 42 43 <script> 44 (function () { 45 if (window.__mediaWired) return; window.__mediaWired = true; 46 var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') }) %>; 47 document.addEventListener('click', function (e) { 48 var c = e.target.closest('[data-copy]'); 49 if (c) { 50 var u = location.origin + c.getAttribute('data-copy'); 51 var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); }; 52 if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); }); 53 else window.prompt('URL', u); 54 return; 55 } 56 var d = e.target.closest('[data-del]'); 57 if (d) { 58 if (!window.confirm(T.delC)) return; 59 fetch('/admin/media/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) }) 60 .then(function (r) { return r.json(); }) 61 .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } else window.alert((j && j.error) || 'Error'); }) 62 .catch(function () { window.alert('Error'); }); 63 return; 64 } 65 if (e.target.closest('#media-cleanup')) { 66 if (!window.confirm(T.cleanC)) return; 67 fetch('/admin/media/cleanup', { method: 'POST', credentials: 'same-origin' }) 68 .then(function (r) { return r.json(); }) 69 .then(function (j) { location.href = '/admin/media?success=' + encodeURIComponent(((j && j.removed) || 0) + ' file(s) removed'); }) 70 .catch(function () { window.alert('Error'); }); 71 } 72 }); 73 })(); 74 </script> 43 <%# Het script staat in assets/js/mod/admin-media.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 44 <%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') } }) %> -
src/views/pages/admin-paid.ejs
rf85b2c3 r952baf3 72 72 </div> 73 73 74 <script> 75 (function () { 76 var field = document.getElementById('pd-redirect'); 77 var btn = document.getElementById('pd-copy'); 78 if (!field || !btn) return; 79 field.addEventListener('focus', function () { field.select(); }); 80 btn.addEventListener('click', function () { 81 field.select(); 82 var done = function () { var t = btn.textContent; btn.textContent = document.getElementById('pd-copy').getAttribute('data-copied'); setTimeout(function () { btn.textContent = t; }, 1400); }; 83 if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(field.value).then(done, function () { try { document.execCommand('copy'); done(); } catch (e) {} }); } 84 else { try { document.execCommand('copy'); done(); } catch (e) {} } 85 }); 86 })(); 87 </script> 74 <%# Het script van deze pagina staat in assets/js/mod/admin-paid.js (shaer-bqr). %> -
src/views/pages/admin-playlists.ejs
rf85b2c3 r952baf3 222 222 </style> 223 223 224 <script> 225 (function() { 226 const csrf = '<%= _csrf %>'; 227 228 document.getElementById('pl-new-btn')?.addEventListener('click', () => { 229 if (typeof window.openPlaylistEditor === 'function') { 230 window.openPlaylistEditor({ mode: 'create', onSaved: () => location.reload() }); 231 } 232 }); 233 234 // Click-to-copy on shortcodes 235 document.querySelectorAll('[data-copy]').forEach(el => { 236 el.addEventListener('click', async () => { 237 try { 238 await navigator.clipboard.writeText(el.dataset.copy); 239 el.classList.add('is-copied'); 240 const original = el.textContent; 241 el.textContent = '✓ <%= t('apl.copied') %>'; 242 setTimeout(() => { el.classList.remove('is-copied'); el.textContent = original; }, 1200); 243 } catch (_) { /* fall back to selection */ } 244 }); 245 }); 246 247 document.querySelectorAll('[data-pl-edit]').forEach(btn => { 248 btn.addEventListener('click', () => { 249 if (typeof window.openPlaylistEditor === 'function') { 250 window.openPlaylistEditor({ mode: 'edit', id: btn.dataset.id, onSaved: () => location.reload() }); 251 } 252 }); 253 }); 254 255 document.querySelectorAll('[data-pl-delete]').forEach(btn => { 256 btn.addEventListener('click', async () => { 257 const id = btn.dataset.id; 258 const title = btn.dataset.title || id; 259 if (!confirm('<%= t('apl.delete_confirm') %>'.replace('{title}', title))) return; 260 try { 261 const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, { 262 method: 'POST', 263 headers: { 'X-CSRF-Token': csrf }, 264 credentials: 'same-origin', 265 }); 266 const j = await r.json(); 267 if (j.ok) location.reload(); 268 else alert('<%= t('apl.delete_failed') %>: ' + (j.error || '')); 269 } catch (err) { 270 alert('<%= t('apl.delete_failed') %>: ' + err.message); 271 } 272 }); 273 }); 274 275 // P52 — deep-link from playlist embed (?edit=<id>) auto-opens the editor. 276 // openPlaylistEditor is defined synchronously by the included partial, so 277 // it's available by the time this IIFE runs. 278 (function deepLinkEdit() { 279 const params = new URLSearchParams(location.search); 280 const editId = params.get('edit'); 281 if (!editId) return; 282 if (typeof window.openPlaylistEditor !== 'function') return; 283 // Strip the query param immediately so reload after save doesn't re-open. 284 history.replaceState({}, '', location.pathname); 285 window.openPlaylistEditor({ 286 mode: 'edit', 287 id: editId, 288 onSaved: () => location.reload(), 289 }); 290 })(); 291 })(); 292 </script> 224 <%# Het script staat in assets/js/mod/admin-playlists.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 225 <%- include('../partials/page-data', { pageData: { csrf: _csrf, copied: t('apl.copied'), delConfirm: t('apl.delete_confirm'), delFailed: t('apl.delete_failed') } }) %> -
src/views/pages/admin-push.ejs
rf85b2c3 r952baf3 58 58 i18n: { on: t('push.state_on'), off: t('push.state_off'), denied: t('push.state_denied'), unknown: t('push.state_unknown'), unsupported: t('push.state_unsupported'), failed: t('push.enable_failed') }, 59 59 }).replace(/</g, '\\u003c') %></script> 60 <script> 61 (function () { 62 var cfg = JSON.parse(document.getElementById('np-data').textContent); 63 var elState = document.getElementById('np-state'); 64 var btnOn = document.getElementById('np-on'), btnOff = document.getElementById('np-off'), btnTest = document.getElementById('np-test'); 65 var alertsBox = document.getElementById('np-alerts'), savedMsg = document.getElementById('np-saved'); 66 var currentEndpoint = null; 67 68 var isIos = /iPad|iPhone|iPod/.test(navigator.userAgent); 69 var standalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; 70 if (isIos && !standalone) document.getElementById('np-ios-hint').hidden = false; 71 72 if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) { 73 document.getElementById('np-unsupported').hidden = false; 74 elState.textContent = cfg.i18n.unsupported; 75 return; 76 } 77 78 function b64ToU8(s) { 79 var pad = '='.repeat((4 - (s.length % 4)) % 4); 80 var raw = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/')); 81 var out = new Uint8Array(raw.length); 82 for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); 83 return out; 84 } 85 function post(url, body) { 86 return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }); 87 } 88 function deviceLabel() { 89 var ua = navigator.userAgent; 90 var browser = /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser'; 91 var os = /Android/.test(ua) ? 'Android' : /iPad|iPhone|iPod/.test(ua) ? 'iOS' : /Mac/.test(ua) ? 'macOS' : /Win/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : ''; 92 return (browser + (os ? ' op ' + os : '')); 93 } 94 function readAlertBoxes() { 95 var out = {}; 96 alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { out[cb.getAttribute('data-alert')] = cb.checked ? 1 : 0; }); 97 return out; 98 } 99 function setAlertBoxes(alerts) { 100 alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { 101 cb.checked = !!alerts[cb.getAttribute('data-alert')]; 102 }); 103 } 104 105 function render(sub) { 106 currentEndpoint = sub ? sub.endpoint : null; 107 elState.textContent = sub ? cfg.i18n.on : (Notification.permission === 'denied' ? cfg.i18n.denied : cfg.i18n.off); 108 btnOn.hidden = !!sub || Notification.permission === 'denied'; 109 btnOff.hidden = !sub; 110 btnTest.hidden = !sub; 111 alertsBox.hidden = !sub; 112 } 113 114 navigator.serviceWorker.ready.then(function (reg) { 115 return reg.pushManager.getSubscription(); 116 }).then(function (sub) { 117 // Show this device's SAVED prefs when we know them, defaults otherwise. 118 setAlertBoxes((sub && cfg.saved[sub.endpoint]) ? Object.assign({}, cfg.alerts, cfg.saved[sub.endpoint]) : cfg.alerts); 119 render(sub); 120 }).catch(function () { elState.textContent = cfg.i18n.unknown; }); 121 122 btnOn.addEventListener('click', function () { 123 btnOn.disabled = true; 124 Notification.requestPermission().then(function (perm) { 125 if (perm !== 'granted') { btnOn.disabled = false; render(null); return; } 126 navigator.serviceWorker.ready.then(function (reg) { 127 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToU8(cfg.vapid) }); 128 }).then(function (sub) { 129 return post('/push/subscribe', { subscription: sub.toJSON(), alerts: readAlertBoxes(), uaLabel: deviceLabel() }) 130 .then(function (r) { if (!r.ok) throw new Error('subscribe failed'); render(sub); location.reload(); }); 131 }).catch(function () { btnOn.disabled = false; elState.textContent = cfg.i18n.failed; }); 132 }); 133 }); 134 135 btnOff.addEventListener('click', function () { 136 navigator.serviceWorker.ready.then(function (reg) { return reg.pushManager.getSubscription(); }).then(function (sub) { 137 if (!sub) { render(null); return; } 138 var ep = sub.endpoint; 139 sub.unsubscribe().then(function () { return post('/push/unsubscribe', { endpoint: ep }); }) 140 .then(function () { location.reload(); }); 141 }); 142 }); 143 144 btnTest.addEventListener('click', function () { 145 btnTest.disabled = true; 146 post('/push/test').then(function () { setTimeout(function () { btnTest.disabled = false; }, 2000); }); 147 }); 148 149 alertsBox.addEventListener('change', function () { 150 if (!currentEndpoint) return; 151 post('/push/alerts', { endpoint: currentEndpoint, alerts: readAlertBoxes() }).then(function (r) { 152 if (r.ok) { savedMsg.hidden = false; setTimeout(function () { savedMsg.hidden = true; }, 1500); } 153 }); 154 }); 155 156 document.querySelectorAll('.np-remove').forEach(function (btn) { 157 btn.addEventListener('click', function () { 158 post('/push/unsubscribe', { endpoint: btn.getAttribute('data-endpoint') }).then(function () { location.reload(); }); 159 }); 160 }); 161 })(); 162 </script> 60 <%# Het script van deze pagina staat in assets/js/mod/admin-push.js (shaer-bqr). %> 163 61 <% } %> -
src/views/pages/admin-settings.ejs
rf85b2c3 r952baf3 27 27 <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div> 28 28 </form> 29 <script> 30 (function () { 31 var f = document.getElementById('mode-form'); 32 if (!f || f.__age18wired) return; f.__age18wired = true; 33 f.addEventListener('submit', function (e) { 34 var sel = f.querySelector('input[name=mode]:checked'); 35 if (sel && sel.value === 'cirkels' && f.getAttribute('data-was-cirkels') === '0') { 36 if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault(); 37 } 38 }); 39 })(); 40 </script> 29 <%# Het script van deze pagina staat in assets/js/mod/admin-settings.js (shaer-bqr). %> 41 30 </section> 42 31 -
src/views/pages/admin-site-edit.ejs
rf85b2c3 r952baf3 126 126 <%# Live preview: selecting an accent / theme / palette re-themes this page 127 127 instantly (reverts on reload; persists on save). %> 128 <script> 129 (function(){ 130 if (window.__themePreviewWired) return; window.__themePreviewWired = true; 131 var html = document.documentElement; 132 function applyAccent(hex){ 133 if(!/^#[0-9a-fA-F]{6}$/.test(hex)) return; 134 var sa = document.getElementById('pcms-site-accent'); 135 if(!sa){ sa = document.createElement('style'); sa.id = 'pcms-site-accent'; document.head.appendChild(sa); } 136 sa.textContent = ':root,[data-palette]{--accent:'+hex+';--accent-soft:color-mix(in srgb,'+hex+' 80%,white);--accent-tint:color-mix(in srgb,'+hex+' 12%,transparent);}'; 137 } 138 document.addEventListener('change', function(e){ 139 var t = e.target; if(!t || !t.name) return; 140 if(t.name === 'palette'){ html.setAttribute('data-palette', t.value); } 141 else if(t.name === 'accent'){ applyAccent(t.value); } 142 else if(t.name === 'theme_override'){ 143 if(t.value === 'light' || t.value === 'dark'){ html.setAttribute('data-theme', t.value); } 144 else { var dk = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; html.setAttribute('data-theme', dk ? 'dark' : 'light'); } 145 } 146 }); 147 })(); 148 </script> 128 <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %> 149 129 150 130 <fieldset> … … 275 255 </div> 276 256 277 <script> 278 (function() { 279 // Slug URL field: show the real host as a dimmed prefix (the slug itself is 280 // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>. 281 var sh = document.getElementById('slug-host'); 282 if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/'); 283 })(); 284 (function() { 285 // Profile-links repeater: add row from <template>, remove on click. 286 var rows = document.getElementById('profile-links-rows'); 287 var tpl = document.getElementById('profile-link-template'); 288 var add = document.getElementById('profile-link-add'); 289 if (!rows || !tpl || !add) return; 290 291 add.addEventListener('click', function() { 292 var clone = tpl.content.cloneNode(true); 293 rows.appendChild(clone); 294 }); 295 rows.addEventListener('click', function(e) { 296 if (e.target && e.target.classList.contains('pl-remove')) { 297 var row = e.target.closest('.profile-link-row'); 298 if (row) row.remove(); 299 } 300 }); 301 })(); 302 303 // P63 — Profile photo picker: upload via /admin/sites/upload-photo, 304 // then write the returned URL into the visible input. Live thumb preview. 305 (function() { 306 var picker = document.getElementById('photo-picker'); 307 if (!picker) return; 308 var thumb = document.getElementById('photo-thumb'); 309 var preview = document.getElementById('photo-preview'); 310 var urlEl = document.getElementById('photo-url'); 311 var trigger = document.getElementById('photo-upload-trigger'); 312 var clear = document.getElementById('photo-clear'); 313 var field = document.getElementById('photo-upload-field'); 314 var status = document.getElementById('photo-status'); 315 316 function showPreview(url) { 317 if (url) { 318 preview.src = url; 319 preview.hidden = false; 320 thumb.removeAttribute('data-empty'); 321 clear.hidden = false; 322 } else { 323 preview.src = ''; 324 preview.hidden = true; 325 thumb.setAttribute('data-empty', ''); 326 clear.hidden = true; 327 } 328 } 329 330 if (urlEl) { 331 urlEl.addEventListener('input', function() { 332 showPreview(urlEl.value.trim()); 333 }); 334 } 335 336 if (trigger && field) { 337 trigger.addEventListener('click', function() { field.click(); }); 338 field.addEventListener('change', async function() { 339 var file = field.files && field.files[0]; 340 if (!file) return; 341 status.classList.remove('is-error'); 342 status.textContent = 'Uploaden…'; 343 try { 344 var fd = new FormData(); 345 fd.append('photo', file); 346 var r = await fetch('/admin/sites/upload-photo', { 347 method: 'POST', 348 body: fd, 349 credentials: 'same-origin', 350 }); 351 var j = await r.json(); 352 if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')')); 353 urlEl.value = j.url; 354 showPreview(j.url); 355 status.textContent = 'Geüpload ✓'; 356 setTimeout(function() { status.textContent = ''; }, 2000); 357 } catch (e) { 358 status.classList.add('is-error'); 359 status.textContent = 'Mislukt: ' + e.message; 360 } finally { 361 field.value = ''; 362 } 363 }); 364 } 365 366 if (clear) { 367 clear.addEventListener('click', function() { 368 urlEl.value = ''; 369 showPreview(''); 370 status.textContent = ''; 371 // Note: doesn't delete the file from disk — saving the form with empty 372 // URL leaves the file orphaned on the server. Acceptable for now. 373 }); 374 } 375 })(); 376 </script> 257 377 258 378 259 <style> -
src/views/pages/admin-videos.ejs
rf85b2c3 r952baf3 40 40 </div> 41 41 42 <script> 43 (function () { 44 if (window.__videosWired) return; window.__videosWired = true; 45 var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') }) %>; 46 document.addEventListener('click', function (e) { 47 var c = e.target.closest('[data-copy]'); 48 if (c) { 49 var u = location.origin + c.getAttribute('data-copy'); 50 var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); }; 51 if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); }); 52 else window.prompt('URL', u); 53 return; 54 } 55 var d = e.target.closest('[data-del]'); 56 if (d) { 57 if (!window.confirm(T.delC)) return; 58 fetch('/admin/media/videos/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) }) 59 .then(function (r) { return r.json(); }) 60 .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } }) 61 .catch(function () {}); 62 } 63 }); 64 })(); 65 </script> 42 <%# Het script staat in assets/js/mod/admin-videos.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 43 <%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') } }) %> -
src/views/pages/auth-register.ejs
rf85b2c3 r952baf3 41 41 .setup-handle-hint code { color: var(--accent); font-weight: 600; background: color-mix(in srgb, var(--accent) 12%, transparent); padding: .05rem .35rem; border-radius: 6px; white-space: nowrap; } 42 42 </style> 43 <script> 44 (function () { 45 var u = document.getElementById('setup-username'), out = document.getElementById('setup-handle'); 46 if (!u || !out) return; 47 function upd() { 48 var v = (u.value || '').toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'username'; 49 out.textContent = '@' + v + '@' + location.host; 50 } 51 u.addEventListener('input', upd); 52 upd(); 53 })(); 54 </script> 43 <%# Het script van deze pagina staat in assets/js/mod/auth-register.js (shaer-bqr). %> -
src/views/pages/authorize-interaction.ejs
rf85b2c3 r952baf3 12 12 <p class="fedi-bm-help"><%= t('fedi.bm_help') %></p> 13 13 </div> 14 <script> 15 (function () { 16 if (window.__fediBmWired) return; window.__fediBmWired = true; 17 var a = document.getElementById('fedi-bm-btn'); if (!a) return; 18 a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))"); 19 a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); }); 20 })(); 21 </script> 14 <%# Het script van deze pagina staat in assets/js/mod/authorize-interaction.js (shaer-bqr). %> 22 15 <% if (!manage.length) { %> 23 16 <p class="auth-interact-note"><%= t('fedi.manage_empty') %></p> … … 56 49 <% }); %> 57 50 </ul> 58 <script> 59 (function () { 60 if (window.__fediEditWired) return; window.__fediEditWired = true; 61 document.addEventListener('click', function (e) { 62 var b = e.target.closest && e.target.closest('.fedi-edit-btn'); 63 if (!b) return; 64 var li = b.closest('.fedi-manage-item'); if (!li) return; 65 var form = li.querySelector('.fedi-edit-form'); if (!form) return; 66 var open = form.classList.toggle('is-open'); 67 b.classList.toggle('is-open', open); 68 b.setAttribute('aria-expanded', open ? 'true' : 'false'); 69 if (open) { var ed = form.querySelector('.re-editor') || form.querySelector('textarea'); if (ed) ed.focus(); } 70 }); 71 })(); 72 </script> 51 73 52 <% } %> 74 53 <p class="auth-interact-note"><a href="/"><%= t('fedi.remote_back') %></a></p> … … 331 310 </style> 332 311 333 <script> 334 /* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page. */ 335 (function(){ 336 if (window.__fediReactWired) return; window.__fediReactWired = true; 337 document.addEventListener('submit', function(e){ 338 var f = e.target.closest && e.target.closest('.fedi-react-form'); 339 if (!f) return; 340 e.preventDefault(); 341 var btn = f.querySelector('button'); if (!btn || btn.disabled) return; 342 btn.disabled = true; 343 var body = new URLSearchParams(); 344 new FormData(f).forEach(function(v, k){ body.append(k, v); }); 345 fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' }) 346 .then(function(r){ return r.ok ? r.json() : null; }) 347 .then(function(j){ 348 if (j) { 349 var on = !!j.on; 350 btn.classList.toggle('is-on', on); 351 var lbl = btn.querySelector('.fedi-bigact-label'); 352 if (lbl) lbl.textContent = on ? (btn.getAttribute('data-on') || lbl.textContent) : (btn.getAttribute('data-off') || lbl.textContent); 353 } 354 }) 355 .catch(function(){}) 356 .then(function(){ btn.disabled = false; }); 357 }); 358 })(); 359 </script> 312 -
src/views/pages/download.ejs
rf85b2c3 r952baf3 26 26 <p class="dl-sub"><%= _ready_sub %></p> 27 27 <p><a class="dl-go" href="<%= fileUrl %>"><%= _manual %></a></p> 28 <script> 29 // Auto-start de download (zelfde-origin attachment-link). 30 setTimeout(function(){ try { window.location.href = <%- JSON.stringify(fileUrl) %>; } catch(e){} }, 600); 31 </script> 28 <%# Het script staat in assets/js/mod/download.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %> 32 29 <% } else { %> 33 30 <h1 class="dl-h1"><%= _download_btn %> <%= tr.title %></h1> … … 57 54 .dl-go { padding: 12px 20px; border-radius: 10px; border: none; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; font-size: 15px; cursor: pointer; text-decoration: none; display: inline-block; } 58 55 </style> 56 <%- include('../partials/page-data', { pageData: { fileUrl: fileUrl } }) %> -
src/views/pages/messages.ejs
rf85b2c3 r952baf3 2 2 <%- include('../partials/fedi-tabs', { active: 'berichten' }) %> 3 3 <h1 class="msg-title"><%= t('msg.title') %></h1> 4 <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : success)) %></div><% } %>5 <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : error%></div><% } %>4 <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : (success === 'reply_sent' ? t('msg.reply_sent') : success))) %></div><% } %> 5 <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : (error === 'reply_empty' ? t('msg.reply_empty') : ((error === 'reply_failed' || error === 'reply_target') ? t('msg.reply_failed') : error)) %></div><% } %> 6 6 7 7 <%# FEP-633c: a pending guardianship offer is a special message: the kid … … 25 25 26 26 <div class="msg-filters" role="tablist" aria-label="<%= t('msg.title') %>"> 27 <%# Vier chips, geen zes: Berichten, Gesprekken en Verzonden gingen op in 28 een enkele Gesprekken-view, waarin verzonden en ontvangen in dezelfde 29 draad staan. Activiteit en Moderatie blijven wat ze waren. %> 27 30 <button type="button" class="msg-chip is-on" data-show="all"><%= t('msg.filter_all') %></button> 28 <button type="button" class="msg-chip" data-show="msgs"><%= t('msg.filter_msgs') %></button>29 31 <button type="button" class="msg-chip" data-show="conv"><%= t('msg.filter_conv') %></button> 30 32 <button type="button" class="msg-chip" data-show="act"><%= t('msg.filter_act') %></button> 31 33 <button type="button" class="msg-chip" data-show="mod"><%= t('msg.filter_mod') %></button> 32 <button type="button" class="msg-chip" data-show="sent"><%= t('msg.filter_sent') %></button>33 34 </div> 34 35 <div class="msg-search"> … … 59 60 </div> 60 61 61 <script> 62 (function () { 63 if (window.__msgWired) return; window.__msgWired = true; 64 65 // Filtering: kind chip AND free-text search, combined in JS (search over the 66 // sender and the rendered message text). Empty search + "all" = show all. 67 var list = document.querySelector('.msg-list'); 68 var noMatch = document.querySelector('.msg-nomatch'); 69 var q = document.getElementById('msg-q'); 70 var kind = 'all'; 71 var items = []; 72 function indexItem(li) { 73 // Index once: sender + message body + linked post title + poll text. 74 var body = li.querySelector('.msg-content'); 75 var post = li.querySelector('.msg-post'); 76 var poll = li.querySelector('.msg-poll'); 77 li._search = ((li.getAttribute('data-who') || '') + ' ' + 78 (body ? body.textContent : '') + ' ' + (post ? post.textContent : '') + ' ' + 79 (poll ? poll.textContent : '')).toLowerCase(); 80 } 81 // Re-collect + index; called on load and after each "Load more" append so new 82 // items join the filter/search (and inherit the active chip via apply()). 83 function reindex() { 84 items = list ? Array.prototype.slice.call(list.querySelectorAll('.msg-item')) : []; 85 items.forEach(function (li) { if (!li._search) indexItem(li); }); 86 } 87 reindex(); 88 function apply() { 89 if (!list) return; 90 var term = (q && q.value || '').trim().toLowerCase(); 91 var shown = 0; 92 items.forEach(function (li) { 93 var ok = (kind === 'all' || li.getAttribute('data-kind') === kind) && 94 (!term || li._search.indexOf(term) !== -1); 95 li.style.display = ok ? '' : 'none'; 96 if (ok) shown++; 97 }); 98 if (noMatch) noMatch.hidden = shown !== 0; 99 } 100 document.addEventListener('click', function (e) { 101 var chip = e.target.closest('.msg-chip'); if (!chip) return; 102 kind = chip.getAttribute('data-show'); 103 document.querySelectorAll('.msg-chip').forEach(function (c) { c.classList.toggle('is-on', c === chip); }); 104 apply(); 105 }); 106 if (q) q.addEventListener('input', apply); 107 // After a "Load more" append (htmx), index the new rows and re-apply the filter. 108 document.body.addEventListener('htmx:afterSettle', function (e) { 109 if (e.target && e.target.id === 'msg-list') { reindex(); apply(); } 110 }); 111 112 var a = document.getElementById('fedi-bm-btn'); 113 if (a) { 114 a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))"); 115 a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); }); 116 } 117 })(); 118 </script> 62 <%# Filteren, zoeken en het in-/uitklappen zitten in assets/js/mod/messages.js. 63 Inline script hier wordt door de CSP geweigerd zodra je deze pagina via een 64 link BINNEN de site opent -- zie shaer-0i6. De shell laadt de module op 65 body[data-js]; deze pagina vraagt erom met pageJs (routes/posts.js). %> 119 66 120 67 <style> … … 138 85 139 86 .msg-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .55rem; } 87 140 88 141 89 .msg-item { display: flex; gap: .8rem; padding: .85rem .95rem; border-radius: 14px; align-items: flex-start; … … 148 96 background: color-mix(in srgb, var(--accent, #06c) 4%, transparent); } 149 97 .msg-sent { border-left: 3px solid color-mix(in srgb, var(--accent, #06c) 55%, transparent); } 98 99 /* ── Gesprekken ────────────────────────────────────────────────────── 100 NA .msg-item, en dat is geen smaak: .msg-item zet display:flex, en bij 101 gelijke specificiteit wint de laatste regel. Stond dit ervoor, dan kwam de 102 draadkop NAAST de bubbels te staan in plaats van erboven. */ 103 .msg-thread { display: block; } 104 .msg-thread-head { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-bottom: .4rem; } 105 /* De tegenpartij is een labeltje bij het gesprek, geen kop erboven: een 106 kleine verzonken uitsparing, zodat de bubbels eronder de ruimte krijgen. 107 Het aantal zit in dezelfde uitsparing in plaats van ernaast. */ 108 /* Ook de knop die het gesprek in- en uitklapt. Een button erft geen font en 109 geen kleur, dus die staan hier expliciet; zonder dat wordt het ineens 110 systeemblauw in 13px. */ 111 .msg-thread-who { display: inline-flex; align-items: center; gap: .3rem; min-width: 0; 112 font: inherit; font-size: .78em; line-height: 1.5; font-weight: 500; 113 padding: .05rem .5rem; border-radius: 999px; border: 0; cursor: pointer; 114 color: color-mix(in srgb, var(--ink, #000) 62%, transparent); 115 background: color-mix(in srgb, var(--ink, #000) 5%, transparent); 116 box-shadow: inset 0 1px 2px color-mix(in srgb, var(--ink, #000) 9%, transparent); 117 max-width: 100%; overflow: hidden; white-space: nowrap; 118 /* Ruimer aanraakvlak dan de tekst: een chip van 18px hoog is op een telefoon 119 niet te raken. De uitsparing blijft klein, het doel wordt groter. */ 120 min-height: 32px; } 121 .msg-thread-who:hover { background: color-mix(in srgb, var(--ink, #000) 9%, transparent); } 122 .msg-thread-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 123 .msg-thread-chevron { width: 12px; height: 12px; flex: none; opacity: .55; 124 transition: transform .18s ease; } 125 /* Ingeklapt: de bubbels en het antwoordveld verdwijnen, de uitsparing met de 126 naam en het AANTAL blijft staan -- dat aantal is juist ingeklapt het meest 127 waard. */ 128 .msg-thread.is-collapsed .msg-thread-msgs, 129 .msg-thread.is-collapsed .msg-thread-foot { display: none; } 130 /* Tijdens het zoeken wint de treffer van de dichtgeklapte stand. */ 131 .msg-thread.is-collapsed.is-search-open .msg-thread-msgs { display: flex; } 132 .msg-thread.is-collapsed.is-search-open .msg-thread-foot { display: flex; } 133 .msg-thread.is-collapsed .msg-thread-chevron { transform: rotate(-90deg); } 134 .msg-thread.is-collapsed .msg-thread-head { margin-bottom: 0; } 135 @media (prefers-reduced-motion: reduce) { .msg-thread-chevron { transition: none; } } 136 .msg-thread-count { font-variant-numeric: tabular-nums; opacity: .75; flex: 0 0 auto; 137 padding-inline-start: .3rem; border-inline-start: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent); } 138 .msg-thread-post { display: inline-flex; align-items: center; gap: .3rem; font-size: .85em; 139 margin-inline-start: auto; min-width: 0; max-width: 100%; 140 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 141 .msg-thread-post svg { width: 13px; height: 13px; flex: none; } 142 .msg-thread-msgs { list-style: none; margin: 0; padding: 0 0 0 .7rem; display: flex; flex-direction: column; gap: .5rem; 143 border-inline-start: 2px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); } 144 .msg-sub { display: flex; gap: .6rem; align-items: flex-start; min-width: 0; } 145 .msg-sub > .msg-body { min-width: 0; } 146 /* Jouw eigen bijdrage onderscheidt zich met een tint en de accentlijn die 147 .msg-sent al had -- niet met row-reverse: dat duwde de avatar buiten de 148 kaart zodra de regel smaller werd dan zijn inhoud. */ 149 .msg-sub.msg-sent { border-radius: 10px; padding: .3rem .45rem; 150 background: color-mix(in srgb, var(--accent, #06c) 5%, transparent); } 151 /* Een lange naam of tijd mag de bubbel niet uit de kaart duwen. */ 152 .msg-sub .msg-line { flex-wrap: wrap; } 153 154 /* De voet van een gesprek: antwoorden en zwaaien naast elkaar, ingeklapt. 155 Een open editor onder elk gesprek maakt de lijst weer onleesbaar -- precies 156 wat deze weergave moest oplossen. */ 157 .msg-thread-foot { display: flex; align-items: flex-start; gap: .5rem; margin-top: .5rem; flex-wrap: wrap; } 158 .msg-reply { flex: 1 1 12rem; min-width: 0; } 159 .msg-reply > summary { cursor: pointer; font-size: .82rem; color: var(--ink-soft, #888); } 160 .msg-reply[open] > summary { margin-bottom: .4rem; } 161 .msg-reply-form { min-width: 0; } 162 .msg-wave button { line-height: 1.2; } 163 164 @media (max-width: 560px) { 165 /* Op een telefoon is 40px avatar per bubbel puur verlies: de naam staat er 166 al naast, en de inhoud heeft de breedte harder nodig. */ 167 .msg-sub > .msg-av { display: none; } 168 .msg-thread-msgs { padding-inline-start: .55rem; } 169 .msg-thread-post { margin-inline-start: 0; } 170 .msg-item { padding: .7rem .6rem; gap: .6rem; } 171 } 150 172 151 173 .msg-av { position: relative; flex: 0 0 40px; width: 40px; height: 40px; border-radius: 50%; -
src/views/pages/news.ejs
rf85b2c3 r952baf3 41 41 .tl-paste-go { background: var(--accent); color: var(--paper); } 42 42 </style> 43 <script> 44 (function () { 45 if (window.__tlPasteWired) return; window.__tlPasteWired = true; 46 function modal() { return document.getElementById('tl-paste'); } 47 function openM() { 48 var m = modal(); if (!m) return; 49 var inp = m.querySelector('.tl-paste-input'); 50 m.classList.add('is-open'); 51 if (inp) { 52 inp.value = ''; inp.focus(); 53 if (navigator.clipboard && navigator.clipboard.readText) { 54 navigator.clipboard.readText().then(function (t) { 55 t = (t || '').trim(); 56 if (/^https?:\/\//i.test(t) && !inp.value) { inp.value = t; inp.select(); } 57 }).catch(function () {}); 58 } 59 } 60 } 61 function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); } 62 function go() { 63 var m = modal(); if (!m) return; 64 var inp = m.querySelector('.tl-paste-input'), v = (inp ? inp.value : '').trim(); 65 if (!/^https?:\/\//i.test(v)) { if (inp) inp.focus(); return; } 66 location.href = '/authorize_interaction?uri=' + encodeURIComponent(v); 67 } 68 document.addEventListener('click', function (e) { 69 if (e.target.closest && e.target.closest('#tl-paste-btn')) { e.preventDefault(); openM(); return; } 70 if (e.target.closest && e.target.closest('.tl-paste-go')) { e.preventDefault(); go(); return; } 71 if (e.target.closest && (e.target.closest('.tl-paste-cancel') || e.target.closest('.tl-paste-x'))) { e.preventDefault(); closeM(); return; } 72 var op = document.querySelector('.tl-paste.is-open'); if (op && e.target === op) closeM(); 73 }); 74 document.addEventListener('keydown', function (e) { 75 var m = document.querySelector('.tl-paste.is-open'); if (!m) return; 76 if (e.key === 'Escape') closeM(); 77 else if (e.key === 'Enter' && e.target.closest && e.target.closest('.tl-paste')) { e.preventDefault(); go(); } 78 }); 79 })(); 80 </script> 43 <%# Het script staat in assets/js/mod/news.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %> 81 44 <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %> 82 45 <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %> … … 184 147 .tl-act-block:hover { background: color-mix(in srgb, #d9534f 14%, transparent); border-color: color-mix(in srgb, #d9534f 50%, transparent); } 185 148 </style> 186 187 <script> 188 (function(){ 189 if (window.__fediRemoteWired) return; window.__fediRemoteWired = true; 190 var current = null, currentBtn = null; 191 function close(){ if (current) { current.remove(); current = null; currentBtn = null; } } 192 function go(raw, uri){ var d=(raw||'').trim().replace(/^@?[^@\s]*@/,'').replace(/^https?:\/\//i,'').replace(/\/.*$/,'').trim(); if(d){ try{ localStorage.setItem('pcmsFediServer', d); }catch(e){} location.href='https://'+d+'/authorize_interaction?uri='+encodeURIComponent(uri||''); } } 193 function place(f, b){ var r=b.getBoundingClientRect(); f.style.top=(r.bottom+window.scrollY+6)+'px'; f.style.left=Math.max(8, Math.min(r.left+window.scrollX, window.scrollX+window.innerWidth-340))+'px'; } 194 document.addEventListener('click', function(e){ 195 if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; } 196 if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return; 197 var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn'); 198 if (b) { 199 e.preventDefault(); 200 if (currentBtn === b) { close(); return; } 201 close(); 202 var f = document.createElement('form'); f.className='fedi-remote-form'; f.dataset.uri = b.getAttribute('data-fedi-uri')||''; 203 f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false"><button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">→</button><button type="button" class="fedi-remote-cancel" aria-label="x">×</button>'; 204 var _inp = f.querySelector('input'); _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social'; 205 try{ var _sv = localStorage.getItem('pcmsFediServer'); if(_sv) _inp.value = _sv; }catch(e){} 206 document.body.appendChild(f); place(f, b); current=f; currentBtn=b; _inp.focus(); _inp.select(); 207 return; 208 } 209 if (current) close(); 210 }); 211 document.addEventListener('submit', function(e){ var f=e.target.closest && e.target.closest('.fedi-remote-form'); if(!f) return; e.preventDefault(); go(f.querySelector('input').value, f.dataset.uri); }); 212 document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); }); 213 window.addEventListener('scroll', close, true); 214 })(); 215 216 /* Short feed videos (gif/cover loops like an animated cover) autoplay + loop muted like a GIF; 217 longer real videos keep their controls. Decided on the actual duration once metadata loads. */ 218 (function(){ 219 document.querySelectorAll('.tl-media-video').forEach(function(v){ 220 if (v.dataset.gifWired) return; v.dataset.gifWired = '1'; 221 var decide = function(){ 222 if (v.duration && v.duration <= 30) { 223 v.removeAttribute('controls'); v.loop = true; v.muted = true; v.play().catch(function(){}); 224 } 225 }; 226 if (v.readyState >= 1) decide(); else v.addEventListener('loadedmetadata', decide, { once: true }); 227 }); 228 })(); 229 230 /* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page (no reload, no banner). */ 231 (function(){ 232 if (window.__newsReactWired) return; window.__newsReactWired = true; 233 document.addEventListener('submit', function(e){ 234 var f = e.target.closest && e.target.closest('.tl-react-form'); 235 if (!f) return; 236 e.preventDefault(); 237 var btn = f.querySelector('button'); if (!btn || btn.disabled) return; 238 btn.disabled = true; 239 var body = new URLSearchParams(); 240 new FormData(f).forEach(function(v, k){ body.append(k, v); }); 241 fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' }) 242 .then(function(r){ return r.ok ? r.json() : null; }) 243 .then(function(j){ if (j) btn.classList.toggle('is-on', !!j.on); }) 244 .catch(function(){}) 245 .then(function(){ btn.disabled = false; }); 246 }); 247 })(); 248 </script> 249 250 <script> 251 // Collapse long post bodies to a max height with a "read more" toggle — only when the content 252 // actually overflows. Runs on every /news render (full load + htmx swap); a per-element flag 253 // prevents double-wiring. 254 (function(){ 255 var RM = '<%= t("tl.read_more") %>', SL = '<%= t("tl.show_less") %>'; 256 document.querySelectorAll('.tl-content:not(.nsfw-media):not([data-rm])').forEach(function(c){ 257 c.setAttribute('data-rm', '1'); 258 if (c.scrollHeight > 360) { 259 c.classList.add('tl-clamp'); 260 var b = document.createElement('button'); 261 b.type = 'button'; b.className = 'tl-readmore'; b.textContent = RM; 262 b.addEventListener('click', function(){ 263 b.textContent = c.classList.toggle('tl-clamp') ? RM : SL; 264 }); 265 c.insertAdjacentElement('afterend', b); 266 } 267 }); 268 })(); 269 </script> 149 <%- include('../partials/page-data', { pageData: { readMore: t('tl.read_more'), showLess: t('tl.show_less') } }) %> -
src/views/pages/paid-gate.ejs
rf85b2c3 r952baf3 31 31 32 32 <script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script> 33 <script> 34 (function () { 35 var base = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>"; 36 var slug = "<%= pgSlug %>"; 37 var hasPatron = <%= _hasPatron ? 'true' : 'false' %>; 38 var I = { join: "<%= t('pgate.join_short') %>", confirm: "<%= t('pgate.confirm') %>", failed: "<%= t('pgate.failed') %>", error: "<%= t('pgate.error') %>" }; 39 var btn = document.getElementById('pg-unlock'); 40 var status = document.getElementById('pg-status'); 41 function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); } 42 function toLink() { location.href = base + '/paid/link?post=' + encodeURIComponent(slug); } 43 44 // No WebAuthn here: an assertion is impossible. With a Patreon page there is 45 // already a "Word supporter" button, so hide the (dead) unlock button rather 46 // than turn it into a second "Word supporter". Without one, this IS the button. 47 if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) { 48 if (hasPatron) { btn.style.display = 'none'; } 49 else { btn.textContent = I.join; btn.addEventListener('click', toLink); } 50 return; 51 } 52 53 btn.addEventListener('click', function () { 54 btn.disabled = true; 55 say(I.confirm); 56 fetch(base + '/paid/challenge?post=' + encodeURIComponent(slug)) 57 .then(function (r) { if (!r.ok) throw { link: true }; return r.json(); }) 58 .then(function (data) { 59 return window.SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: data.options }) 60 .then(function (response) { 61 return fetch(base + '/paid/unlock', { 62 method: 'POST', headers: { 'Content-Type': 'application/json' }, 63 body: JSON.stringify({ response: response, blob: data.blob }), 64 }); 65 }); 66 }) 67 .then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); }) 68 .then(function (res) { 69 if (res.j && res.j.ok && res.j.redirect) { 70 // Reload the real post page via the one-shot unlock capability, so it 71 // renders through its normal template (layout, styles, audio). 72 location.href = res.j.redirect; 73 } else if (res.status === 403) { 74 toLink(); // no valid passkey yet (or lapsed tier): link via Patreon 75 } else { 76 btn.disabled = false; say(I.failed, true); 77 } 78 }) 79 .catch(function (e) { 80 if (e && e.link) { toLink(); return; } 81 if (e && e.name === 'NotAllowedError') { toLink(); return; } // cancelled / no passkey -> link 82 btn.disabled = false; say(I.error, true); 83 }); 84 }); 85 })(); 86 </script> 33 <%# Het script staat in assets/js/mod/paid-gate.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 87 34 88 35 <style> … … 105 52 .pg-status.is-err { color: #c0392b; } 106 53 </style> 54 <%- include('../partials/page-data', { pageData: { base: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : ''), slug: pgSlug, hasPatron: !!_hasPatron, i18n: { join: t('pgate.join_short'), confirm: t('pgate.confirm'), failed: t('pgate.failed'), error: t('pgate.error') } } }) %> -
src/views/pages/paid-passkey.ejs
rf85b2c3 r952baf3 12 12 13 13 <script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script> 14 <script> 15 (function () { 16 var options = <%- optionsJson %>; 17 var blob = "<%= regBlob %>"; 18 var I = { unsupported: "<%= t('ppk.unsupported') %>", follow: "<%= t('ppk.follow') %>", done: "<%= t('ppk.done') %>", failed: "<%= t('ppk.failed') %>", cancelled: "<%= t('ppk.cancelled') %>", error: "<%= t('pgate.error') %>" }; 19 var postUrl = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>"; 20 var btn = document.getElementById('pk-go'); 21 var status = document.getElementById('pk-status'); 22 function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); } 23 24 if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) { 25 btn.disabled = true; 26 say(I.unsupported, true); 27 return; 28 } 29 btn.addEventListener('click', function () { 30 btn.disabled = true; 31 say(I.follow); 32 window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: options }) 33 .then(function (response) { 34 return fetch('/paid/register', { 35 method: 'POST', headers: { 'Content-Type': 'application/json' }, 36 body: JSON.stringify({ response: response, blob: blob }), 37 }); 38 }) 39 .then(function (r) { return r.json(); }) 40 .then(function (j) { 41 if (j && j.ok) { say(I.done); setTimeout(function () { location.href = postUrl; }, 900); } 42 else { btn.disabled = false; say(I.failed.replace('{err}', (j && j.error) || '?'), true); } 43 }) 44 .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? I.cancelled : I.error, true); }); 45 }); 46 })(); 47 </script> 14 <%# Het script staat in assets/js/mod/paid-passkey.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 48 15 49 16 <style> … … 58 25 .pk-status.is-err { color: #c0392b; } 59 26 </style> 27 <%- include('../partials/page-data', { pageData: { options: JSON.parse(optionsJson), blob: regBlob, i18n: { unsupported: t('ppk.unsupported'), follow: t('ppk.follow'), done: t('ppk.done'), failed: t('ppk.failed'), cancelled: t('ppk.cancelled'), error: t('pgate.error') }, postUrl: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') + '/' + postSlug } }) %> -
src/views/pages/post-edit.ejs
rf85b2c3 r952baf3 272 272 <% } else { %> 273 273 <p id="pe-fedi-audio-warn" style="font-size:12px;color:#c0392b;margin:2px 0 0 26px" hidden>⚠️ <%= t('pedit.fedi_audio_oneway') %></p> 274 <script> 275 (function () { 276 var cb = document.getElementById('pe-fedi-audio'), w = document.getElementById('pe-fedi-audio-warn'); 277 if (cb && w && !cb.__wired) { cb.__wired = true; cb.addEventListener('change', function () { w.hidden = !cb.checked; }); } 278 })(); 279 </script> 274 <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 280 275 <% } %> 281 <script> 282 (function () { 283 var cw = document.getElementById('pe-cw'), nsfw = document.getElementById('pe-nsfw'); 284 // Typing a warning text implies the post is sensitive → auto-tick NSFW. 285 if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true; 286 cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; }); 287 } 288 })(); 289 </script> 276 290 277 <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen. 291 278 var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; } … … 335 322 </select> 336 323 </div> 337 <script> 338 (function () { 339 var box = document.getElementById('pe-poll-fields'); 340 var tog = document.getElementById('pe-poll-toggle'); 341 var opts = document.getElementById('pe-poll-opts'); 342 var add = document.getElementById('pe-poll-add'); 343 if (!box || !opts) return; 344 if (tog && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); } 345 var PH = opts.getAttribute('data-ph') || '', DEL = opts.getAttribute('data-del') || ''; 346 function rows() { return opts.querySelectorAll('.pe-poll-row'); } 347 // A poll needs at least 2 options: hide the ✕ at the minimum, and cap adding at 8. 348 function refresh() { 349 var n = rows().length; 350 opts.querySelectorAll('.pe-poll-del').forEach(function (b) { b.hidden = n <= 2; }); 351 if (add) add.disabled = n >= 8; 352 } 353 function makeRow() { 354 var row = document.createElement('div'); row.className = 'pe-poll-row'; 355 var i = document.createElement('input'); i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100; i.placeholder = PH; 356 var d = document.createElement('button'); d.type = 'button'; d.className = 'pe-poll-del'; d.setAttribute('aria-label', DEL); d.title = DEL; d.innerHTML = '×'; 357 row.appendChild(i); row.appendChild(d); return row; 358 } 359 if (add && !add.__wired) { add.__wired = true; add.addEventListener('click', function () { if (rows().length >= 8) return; opts.appendChild(makeRow()); refresh(); }); } 360 if (!opts.__wired) { opts.__wired = true; opts.addEventListener('click', function (e) { var d = e.target.closest('.pe-poll-del'); if (!d || rows().length <= 2) return; d.closest('.pe-poll-row').remove(); refresh(); }); } 361 refresh(); 362 })(); 363 </script> 324 364 325 <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %> 365 326 <label class="pe-checkbox"> … … 377 338 </label> 378 339 </div> 379 <script> 380 (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price'); 381 if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })(); 382 </script> 340 383 341 <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %> 384 342 <label class="pe-checkbox" style="margin-top:8px"> … … 392 350 <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div> 393 351 </div> 394 <script> 395 (function () { 396 var cb = document.getElementById('pe-sched-toggle'); 397 var box = document.getElementById('pe-sched-fields'); 398 if (!cb || !box) return; 399 var inp = document.getElementById('pe-publish-at'); 400 var SITE_TZ = '<%= timezone || '' %>'; // configured site timezone; empty = browser local 401 var pad = function (n) { return String(n).padStart(2, '0'); }; 402 // Offset (ms) between a timezone and UTC at a given moment. 403 function tzOffset(date, tz) { 404 var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); 405 var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; }); 406 return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime(); 407 } 408 // datetime-local "wall time" (in the site zone) → UTC Date. 409 function wallToUtc(wall) { 410 if (!SITE_TZ) return new Date(wall); 411 var guess = new Date(wall + ':00Z').getTime(); 412 return new Date(guess - tzOffset(new Date(guess), SITE_TZ)); 413 } 414 // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone. 415 function utcToWall(iso) { 416 var d = new Date(iso); if (isNaN(d)) return ''; 417 if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()); 418 var f = new Intl.DateTimeFormat('en-CA', { timeZone: SITE_TZ, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); 419 var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; }); 420 return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute; 421 } 422 // Prefill: stored UTC → wall time in the site zone. 423 if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso); 424 // "Scheduled for" in human-readable time in the site zone. 425 var when = document.getElementById('pe-sched-when'); 426 if (when && when.dataset.iso) { 427 var dw = new Date(when.dataset.iso); 428 if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined); 429 } 430 function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; } 431 cb.addEventListener('change', sync); sync(); 432 // On save: wall time in the site zone → UTC-ISO via a hidden field. 433 var form = cb.closest('form'); 434 if (form) { 435 form.addEventListener('submit', function () { 436 if (inp) inp.removeAttribute('name'); 437 var old = form.querySelector('input[data-pa-utc]'); 438 if (old) old.remove(); 439 if (cb.checked && inp && inp.value) { 440 var d2 = wallToUtc(inp.value); 441 if (!isNaN(d2)) { 442 var h = document.createElement('input'); 443 h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', ''); 444 h.value = d2.toISOString(); 445 form.appendChild(h); 446 } 447 } 448 }); 449 } 450 })(); 451 </script> 352 452 353 <% } %> 453 354 </div> … … 1281 1182 </style> 1282 1183 1283 <script> 1284 (function() { 1285 1286 // ── Cover upload ──────────────────────────────────────────────── 1287 const coverField = document.getElementById('cover-upload-field'); 1288 const coverTrigger = document.getElementById('cover-upload-trigger'); 1289 const coverUrl = document.getElementById('cover-url-field'); 1290 const coverVideo = document.getElementById('cover-video-field'); 1291 const coverStatus = document.getElementById('cover-upload-status'); 1292 const coverWrap = document.getElementById('cover-preview-wrap'); 1293 const coverImg = document.getElementById('cover-preview-img'); 1294 1295 async function uploadImage(file) { 1296 const fd = new FormData(); 1297 fd.append('image', file); 1298 const res = await fetch('/posts/upload-image', { method: 'POST', body: fd }); 1299 if (!res.ok) { 1300 const j = await res.json().catch(() => ({})); 1301 throw new Error(j.error || ('Upload failed (' + res.status + ')')); 1302 } 1303 return await res.json(); // {url, size, mime} 1304 } 1305 1306 // ── Image editor (rotate / crop / mirror) ────────── 1307 // Lazy-load Cropper.js (locally vendored) on first use. 1308 let _cropperReady = null; 1309 function ensureCropper() { 1310 if (window.Cropper) return Promise.resolve(); 1311 if (_cropperReady) return _cropperReady; 1312 _cropperReady = new Promise((resolve, reject) => { 1313 if (!document.querySelector('link[data-cropper-css]')) { 1314 const l = document.createElement('link'); 1315 l.rel = 'stylesheet'; l.href = '/assets/vendor/cropper.min.css'; l.setAttribute('data-cropper-css', ''); 1316 document.head.appendChild(l); 1317 } 1318 const s = document.createElement('script'); 1319 s.src = '/assets/vendor/cropper.min.js'; 1320 s.onload = () => resolve(); 1321 s.onerror = () => reject(new Error('cropper load failed')); 1322 document.head.appendChild(s); 1323 }); 1324 return _cropperReady; 1325 } 1326 1327 // True for an animated WebP (VP8X chunk with the animation flag set) — like a GIF it must skip 1328 // the canvas editor, otherwise it'd be flattened to a single static frame. 1329 async function isAnimatedWebpFile(file) { 1330 if (!file || file.type !== 'image/webp') return false; 1331 try { 1332 const b = new Uint8Array(await file.slice(0, 40).arrayBuffer()); 1333 return b.length >= 21 && String.fromCharCode(b[12], b[13], b[14], b[15]) === 'VP8X' && (b[20] & 0x02) !== 0; 1334 } catch (_) { return false; } 1335 } 1336 1337 // Opens the editor for a chosen file; resolves with an edited File, 1338 // or null if the user cancels. Animated images (GIF / animated WebP) are NOT sent through the 1339 // canvas editor (they would become static) — those upload directly. 1340 async function openImageEditor(file) { 1341 if (!file || !file.type || !file.type.startsWith('image/')) return file; 1342 if (file.type === 'image/gif') return file; // preserve animation 1343 if (await isAnimatedWebpFile(file)) return file; // animated WebP → preserve animation 1344 try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly 1345 1346 return new Promise((resolve) => { 1347 const back = document.createElement('div'); 1348 back.className = 'imed-backdrop'; 1349 back.innerHTML = 1350 '<div class="imed-modal" role="dialog" aria-modal="true" aria-label="<%= t('imed.title') %>">' + 1351 '<div class="imed-stage"><img alt=""></div>' + 1352 '<div class="imed-tools">' + 1353 '<button type="button" data-act="rl" title="<%= t('imed.rotate_left') %>">⟲</button>' + 1354 '<button type="button" data-act="rr" title="<%= t('imed.rotate_right') %>">⟳</button>' + 1355 '<button type="button" data-act="fh" title="<%= t('imed.flip_h') %>">⇆</button>' + 1356 '<button type="button" data-act="fv" title="<%= t('imed.flip_v') %>">⇅</button>' + 1357 '<button type="button" data-act="zi" title="<%= t('imed.zoom_in') %>">+</button>' + 1358 '<button type="button" data-act="zo" title="<%= t('imed.zoom_out') %>">-</button>' + 1359 '<button type="button" data-act="reset" title="<%= t('imed.reset') %>">↺</button>' + 1360 '</div>' + 1361 '<div class="imed-actions">' + 1362 '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary"><%= t('imed.cancel') %></button>' + 1363 '<button type="button" data-act="apply" class="pe-btn pe-btn-primary"><%= t('imed.apply') %></button>' + 1364 '</div>' + 1365 '</div>'; 1366 document.body.appendChild(back); 1367 const img = back.querySelector('img'); 1368 const url = URL.createObjectURL(file); 1369 let cropper = null, sx = 1, sy = 1; 1370 1371 function cleanup() { 1372 try { if (cropper) cropper.destroy(); } catch (_) {} 1373 URL.revokeObjectURL(url); 1374 back.remove(); 1375 document.removeEventListener('keydown', onKey); 1376 } 1377 function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } } 1378 document.addEventListener('keydown', onKey); 1379 1380 img.onload = () => { 1381 cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true }); 1382 }; 1383 img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original 1384 img.src = url; 1385 1386 back.addEventListener('click', (e) => { 1387 const btn = e.target.closest('[data-act]'); 1388 if (e.target === back) { cleanup(); resolve(null); return; } 1389 if (!btn || !cropper) return; 1390 const a = btn.getAttribute('data-act'); 1391 if (a === 'rl') cropper.rotate(-90); 1392 else if (a === 'rr') cropper.rotate(90); 1393 else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); } 1394 else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); } 1395 else if (a === 'zi') cropper.zoom(0.1); 1396 else if (a === 'zo') cropper.zoom(-0.1); 1397 else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); } 1398 else if (a === 'cancel') { cleanup(); resolve(null); } 1399 else if (a === 'apply') { 1400 const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' }); 1401 const png = (file.type === 'image/png' || file.type === 'image/webp'); 1402 const mime = png ? 'image/png' : 'image/jpeg'; 1403 const ext = png ? '.png' : '.jpg'; 1404 canvas.toBlob((blob) => { 1405 cleanup(); 1406 if (!blob) { resolve(file); return; } 1407 const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, ''); 1408 resolve(new File([blob], base + ext, { type: mime })); 1409 }, mime, 0.92); 1410 } 1411 }); 1412 }); 1413 } 1414 1415 function showCoverPreview(url) { 1416 if (!coverWrap || !coverImg) return; 1417 if (url) { 1418 coverImg.src = url; 1419 coverImg.hidden = false; 1420 coverWrap.removeAttribute('data-empty'); 1421 const emptyIcon = coverWrap.querySelector('.pe-cover-empty'); 1422 if (emptyIcon) emptyIcon.remove(); 1423 } else { 1424 coverImg.hidden = true; 1425 coverImg.src = ''; 1426 coverWrap.setAttribute('data-empty', ''); 1427 if (!coverWrap.querySelector('.pe-cover-empty')) { 1428 const span = document.createElement('span'); 1429 span.className = 'pe-cover-empty'; 1430 span.textContent = '🖼'; 1431 coverWrap.appendChild(span); 1432 } 1433 } 1434 } 1435 1436 if (coverTrigger && coverField) { 1437 coverTrigger.addEventListener('click', () => coverField.click()); 1438 } 1439 if (coverField) { 1440 coverField.addEventListener('change', async () => { 1441 if (!coverField.files[0]) return; 1442 const edited = await openImageEditor(coverField.files[0]); 1443 coverField.value = ''; 1444 if (!edited) return; // cancelled 1445 coverStatus.classList.remove('is-error'); 1446 coverStatus.textContent = '<%= t('pedit.js_uploading') %>'; 1447 try { 1448 const j = await uploadImage(edited); 1449 coverUrl.value = j.url; 1450 if (coverVideo) coverVideo.value = j.video || ''; // muted loop MP4 for an animated cover 1451 showCoverPreview(j.url); 1452 coverStatus.textContent = (j.video ? '🎬 ' : '') + '<%= t('pedit.js_uploaded') %> ✓'; 1453 setTimeout(() => { coverStatus.textContent = ''; }, 2000); 1454 } catch (e) { 1455 coverStatus.classList.add('is-error'); 1456 coverStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message; 1457 } 1458 }); 1459 } 1460 // Live-update preview when user pastes a URL manually 1461 if (coverUrl) { 1462 coverUrl.addEventListener('input', () => { 1463 const v = coverUrl.value.trim(); 1464 if (v) showCoverPreview(v); else showCoverPreview(''); 1465 }); 1466 } 1467 1468 // ── WYSIWYG editor (P58) ──────────────────────────────────────── 1469 // Architecture: 1470 // - Visible <div contenteditable> (`#content-editor`) is what the user 1471 // types in; it shows real HTML (formatted, not raw markup). 1472 // - Hidden <input name="content"> (`#content-hidden`) is what submits. 1473 // On submit we serialize the editor's HTML into it, with shortcode 1474 // chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text. 1475 // - Initial content comes from a <script type="application/json"> tag 1476 // to avoid HTML-escape-into-DOM issues; we set innerHTML once on load 1477 // and walk text nodes to render shortcode tokens as chips. 1478 const contentField = document.getElementById('content-upload-field'); 1479 const contentBtn = document.getElementById('insert-image-btn'); 1480 const contentStatus = document.getElementById('content-upload-status'); 1481 const editor = document.getElementById('content-editor'); 1482 const hiddenField = document.getElementById('content-hidden'); 1483 const charCountEl = document.getElementById('char-count'); 1484 const initialEl = document.getElementById('initial-content'); 1485 const toolbar = document.getElementById('pe-toolbar'); 1486 const form = editor && editor.closest('form'); 1487 1488 if (!editor) return; 1489 1490 // Auto-focus the title only on desktop (mouse/trackpad). On touch this would 1491 // immediately open the keyboard when the editor opens — not desired. 1492 try { 1493 const titleInput = form && form.querySelector('input[name="title"]'); 1494 if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) { 1495 titleInput.focus({ preventScroll: true }); 1496 } 1497 } catch (_) {} 1498 1499 // ── Shortcode chip rendering / serialization ──────────────────── 1500 // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]] 1501 // — but we DON'T want to chipify text the user is mid-typing inside an 1502 // HTML attribute; since chipify only walks text nodes (never attribute 1503 // values) that's already safe. 1504 const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g; 1505 1506 const SC_ICONS = { 1507 track: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>', 1508 album: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>', 1509 playlist: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>', 1510 embed: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 9 15.5 12 10 15"/></svg>', 1511 }; 1512 1513 function chipLabel(kind, value) { 1514 if (kind === 'track') { 1515 // UUIDs are noisy — show a 6-char prefix for visual hint 1516 const v = String(value || ''); 1517 return '<%= t('pedit.chip_track') %> ' + (v.length > 8 ? v.slice(0, 6) + '…' : v); 1518 } 1519 if (kind === 'album') return '<%= t('pedit.chip_album') %> ' + value; 1520 if (kind === 'playlist') return '<%= t('pedit.chip_playlist') %> ' + value; 1521 if (kind === 'embed') { 1522 const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, ''); 1523 return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean); 1524 } 1525 return value; 1526 } 1527 1528 function makeChip(kind, value) { 1529 const span = document.createElement('span'); 1530 span.className = 'sc-chip'; 1531 span.contentEditable = 'false'; 1532 span.setAttribute('data-sc', kind + ':' + value); 1533 span.innerHTML = 1534 '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' + 1535 '<span class="sc-chip-label"></span>'; 1536 span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value); 1537 return span; 1538 } 1539 1540 // Walk text nodes inside `root` and replace [[type:value]] tokens with chips. 1541 function chipifyShortcodes(root) { 1542 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); 1543 const targets = []; 1544 while (walker.nextNode()) { 1545 const n = walker.currentNode; 1546 // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears) 1547 if (n.parentElement && n.parentElement.closest('.sc-chip')) continue; 1548 if (SC_RE.test(n.nodeValue)) targets.push(n); 1549 SC_RE.lastIndex = 0; 1550 } 1551 for (const node of targets) { 1552 const txt = node.nodeValue; 1553 const frag = document.createDocumentFragment(); 1554 let last = 0; 1555 let m; 1556 SC_RE.lastIndex = 0; 1557 while ((m = SC_RE.exec(txt)) !== null) { 1558 if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index))); 1559 frag.appendChild(makeChip(m[1], m[2].trim())); 1560 last = m.index + m[0].length; 1561 } 1562 if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last))); 1563 node.parentNode.replaceChild(frag, node); 1564 } 1565 } 1566 1567 // Inverse of chipify: clone the editor, replace every chip with its text. 1568 function serializeChips(rootClone) { 1569 const chips = rootClone.querySelectorAll('.sc-chip[data-sc]'); 1570 for (const c of chips) { 1571 const txt = '[[' + c.getAttribute('data-sc') + ']]'; 1572 c.replaceWith(document.createTextNode(txt)); 1573 } 1574 } 1575 1576 // ── Boot: load initial content as HTML, then render shortcodes as chips 1577 try { 1578 const initial = JSON.parse(initialEl.textContent || '""'); 1579 editor.innerHTML = initial || ''; 1580 chipifyShortcodes(editor); 1581 } catch (e) { 1582 console.error('[editor] could not parse initial content', e); 1583 editor.innerHTML = ''; 1584 } 1585 1586 // ── Char counter 1587 function updateCharCount() { 1588 const text = (editor.innerText || '').replace(/\s+/g, ' ').trim(); 1589 if (charCountEl) charCountEl.textContent = String(text.length); 1590 } 1591 updateCharCount(); 1592 editor.addEventListener('input', updateCharCount); 1593 1594 // ── Toolbar wiring 1595 // Lock the scroll position around an edit command. execCommand/insert scrolls 1596 // the caret into view by default → the view "jumps" when clicking a formatting 1597 // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …) 1598 // + the page and restore them — sync and over a few frames, because Chrome 1599 // sometimes scrolls a frame later. The user scrolls themselves. 1600 function scrollableAncestors(el) { 1601 const list = []; 1602 let node = el; 1603 while (node && node !== document.body && node !== document.documentElement) { 1604 const oy = getComputedStyle(node).overflowY; 1605 if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node); 1606 node = node.parentElement; 1607 } 1608 return list; 1609 } 1610 function keepScroll(fn) { 1611 // In fullscreen the page is locked (body overflow:hidden) and the field may 1612 // scroll to the caret freely — no page jump possible, so nothing to fix. 1613 const frame = document.querySelector('.pe-editor-frame'); 1614 if (frame && frame.classList.contains('pe-fs')) { fn(); return; } 1615 const wx = window.scrollX, wy = window.scrollY; 1616 const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; }); 1617 const restore = function () { 1618 window.scrollTo(wx, wy); 1619 anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; }); 1620 }; 1621 fn(); 1622 restore(); 1623 requestAnimationFrame(restore); 1624 } 1625 function execCmd(cmd, arg) { 1626 keepScroll(function () { 1627 editor.focus({ preventScroll: true }); 1628 document.execCommand(cmd, false, arg); 1629 }); 1630 updateToolbarState(); 1631 updateCharCount(); 1632 } 1633 function wrapCode() { 1634 const sel = window.getSelection(); 1635 if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return; 1636 keepScroll(function () { 1637 const range = sel.getRangeAt(0); 1638 const code = document.createElement('code'); 1639 code.textContent = sel.toString(); 1640 range.deleteContents(); 1641 range.insertNode(code); 1642 // Move caret after the new node 1643 range.setStartAfter(code); 1644 range.collapse(true); 1645 sel.removeAllRanges(); 1646 sel.addRange(range); 1647 editor.focus({ preventScroll: true }); 1648 }); 1649 } 1650 function linkPrompt() { 1651 const url = window.prompt('<%= t('pedit.js_link_prompt') %>'); 1652 if (!url) return; 1653 execCmd('createLink', url); 1654 } 1655 // Is the current selection inside a <blockquote> within the editor? Return it. 1656 function blockquoteAncestor() { 1657 const sel = window.getSelection(); 1658 if (!sel || sel.rangeCount === 0) return null; 1659 let node = sel.anchorNode; 1660 while (node && node !== editor) { 1661 if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node; 1662 node = node.parentNode; 1663 } 1664 return null; 1665 } 1666 // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but 1667 // can never turn it OFF (browser quirk). If the caret is already in a quote → 1668 // unwrap it; otherwise apply blockquote. 1669 function toggleBlockquote() { 1670 keepScroll(function () { 1671 editor.focus({ preventScroll: true }); 1672 const bq = blockquoteAncestor(); 1673 if (bq) { 1674 const parent = bq.parentNode; 1675 // Extract content from the quote in place, then remove the empty wrapper. 1676 const ref = bq; 1677 let firstMoved = null; 1678 while (bq.firstChild) { 1679 const child = bq.firstChild; 1680 if (!firstMoved) firstMoved = child; 1681 parent.insertBefore(child, ref); 1682 } 1683 parent.removeChild(bq); 1684 // Restore the caret inside the unwrapped content. 1685 if (firstMoved) { 1686 const sel = window.getSelection(); 1687 const range = document.createRange(); 1688 range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent); 1689 range.collapse(false); 1690 sel.removeAllRanges(); 1691 sel.addRange(range); 1692 } 1693 } else { 1694 document.execCommand('formatBlock', false, 'blockquote'); 1695 } 1696 }); 1697 updateToolbarState(); 1698 updateCharCount(); 1699 } 1700 1701 if (toolbar) { 1702 // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection 1703 // from the editor field. Without this the selection is lost on tap 1704 // → execCommand operates on an empty selection (bold can no longer be toggled OFF) 1705 // and the browser scrolls the caret back into view (the "jump down"). preventDefault 1706 // on mousedown keeps focus in the editor; the click still fires normally. 1707 toolbar.addEventListener('mousedown', (e) => { 1708 if (e.target.closest('button')) e.preventDefault(); 1709 }); 1710 toolbar.addEventListener('click', (e) => { 1711 const btn = e.target.closest('button[data-cmd]'); 1712 if (!btn) return; 1713 e.preventDefault(); 1714 const cmd = btn.dataset.cmd; 1715 const arg = btn.dataset.arg || null; 1716 if (cmd === 'link-prompt') linkPrompt(); 1717 else if (cmd === 'code-wrap') wrapCode(); 1718 else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote(); 1719 else execCmd(cmd, arg); 1720 }); 1721 } 1722 1723 // ── Full-screen writing mode: the writing field fills the whole page. 1724 const fsBtn = document.getElementById('pe-fullscreen-btn'); 1725 const editorFrame = document.querySelector('.pe-editor-frame'); 1726 const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches); 1727 1728 // On mobile the keyboard pushes the visible (visual) viewport up while 1729 // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar 1730 // slides out of view. Keep the fullscreen frame aligned to the visual 1731 // viewport (top + height) so the toolbar stays visible at the top. 1732 function syncFsViewport() { 1733 if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return; 1734 const vv = window.visualViewport; 1735 if (!vv) return; 1736 editorFrame.style.top = vv.offsetTop + 'px'; 1737 editorFrame.style.height = vv.height + 'px'; 1738 } 1739 function clearFsViewport() { 1740 if (!editorFrame) return; 1741 editorFrame.style.top = ''; 1742 editorFrame.style.height = ''; 1743 } 1744 function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); } 1745 function applyFs(on) { 1746 if (!editorFrame) return; 1747 editorFrame.classList.toggle('pe-fs', on); 1748 document.body.classList.toggle('pe-fs-open', on); 1749 document.documentElement.classList.toggle('pe-fs-open', on); 1750 if (fsBtn) { 1751 fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); 1752 fsBtn.title = on ? '<%= t('pedit.tb_done') %>' : '<%= t('pedit.tb_fullscreen') %>'; 1753 } 1754 if (window.visualViewport) { 1755 if (on) { 1756 window.visualViewport.addEventListener('resize', syncFsViewport); 1757 window.visualViewport.addEventListener('scroll', syncFsViewport); 1758 syncFsViewport(); 1759 } else { 1760 window.visualViewport.removeEventListener('resize', syncFsViewport); 1761 window.visualViewport.removeEventListener('scroll', syncFsViewport); 1762 clearFsViewport(); 1763 } 1764 } 1765 // On touch the field is NOT editable inline; only in fullscreen. 1766 if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false'); 1767 if (on) { 1768 editor.focus({ preventScroll: true }); 1769 } else { 1770 if (isTouch) editor.blur(); 1771 // On close: scroll to the TOP of the content instead of staying 1772 // somewhere at the bottom (footer). 1773 requestAnimationFrame(function () { 1774 try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {} 1775 }); 1776 } 1777 } 1778 // The fullscreen writing "page": opening pushes a history state so the browser 1779 // back button (and the Done button) closes it and returns you to the form — feels 1780 // like a separate page, but all form fields remain intact (same DOM). 1781 function openFs() { 1782 if (isFs()) return; 1783 try { history.pushState({ peFs: true }, ''); } catch (_) {} 1784 applyFs(true); 1785 } 1786 function closeFs() { 1787 if (!isFs()) return; 1788 if (history.state && history.state.peFs) history.back(); // → popstate closes it 1789 else applyFs(false); 1790 } 1791 function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); } 1792 window.addEventListener('popstate', function () { if (isFs()) applyFs(false); }); 1793 if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen); 1794 var fsDoneBtn = document.getElementById('pe-fs-done'); 1795 if (fsDoneBtn) fsDoneBtn.addEventListener('click', closeFs); 1796 document.addEventListener('keydown', (e) => { 1797 if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); } 1798 }); 1799 1800 // On mobile/tablet (touch): the content field is NOT editable inline — it is 1801 // not a text field there. One tap → fullscreen, where it becomes editable 1802 // (toggleFullscreen toggles contenteditable). This prevents inline typing. 1803 if (isTouch) { 1804 editor.setAttribute('contenteditable', 'false'); 1805 editor.classList.add('pe-tap-to-edit'); 1806 editor.addEventListener('click', function () { 1807 if (!isFs()) openFs(); 1808 }); 1809 } 1810 1811 // Reflect bold/italic/list state on the toolbar buttons 1812 function updateToolbarState() { 1813 if (!toolbar) return; 1814 const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList']; 1815 for (const cmd of cmds) { 1816 const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]'); 1817 if (!btn) continue; 1818 try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {} 1819 } 1820 // Quote button: active when the caret is inside a <blockquote> (toggle feedback). 1821 const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]'); 1822 if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor()); 1823 } 1824 document.addEventListener('selectionchange', () => { 1825 if (document.activeElement === editor) updateToolbarState(); 1826 }); 1827 1828 // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K 1829 editor.addEventListener('keydown', (e) => { 1830 const mod = e.ctrlKey || e.metaKey; 1831 if (!mod) return; 1832 const k = e.key.toLowerCase(); 1833 if (k === 'b') { e.preventDefault(); execCmd('bold'); } 1834 else if (k === 'i') { e.preventDefault(); execCmd('italic'); } 1835 else if (k === 'u') { e.preventDefault(); execCmd('underline'); } 1836 else if (k === 'k') { e.preventDefault(); linkPrompt(); } 1837 }); 1838 1839 // Paste: keep it simple — strip formatting unless user wants it. Default 1840 // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline 1841 // styles from clipboard only when shift is held — otherwise plain text. 1842 editor.addEventListener('paste', (e) => { 1843 if (e.shiftKey) return; // user wants formatted paste 1844 const text = (e.clipboardData || window.clipboardData).getData('text/plain'); 1845 if (text == null) return; 1846 e.preventDefault(); 1847 document.execCommand('insertText', false, text); 1848 }); 1849 1850 // ── Image upload (button + drag-drop into the editor) 1851 async function uploadAndInsertImage(file) { 1852 const edited = await openImageEditor(file); 1853 if (!edited) return; // cancelled 1854 contentStatus.classList.remove('is-error'); 1855 contentStatus.textContent = '<%= t('pedit.js_uploading') %>'; 1856 try { 1857 const j = await uploadImage(edited); 1858 const img = '<img src="' + j.url + '" alt="">'; 1859 editor.focus({ preventScroll: true }); 1860 document.execCommand('insertHTML', false, img); 1861 contentStatus.textContent = '<%= t('pedit.js_inserted') %> ✓'; 1862 setTimeout(() => { contentStatus.textContent = ''; }, 2000); 1863 updateCharCount(); 1864 } catch (e) { 1865 contentStatus.classList.add('is-error'); 1866 contentStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message; 1867 } 1868 } 1869 1870 if (contentBtn && contentField) { 1871 contentBtn.addEventListener('click', () => contentField.click()); 1872 contentField.addEventListener('change', () => { 1873 if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]); 1874 contentField.value = ''; 1875 }); 1876 1877 editor.addEventListener('dragover', (e) => { 1878 if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { 1879 e.preventDefault(); 1880 editor.classList.add('is-dragover'); 1881 } 1882 }); 1883 editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover')); 1884 editor.addEventListener('drop', async (e) => { 1885 editor.classList.remove('is-dragover'); 1886 const files = e.dataTransfer && e.dataTransfer.files; 1887 if (!files || !files.length) return; 1888 e.preventDefault(); 1889 for (const f of files) { 1890 if (f.type.startsWith('image/')) await uploadAndInsertImage(f); 1891 } 1892 }); 1893 } 1894 1895 // ── Insert chip helpers (track / playlist) 1896 function insertChip(kind, value) { 1897 editor.focus({ preventScroll: true }); 1898 const chip = makeChip(kind, value); 1899 // Insert at caret using the Selection API (execCommand insertNode) 1900 const sel = window.getSelection(); 1901 if (sel && sel.rangeCount > 0) { 1902 const range = sel.getRangeAt(0); 1903 range.deleteContents(); 1904 range.insertNode(chip); 1905 // Insert a trailing space so the user can keep typing after the chip 1906 const space = document.createTextNode('\u00A0'); 1907 chip.after(space); 1908 range.setStartAfter(space); 1909 range.collapse(true); 1910 sel.removeAllRanges(); 1911 sel.addRange(range); 1912 } else { 1913 editor.appendChild(chip); 1914 editor.appendChild(document.createTextNode('\u00A0')); 1915 } 1916 updateCharCount(); 1917 } 1918 1919 // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes 1920 // an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp). 1921 const embedBtn = document.getElementById('insert-embed-btn'); 1922 if (embedBtn) { 1923 embedBtn.addEventListener('click', () => { 1924 const raw = window.prompt('<%= t('pedit.js_embed_prompt') %>'); 1925 if (!raw) return; 1926 const url = raw.trim(); 1927 if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; } 1928 insertChip('embed', url); 1929 }); 1930 } 1931 1932 // ── Track insert: opens the track-picker modal (P59) 1933 const trackBtn = document.getElementById('insert-track-btn'); 1934 const trackPicker = document.getElementById('track-picker'); 1935 if (trackBtn && trackPicker) { 1936 const tpList = document.getElementById('tp-list'); 1937 const tpEmpty = document.getElementById('tp-empty'); 1938 const tpSearch = document.getElementById('tp-search'); 1939 let tpCache = null; // cached track list (fetched once per page load) 1940 let tpLastFocus = null; // element to restore focus to on close 1941 1942 const SVG_NOTE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>'; 1943 1944 function fmtDur(sec) { 1945 sec = Math.max(0, Math.floor(sec || 0)); 1946 const m = Math.floor(sec / 60), s = sec % 60; 1947 return m + ':' + String(s).padStart(2, '0'); 1948 } 1949 function escAttr(s) { 1950 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ 1951 '&':'&','<':'<','>':'>','"':'"',"'":''' 1952 }[c])); 1953 } 1954 1955 function renderList(filter) { 1956 if (!Array.isArray(tpCache)) return; 1957 const q = (filter || '').trim().toLowerCase(); 1958 const filtered = q 1959 ? tpCache.filter(t => 1960 (t.title || '').toLowerCase().includes(q) || 1961 (t.artist || '').toLowerCase().includes(q)) 1962 : tpCache; 1963 1964 if (!filtered.length) { 1965 tpList.innerHTML = ''; 1966 tpEmpty.textContent = q ? '<%= t('pedit.js_no_tracks_found') %> ' + q : '<%= t('pedit.js_no_tracks_yet') %>'; 1967 tpList.appendChild(tpEmpty); 1968 return; 1969 } 1970 1971 tpList.innerHTML = filtered.map(t => { 1972 const cov = t.cover 1973 ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>' 1974 : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>'; 1975 const dis = t.playable ? '' : ' aria-disabled="true"'; 1976 const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : ''; 1977 return ( 1978 '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' + 1979 cov + 1980 '<span class="tp-meta">' + 1981 '<span class="tp-row-title">' + escAttr(t.title) + '</span>' + 1982 sub + 1983 '</span>' + 1984 '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' + 1985 '</button>' 1986 ); 1987 }).join(''); 1988 } 1989 1990 async function loadTracks() { 1991 if (Array.isArray(tpCache)) return tpCache; 1992 tpEmpty.textContent = '<%= t('pedit.js_tracks_loading') %>'; 1993 try { 1994 const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' }); 1995 const j = await r.json(); 1996 tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : []; 1997 } catch (e) { 1998 tpCache = []; 1999 tpEmpty.textContent = '<%= t('pedit.js_tracks_load_fail') %>: ' + e.message; 2000 } 2001 return tpCache; 2002 } 2003 2004 function openPicker() { 2005 tpLastFocus = document.activeElement; 2006 trackPicker.hidden = false; 2007 trackPicker.setAttribute('aria-hidden', 'false'); 2008 document.body.classList.add('tp-locked'); 2009 tpSearch.value = ''; 2010 renderList(''); 2011 // Defer focus so the open animation doesn't get jumped 2012 setTimeout(() => tpSearch.focus(), 30); 2013 } 2014 function closePicker() { 2015 trackPicker.hidden = true; 2016 trackPicker.setAttribute('aria-hidden', 'true'); 2017 document.body.classList.remove('tp-locked'); 2018 if (tpLastFocus && typeof tpLastFocus.focus === 'function') { 2019 try { tpLastFocus.focus(); } catch(_) {} 2020 } 2021 } 2022 2023 trackBtn.addEventListener('click', async () => { 2024 openPicker(); 2025 await loadTracks(); 2026 renderList(tpSearch.value); 2027 }); 2028 2029 // Close: backdrop click, [data-tp-close], or Escape 2030 trackPicker.addEventListener('click', (e) => { 2031 if (e.target.closest('[data-tp-close]')) { 2032 closePicker(); 2033 return; 2034 } 2035 const row = e.target.closest('.tp-row[data-track-id]'); 2036 if (row) { 2037 if (row.getAttribute('aria-disabled') === 'true') return; 2038 const id = row.dataset.trackId; 2039 if (id) { 2040 insertChip('track', id); 2041 closePicker(); 2042 } 2043 } 2044 }); 2045 document.addEventListener('keydown', (e) => { 2046 if (!trackPicker.hidden && e.key === 'Escape') { 2047 e.preventDefault(); 2048 closePicker(); 2049 } 2050 }); 2051 2052 // Live filter 2053 tpSearch.addEventListener('input', () => renderList(tpSearch.value)); 2054 } 2055 2056 // ── Playlist insert (open existing or create new via modal) 2057 const playlistBtn = document.getElementById('insert-playlist-btn'); 2058 if (playlistBtn) { 2059 playlistBtn.addEventListener('click', async () => { 2060 if (typeof window.openPlaylistEditor !== 'function') { 2061 alert('<%= t('pedit.js_playlist_editor_missing') %>'); 2062 return; 2063 } 2064 try { 2065 const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' }); 2066 const j = await r.json(); 2067 if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) { 2068 const choice = prompt( 2069 '<%= t('pedit.js_playlist_existing') %>\n\n' + 2070 j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') + 2071 '\n\n<%= t('pedit.js_playlist_choose') %>' 2072 ); 2073 if (choice && /^\d+$/.test(choice.trim())) { 2074 const idx = parseInt(choice.trim(), 10) - 1; 2075 if (idx >= 0 && idx < j.playlists.length) { 2076 insertChip('playlist', j.playlists[idx].id); 2077 return; 2078 } 2079 } 2080 if (choice === null) return; 2081 } 2082 } catch (_) { /* fall through to create */ } 2083 2084 window.openPlaylistEditor({ 2085 mode: 'create', 2086 onSaved: ({ id }) => insertChip('playlist', id), 2087 }); 2088 }); 2089 } 2090 2091 // ── Post type: segmented control + type-aware panels ────────── 2092 (function () { 2093 const typeInput = document.getElementById('pe-type-input'); 2094 const card = document.querySelector('.pe-type-card'); 2095 if (!typeInput || !card) return; 2096 const seg = card.querySelector('.pe-typeseg'); 2097 const panels = card.querySelectorAll('.pe-type-panel'); 2098 2099 function applyType(tt) { 2100 typeInput.value = tt; 2101 seg.querySelectorAll('.pe-typeseg-btn').forEach(b => { 2102 const on = b.dataset.type === tt; 2103 b.classList.toggle('is-active', on); 2104 b.setAttribute('aria-checked', on ? 'true' : 'false'); 2105 }); 2106 panels.forEach(p => { p.hidden = (p.dataset.panel !== tt); }); 2107 } 2108 seg.addEventListener('click', (e) => { 2109 const btn = e.target.closest('.pe-typeseg-btn'); 2110 if (btn) applyType(btn.dataset.type); 2111 }); 2112 applyType(typeInput.value || 'post'); 2113 2114 // Video URL → [[embed:url]] chip 2115 const vBtn = document.getElementById('pe-video-insert'); 2116 const vUrl = document.getElementById('pe-video-url'); 2117 if (vBtn && vUrl) { 2118 const doInsert = () => { 2119 const url = (vUrl.value || '').trim(); 2120 if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; } 2121 insertChip('embed', url); 2122 vUrl.value = ''; 2123 }; 2124 vBtn.addEventListener('click', doInsert); 2125 vUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doInsert(); } }); 2126 } 2127 2128 // Audio: inline upload → transcodes server-side → [[track:id]] chip 2129 const drop = document.getElementById('pe-audio-drop'); 2130 const fileInput = document.getElementById('pe-audio-file'); 2131 const list = document.getElementById('pe-audio-list'); 2132 if (drop && fileInput && list) { 2133 const pick = () => fileInput.click(); 2134 drop.addEventListener('click', pick); 2135 drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } }); 2136 ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('is-drag'); })); 2137 ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('is-drag'); })); 2138 drop.addEventListener('drop', (e) => { if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); }); 2139 fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; }); 2140 2141 function clientDuration(f) { 2142 return new Promise((resolve) => { 2143 try { 2144 const u = URL.createObjectURL(f); 2145 const a = document.createElement('audio'); 2146 a.preload = 'metadata'; 2147 a.onloadedmetadata = () => { URL.revokeObjectURL(u); resolve(Number.isFinite(a.duration) ? Math.round(a.duration) : null); }; 2148 a.onerror = () => { URL.revokeObjectURL(u); resolve(null); }; 2149 a.src = u; 2150 } catch (_) { resolve(null); } 2151 }); 2152 } 2153 async function handleFiles(files) { 2154 for (const f of Array.from(files || [])) await uploadOne(f); 2155 } 2156 async function uploadOne(f) { 2157 const li = document.createElement('li'); 2158 li.className = 'pe-audio-item'; 2159 const nameEl = document.createElement('span'); 2160 nameEl.className = 'pe-audio-item-name'; 2161 nameEl.textContent = f.name; 2162 const stateEl = document.createElement('span'); 2163 stateEl.className = 'pe-audio-item-state'; 2164 stateEl.textContent = '⏳ <%= t('pedit.audio_up_busy') %>'; 2165 li.appendChild(nameEl); li.appendChild(stateEl); 2166 list.appendChild(li); 2167 try { 2168 const dur = await clientDuration(f); 2169 const fd = new FormData(); 2170 fd.append('audio', f); 2171 if (dur) fd.append('duration', String(dur)); 2172 const res = await fetch('/admin/audio/upload', { 2173 method: 'POST', body: fd, 2174 headers: { 'Accept': 'application/json' }, 2175 credentials: 'same-origin', 2176 }); 2177 const j = await res.json().catch(() => ({})); 2178 if (!res.ok || !j.ok || !j.id) throw new Error(j.error || ('HTTP ' + res.status)); 2179 insertChip('track', j.id); 2180 stateEl.textContent = '✓ <%= t('pedit.audio_up_done') %>'; 2181 li.classList.add('is-done'); 2182 } catch (err) { 2183 stateEl.textContent = '✕ <%= t('pedit.audio_up_fail') %>: ' + err.message; 2184 li.classList.add('is-fail'); 2185 } 2186 } 2187 } 2188 })(); 2189 2190 // ── Submit: serialize editor contents into the hidden field 2191 if (form && hiddenField) { 2192 form.addEventListener('submit', () => { 2193 const clone = editor.cloneNode(true); 2194 serializeChips(clone); 2195 hiddenField.value = clone.innerHTML; 2196 }); 2197 } 2198 })(); 2199 </script> 1184 2200 1185 2201 1186 <%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small … … 2229 1214 <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %> 2230 1215 <% } %> 2231 2232 <script> 2233 (function () { 2234 // Pin: checkbox toggles the hidden rank field (0 = not pinned), 2235 // ▲▼ shifts the position, with a readable description instead of a raw number. 2236 var toggle = document.getElementById('pin-toggle'); 2237 var rank = document.getElementById('pin-rank'); 2238 var pos = document.getElementById('pin-pos'); 2239 var label = document.getElementById('pin-label'); 2240 var up = document.getElementById('pin-up'); // higher = lower number (towards 1/top) 2241 var down = document.getElementById('pin-down'); 2242 if (!toggle || !rank || !pos) return; 2243 2244 function descr(n) { 2245 n = Number(n) || 0; 2246 if (n <= 1) return '<%= t('pedit.pin_top') %>'; 2247 return n + '<%= t('pedit.pin_nth_suffix') %>'; 2248 } 2249 function render() { 2250 var on = toggle.checked; 2251 pos.hidden = !on; 2252 if (on && Number(rank.value) < 1) rank.value = 1; 2253 if (!on) rank.value = 0; 2254 if (label) label.textContent = on ? descr(rank.value) : ''; 2255 if (up) up.disabled = Number(rank.value) <= 1; 2256 } 2257 toggle.addEventListener('change', render); 2258 if (up) up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); }); 2259 if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); }); 2260 render(); 2261 })(); 2262 2263 (function () { 2264 // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible 2265 // obstacles by setting a dynamic bottom offset = the greater of: 2266 // 1) the height of the keyboard area NOT covered by the layout viewport 2267 // (on iOS the visual viewport shifts; on Android the layout viewport shrinks 2268 // due to interactive-widget=resizes-content → offset ≈ 0); 2269 // 2) the height of the playing audio player (fixed, z-index 1000). 2270 // We stick with sticky (no fixed/top tricks → no bar floating in the middle). 2271 var bar = document.querySelector('.pe-actions'); 2272 if (!bar) return; 2273 var vv = window.visualViewport; 2274 function position() { 2275 var ap = document.querySelector('.audio-player'); 2276 var playing = document.body.classList.contains('has-audio-player') && 2277 ap && getComputedStyle(ap).display !== 'none'; 2278 var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0; 2279 var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0; 2280 var offset = Math.max(audioOffset, kbCovered); 2281 bar.style.bottom = offset ? offset + 'px' : ''; 2282 } 2283 position(); 2284 window.addEventListener('resize', position); 2285 if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); } 2286 // has-audio-player is toggled via a body class → observe it. 2287 try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {} 2288 })(); 2289 </script> 1216 <%- include('../partials/page-data', { pageData: { _timezone: (typeof timezone !== 'undefined' ? timezone : ''), apply: t('imed.apply'), audio_up_busy: t('pedit.audio_up_busy'), audio_up_done: t('pedit.audio_up_done'), audio_up_fail: t('pedit.audio_up_fail'), cancel: t('imed.cancel'), chip_album: t('pedit.chip_album'), chip_playlist: t('pedit.chip_playlist'), chip_track: t('pedit.chip_track'), flip_h: t('imed.flip_h'), flip_v: t('imed.flip_v'), js_embed_invalid: t('pedit.js_embed_invalid'), js_embed_prompt: t('pedit.js_embed_prompt'), js_failed: t('pedit.js_failed'), js_inserted: t('pedit.js_inserted'), js_link_prompt: t('pedit.js_link_prompt'), js_no_tracks_found: t('pedit.js_no_tracks_found'), js_no_tracks_yet: t('pedit.js_no_tracks_yet'), js_playlist_choose: t('pedit.js_playlist_choose'), js_playlist_editor_missing: t('pedit.js_playlist_editor_missing'), js_playlist_existing: t('pedit.js_playlist_existing'), js_tracks_load_fail: t('pedit.js_tracks_load_fail'), js_tracks_loading: t('pedit.js_tracks_loading'), js_uploaded: t('pedit.js_uploaded'), js_uploading: t('pedit.js_uploading'), pin_nth_suffix: t('pedit.pin_nth_suffix'), pin_top: t('pedit.pin_top'), reset: t('imed.reset'), rotate_left: t('imed.rotate_left'), rotate_right: t('imed.rotate_right'), tb_done: t('pedit.tb_done'), tb_fullscreen: t('pedit.tb_fullscreen'), title: t('imed.title'), zoom_in: t('imed.zoom_in'), zoom_out: t('imed.zoom_out') } }) %> -
src/views/pages/post.ejs
rf85b2c3 r952baf3 147 147 </section> 148 148 <% } %> 149 <script> 150 (function(){ 151 if (window.__fediRemoteWired) return; window.__fediRemoteWired = true; 152 var current = null, currentBtn = null; 153 function close(){ if (current) { current.remove(); current = null; currentBtn = null; } } 154 function go(raw, uri){ 155 var d = (raw||'').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim(); 156 if (d) { try { localStorage.setItem('pcmsFediServer', d); } catch(e){} location.href = 'https://' + d + '/authorize_interaction?uri=' + encodeURIComponent(uri||''); } 157 } 158 function place(f, b){ 159 var r = b.getBoundingClientRect(); 160 f.style.top = (r.bottom + window.scrollY + 6) + 'px'; 161 f.style.left = Math.max(8, Math.min(r.left + window.scrollX, window.scrollX + window.innerWidth - 340)) + 'px'; 162 } 163 document.addEventListener('click', function(e){ 164 if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; } 165 if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return; // click inside → keep 166 var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn'); 167 if (b) { 168 e.preventDefault(); 169 if (currentBtn === b) { close(); return; } // toggle off 170 close(); 171 var f = document.createElement('form'); 172 f.className = 'fedi-remote-form'; 173 f.dataset.uri = b.getAttribute('data-fedi-uri') || ''; 174 f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false">' 175 + '<button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">→</button>' 176 + '<button type="button" class="fedi-remote-cancel" aria-label="x">×</button>'; 177 var _inp = f.querySelector('input'); 178 _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social'; 179 try { var _sv = localStorage.getItem('pcmsFediServer'); if (_sv) _inp.value = _sv; } catch(e){} 180 document.body.appendChild(f); // floating popover → no layout reflow 181 place(f, b); current = f; currentBtn = b; 182 _inp.focus(); _inp.select(); 183 return; 184 } 185 if (current) close(); // click anywhere else closes it 186 }); 187 document.addEventListener('submit', function(e){ 188 var f = e.target.closest && e.target.closest('.fedi-remote-form'); 189 if (!f) return; e.preventDefault(); 190 go(f.querySelector('input').value, f.dataset.uri); 191 }); 192 document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); }); 193 window.addEventListener('scroll', close, true); 194 })(); 195 </script> 149 <%# Het remote-reply-veld zit in assets/js/mod/post.js. Inline script hier wordt 150 door de CSP geweigerd zodra je deze pagina via een link binnen de site opent 151 -- zie shaer-0i6. %> 196 152 197 153 -
src/views/partials/bottom-tab.ejs
rf85b2c3 r952baf3 302 302 </style> 303 303 304 <script> 305 // Bottom-tab search button → opens #search-overlay. Using event delegation + looking 306 // up the overlay on EVERY click: the overlay lives in the chrome that is OOB-replaced 307 // during htmx navigation, so a once-captured reference goes stale 308 // (button did nothing afterwards). Guard against double-wiring. 309 (function() { 310 if (window.__btSearchWired) return; 311 window.__btSearchWired = true; 312 document.addEventListener('click', function(e) { 313 if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return; 314 var o = document.getElementById('search-overlay'); 315 if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } 316 }); 317 })(); 318 </script> 304 <%# De zoekknop zit nu in de chrome-module (assets/js/mod/chrome.js). 305 Inline script hier wordt door de CSP geweigerd zodra deze partial via htmx 306 binnenkomt, en dat is bij elke navigatie -- zie shaer-0i6. %> -
src/views/partials/msg-item.ejs
rf85b2c3 r952baf3 1 <% if (n.type === 'thread') { %> 2 <% 3 var _tseen = (typeof seen !== 'undefined' && seen) ? seen : 0; 4 // Een draad is nieuw zodra EEN bericht erin nieuw is. 5 var _tnew = !!_tseen && (n.messages || []).some(function (m) { 6 return m.created_at && Date.parse(m.created_at) > _tseen; 7 }); 8 var _tpeople = (n.people || []).map(function (p) { return p.name || p.handle || '?'; }); 9 // Alleen jij aan het woord: dan is de tegenpartij het adres waaraan 10 // je schreef, niet een lege naam. 11 var _tto = ((n.messages || []).find(function (m) { return m.to_handle; }) || {}).to_handle; 12 var _tlabel = _tpeople.length ? _tpeople.join(', ') : (_tto || t('msg.you')); 13 %> 14 <li class="msg-item msg-thread<%= _tnew ? ' is-new' : '' %>" data-kind="conv" data-who="<%= (_tpeople.join(' ') + ' ' + (_tto || '')).toLowerCase() %>"> 15 <div class="msg-thread-head"> 16 <%# Tegenpartij en aantal in EEN uitsparing: het is een label bij het 17 gesprek, geen kop erboven. Zo houdt de inhoud de ruimte. 18 19 Deze uitsparing is ook de knop die het gesprek in- en uitklapt 20 (Barts aanwijzing): tikken op de tegenpartij is waar je van 21 nature naar wijst als je "dit gesprek" bedoelt. Een BUTTON en 22 geen span met een klikhandler, want dan werkt hij ook met het 23 toetsenbord en leest een schermlezer de stand voor. 24 25 Zonder JavaScript doet hij niets, en dan staat het gesprek 26 gewoon open -- dat is ook de stand waar we mee beginnen. %> 27 <button type="button" class="msg-thread-who" aria-expanded="true"> 28 <span class="msg-thread-name"><%= _tlabel %></span> 29 <% if (n.count > 1) { %><span class="msg-thread-count"><%= n.count %></span><% } %> 30 <svg class="msg-thread-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg> 31 </button> 32 <% if (_tnew) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %> 33 <%# De context bij de draad: waar gaat dit gesprek over? Zonder deze 34 link is een antwoord in een lijst niet te plaatsen. %> 35 <% if (n.post) { %> 36 <a class="msg-thread-post" href="/<%= n.post.slug %>"> 37 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> 38 <%= n.post.title || n.post.slug %> 39 </a> 40 <% } %> 41 </div> 42 <ul class="msg-thread-msgs"> 43 <% (n.messages || []).forEach(function (m) { %><%- include('msg-item', { n: m, seen: _tseen, inThread: true }) %><% }); %> 44 </ul> 45 <%# Antwoorden vanuit de draad zelf. Ingeklapt, want een open editor 46 onder elk gesprek maakt de lijst onleesbaar -- juist het probleem 47 dat deze weergave moest oplossen. De route kiest het pad: een 48 draad aan een post krijgt een reply, een draad aan een persoon een 49 direct bericht. %> 50 <% var _rt = n.replyTo || {}; var _kan = canMutate && ((_rt.interactionId && _rt.postSlug) || _rt.actorUri); %> 51 <% if (_kan) { %> 52 <div class="msg-thread-foot"> 53 <details class="msg-reply"> 54 <summary><%= t('fedi.reply') %></summary> 55 <div class="msg-reply-form"> 56 <%- include('../partials/reply-editor', { 57 action: (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') + '/messages/reply', 58 hiddenFields: [ 59 { name: 'interaction_id', value: _rt.interactionId || '' }, 60 { name: 'post_slug', value: _rt.postSlug || '' }, 61 { name: 'to', value: _rt.actorUri || '' }, 62 ], 63 placeholder: t('fedi.reply_ph'), 64 submitLabel: t('fedi.reply'), 65 rows: 2, 66 }) %> 67 </div> 68 </details> 69 <%# De zwaai blijft een eigen knop: het is geen antwoord maar een 70 seintje (FEP-633c), en die twee horen niet op een hoop. %> 71 <% if (_rt.actorUri) { %> 72 <form method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" class="msg-wave"> 73 <input type="hidden" name="to" value="<%= _rt.actorUri %>"> 74 <button class="btn small" name="text" value="👋" title="<%= t('msg.wave_back') %>">👋</button> 75 </form> 76 <% } %> 77 </div> 78 <% } %> 79 </li> 80 <% } else { %> 1 81 <% 2 82 var _seen = (typeof seen !== 'undefined' && seen) ? seen : 0; … … 5 85 var _who = n.name || n.handle || ''; 6 86 var _priv0 = (n.visibility === 'followers' || n.visibility === 'direct'); 7 // Buckets (klonkt-demo-3jf): Messages = @mentions + private (DM) replies; 8 // Activity = likes/boosts/follows; Moderation = reports; Sent = your 9 // replies; Conversations = every other (public) reply. 10 var _kind = _t === 'sent' ? 'sent' 11 : (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act' 87 // Emmers: Activiteit = likes/boosts/follows/polls, Moderatie = 88 // reports, en al het overige is gesprek. De oude emmers 'msgs' en 89 // 'sent' bestaan niet meer sinds Berichten, Gesprekken en Verzonden 90 // samenvielen -- en dat is geen cosmetische hernoeming: een 91 // gespreksregel die om wat voor reden dan ook geen draad werd (geen 92 // post, geen herkenbare tegenpartij) hield anders een data-kind 93 // waarvoor geen chip meer bestond, en was daarmee onder ELKE chip 94 // behalve Alles onzichtbaar. 95 var _kind = (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act' 12 96 : _t === 'report' ? 'mod' 13 : (_t === 'mention' || _priv0) ? 'msgs'14 97 : 'conv'; 98 // Binnen een draad is dit geen zelfstandige regel: de chip filtert op 99 // de draad, niet op de losse zinnen erin. 100 var _sub = (typeof inThread !== 'undefined' && inThread); 15 101 var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase(); 16 102 var _priv = (n.visibility === 'followers' || n.visibility === 'direct'); 17 103 %> 18 <li class=" msg-item msg-<%= _t %><%= _new ? ' is-new' : '' %>" data-kind="<%= _kind %>"data-who="<%= String(_who).toLowerCase() %>">104 <li class="<%= _sub ? 'msg-sub' : 'msg-item' %> msg-<%= _t %><%= _new ? ' is-new' : '' %>"<% if (!_sub) { %> data-kind="<%= _kind %>"<% } %> data-who="<%= String(_who).toLowerCase() %>"> 19 105 <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true"> 20 106 <% if (_t === 'sent') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> … … 38 124 <% if (_t === 'sent') { %> 39 125 <span class="msg-who"><%= t('msg.you') %></span> 40 <% if (n.to_handle ) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>126 <% if (n.to_handle && !_sub) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %> 41 127 <% } else { %> 42 128 <a class="msg-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%- emojiName(_who, n.actor_emoji_json) %></a> 43 <% if (n.handle && n.name) { %><span class="msg-handle"><%= n.handle %></span><% } %> 129 <% /* De naam blijft: binnen een draad moet je zien WIE wat zei. 130 De handle erachter niet -- die staat in de kop. */ %> 131 <% if (n.handle && n.name && !_sub) { %><span class="msg-handle"><%= n.handle %></span><% } %> 44 132 <% } %> 45 133 <% if (_new) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %> … … 50 138 </div> 51 139 140 <% var _hasWhat = !_sub || _priv 141 || (_t === 'mention' && (n.help_request || n.wave || n.note_url)) 142 || (_t === 'sent' && n.in_reply_to); %> 143 <% if (_hasWhat) { %> 52 144 <div class="msg-what"> 53 145 <% if (n.count && n.count > 1) { %> … … 60 152 <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %> 61 153 <% } else if (_t === 'mention' && n.wave) { %>👋 <%= t('msg.waved_at_you') %> 154 <% } else if (_sub) { %><%# binnen een draad zegt de kop al dat dit een gesprek is %> 62 155 <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %> 63 156 <% } else if (_t === 'sent') { %><%= t('msg.sent_reply') %> … … 65 158 <% } else { %><%= t('notif.replied') %><% } %> 66 159 <% if (_priv) { %><span class="msg-priv" title="<%= t('msg.private_hint') %>">🔒 <%= t('msg.private') %></span><% } %> 67 <% if (n.post_slug ) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>160 <% if (n.post_slug && !_sub) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %> 68 161 <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %> 69 162 <% if (_t === 'sent' && n.in_reply_to) { %><a class="msg-post" href="<%= n.in_reply_to %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %> 70 163 </div> 164 <% } %> 71 165 72 166 <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div> … … 116 210 </div> 117 211 </li> 212 <% } %> -
src/views/partials/playlist-editor.ejs
rf85b2c3 r952baf3 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). %> -
src/views/partials/profile-header.ejs
rf85b2c3 r952baf3 160 160 </div> 161 161 <% } %> 162 <script> 163 (function () { 164 if (window.__pcmsFediFollowWired) return; 165 window.__pcmsFediFollowWired = true; 166 function modal() { return document.querySelector('.pf-follow'); } 167 function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); } 168 function go() { 169 var m = modal(); if (!m) return; 170 var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : ''; 171 // Strip a full @user@host handle (and any scheme/path) down to the server host. 172 var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim(); 173 if (!server) { if (inp) inp.focus(); return; } 174 try { localStorage.setItem('pcmsFediServer', server); } catch (e) {} 175 var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || ''); 176 location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor); 177 } 178 document.addEventListener('click', function (e) { 179 var b = e.target.closest && e.target.closest('.profile-fedi-link'); 180 if (b) { e.preventDefault(); 181 var m = modal(); if (!m) return; 182 m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || ''); 183 var inp = m.querySelector('.pf-follow-input'); 184 try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {} 185 m.classList.add('is-open'); 186 setTimeout(function () { if (inp) inp.focus(); }, 30); 187 return; 188 } 189 if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; } 190 if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; } 191 var open = document.querySelector('.pf-follow.is-open'); 192 if (open && e.target === open) closeM(); /* click on backdrop */ 193 }); 194 document.addEventListener('keydown', function (e) { 195 var m = document.querySelector('.pf-follow.is-open'); if (!m) return; 196 if (e.key === 'Escape') { closeM(); } 197 else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); } 198 }); 199 })(); 200 201 /* Profile summary: click the avatar to open a modal with photo + details. */ 202 (function () { 203 if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true; 204 function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); } 205 document.addEventListener('click', function (e) { 206 if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; } 207 var open = document.querySelector('.pf-summary.is-open'); 208 if (open && e.target === open) { close(); return; } /* click on backdrop */ 209 var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]'); 210 if (!t) return; 211 e.preventDefault(); 212 var root = (t.closest && t.closest('#pcms-chrome')) || document; 213 var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary'); 214 if (modal) modal.classList.add('is-open'); 215 }); 216 document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); }); 217 })(); 218 </script> 162 <%# Het script van de profielkop staat in assets/js/mod/chrome.js: deze partial 163 zit in de OOB-chrome en wordt bij elke htmx-navigatie opnieuw ingevoegd 164 (shaer-bqr). %> 219 165 <% } %> 220 166 -
src/views/partials/profile-sheet.ejs
rf85b2c3 r952baf3 105 105 <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg> 106 106 <span class="psi-label"><%= t('nav.theme_label') %></span> 107 <span class="psi-meta" id="profile-sheet-theme-state" ><%= t('nav.dark') %></span>107 <span class="psi-meta" id="profile-sheet-theme-state" data-dark="<%= t('nav.dark') %>" data-light="<%= t('nav.light') %>"><%= t('nav.dark') %></span> 108 108 </button> 109 109 </li> … … 326 326 </style> 327 327 328 <script> 329 (function() { 330 const sheet = document.getElementById('profile-sheet'); 331 if (!sheet) return; 332 333 const backdrop = document.getElementById('profile-sheet-backdrop'); 334 const panel = sheet.querySelector('.profile-sheet-panel'); 335 const closeBtn = document.getElementById('profile-sheet-close'); 336 const dragZone = document.getElementById('profile-sheet-drag-zone'); 337 const themeBtn = document.getElementById('profile-sheet-theme'); 338 const themeLbl = document.getElementById('profile-sheet-theme-state'); 339 340 function openSheet() { 341 sheet.classList.add('is-open'); 342 sheet.setAttribute('aria-hidden', 'false'); 343 document.body.classList.add('profile-sheet-locked'); 344 syncTheme(); 345 } 346 function closeSheet() { 347 sheet.classList.remove('is-open'); 348 sheet.setAttribute('aria-hidden', 'true'); 349 document.body.classList.remove('profile-sheet-locked'); 350 panel.style.removeProperty('--pcms-drag-y'); 351 } 352 353 // Open: any element with [data-profile-sheet-toggle] 354 document.addEventListener('click', function(e) { 355 const trigger = e.target.closest('[data-profile-sheet-toggle]'); 356 if (trigger) { 357 e.preventDefault(); 358 openSheet(); 359 } 360 }); 361 362 // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item 363 if (backdrop) backdrop.addEventListener('click', closeSheet); 364 if (closeBtn) closeBtn.addEventListener('click', closeSheet); 365 document.addEventListener('keydown', (e) => { 366 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet(); 367 }); 368 369 // Menu items that navigate: close synchronously before nav fires 370 sheet.querySelectorAll('[data-close-sheet]').forEach((el) => { 371 el.addEventListener('click', closeSheet); 372 }); 373 374 // Drag-down-to-close on touch. 375 // Uses --pcms-drag-y custom property rather than overwriting the panel's 376 // transform string. This composes correctly with any horizontal centering 377 // (currently none here, but the pattern matches audio-sheet for safety). 378 let startY = 0, lastY = 0, dragging = false; 379 function onDown(e) { 380 if (e.pointerType !== 'touch') return; 381 if (panel.scrollTop > 0) return; 382 startY = lastY = e.clientY; 383 dragging = true; 384 panel.classList.add('is-dragging'); 385 } 386 function onMove(e) { 387 if (!dragging) return; 388 lastY = e.clientY; 389 const dy = Math.max(0, lastY - startY); 390 panel.style.setProperty('--pcms-drag-y', dy + 'px'); 391 } 392 function onUp() { 393 if (!dragging) return; 394 dragging = false; 395 panel.classList.remove('is-dragging'); 396 const dy = lastY - startY; 397 if (dy > 80) closeSheet(); 398 else panel.style.removeProperty('--pcms-drag-y'); 399 } 400 if (window.PointerEvent && dragZone) { 401 dragZone.addEventListener('pointerdown', onDown); 402 document.addEventListener('pointermove', onMove); 403 document.addEventListener('pointerup', onUp); 404 document.addEventListener('pointercancel', onUp); 405 } 406 407 // Theme toggle inside sheet — syncs label 408 function syncTheme() { 409 if (!themeLbl) return; 410 const t = document.documentElement.getAttribute('data-theme') || 'dark'; 411 themeLbl.textContent = t === 'dark' ? '<%= t('nav.dark') %>' : '<%= t('nav.light') %>'; 412 } 413 if (themeBtn) { 414 themeBtn.addEventListener('click', function() { 415 const cur = document.documentElement.getAttribute('data-theme') || 'dark'; 416 const next = cur === 'dark' ? 'light' : 'dark'; 417 document.documentElement.setAttribute('data-theme', next); 418 try { localStorage.setItem('pcms-theme', next); } catch (e) {} 419 syncTheme(); 420 }); 421 } 422 })(); 423 </script> 328 <%# Het script van het profielblad staat in assets/js/mod/chrome.js (shaer-bqr). %> -
src/views/partials/topnav.ejs
rf85b2c3 r952baf3 176 176 177 177 <!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) --> 178 <div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search"> 178 <%# De teksten staan op het element dat ze gebruikt, niet in het script: een 179 module is een statisch bestand en kan geen vertaling interpoleren. %> 180 <div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search" 181 data-i18n="<%= JSON.stringify({ posts: t('search.section_posts'), tracks: t('search.section_tracks'), events: t('search.section_events'), pages: t('search.section_pages'), all: t('search.suggest_all'), none: t('search.suggest_empty') }) %>"> 179 182 <div class="container"> 180 183 <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search"> … … 191 194 </div> 192 195 193 <script> 194 (function() { 195 // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation 196 // → without this guard the script would stack EXTRA listeners on every navigation, 197 // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working"). 198 // Everything below uses event delegation on body/document, so it also works for 199 // buttons that appear after this run (OOB). 200 if (window.__pcmsChromeWired) return; 201 window.__pcmsChromeWired = true; 202 203 function toggleTheme() { 204 var cur = document.documentElement.getAttribute('data-theme') || 'dark'; 205 var next = cur === 'dark' ? 'light' : 'dark'; 206 document.documentElement.setAttribute('data-theme', next); 207 try { localStorage.setItem('pcms-theme', next); } catch (e) {} 208 } 209 function overlay() { return document.getElementById('search-overlay'); } 210 function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } } 211 function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } } 212 213 document.body.addEventListener('click', function(e) { 214 if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; } 215 if (e.target.closest('#search-toggle')) { openSearch(); return; } 216 if (e.target.closest('#search-close')) { closeSearch(); return; } 217 // Close open dropdowns (user menu + language picker) on click outside. 218 document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) { 219 if (!d.contains(e.target)) d.removeAttribute('open'); 220 }); 221 }); 222 223 document.addEventListener('keydown', function(e) { 224 if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); } 225 }); 226 227 // ── Live results while typing ─────────────────────────────────── 228 var _ST = { 229 posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>', 230 events: '<%= t('search.section_events') %>', pages: '<%= t('search.section_pages') %>', 231 all: '<%= t('search.suggest_all') %>', none: '<%= t('search.suggest_empty') %>' 232 }; 233 function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); } 234 function renderSuggest(box, d, q, ov) { 235 var html = ''; 236 function grp(title, items, fmt) { 237 if (!items || !items.length) return; 238 html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>'; 239 } 240 grp(_ST.posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; }); 241 grp(_ST.tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; }); 242 grp(_ST.events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; }); 243 grp(_ST.pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; }); 244 var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length); 245 if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_ST.none) + '</div>'; return; } 246 var action = (ov && ov.getAttribute('data-action')) || '/search'; 247 html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_ST.all) + '</a>'; 248 box.innerHTML = html; 249 } 250 var _sTimer; 251 document.addEventListener('input', function(e) { 252 var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]'); 253 if (!inp) return; 254 var box = document.getElementById('search-suggest'); 255 if (!box) return; 256 var q = inp.value.trim(); 257 clearTimeout(_sTimer); 258 if (q.length < 2) { box.innerHTML = ''; return; } 259 _sTimer = setTimeout(function() { 260 var ov = document.getElementById('search-overlay'); 261 var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest'; 262 fetch(url + '?q=' + encodeURIComponent(q)) 263 .then(function(r){ return r.ok ? r.json() : null; }) 264 .then(function(d){ if (d) renderSuggest(box, d, q, ov); }) 265 .catch(function(){}); 266 }, 200); 267 }); 268 // Click on a suggestion → close the overlay (the link/boost handles navigation). 269 document.addEventListener('click', function(e) { 270 if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; } 271 }); 272 })(); 273 </script> 196 <%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %> 274 197 275 198 <style> -
src/views/partials/track-editor.ejs
rf85b2c3 r952baf3 284 284 </style> 285 285 286 <script> 287 (function() { 288 289 function esc(s) { 290 return String(s == null ? '' : s) 291 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') 292 .replace(/"/g, '"').replace(/'/g, '''); 293 } 294 295 async function api(method, url, body) { 296 const opts = { method, credentials: 'same-origin', headers: {} }; 297 if (body !== undefined) { 298 if (body instanceof FormData) { 299 opts.body = body; 300 } else { 301 opts.headers['Content-Type'] = 'application/json'; 302 opts.body = JSON.stringify(body); 303 } 304 } 305 const res = await fetch(url, opts); 306 let data; 307 try { data = await res.json(); } 308 catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); } 309 if (!res.ok) data.ok = false; 310 return data; 311 } 312 313 /** 314 * Open the track editor. 315 * @param {object} opts 316 * @param {string} opts.id — track id to edit 317 * @param {function?} opts.onSaved — called with updated track on success 318 */ 319 window.openTrackEditor = async function openTrackEditor({ id, onSaved }) { 320 if (!id) return; 321 let track, albumSuggestions = []; 322 try { 323 const [trackJson, listJson] = await Promise.all([ 324 api('GET', '/admin/audio/api/' + encodeURIComponent(id)), 325 api('GET', '/admin/audio/api/albums'), 326 ]); 327 if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden'); 328 track = trackJson.track; 329 if (listJson.ok && Array.isArray(listJson.albums)) { 330 albumSuggestions = listJson.albums.filter(Boolean); 331 } 332 } catch (err) { 333 alert('Track ophalen mislukt: ' + err.message); 334 return; 335 } 336 337 const backdrop = document.createElement('div'); 338 backdrop.className = 'te-backdrop'; 339 backdrop.innerHTML = ` 340 <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken"> 341 <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div> 342 343 <div class="te-header"> 344 <h3>✎ Track bewerken</h3> 345 <button type="button" class="te-close" aria-label="Sluiten">×</button> 346 </div> 347 348 <div class="te-body"> 349 350 ${track.stream_url ? ` 351 <div class="te-preview"> 352 <div class="te-preview-cover"> 353 ${track.cover_url 354 ? `<img src="${esc(track.cover_url)}" alt="">` 355 : `🎵`} 356 </div> 357 <div class="te-preview-meta"> 358 <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div> 359 <div class="te-preview-sub"> 360 ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''} 361 </div> 362 </div> 363 <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button> 364 </div> 365 ` : ''} 366 367 <div class="te-form"> 368 369 <label class="te-field"> 370 <span>Titel <span class="te-required" aria-hidden="true">*</span></span> 371 <input type="text" id="te-title" maxlength="200" required 372 autocomplete="off" autocapitalize="words" spellcheck="false" 373 value="${esc(track.title || '')}"> 374 </label> 375 376 <div class="te-row te-row-2"> 377 <label class="te-field"> 378 <span>Artiest</span> 379 <input type="text" id="te-artist" maxlength="200" 380 autocomplete="off" autocapitalize="words" spellcheck="false" 381 value="${esc(track.artist || '')}"> 382 </label> 383 <label class="te-field"> 384 <span>Album</span> 385 <input type="text" id="te-album" maxlength="200" 386 autocomplete="off" autocapitalize="words" spellcheck="false" 387 list="te-album-list" value="${esc(track.album || '')}"> 388 <datalist id="te-album-list"> 389 ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')} 390 </datalist> 391 </label> 392 </div> 393 394 <label class="te-field"> 395 <span>Duur <small>(seconden — automatisch bepaald, hier te overschrijven)</small></span> 396 <input type="number" id="te-duration" min="0" step="1" 397 inputmode="numeric" pattern="[0-9]*" 398 value="${track.duration || ''}" placeholder="auto"> 399 </label> 400 401 <div class="te-row te-row-2"> 402 <label class="te-field"> 403 <span>Eigenaar / credit <small>(copyright-houder)</small></span> 404 <div class="te-credit-row"> 405 <input type="text" id="te-credit" maxlength="200" 406 autocomplete="off" spellcheck="false" 407 placeholder="bv. © 2025 Mara Vos" 408 value="${esc(track.credit || '')}"> 409 <button type="button" class="te-sym-btn" id="te-credit-copyr" 410 title="© invoegen" aria-label="Copyright-teken invoegen">©</button> 411 </div> 412 </label> 413 <label class="te-field"> 414 <span>Licentie</span> 415 <input type="text" id="te-license" maxlength="120" 416 autocomplete="off" spellcheck="false" list="te-license-list" 417 placeholder="Alle rechten voorbehouden" 418 value="${esc(track.license || '')}"> 419 <datalist id="te-license-list"> 420 <option value="Alle rechten voorbehouden"></option> 421 <option value="CC BY 4.0"></option> 422 <option value="CC BY-SA 4.0"></option> 423 <option value="CC BY-NC 4.0"></option> 424 <option value="CC BY-NC-SA 4.0"></option> 425 <option value="CC BY-ND 4.0"></option> 426 <option value="CC0 1.0 (publiek domein)"></option> 427 </datalist> 428 </label> 429 </div> 430 431 <div class="te-field"> 432 <span>Open in <small>(links naar dezelfde track elders)</small></span> 433 <input type="url" id="te-link-spotify" inputmode="url" autocomplete="off" spellcheck="false" 434 placeholder="Spotify-URL (https://open.spotify.com/…)" value="${esc(track.link_spotify || '')}"> 435 <input type="url" id="te-link-youtube" inputmode="url" autocomplete="off" spellcheck="false" 436 placeholder="YouTube-URL (https://youtu.be/…)" value="${esc(track.link_youtube || '')}"> 437 <input type="url" id="te-link-soundcloud" inputmode="url" autocomplete="off" spellcheck="false" 438 placeholder="SoundCloud-URL (https://soundcloud.com/…)" value="${esc(track.link_soundcloud || '')}"> 439 </div> 440 441 <div class="te-field"> 442 <span>Audiobestand</span> 443 <div class="te-cover-btn-row"> 444 <input type="file" id="te-audio-file" accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg,.aac" hidden> 445 <button type="button" class="te-cover-btn" id="te-audio-pick">${track.stream_url ? '🔁 Vervang audiobestand' : '🎵 Audiobestand kiezen'}</button> 446 <span class="te-cover-status" id="te-audio-status"></span> 447 </div> 448 </div> 449 450 <div class="te-field"> 451 <span>Cover</span> 452 <div class="te-cover-row"> 453 <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button" 454 aria-label="Klik om cover te kiezen"> 455 ${track.cover_url 456 ? `<img src="${esc(track.cover_url)}" alt="">` 457 : `<span class="te-cover-empty">🎨</span>`} 458 </div> 459 <input type="file" id="te-cover-file" 460 accept="image/jpeg,image/png,image/webp,image/gif" hidden> 461 <div class="te-cover-actions"> 462 <div class="te-cover-btn-row"> 463 <button type="button" class="te-cover-btn" id="te-cover-pick"> 464 📷 Foto kiezen 465 </button> 466 ${track.cover_url ? ` 467 <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove"> 468 × Verwijder 469 </button>` : ''} 470 </div> 471 <input type="text" id="te-cover-url" inputmode="url" 472 placeholder="/media/… of https://…" 473 autocomplete="off" autocapitalize="none" spellcheck="false" 474 value="${esc(track.cover_url || '')}"> 475 <div class="te-cover-status" id="te-cover-status"></div> 476 </div> 477 </div> 478 </div> 479 480 </div> 481 </div> 482 483 <div class="te-footer"> 484 <button type="button" class="te-btn" id="te-cancel">Annuleren</button> 485 <div class="te-footer-spacer"></div> 486 <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button> 487 </div> 488 </div> 489 `; 490 491 document.body.appendChild(backdrop); 492 document.body.classList.add('te-modal-open'); 493 494 const $ = sel => backdrop.querySelector(sel); 495 496 // ── Inline preview player (routed through global mini-player) ── 497 // We don't build our own <audio>; instead we tell the global 498 // window.pcmsAudioPlayer to load this single track. Visual state 499 // syncs against the global audio element so toggle works correctly 500 // even if the user pauses from the mini-bar. 501 const previewBtn = $('#te-preview-play'); 502 if (previewBtn) { 503 const setPreviewState = (playing) => { 504 previewBtn.textContent = playing ? '⏸' : '▶'; 505 previewBtn.classList.toggle('is-playing', playing); 506 previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen'); 507 }; 508 const isOurTrack = () => { 509 // audio.src is a blob: URL (Spotify-style playback) — compare against 510 // the player's logical current-track URL instead. 511 const player = window.pcmsAudioPlayer; 512 const cur = player && player.currentTrack(); 513 return !!(cur && track.stream_url && cur.url === track.stream_url); 514 }; 515 const resync = () => { 516 const audio = document.getElementById('audio-element'); 517 const playing = audio && !audio.paused && !audio.ended && isOurTrack(); 518 setPreviewState(!!playing); 519 }; 520 521 previewBtn.addEventListener('click', () => { 522 const player = window.pcmsAudioPlayer; 523 if (!player || !track.stream_url) { 524 console.warn('preview: miniplayer or url missing'); 525 return; 526 } 527 const audio = document.getElementById('audio-element'); 528 if (audio && isOurTrack()) { 529 // Same track loaded — toggle 530 if (audio.paused) player.play(); else player.pause(); 531 } else { 532 player.setQueue([{ 533 url: track.stream_url, 534 title: track.title || '(zonder titel)', 535 artist: track.artist || '', 536 album: track.album || '', 537 cover: track.cover_url || '', 538 }], 0); 539 } 540 }); 541 542 const audio = document.getElementById('audio-element'); 543 if (audio) { 544 const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied']; 545 evs.forEach(ev => audio.addEventListener(ev, resync)); 546 // Detach listeners on close so we don't leak them 547 backdrop._previewCleanup = () => { 548 evs.forEach(ev => audio.removeEventListener(ev, resync)); 549 }; 550 resync(); // initial state 551 } 552 } 553 554 function close() { 555 // Detach our resync listeners (mini-player stays running) 556 if (backdrop._previewCleanup) backdrop._previewCleanup(); 557 document.body.classList.remove('te-modal-open'); 558 document.removeEventListener('keydown', onEsc); 559 backdrop.remove(); 560 } 561 function onEsc(e) { if (e.key === 'Escape') close(); } 562 document.addEventListener('keydown', onEsc); 563 564 // A click on the backdrop does NOT close the editor — too easy to lose edits by 565 // mis-clicking outside. Close deliberately via ×, Cancel or Esc. 566 $('.te-close').addEventListener('click', close); 567 $('#te-cancel').addEventListener('click', close); 568 569 // ── Cover picking + URL paste + drag-drop ──────────────── 570 const thumb = $('#te-cover-thumb'); 571 const fileInput = $('#te-cover-file'); 572 const urlInput = $('#te-cover-url'); 573 const status = $('#te-cover-status'); 574 const pickBtn = $('#te-cover-pick'); 575 const removeBtn = $('#te-cover-remove'); 576 577 function setStatus(text, kind) { 578 status.textContent = text || ''; 579 status.className = 'te-cover-status' + (kind ? ' is-' + kind : ''); 580 } 581 function setThumb(url) { 582 if (url) { 583 thumb.innerHTML = `<img src="${esc(url)}" alt="">`; 584 } else { 585 thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`; 586 } 587 } 588 589 pickBtn.addEventListener('click', () => fileInput.click()); 590 thumb.addEventListener('click', () => fileInput.click()); 591 thumb.addEventListener('keydown', e => { 592 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } 593 }); 594 595 async function uploadCoverFile(file) { 596 if (!file) return; 597 if (!/^image\//.test(file.type)) { 598 setStatus('Alleen afbeeldingen toegestaan', 'error'); return; 599 } 600 setStatus('Uploaden…', null); 601 const fd = new FormData(); 602 fd.append('cover', file); 603 try { 604 const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', { 605 method: 'POST', body: fd, credentials: 'same-origin', 606 }); 607 const j = await r.json(); 608 if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt'); 609 urlInput.value = j.cover_url || ''; 610 setThumb(j.cover_url); 611 setStatus('✓ Geüpload', 'ok'); 612 } catch (err) { 613 setStatus('Mislukt: ' + err.message, 'error'); 614 } 615 } 616 617 fileInput.addEventListener('change', e => { 618 const f = e.target.files && e.target.files[0]; 619 if (f) uploadCoverFile(f); 620 fileInput.value = ''; 621 }); 622 623 // Drag-drop on thumb (desktop nicety) 624 ['dragenter', 'dragover'].forEach(ev => 625 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); })); 626 ['dragleave', 'drop'].forEach(ev => 627 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); })); 628 thumb.addEventListener('drop', e => { 629 const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; 630 if (f) uploadCoverFile(f); 631 }); 632 633 // URL paste auto-preview 634 urlInput.addEventListener('input', () => { 635 const v = urlInput.value.trim(); 636 setThumb(v); 637 }); 638 639 if (removeBtn) { 640 removeBtn.addEventListener('click', () => { 641 urlInput.value = ''; 642 setThumb(''); 643 removeBtn.remove(); 644 }); 645 } 646 647 // ── Replace the audio file of this track ────────────────── 648 const aPick = $('#te-audio-pick'); 649 const aFile = $('#te-audio-file'); 650 const aStatus = $('#te-audio-status'); 651 if (aPick && aFile) { 652 aPick.addEventListener('click', () => aFile.click()); 653 aFile.addEventListener('change', async (e) => { 654 const f = e.target.files && e.target.files[0]; 655 aFile.value = ''; 656 if (!f) return; 657 aStatus.textContent = '⏳ Converteren… (kan even duren)'; aStatus.className = 'te-cover-status'; 658 aPick.disabled = true; 659 try { 660 const fd = new FormData(); 661 fd.append('audio', f); 662 const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id) + '/replace-audio', fd); 663 if (!j.ok) throw new Error(j.error || 'mislukt'); 664 aStatus.textContent = '✓ Vervangen'; aStatus.className = 'te-cover-status is-ok'; 665 track.stream_url = j.stream_url || track.stream_url; 666 if (j.duration) { const d = $('#te-duration'); if (d) d.value = j.duration; } 667 } catch (err) { 668 aStatus.textContent = 'Mislukt: ' + err.message; aStatus.className = 'te-cover-status is-error'; 669 } finally { aPick.disabled = false; } 670 }); 671 } 672 673 // ── Insert © symbol into the credit field ───────────────── 674 const copyrBtn = $('#te-credit-copyr'); 675 if (copyrBtn) { 676 copyrBtn.addEventListener('click', () => { 677 const inp = $('#te-credit'); 678 if (!inp) return; 679 const sym = '© '; 680 const start = inp.selectionStart != null ? inp.selectionStart : inp.value.length; 681 const end = inp.selectionEnd != null ? inp.selectionEnd : inp.value.length; 682 inp.value = inp.value.slice(0, start) + sym + inp.value.slice(end); 683 inp.focus(); 684 const pos = start + sym.length; 685 try { inp.setSelectionRange(pos, pos); } catch (e) {} 686 }); 687 } 688 689 // ── Save ──────────────────────────────────────────────── 690 $('#te-save').addEventListener('click', async () => { 691 const titleEl = $('#te-title'); 692 const title = titleEl.value.trim(); 693 if (!title) { 694 titleEl.focus(); 695 alert('Titel is verplicht'); 696 return; 697 } 698 const saveBtn = $('#te-save'); 699 saveBtn.disabled = true; 700 saveBtn.textContent = '⏳ Opslaan…'; 701 702 try { 703 const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), { 704 title, 705 artist: $('#te-artist').value.trim() || null, 706 album: $('#te-album').value.trim() || null, 707 credit: $('#te-credit').value.trim() || null, 708 license: $('#te-license').value.trim() || null, 709 link_spotify: $('#te-link-spotify').value.trim() || null, 710 link_youtube: $('#te-link-youtube').value.trim() || null, 711 link_soundcloud: $('#te-link-soundcloud').value.trim() || null, 712 duration: $('#te-duration').value ? Number($('#te-duration').value) : null, 713 cover_url: urlInput.value.trim() || null, 714 }); 715 if (!j.ok) throw new Error(j.error || 'Opslaan mislukt'); 716 if (typeof onSaved === 'function') onSaved(j.track || { id, title, 717 artist: $('#te-artist').value.trim() || null, 718 album: $('#te-album').value.trim() || null, 719 cover_url: urlInput.value.trim() || null }); 720 close(); 721 } catch (err) { 722 alert('Opslaan mislukt: ' + err.message); 723 saveBtn.disabled = false; 724 saveBtn.textContent = '💾 Opslaan'; 725 } 726 }); 727 728 // ── Auto-duration ─────────────────────────────────────────── 729 // The server already determines duration automatically on upload. This is the 730 // fallback/UX layer: if an admin opens an existing track without a duration, 731 // we read it from the audio metadata and fill the field — so you never need 732 // to type seconds manually. An existing value is never overwritten. We fetch 733 // the bytes via the same header gate as the player (X-Audio-Player). 734 (async function autoDuration() { 735 const durEl = $('#te-duration'); 736 if (!durEl || !track.stream_url) return; 737 if (durEl.value && Number(durEl.value) > 0) return; // already filled → leave it alone 738 let objUrl = null; 739 try { 740 const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } }); 741 if (!r.ok) return; 742 objUrl = URL.createObjectURL(await r.blob()); 743 const probe = new Audio(); 744 probe.preload = 'metadata'; 745 probe.addEventListener('loadedmetadata', () => { 746 if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) { 747 durEl.value = Math.round(probe.duration); 748 } 749 if (objUrl) URL.revokeObjectURL(objUrl); 750 }); 751 probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); }); 752 probe.src = objUrl; 753 } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); } 754 })(); 755 756 // Focus title for fast typing 757 setTimeout(() => $('#te-title').focus(), 60); 758 }; 759 })(); 760 </script> 286 <%# Het script van deze pagina staat in assets/js/mod/track-editor.js (shaer-bqr). %> -
src/views/shell.ejs
rf85b2c3 r952baf3 339 339 </head> 340 340 341 <body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data- feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">341 <body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-js="chrome<%= (typeof pageJs !== 'undefined' && pageJs) ? ' ' + pageJs : '' %>" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>"> 342 342 343 343 <% if (typeof isViewer !== 'undefined' && isViewer) { %> … … 449 449 } 450 450 451 // Welke modules deze pagina wil (shaer-bqr). De bootstrap hieronder leest 452 // dit zodra deze handler klaar is. 453 if (typeof d.js === 'string' && /^[a-z0-9 -]*$/.test(d.js)) { 454 document.body.setAttribute('data-js', d.js); 455 } 456 451 457 var next = d.bodyClass; 452 458 if (!next) return; … … 467 473 // de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere 468 474 // htmx:historyRestore-handler is vervallen nu htmx-history uitstaat.) 475 })(); 476 </script> 477 478 <!-- Modules laden (shaer-bqr). Inline script in gewisselde inhoud wordt door de 479 CSP geweigerd: de nonce rouleert per verzoek, dus een script dat via htmx 480 binnenkomt draagt er een die dit document niet kent (shaer-0i6). Alles wat 481 bij een pagina hoort komt daarom uit een module, en die wordt HIER geladen — 482 vanuit de shell, die alleen bij een volledige laadbeurt binnenkomt en dus 483 wél de goede nonce heeft. 484 485 Een dynamische import vanuit een vertrouwd (genonced) script is precies waar 486 'strict-dynamic' voor bedoeld is, dus de module zelf heeft geen nonce nodig. --> 487 <script> 488 (function () { 489 if (window.__modBoot) return; 490 window.__modBoot = true; 491 492 var loaded = {}; 493 function load() { 494 var names = (document.body.getAttribute('data-js') || '').trim().split(/\s+/); 495 names.forEach(function (name) { 496 // Streng: deze waarde komt uit een template en wordt een PAD. Alleen 497 // kleine letters, cijfers en streepjes; nooit een punt of een schuine 498 // streep. 499 if (!name || loaded[name] || !/^[a-z0-9-]+$/.test(name)) return; 500 loaded[name] = 1; 501 import('/assets/js/mod/' + name + '.js').catch(function (e) { 502 console.warn('[mod] ' + name + ' laadde niet:', e && e.message); 503 }); 504 }); 505 } 506 load(); 507 // Bij een htmx-navigatie wisselt de INHOUD, niet de body. De nav-trigger 508 // hieronder zet data-js opnieuw; daarna halen we op wat er nieuw bij staat. 509 // Een module die er al is wordt niet opnieuw geimporteerd -- vandaar dat elke 510 // module gedelegeerd moet werken en tegen een tweede aanroep moet kunnen. 511 document.body.addEventListener('pcmsNav', load); 469 512 })(); 470 513 </script>
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)