Changeset 952baf3 in Klonkt for src/views/pages
- 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/pages
- Files:
-
- 19 edited
-
admin-audio.ejs (modified) (1 diff)
-
admin-epk.ejs (modified) (1 diff)
-
admin-help.ejs (modified) (1 diff)
-
admin-media.ejs (modified) (1 diff)
-
admin-paid.ejs (modified) (1 diff)
-
admin-playlists.ejs (modified) (1 diff)
-
admin-push.ejs (modified) (1 diff)
-
admin-settings.ejs (modified) (1 diff)
-
admin-site-edit.ejs (modified) (2 diffs)
-
admin-videos.ejs (modified) (1 diff)
-
auth-register.ejs (modified) (1 diff)
-
authorize-interaction.ejs (modified) (3 diffs)
-
download.ejs (modified) (2 diffs)
-
messages.ejs (modified) (5 diffs)
-
news.ejs (modified) (2 diffs)
-
paid-gate.ejs (modified) (2 diffs)
-
paid-passkey.ejs (modified) (2 diffs)
-
post-edit.ejs (modified) (6 diffs)
-
post.ejs (modified) (1 diff)
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
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)