// De posteditor -- verplaatst uit inline script, shaer-bqr. // // Zeven blokken, ruim 1000 regels. Alle servertekst komt uit pageData() en gaat // door esc(): de EJS-tag ontsnapte die vroeger ook, dus dit is gedragsgelijk en // het houdt een apostrof in een vertaling uit de HTML die hier geplakt wordt. import { pageData, esc, makeSweeper } from './lib.js'; // Dezelfde regel die de server gebruikt -- zie shared/post-music-type.js voor // waarom hij daar staat en niet twee keer. import { afleidenUitInsluitingen, SOORTEN } from '../shared/post-music-type.js'; // De oude inline scripts draaiden bij ELKE render; een module draait zijn // top-level een keer per sessie. Vandaar init(): de bootstrap roept hem aan // bij elke paginawissel waarop deze module actief is, en de veger haalt eerst // de document/window-listeners van de vorige pagina weg -- die overleven de // swap, met closures naar elementen die al verdwenen zijn (shaer-5s1). const doc = makeSweeper(); let T = {}; let _barObserver = null; export function init() { doc.sweep(); if (_barObserver) { _barObserver.disconnect(); _barObserver = null; } T = pageData(); run(); } function run() { (function () { var cb = document.getElementById('pe-fedi-audio'), w = document.getElementById('pe-fedi-audio-warn'); if (cb && w && !cb.__wired) { cb.__wired = true; cb.addEventListener('change', function () { w.hidden = !cb.checked; }); } })(); // ── volgend blok ── (function () { var cw = document.getElementById('pe-cw'), nsfw = document.getElementById('pe-nsfw'); // Typing a warning text implies the post is sensitive → auto-tick NSFW. if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true; cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; }); } })(); // ── volgend blok ── (function () { var box = document.getElementById('pe-poll-fields'); var tog = document.getElementById('pe-poll-toggle'); var opts = document.getElementById('pe-poll-opts'); var add = document.getElementById('pe-poll-add'); if (!box || !opts) return; if (tog && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); } var PH = opts.getAttribute('data-ph') || '', DEL = opts.getAttribute('data-del') || ''; function rows() { return opts.querySelectorAll('.pe-poll-row'); } // A poll needs at least 2 options: hide the ✕ at the minimum, and cap adding at 8. function refresh() { var n = rows().length; opts.querySelectorAll('.pe-poll-del').forEach(function (b) { b.hidden = n <= 2; }); if (add) add.disabled = n >= 8; } function makeRow() { var row = document.createElement('div'); row.className = 'pe-poll-row'; var i = document.createElement('input'); i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100; i.placeholder = PH; var d = document.createElement('button'); d.type = 'button'; d.className = 'pe-poll-del'; d.setAttribute('aria-label', DEL); d.title = DEL; d.innerHTML = '×'; row.appendChild(i); row.appendChild(d); return row; } if (add && !add.__wired) { add.__wired = true; add.addEventListener('click', function () { if (rows().length >= 8) return; opts.appendChild(makeRow()); refresh(); }); } 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(); }); } refresh(); })(); // ── volgend blok ── (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price'); if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })(); // ── volgend blok ── (function () { var cb = document.getElementById('pe-sched-toggle'); var box = document.getElementById('pe-sched-fields'); if (!cb || !box) return; var inp = document.getElementById('pe-publish-at'); var SITE_TZ = '' + (T._timezone || '') + ''; // configured site timezone; empty = browser local var pad = function (n) { return String(n).padStart(2, '0'); }; // Offset (ms) between a timezone and UTC at a given moment. function tzOffset(date, tz) { 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' }); var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; }); return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime(); } // datetime-local "wall time" (in the site zone) → UTC Date. function wallToUtc(wall) { if (!SITE_TZ) return new Date(wall); var guess = new Date(wall + ':00Z').getTime(); return new Date(guess - tzOffset(new Date(guess), SITE_TZ)); } // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone. function utcToWall(iso) { var d = new Date(iso); if (isNaN(d)) return ''; if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()); 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' }); var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; }); return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute; } // Prefill: stored UTC → wall time in the site zone. if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso); // "Scheduled for" in human-readable time in the site zone. var when = document.getElementById('pe-sched-when'); if (when && when.dataset.iso) { var dw = new Date(when.dataset.iso); if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined); } function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; } cb.addEventListener('change', sync); sync(); // On save: wall time in the site zone → UTC-ISO via a hidden field. var form = cb.closest('form'); if (form) { form.addEventListener('submit', function () { if (inp) inp.removeAttribute('name'); var old = form.querySelector('input[data-pa-utc]'); if (old) old.remove(); if (cb.checked && inp && inp.value) { var d2 = wallToUtc(inp.value); if (!isNaN(d2)) { var h = document.createElement('input'); h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', ''); h.value = d2.toISOString(); form.appendChild(h); } } }); } })(); // ── volgend blok ── (function() { // ── Cover upload ──────────────────────────────────────────────── const coverField = document.getElementById('cover-upload-field'); const coverTrigger = document.getElementById('cover-upload-trigger'); const coverUrl = document.getElementById('cover-url-field'); const coverVideo = document.getElementById('cover-video-field'); const coverStatus = document.getElementById('cover-upload-status'); const coverWrap = document.getElementById('cover-preview-wrap'); const coverImg = document.getElementById('cover-preview-img'); async function uploadImage(file) { const fd = new FormData(); fd.append('image', file); const res = await fetch('/posts/upload-image', { method: 'POST', body: fd }); if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || ('Upload failed (' + res.status + ')')); } return await res.json(); // {url, size, mime} } // ── Image editor (rotate / crop / mirror) ────────── // Lazy-load Cropper.js (locally vendored) on first use. let _cropperReady = null; function ensureCropper() { if (window.Cropper) return Promise.resolve(); if (_cropperReady) return _cropperReady; _cropperReady = new Promise((resolve, reject) => { if (!document.querySelector('link[data-cropper-css]')) { const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = '/assets/vendor/cropper.min.css'; l.setAttribute('data-cropper-css', ''); document.head.appendChild(l); } const s = document.createElement('script'); s.src = '/assets/vendor/cropper.min.js'; s.onload = () => resolve(); s.onerror = () => reject(new Error('cropper load failed')); document.head.appendChild(s); }); return _cropperReady; } // True for an animated WebP (VP8X chunk with the animation flag set) — like a GIF it must skip // the canvas editor, otherwise it'd be flattened to a single static frame. async function isAnimatedWebpFile(file) { if (!file || file.type !== 'image/webp') return false; try { const b = new Uint8Array(await file.slice(0, 40).arrayBuffer()); return b.length >= 21 && String.fromCharCode(b[12], b[13], b[14], b[15]) === 'VP8X' && (b[20] & 0x02) !== 0; } catch (_) { return false; } } // Opens the editor for a chosen file; resolves with an edited File, // or null if the user cancels. Animated images (GIF / animated WebP) are NOT sent through the // canvas editor (they would become static) — those upload directly. async function openImageEditor(file) { if (!file || !file.type || !file.type.startsWith('image/')) return file; if (file.type === 'image/gif') return file; // preserve animation if (await isAnimatedWebpFile(file)) return file; // animated WebP → preserve animation try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly return new Promise((resolve) => { const back = document.createElement('div'); back.className = 'imed-backdrop'; back.innerHTML = ''; document.body.appendChild(back); const img = back.querySelector('img'); const url = URL.createObjectURL(file); let cropper = null, sx = 1, sy = 1; function cleanup() { try { if (cropper) cropper.destroy(); } catch (_) {} URL.revokeObjectURL(url); back.remove(); document.removeEventListener('keydown', onKey); } function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } } document.addEventListener('keydown', onKey); img.onload = () => { cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true }); }; img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original img.src = url; back.addEventListener('click', (e) => { const btn = e.target.closest('[data-act]'); if (e.target === back) { cleanup(); resolve(null); return; } if (!btn || !cropper) return; const a = btn.getAttribute('data-act'); if (a === 'rl') cropper.rotate(-90); else if (a === 'rr') cropper.rotate(90); else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); } else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); } else if (a === 'zi') cropper.zoom(0.1); else if (a === 'zo') cropper.zoom(-0.1); else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); } else if (a === 'cancel') { cleanup(); resolve(null); } else if (a === 'apply') { const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' }); const png = (file.type === 'image/png' || file.type === 'image/webp'); const mime = png ? 'image/png' : 'image/jpeg'; const ext = png ? '.png' : '.jpg'; canvas.toBlob((blob) => { cleanup(); if (!blob) { resolve(file); return; } const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, ''); resolve(new File([blob], base + ext, { type: mime })); }, mime, 0.92); } }); }); } function showCoverPreview(url) { if (!coverWrap || !coverImg) return; if (url) { coverImg.src = url; coverImg.hidden = false; coverWrap.removeAttribute('data-empty'); const emptyIcon = coverWrap.querySelector('.pe-cover-empty'); if (emptyIcon) emptyIcon.remove(); } else { coverImg.hidden = true; coverImg.src = ''; coverWrap.setAttribute('data-empty', ''); if (!coverWrap.querySelector('.pe-cover-empty')) { const span = document.createElement('span'); span.className = 'pe-cover-empty'; span.textContent = '🖼'; coverWrap.appendChild(span); } } } if (coverTrigger && coverField) { coverTrigger.addEventListener('click', () => coverField.click()); } if (coverField) { coverField.addEventListener('change', async () => { if (!coverField.files[0]) return; const edited = await openImageEditor(coverField.files[0]); coverField.value = ''; if (!edited) return; // cancelled coverStatus.classList.remove('is-error'); coverStatus.textContent = '' + esc(T.js_uploading) + ''; try { const j = await uploadImage(edited); coverUrl.value = j.url; if (coverVideo) coverVideo.value = j.video || ''; // muted loop MP4 for an animated cover showCoverPreview(j.url); coverStatus.textContent = (j.video ? '🎬 ' : '') + '' + esc(T.js_uploaded) + ' ✓'; setTimeout(() => { coverStatus.textContent = ''; }, 2000); } catch (e) { coverStatus.classList.add('is-error'); coverStatus.textContent = '' + esc(T.js_failed) + ': ' + e.message; } }); } // Live-update preview when user pastes a URL manually if (coverUrl) { coverUrl.addEventListener('input', () => { const v = coverUrl.value.trim(); if (v) showCoverPreview(v); else showCoverPreview(''); }); } // ── WYSIWYG editor (P58) ──────────────────────────────────────── // Architecture: // - Visible
(`#content-editor`) is what the user // types in; it shows real HTML (formatted, not raw markup). // - Hidden (`#content-hidden`) is what submits. // On submit we serialize the editor's HTML into it, with shortcode // chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text. // - Initial content comes from a