Changeset 952baf3 in Klonkt for src/views/pages


Ignore:
Timestamp:
08/07/2026 05:15:52 PM (5 weeks ago)
Author:
Robin <roboburr@…>
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.
Message:

Merge GitHub-main (1.7.0) met de VPS-lijn

De twee mains waren een dag gedivergeerd en bevatten elk echt werk. GitHub had 66
commits die nooit langs prutfolio.git zijn gekomen, omdat een parallelle sessie
rechtstreeks naar GitHub pushte vanaf een kloon in /tmp op de VPS. De VPS had twee
commits die GitHub niet had. Geen van beide bevatte de ander, en stable had geen van
de twee.

Bewust een merge en geen rebase: dan blijft beide historie intact en wordt er niets
herschreven waar iemand anders al op voortbouwt.

Drie bestanden raakten beide kanten. Alle drie zijn nagekeken, want dat een merge
automatisch slaagt zegt niets over of hij inhoudelijk klopt:

src/services/ActivityPubService.js

  • de sleutelbinding staat nu boven de nieuwe asSlug-aanroep van 1.7.0, dus de controle komt nog steeds voor de handtekeningcontrole

scripts/klonkt-refresh-updater.sh

  • alleen de opzij-aanpak overleefde; systemctl mask staat nergens meer als code

deploy/MULTI-INSTANCE.md

  • spreekt zichzelf niet tegen: beschrijft opzij zetten, met de reden waarom mask weigert

remarks: het gat dat in de review naar boven kwam staat hiermee ook op de 1.7.0-lijn.
De andere bevindingen uit die review staan nog open en zijn niet in deze merge
opgelost; die horen als beads. Ook nog te doen: dezelfde sleutelbinding op stable
als 1.6.1, want daar is het gat nog open bij self-hosters.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

Location:
src/views/pages
Files:
19 edited

Legend:

Unmodified
Added
Removed
  • src/views/pages/admin-audio.ejs

    rf85b2c3 r952baf3  
    573573<%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    574574
    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  
    9090  .admin-page .epk-pick-item input:disabled ~ .epk-pick-title { opacity: 0.45; }
    9191</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  
    145145  .hb-empty { color: var(--ink-muted, var(--ink-soft)); padding: 1rem; text-align: center; }
    146146</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  
    4141</div>
    4242
    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  
    7272</div>
    7373
    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  
    222222</style>
    223223
    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  
    5858  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') },
    5959}).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). %>
    16361<% } %>
  • src/views/pages/admin-settings.ejs

    rf85b2c3 r952baf3  
    2727      <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div>
    2828    </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). %>
    4130  </section>
    4231
  • src/views/pages/admin-site-edit.ejs

    rf85b2c3 r952baf3  
    126126    <%# Live preview: selecting an accent / theme / palette re-themes this page
    127127        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). %>
    149129
    150130    <fieldset>
     
    275255</div>
    276256
    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
    377258
    378259<style>
  • src/views/pages/admin-videos.ejs

    rf85b2c3 r952baf3  
    4040</div>
    4141
    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  
    4141  .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; }
    4242</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  
    1212      <p class="fedi-bm-help"><%= t('fedi.bm_help') %></p>
    1313    </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). %>
    2215    <% if (!manage.length) { %>
    2316      <p class="auth-interact-note"><%= t('fedi.manage_empty') %></p>
     
    5649        <% }); %>
    5750      </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     
    7352    <% } %>
    7453    <p class="auth-interact-note"><a href="/"><%= t('fedi.remote_back') %></a></p>
     
    331310</style>
    332311
    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  
    2626      <p class="dl-sub"><%= _ready_sub %></p>
    2727      <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). %>
    3229    <% } else { %>
    3330      <h1 class="dl-h1"><%= _download_btn %> <%= tr.title %></h1>
     
    5754  .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; }
    5855</style>
     56<%- include('../partials/page-data', { pageData: { fileUrl: fileUrl } }) %>
  • src/views/pages/messages.ejs

    rf85b2c3 r952baf3  
    22  <%- include('../partials/fedi-tabs', { active: 'berichten' }) %>
    33  <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><% } %>
    66
    77  <%# FEP-633c: a pending guardianship offer is a special message: the kid
     
    2525
    2626  <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. %>
    2730    <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>
    2931    <button type="button" class="msg-chip" data-show="conv"><%= t('msg.filter_conv') %></button>
    3032    <button type="button" class="msg-chip" data-show="act"><%= t('msg.filter_act') %></button>
    3133    <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>
    3334  </div>
    3435  <div class="msg-search">
     
    5960</div>
    6061
    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). %>
    11966
    12067<style>
     
    13885
    13986  .msg-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .55rem; }
     87
    14088
    14189  .msg-item { display: flex; gap: .8rem; padding: .85rem .95rem; border-radius: 14px; align-items: flex-start;
     
    14896    background: color-mix(in srgb, var(--accent, #06c) 4%, transparent); }
    14997  .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  }
    150172
    151173  .msg-av { position: relative; flex: 0 0 40px; width: 40px; height: 40px; border-radius: 50%;
  • src/views/pages/news.ejs

    rf85b2c3 r952baf3  
    4141    .tl-paste-go { background: var(--accent); color: var(--paper); }
    4242  </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). %>
    8144  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
    8245  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
     
    184147  .tl-act-block:hover { background: color-mix(in srgb, #d9534f 14%, transparent); border-color: color-mix(in srgb, #d9534f 50%, transparent); }
    185148</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">&rarr;</button><button type="button" class="fedi-remote-cancel" aria-label="x">&times;</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  
    3131
    3232<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). %>
    8734
    8835<style>
     
    10552  .pg-status.is-err { color: #c0392b; }
    10653</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  
    1212
    1313<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). %>
    4815
    4916<style>
     
    5825  .pk-status.is-err { color: #c0392b; }
    5926</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  
    272272        <% } else { %>
    273273          <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). %>
    280275        <% } %>
    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       
    290277        <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen.
    291278           var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; }
     
    335322          </select>
    336323        </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 = '&times;';
    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       
    364325        <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
    365326          <label class="pe-checkbox">
     
    377338            </label>
    378339          </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         
    383341          <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
    384342          <label class="pe-checkbox" style="margin-top:8px">
     
    392350            <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
    393351          </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         
    452353        <% } %>
    453354      </div>
     
    12811182</style>
    12821183
    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         '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
    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
    22001185
    22011186<%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
     
    22291214  <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    22301215<% } %>
    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  
    147147    </section>
    148148  <% } %>
    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">&rarr;</button>'
    176           + '<button type="button" class="fedi-remote-cancel" aria-label="x">&times;</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. %>
    196152
    197153
Note: See TracChangeset for help on using the changeset viewer.