| 1 | // De posteditor -- verplaatst uit inline script, shaer-bqr.
|
|---|
| 2 | //
|
|---|
| 3 | // Zeven blokken, ruim 1000 regels. Alle servertekst komt uit pageData() en gaat
|
|---|
| 4 | // door esc(): de EJS-tag ontsnapte die vroeger ook, dus dit is gedragsgelijk en
|
|---|
| 5 | // het houdt een apostrof in een vertaling uit de HTML die hier geplakt wordt.
|
|---|
| 6 |
|
|---|
| 7 | import { pageData, esc, makeSweeper, VENDOR_V } from './lib.js';
|
|---|
| 8 | // Dezelfde regel die de server gebruikt -- zie shared/post-music-type.js voor
|
|---|
| 9 | // waarom hij daar staat en niet twee keer.
|
|---|
| 10 | import { afleidenUitInsluitingen, SOORTEN } from '../shared/post-music-type.js';
|
|---|
| 11 |
|
|---|
| 12 | // De oude inline scripts draaiden bij ELKE render; een module draait zijn
|
|---|
| 13 | // top-level een keer per sessie. Vandaar init(): de bootstrap roept hem aan
|
|---|
| 14 | // bij elke paginawissel waarop deze module actief is, en de veger haalt eerst
|
|---|
| 15 | // de document/window-listeners van de vorige pagina weg -- die overleven de
|
|---|
| 16 | // swap, met closures naar elementen die al verdwenen zijn (shaer-5s1).
|
|---|
| 17 | const doc = makeSweeper();
|
|---|
| 18 | let T = {};
|
|---|
| 19 | let _barObserver = null;
|
|---|
| 20 |
|
|---|
| 21 | export function init() {
|
|---|
| 22 | doc.sweep();
|
|---|
| 23 | if (_barObserver) { _barObserver.disconnect(); _barObserver = null; }
|
|---|
| 24 | T = pageData();
|
|---|
| 25 | run();
|
|---|
| 26 | }
|
|---|
| 27 |
|
|---|
| 28 | function run() {
|
|---|
| 29 |
|
|---|
| 30 | (function () {
|
|---|
| 31 | var cb = document.getElementById('pe-fedi-audio'), w = document.getElementById('pe-fedi-audio-warn');
|
|---|
| 32 | if (cb && w && !cb.__wired) { cb.__wired = true; cb.addEventListener('change', function () { w.hidden = !cb.checked; }); }
|
|---|
| 33 | })();
|
|---|
| 34 |
|
|---|
| 35 |
|
|---|
| 36 | // ── volgend blok ──
|
|---|
| 37 |
|
|---|
| 38 | (function () {
|
|---|
| 39 | var cw = document.getElementById('pe-cw'), nsfw = document.getElementById('pe-nsfw');
|
|---|
| 40 | // Typing a warning text implies the post is sensitive → auto-tick NSFW.
|
|---|
| 41 | if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true;
|
|---|
| 42 | cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; });
|
|---|
| 43 | }
|
|---|
| 44 | })();
|
|---|
| 45 |
|
|---|
| 46 |
|
|---|
| 47 | // ── volgend blok ──
|
|---|
| 48 |
|
|---|
| 49 | (function () {
|
|---|
| 50 | var box = document.getElementById('pe-poll-fields');
|
|---|
| 51 | var tog = document.getElementById('pe-poll-toggle');
|
|---|
| 52 | var opts = document.getElementById('pe-poll-opts');
|
|---|
| 53 | var add = document.getElementById('pe-poll-add');
|
|---|
| 54 | if (!box || !opts) return;
|
|---|
| 55 | if (tog && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); }
|
|---|
| 56 | var PH = opts.getAttribute('data-ph') || '', DEL = opts.getAttribute('data-del') || '';
|
|---|
| 57 | function rows() { return opts.querySelectorAll('.pe-poll-row'); }
|
|---|
| 58 | // A poll needs at least 2 options: hide the ✕ at the minimum, and cap adding at 8.
|
|---|
| 59 | function refresh() {
|
|---|
| 60 | var n = rows().length;
|
|---|
| 61 | opts.querySelectorAll('.pe-poll-del').forEach(function (b) { b.hidden = n <= 2; });
|
|---|
| 62 | if (add) add.disabled = n >= 8;
|
|---|
| 63 | }
|
|---|
| 64 | function makeRow() {
|
|---|
| 65 | var row = document.createElement('div'); row.className = 'pe-poll-row';
|
|---|
| 66 | var i = document.createElement('input'); i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100; i.placeholder = PH;
|
|---|
| 67 | var d = document.createElement('button'); d.type = 'button'; d.className = 'pe-poll-del'; d.setAttribute('aria-label', DEL); d.title = DEL; d.innerHTML = '×';
|
|---|
| 68 | row.appendChild(i); row.appendChild(d); return row;
|
|---|
| 69 | }
|
|---|
| 70 | if (add && !add.__wired) { add.__wired = true; add.addEventListener('click', function () { if (rows().length >= 8) return; opts.appendChild(makeRow()); refresh(); }); }
|
|---|
| 71 | 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(); }); }
|
|---|
| 72 | refresh();
|
|---|
| 73 | })();
|
|---|
| 74 |
|
|---|
| 75 |
|
|---|
| 76 | // ── volgend blok ──
|
|---|
| 77 |
|
|---|
| 78 | (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price');
|
|---|
| 79 | if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })();
|
|---|
| 80 |
|
|---|
| 81 |
|
|---|
| 82 | // ── volgend blok ──
|
|---|
| 83 |
|
|---|
| 84 | (function () {
|
|---|
| 85 | var cb = document.getElementById('pe-sched-toggle');
|
|---|
| 86 | var box = document.getElementById('pe-sched-fields');
|
|---|
| 87 | if (!cb || !box) return;
|
|---|
| 88 | var inp = document.getElementById('pe-publish-at');
|
|---|
| 89 | var SITE_TZ = '' + (T._timezone || '') + ''; // configured site timezone; empty = browser local
|
|---|
| 90 | var pad = function (n) { return String(n).padStart(2, '0'); };
|
|---|
| 91 | // Offset (ms) between a timezone and UTC at a given moment.
|
|---|
| 92 | function tzOffset(date, tz) {
|
|---|
| 93 | 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' });
|
|---|
| 94 | var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; });
|
|---|
| 95 | return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime();
|
|---|
| 96 | }
|
|---|
| 97 | // datetime-local "wall time" (in the site zone) → UTC Date.
|
|---|
| 98 | function wallToUtc(wall) {
|
|---|
| 99 | if (!SITE_TZ) return new Date(wall);
|
|---|
| 100 | var guess = new Date(wall + ':00Z').getTime();
|
|---|
| 101 | return new Date(guess - tzOffset(new Date(guess), SITE_TZ));
|
|---|
| 102 | }
|
|---|
| 103 | // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone.
|
|---|
| 104 | function utcToWall(iso) {
|
|---|
| 105 | var d = new Date(iso); if (isNaN(d)) return '';
|
|---|
| 106 | if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|---|
| 107 | 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' });
|
|---|
| 108 | var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; });
|
|---|
| 109 | return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
|
|---|
| 110 | }
|
|---|
| 111 | // Prefill: stored UTC → wall time in the site zone.
|
|---|
| 112 | if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso);
|
|---|
| 113 | // "Scheduled for" in human-readable time in the site zone.
|
|---|
| 114 | var when = document.getElementById('pe-sched-when');
|
|---|
| 115 | if (when && when.dataset.iso) {
|
|---|
| 116 | var dw = new Date(when.dataset.iso);
|
|---|
| 117 | if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined);
|
|---|
| 118 | }
|
|---|
| 119 | function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; }
|
|---|
| 120 | cb.addEventListener('change', sync); sync();
|
|---|
| 121 | // On save: wall time in the site zone → UTC-ISO via a hidden field.
|
|---|
| 122 | var form = cb.closest('form');
|
|---|
| 123 | if (form) {
|
|---|
| 124 | form.addEventListener('submit', function () {
|
|---|
| 125 | if (inp) inp.removeAttribute('name');
|
|---|
| 126 | var old = form.querySelector('input[data-pa-utc]');
|
|---|
| 127 | if (old) old.remove();
|
|---|
| 128 | if (cb.checked && inp && inp.value) {
|
|---|
| 129 | var d2 = wallToUtc(inp.value);
|
|---|
| 130 | if (!isNaN(d2)) {
|
|---|
| 131 | var h = document.createElement('input');
|
|---|
| 132 | h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', '');
|
|---|
| 133 | h.value = d2.toISOString();
|
|---|
| 134 | form.appendChild(h);
|
|---|
| 135 | }
|
|---|
| 136 | }
|
|---|
| 137 | });
|
|---|
| 138 | }
|
|---|
| 139 | })();
|
|---|
| 140 |
|
|---|
| 141 |
|
|---|
| 142 | // ── volgend blok ──
|
|---|
| 143 |
|
|---|
| 144 | (function() {
|
|---|
| 145 |
|
|---|
| 146 | // ── Cover upload ────────────────────────────────────────────────
|
|---|
| 147 | const coverField = document.getElementById('cover-upload-field');
|
|---|
| 148 | const coverTrigger = document.getElementById('cover-upload-trigger');
|
|---|
| 149 | const coverUrl = document.getElementById('cover-url-field');
|
|---|
| 150 | const coverVideo = document.getElementById('cover-video-field');
|
|---|
| 151 | const coverStatus = document.getElementById('cover-upload-status');
|
|---|
| 152 | const coverWrap = document.getElementById('cover-preview-wrap');
|
|---|
| 153 | const coverImg = document.getElementById('cover-preview-img');
|
|---|
| 154 |
|
|---|
| 155 | async function uploadImage(file) {
|
|---|
| 156 | const fd = new FormData();
|
|---|
| 157 | fd.append('image', file);
|
|---|
| 158 | const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
|
|---|
| 159 | if (!res.ok) {
|
|---|
| 160 | const j = await res.json().catch(() => ({}));
|
|---|
| 161 | throw new Error(j.error || ('Upload failed (' + res.status + ')'));
|
|---|
| 162 | }
|
|---|
| 163 | return await res.json(); // {url, size, mime}
|
|---|
| 164 | }
|
|---|
| 165 |
|
|---|
| 166 | // ── Image editor (rotate / crop / mirror) ──────────
|
|---|
| 167 | // Lazy-load Cropper.js (locally vendored) on first use.
|
|---|
| 168 | let _cropperReady = null;
|
|---|
| 169 | function ensureCropper() {
|
|---|
| 170 | if (window.Cropper) return Promise.resolve();
|
|---|
| 171 | if (_cropperReady) return _cropperReady;
|
|---|
| 172 | _cropperReady = new Promise((resolve, reject) => {
|
|---|
| 173 | if (!document.querySelector('link[data-cropper-css]')) {
|
|---|
| 174 | const l = document.createElement('link');
|
|---|
| 175 | // Met versie, net als het script eronder (shaer-724): /assets wordt een
|
|---|
| 176 | // jaar gecachet en deze twee bestanden dragen geen versie in hun naam.
|
|---|
| 177 | l.rel = 'stylesheet'; l.href = `/assets/vendor/cropper.min.css?v=${VENDOR_V}`; l.setAttribute('data-cropper-css', '');
|
|---|
| 178 | document.head.appendChild(l);
|
|---|
| 179 | }
|
|---|
| 180 | const s = document.createElement('script');
|
|---|
| 181 | s.src = `/assets/vendor/cropper.min.js?v=${VENDOR_V}`;
|
|---|
| 182 | s.onload = () => resolve();
|
|---|
| 183 | s.onerror = () => reject(new Error('cropper load failed'));
|
|---|
| 184 | document.head.appendChild(s);
|
|---|
| 185 | });
|
|---|
| 186 | return _cropperReady;
|
|---|
| 187 | }
|
|---|
| 188 |
|
|---|
| 189 | // True for an animated WebP (VP8X chunk with the animation flag set) — like a GIF it must skip
|
|---|
| 190 | // the canvas editor, otherwise it'd be flattened to a single static frame.
|
|---|
| 191 | async function isAnimatedWebpFile(file) {
|
|---|
| 192 | if (!file || file.type !== 'image/webp') return false;
|
|---|
| 193 | try {
|
|---|
| 194 | const b = new Uint8Array(await file.slice(0, 40).arrayBuffer());
|
|---|
| 195 | return b.length >= 21 && String.fromCharCode(b[12], b[13], b[14], b[15]) === 'VP8X' && (b[20] & 0x02) !== 0;
|
|---|
| 196 | } catch (_) { return false; }
|
|---|
| 197 | }
|
|---|
| 198 |
|
|---|
| 199 | // Opens the editor for a chosen file; resolves with an edited File,
|
|---|
| 200 | // or null if the user cancels. Animated images (GIF / animated WebP) are NOT sent through the
|
|---|
| 201 | // canvas editor (they would become static) — those upload directly.
|
|---|
| 202 | async function openImageEditor(file) {
|
|---|
| 203 | if (!file || !file.type || !file.type.startsWith('image/')) return file;
|
|---|
| 204 | if (file.type === 'image/gif') return file; // preserve animation
|
|---|
| 205 | if (await isAnimatedWebpFile(file)) return file; // animated WebP → preserve animation
|
|---|
| 206 | try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly
|
|---|
| 207 |
|
|---|
| 208 | return new Promise((resolve) => {
|
|---|
| 209 | const back = document.createElement('div');
|
|---|
| 210 | back.className = 'imed-backdrop';
|
|---|
| 211 | back.innerHTML =
|
|---|
| 212 | '<div class="imed-modal" role="dialog" aria-modal="true" aria-label="' + esc(T.title) + '">' +
|
|---|
| 213 | '<div class="imed-stage"><img alt=""></div>' +
|
|---|
| 214 | '<div class="imed-tools">' +
|
|---|
| 215 | '<button type="button" data-act="rl" title="' + esc(T.rotate_left) + '">⟲</button>' +
|
|---|
| 216 | '<button type="button" data-act="rr" title="' + esc(T.rotate_right) + '">⟳</button>' +
|
|---|
| 217 | '<button type="button" data-act="fh" title="' + esc(T.flip_h) + '">⇆</button>' +
|
|---|
| 218 | '<button type="button" data-act="fv" title="' + esc(T.flip_v) + '">⇅</button>' +
|
|---|
| 219 | '<button type="button" data-act="zi" title="' + esc(T.zoom_in) + '">+</button>' +
|
|---|
| 220 | '<button type="button" data-act="zo" title="' + esc(T.zoom_out) + '">-</button>' +
|
|---|
| 221 | '<button type="button" data-act="reset" title="' + esc(T.reset) + '">↺</button>' +
|
|---|
| 222 | '</div>' +
|
|---|
| 223 | '<div class="imed-actions">' +
|
|---|
| 224 | '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary">' + esc(T.cancel) + '</button>' +
|
|---|
| 225 | '<button type="button" data-act="apply" class="pe-btn pe-btn-primary">' + esc(T.apply) + '</button>' +
|
|---|
| 226 | '</div>' +
|
|---|
| 227 | '</div>';
|
|---|
| 228 | document.body.appendChild(back);
|
|---|
| 229 | const img = back.querySelector('img');
|
|---|
| 230 | const url = URL.createObjectURL(file);
|
|---|
| 231 | let cropper = null, sx = 1, sy = 1;
|
|---|
| 232 |
|
|---|
| 233 | function cleanup() {
|
|---|
| 234 | try { if (cropper) cropper.destroy(); } catch (_) {}
|
|---|
| 235 | URL.revokeObjectURL(url);
|
|---|
| 236 | back.remove();
|
|---|
| 237 | document.removeEventListener('keydown', onKey);
|
|---|
| 238 | }
|
|---|
| 239 | function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } }
|
|---|
| 240 | document.addEventListener('keydown', onKey);
|
|---|
| 241 |
|
|---|
| 242 | img.onload = () => {
|
|---|
| 243 | cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true });
|
|---|
| 244 | };
|
|---|
| 245 | img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original
|
|---|
| 246 | img.src = url;
|
|---|
| 247 |
|
|---|
| 248 | back.addEventListener('click', (e) => {
|
|---|
| 249 | const btn = e.target.closest('[data-act]');
|
|---|
| 250 | if (e.target === back) { cleanup(); resolve(null); return; }
|
|---|
| 251 | if (!btn || !cropper) return;
|
|---|
| 252 | const a = btn.getAttribute('data-act');
|
|---|
| 253 | if (a === 'rl') cropper.rotate(-90);
|
|---|
| 254 | else if (a === 'rr') cropper.rotate(90);
|
|---|
| 255 | else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); }
|
|---|
| 256 | else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); }
|
|---|
| 257 | else if (a === 'zi') cropper.zoom(0.1);
|
|---|
| 258 | else if (a === 'zo') cropper.zoom(-0.1);
|
|---|
| 259 | else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); }
|
|---|
| 260 | else if (a === 'cancel') { cleanup(); resolve(null); }
|
|---|
| 261 | else if (a === 'apply') {
|
|---|
| 262 | const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' });
|
|---|
| 263 | const png = (file.type === 'image/png' || file.type === 'image/webp');
|
|---|
| 264 | const mime = png ? 'image/png' : 'image/jpeg';
|
|---|
| 265 | const ext = png ? '.png' : '.jpg';
|
|---|
| 266 | canvas.toBlob((blob) => {
|
|---|
| 267 | cleanup();
|
|---|
| 268 | if (!blob) { resolve(file); return; }
|
|---|
| 269 | const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, '');
|
|---|
| 270 | resolve(new File([blob], base + ext, { type: mime }));
|
|---|
| 271 | }, mime, 0.92);
|
|---|
| 272 | }
|
|---|
| 273 | });
|
|---|
| 274 | });
|
|---|
| 275 | }
|
|---|
| 276 |
|
|---|
| 277 | function showCoverPreview(url) {
|
|---|
| 278 | if (!coverWrap || !coverImg) return;
|
|---|
| 279 | if (url) {
|
|---|
| 280 | coverImg.src = url;
|
|---|
| 281 | coverImg.hidden = false;
|
|---|
| 282 | coverWrap.removeAttribute('data-empty');
|
|---|
| 283 | const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
|
|---|
| 284 | if (emptyIcon) emptyIcon.remove();
|
|---|
| 285 | } else {
|
|---|
| 286 | coverImg.hidden = true;
|
|---|
| 287 | coverImg.src = '';
|
|---|
| 288 | coverWrap.setAttribute('data-empty', '');
|
|---|
| 289 | if (!coverWrap.querySelector('.pe-cover-empty')) {
|
|---|
| 290 | const span = document.createElement('span');
|
|---|
| 291 | span.className = 'pe-cover-empty';
|
|---|
| 292 | span.textContent = '🖼';
|
|---|
| 293 | coverWrap.appendChild(span);
|
|---|
| 294 | }
|
|---|
| 295 | }
|
|---|
| 296 | }
|
|---|
| 297 |
|
|---|
| 298 | if (coverTrigger && coverField) {
|
|---|
| 299 | coverTrigger.addEventListener('click', () => coverField.click());
|
|---|
| 300 | }
|
|---|
| 301 | if (coverField) {
|
|---|
| 302 | coverField.addEventListener('change', async () => {
|
|---|
| 303 | if (!coverField.files[0]) return;
|
|---|
| 304 | const edited = await openImageEditor(coverField.files[0]);
|
|---|
| 305 | coverField.value = '';
|
|---|
| 306 | if (!edited) return; // cancelled
|
|---|
| 307 | coverStatus.classList.remove('is-error');
|
|---|
| 308 | coverStatus.textContent = '' + esc(T.js_uploading) + '';
|
|---|
| 309 | try {
|
|---|
| 310 | const j = await uploadImage(edited);
|
|---|
| 311 | coverUrl.value = j.url;
|
|---|
| 312 | if (coverVideo) coverVideo.value = j.video || ''; // muted loop MP4 for an animated cover
|
|---|
| 313 | showCoverPreview(j.url);
|
|---|
| 314 | coverStatus.textContent = (j.video ? '🎬 ' : '') + '' + esc(T.js_uploaded) + ' ✓';
|
|---|
| 315 | setTimeout(() => { coverStatus.textContent = ''; }, 2000);
|
|---|
| 316 | } catch (e) {
|
|---|
| 317 | coverStatus.classList.add('is-error');
|
|---|
| 318 | coverStatus.textContent = '' + esc(T.js_failed) + ': ' + e.message;
|
|---|
| 319 | }
|
|---|
| 320 | });
|
|---|
| 321 | }
|
|---|
| 322 | // Live-update preview when user pastes a URL manually
|
|---|
| 323 | if (coverUrl) {
|
|---|
| 324 | coverUrl.addEventListener('input', () => {
|
|---|
| 325 | const v = coverUrl.value.trim();
|
|---|
| 326 | if (v) showCoverPreview(v); else showCoverPreview('');
|
|---|
| 327 | });
|
|---|
| 328 | }
|
|---|
| 329 |
|
|---|
| 330 | // ── WYSIWYG editor (P58) ────────────────────────────────────────
|
|---|
| 331 | // Architecture:
|
|---|
| 332 | // - Visible <div contenteditable> (`#content-editor`) is what the user
|
|---|
| 333 | // types in; it shows real HTML (formatted, not raw markup).
|
|---|
| 334 | // - Hidden <input name="content"> (`#content-hidden`) is what submits.
|
|---|
| 335 | // On submit we serialize the editor's HTML into it, with shortcode
|
|---|
| 336 | // chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
|
|---|
| 337 | // - Initial content comes from a <script type="application/json"> tag
|
|---|
| 338 | // to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
|
|---|
| 339 | // and walk text nodes to render shortcode tokens as chips.
|
|---|
| 340 | const contentField = document.getElementById('content-upload-field');
|
|---|
| 341 | const contentBtn = document.getElementById('insert-image-btn');
|
|---|
| 342 | const contentStatus = document.getElementById('content-upload-status');
|
|---|
| 343 | const editor = document.getElementById('content-editor');
|
|---|
| 344 | const hiddenField = document.getElementById('content-hidden');
|
|---|
| 345 | const charCountEl = document.getElementById('char-count');
|
|---|
| 346 | const initialEl = document.getElementById('initial-content');
|
|---|
| 347 | const toolbar = document.getElementById('pe-toolbar');
|
|---|
| 348 | const form = editor && editor.closest('form');
|
|---|
| 349 |
|
|---|
| 350 | if (!editor) return;
|
|---|
| 351 |
|
|---|
| 352 | // Auto-focus the title only on desktop (mouse/trackpad). On touch this would
|
|---|
| 353 | // immediately open the keyboard when the editor opens — not desired.
|
|---|
| 354 | try {
|
|---|
| 355 | const titleInput = form && form.querySelector('input[name="title"]');
|
|---|
| 356 | if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
|
|---|
| 357 | titleInput.focus({ preventScroll: true });
|
|---|
| 358 | }
|
|---|
| 359 | } catch (_) {}
|
|---|
| 360 |
|
|---|
| 361 | // ── Shortcode chip rendering / serialization ────────────────────
|
|---|
| 362 | // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
|
|---|
| 363 | // — but we DON'T want to chipify text the user is mid-typing inside an
|
|---|
| 364 | // HTML attribute; since chipify only walks text nodes (never attribute
|
|---|
| 365 | // values) that's already safe.
|
|---|
| 366 | const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g;
|
|---|
| 367 |
|
|---|
| 368 | const SC_ICONS = {
|
|---|
| 369 | 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>',
|
|---|
| 370 | 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>',
|
|---|
| 371 | 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>',
|
|---|
| 372 | 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>',
|
|---|
| 373 | };
|
|---|
| 374 |
|
|---|
| 375 | function chipLabel(kind, value) {
|
|---|
| 376 | if (kind === 'track') {
|
|---|
| 377 | // UUIDs are noisy — show a 6-char prefix for visual hint
|
|---|
| 378 | const v = String(value || '');
|
|---|
| 379 | return '' + esc(T.chip_track) + ' ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
|
|---|
| 380 | }
|
|---|
| 381 | if (kind === 'album') return '' + esc(T.chip_album) + ' ' + value;
|
|---|
| 382 | if (kind === 'playlist') return '' + esc(T.chip_playlist) + ' ' + value;
|
|---|
| 383 | if (kind === 'embed') {
|
|---|
| 384 | const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, '');
|
|---|
| 385 | return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean);
|
|---|
| 386 | }
|
|---|
| 387 | return value;
|
|---|
| 388 | }
|
|---|
| 389 |
|
|---|
| 390 | function makeChip(kind, value) {
|
|---|
| 391 | const span = document.createElement('span');
|
|---|
| 392 | span.className = 'sc-chip';
|
|---|
| 393 | span.contentEditable = 'false';
|
|---|
| 394 | span.setAttribute('data-sc', kind + ':' + value);
|
|---|
| 395 | span.innerHTML =
|
|---|
| 396 | '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
|
|---|
| 397 | '<span class="sc-chip-label"></span>';
|
|---|
| 398 | span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
|
|---|
| 399 | return span;
|
|---|
| 400 | }
|
|---|
| 401 |
|
|---|
| 402 | // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
|
|---|
| 403 | function chipifyShortcodes(root) {
|
|---|
| 404 | const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
|---|
| 405 | const targets = [];
|
|---|
| 406 | while (walker.nextNode()) {
|
|---|
| 407 | const n = walker.currentNode;
|
|---|
| 408 | // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
|
|---|
| 409 | if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
|
|---|
| 410 | if (SC_RE.test(n.nodeValue)) targets.push(n);
|
|---|
| 411 | SC_RE.lastIndex = 0;
|
|---|
| 412 | }
|
|---|
| 413 | for (const node of targets) {
|
|---|
| 414 | const txt = node.nodeValue;
|
|---|
| 415 | const frag = document.createDocumentFragment();
|
|---|
| 416 | let last = 0;
|
|---|
| 417 | let m;
|
|---|
| 418 | SC_RE.lastIndex = 0;
|
|---|
| 419 | while ((m = SC_RE.exec(txt)) !== null) {
|
|---|
| 420 | if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
|
|---|
| 421 | frag.appendChild(makeChip(m[1], m[2].trim()));
|
|---|
| 422 | last = m.index + m[0].length;
|
|---|
| 423 | }
|
|---|
| 424 | if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
|
|---|
| 425 | node.parentNode.replaceChild(frag, node);
|
|---|
| 426 | }
|
|---|
| 427 | }
|
|---|
| 428 |
|
|---|
| 429 | // Inverse of chipify: clone the editor, replace every chip with its text.
|
|---|
| 430 | function serializeChips(rootClone) {
|
|---|
| 431 | const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
|
|---|
| 432 | for (const c of chips) {
|
|---|
| 433 | const txt = '[[' + c.getAttribute('data-sc') + ']]';
|
|---|
| 434 | c.replaceWith(document.createTextNode(txt));
|
|---|
| 435 | }
|
|---|
| 436 | }
|
|---|
| 437 |
|
|---|
| 438 | // ── Boot: load initial content as HTML, then render shortcodes as chips
|
|---|
| 439 | try {
|
|---|
| 440 | const initial = JSON.parse(initialEl.textContent || '""');
|
|---|
| 441 | editor.innerHTML = initial || '';
|
|---|
| 442 | chipifyShortcodes(editor);
|
|---|
| 443 | } catch (e) {
|
|---|
| 444 | console.error('[editor] could not parse initial content', e);
|
|---|
| 445 | editor.innerHTML = '';
|
|---|
| 446 | }
|
|---|
| 447 |
|
|---|
| 448 | // ── Char counter
|
|---|
| 449 | function updateCharCount() {
|
|---|
| 450 | const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
|
|---|
| 451 | if (charCountEl) charCountEl.textContent = String(text.length);
|
|---|
| 452 | }
|
|---|
| 453 | updateCharCount();
|
|---|
| 454 | editor.addEventListener('input', updateCharCount);
|
|---|
| 455 |
|
|---|
| 456 | // ── Toolbar wiring
|
|---|
| 457 | // Lock the scroll position around an edit command. execCommand/insert scrolls
|
|---|
| 458 | // the caret into view by default → the view "jumps" when clicking a formatting
|
|---|
| 459 | // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …)
|
|---|
| 460 | // + the page and restore them — sync and over a few frames, because Chrome
|
|---|
| 461 | // sometimes scrolls a frame later. The user scrolls themselves.
|
|---|
| 462 | function scrollableAncestors(el) {
|
|---|
| 463 | const list = [];
|
|---|
| 464 | let node = el;
|
|---|
| 465 | while (node && node !== document.body && node !== document.documentElement) {
|
|---|
| 466 | const oy = getComputedStyle(node).overflowY;
|
|---|
| 467 | if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node);
|
|---|
| 468 | node = node.parentElement;
|
|---|
| 469 | }
|
|---|
| 470 | return list;
|
|---|
| 471 | }
|
|---|
| 472 | function keepScroll(fn) {
|
|---|
| 473 | // In fullscreen the page is locked (body overflow:hidden) and the field may
|
|---|
| 474 | // scroll to the caret freely — no page jump possible, so nothing to fix.
|
|---|
| 475 | const frame = document.querySelector('.pe-editor-frame');
|
|---|
| 476 | if (frame && frame.classList.contains('pe-fs')) { fn(); return; }
|
|---|
| 477 | const wx = window.scrollX, wy = window.scrollY;
|
|---|
| 478 | const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; });
|
|---|
| 479 | const restore = function () {
|
|---|
| 480 | window.scrollTo(wx, wy);
|
|---|
| 481 | anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; });
|
|---|
| 482 | };
|
|---|
| 483 | fn();
|
|---|
| 484 | restore();
|
|---|
| 485 | requestAnimationFrame(restore);
|
|---|
| 486 | }
|
|---|
| 487 | function execCmd(cmd, arg) {
|
|---|
| 488 | keepScroll(function () {
|
|---|
| 489 | editor.focus({ preventScroll: true });
|
|---|
| 490 | document.execCommand(cmd, false, arg);
|
|---|
| 491 | });
|
|---|
| 492 | updateToolbarState();
|
|---|
| 493 | updateCharCount();
|
|---|
| 494 | }
|
|---|
| 495 | function wrapCode() {
|
|---|
| 496 | const sel = window.getSelection();
|
|---|
| 497 | if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
|
|---|
| 498 | keepScroll(function () {
|
|---|
| 499 | const range = sel.getRangeAt(0);
|
|---|
| 500 | const code = document.createElement('code');
|
|---|
| 501 | code.textContent = sel.toString();
|
|---|
| 502 | range.deleteContents();
|
|---|
| 503 | range.insertNode(code);
|
|---|
| 504 | // Move caret after the new node
|
|---|
| 505 | range.setStartAfter(code);
|
|---|
| 506 | range.collapse(true);
|
|---|
| 507 | sel.removeAllRanges();
|
|---|
| 508 | sel.addRange(range);
|
|---|
| 509 | editor.focus({ preventScroll: true });
|
|---|
| 510 | });
|
|---|
| 511 | }
|
|---|
| 512 | function linkPrompt() {
|
|---|
| 513 | const url = window.prompt('' + esc(T.js_link_prompt) + '');
|
|---|
| 514 | if (!url) return;
|
|---|
| 515 | execCmd('createLink', url);
|
|---|
| 516 | }
|
|---|
| 517 | // Is the current selection inside a <blockquote> within the editor? Return it.
|
|---|
| 518 | function blockquoteAncestor() {
|
|---|
| 519 | const sel = window.getSelection();
|
|---|
| 520 | if (!sel || sel.rangeCount === 0) return null;
|
|---|
| 521 | let node = sel.anchorNode;
|
|---|
| 522 | while (node && node !== editor) {
|
|---|
| 523 | if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node;
|
|---|
| 524 | node = node.parentNode;
|
|---|
| 525 | }
|
|---|
| 526 | return null;
|
|---|
| 527 | }
|
|---|
| 528 | // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but
|
|---|
| 529 | // can never turn it OFF (browser quirk). If the caret is already in a quote →
|
|---|
| 530 | // unwrap it; otherwise apply blockquote.
|
|---|
| 531 | function toggleBlockquote() {
|
|---|
| 532 | keepScroll(function () {
|
|---|
| 533 | editor.focus({ preventScroll: true });
|
|---|
| 534 | const bq = blockquoteAncestor();
|
|---|
| 535 | if (bq) {
|
|---|
| 536 | const parent = bq.parentNode;
|
|---|
| 537 | // Extract content from the quote in place, then remove the empty wrapper.
|
|---|
| 538 | const ref = bq;
|
|---|
| 539 | let firstMoved = null;
|
|---|
| 540 | while (bq.firstChild) {
|
|---|
| 541 | const child = bq.firstChild;
|
|---|
| 542 | if (!firstMoved) firstMoved = child;
|
|---|
| 543 | parent.insertBefore(child, ref);
|
|---|
| 544 | }
|
|---|
| 545 | parent.removeChild(bq);
|
|---|
| 546 | // Restore the caret inside the unwrapped content.
|
|---|
| 547 | if (firstMoved) {
|
|---|
| 548 | const sel = window.getSelection();
|
|---|
| 549 | const range = document.createRange();
|
|---|
| 550 | range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent);
|
|---|
| 551 | range.collapse(false);
|
|---|
| 552 | sel.removeAllRanges();
|
|---|
| 553 | sel.addRange(range);
|
|---|
| 554 | }
|
|---|
| 555 | } else {
|
|---|
| 556 | document.execCommand('formatBlock', false, 'blockquote');
|
|---|
| 557 | }
|
|---|
| 558 | });
|
|---|
| 559 | updateToolbarState();
|
|---|
| 560 | updateCharCount();
|
|---|
| 561 | }
|
|---|
| 562 |
|
|---|
| 563 | if (toolbar) {
|
|---|
| 564 | // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection
|
|---|
| 565 | // from the editor field. Without this the selection is lost on tap
|
|---|
| 566 | // → execCommand operates on an empty selection (bold can no longer be toggled OFF)
|
|---|
| 567 | // and the browser scrolls the caret back into view (the "jump down"). preventDefault
|
|---|
| 568 | // on mousedown keeps focus in the editor; the click still fires normally.
|
|---|
| 569 | toolbar.addEventListener('mousedown', (e) => {
|
|---|
| 570 | if (e.target.closest('button')) e.preventDefault();
|
|---|
| 571 | });
|
|---|
| 572 | toolbar.addEventListener('click', (e) => {
|
|---|
| 573 | const btn = e.target.closest('button[data-cmd]');
|
|---|
| 574 | if (!btn) return;
|
|---|
| 575 | e.preventDefault();
|
|---|
| 576 | const cmd = btn.dataset.cmd;
|
|---|
| 577 | const arg = btn.dataset.arg || null;
|
|---|
| 578 | if (cmd === 'link-prompt') linkPrompt();
|
|---|
| 579 | else if (cmd === 'code-wrap') wrapCode();
|
|---|
| 580 | else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote();
|
|---|
| 581 | else execCmd(cmd, arg);
|
|---|
| 582 | });
|
|---|
| 583 | }
|
|---|
| 584 |
|
|---|
| 585 | // ── Full-screen writing mode: the writing field fills the whole page.
|
|---|
| 586 | const fsBtn = document.getElementById('pe-fullscreen-btn');
|
|---|
| 587 | const editorFrame = document.querySelector('.pe-editor-frame');
|
|---|
| 588 | const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
|
|---|
| 589 | // OP TOUCH IS DIT GEEN LUXE MAAR DE ENIGE INGANG (shaer-kd1). Buiten
|
|---|
| 590 | // fullscreen is de toolbar daar verborgen (@media (pointer: coarse) in
|
|---|
| 591 | // pages/post-edit.ejs), en het veld staat op contenteditable=false. Ontbreekt
|
|---|
| 592 | // een van deze twee elementen, dan kun je op een telefoon NIET TYPEN -- en tot
|
|---|
| 593 | // nu toe gebeurde dat zonder één spoor: applyFs deed een kale `return`.
|
|---|
| 594 | if (isTouch && (!editorFrame || !editor)) {
|
|---|
| 595 | console.warn('[post-edit] fullscreen onbereikbaar op touch:',
|
|---|
| 596 | 'frame=' + !!editorFrame, 'editor=' + !!editor,
|
|---|
| 597 | '-- de toolbar is hier verborgen, dus dit betekent: niet kunnen typen');
|
|---|
| 598 | }
|
|---|
| 599 |
|
|---|
| 600 | // On mobile the keyboard pushes the visible (visual) viewport up while
|
|---|
| 601 | // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar
|
|---|
| 602 | // slides out of view. Keep the fullscreen frame aligned to the visual
|
|---|
| 603 | // viewport (top + height) so the toolbar stays visible at the top.
|
|---|
| 604 | function syncFsViewport() {
|
|---|
| 605 | if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
|
|---|
| 606 | const vv = window.visualViewport;
|
|---|
| 607 | if (!vv) return;
|
|---|
| 608 | editorFrame.style.top = vv.offsetTop + 'px';
|
|---|
| 609 | editorFrame.style.height = vv.height + 'px';
|
|---|
| 610 | }
|
|---|
| 611 | function clearFsViewport() {
|
|---|
| 612 | if (!editorFrame) return;
|
|---|
| 613 | editorFrame.style.top = '';
|
|---|
| 614 | editorFrame.style.height = '';
|
|---|
| 615 | }
|
|---|
| 616 | function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); }
|
|---|
| 617 | function applyFs(on) {
|
|---|
| 618 | if (!editorFrame) {
|
|---|
| 619 | // Was een kale `return`. Op touch is dit het verschil tussen "fullscreen
|
|---|
| 620 | // werkt niet" en "je kunt niet typen", en het gebeurde zonder spoor.
|
|---|
| 621 | console.warn('[post-edit] fullscreen kan niet: .pe-editor-frame ontbreekt');
|
|---|
| 622 | return;
|
|---|
| 623 | }
|
|---|
| 624 | editorFrame.classList.toggle('pe-fs', on);
|
|---|
| 625 | document.body.classList.toggle('pe-fs-open', on);
|
|---|
| 626 | document.documentElement.classList.toggle('pe-fs-open', on);
|
|---|
| 627 | if (fsBtn) {
|
|---|
| 628 | fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|---|
| 629 | fsBtn.title = on ? '' + esc(T.tb_done) + '' : '' + esc(T.tb_fullscreen) + '';
|
|---|
| 630 | }
|
|---|
| 631 | if (window.visualViewport) {
|
|---|
| 632 | if (on) {
|
|---|
| 633 | window.visualViewport.addEventListener('resize', syncFsViewport);
|
|---|
| 634 | window.visualViewport.addEventListener('scroll', syncFsViewport);
|
|---|
| 635 | syncFsViewport();
|
|---|
| 636 | } else {
|
|---|
| 637 | window.visualViewport.removeEventListener('resize', syncFsViewport);
|
|---|
| 638 | window.visualViewport.removeEventListener('scroll', syncFsViewport);
|
|---|
| 639 | clearFsViewport();
|
|---|
| 640 | }
|
|---|
| 641 | }
|
|---|
| 642 | // On touch the field is NOT editable inline; only in fullscreen.
|
|---|
| 643 | if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false');
|
|---|
| 644 | if (on) {
|
|---|
| 645 | editor.focus({ preventScroll: true });
|
|---|
| 646 | } else {
|
|---|
| 647 | if (isTouch) editor.blur();
|
|---|
| 648 | // On close: scroll to the TOP of the content instead of staying
|
|---|
| 649 | // somewhere at the bottom (footer).
|
|---|
| 650 | requestAnimationFrame(function () {
|
|---|
| 651 | try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {}
|
|---|
| 652 | });
|
|---|
| 653 | }
|
|---|
| 654 | }
|
|---|
| 655 | // The fullscreen writing "page": opening pushes a history state so the browser
|
|---|
| 656 | // back button (and the Done button) closes it and returns you to the form — feels
|
|---|
| 657 | // like a separate page, but all form fields remain intact (same DOM).
|
|---|
| 658 | function openFs() {
|
|---|
| 659 | if (isFs()) return;
|
|---|
| 660 | try { history.pushState({ peFs: true }, ''); } catch (_) {}
|
|---|
| 661 | applyFs(true);
|
|---|
| 662 | }
|
|---|
| 663 | function closeFs() {
|
|---|
| 664 | if (!isFs()) return;
|
|---|
| 665 | if (history.state && history.state.peFs) history.back(); // → popstate closes it
|
|---|
| 666 | else applyFs(false);
|
|---|
| 667 | }
|
|---|
| 668 | function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); }
|
|---|
| 669 | doc.on(window, 'popstate', function () { if (isFs()) applyFs(false); });
|
|---|
| 670 | // __wired zoals overal in run(): init() draait bij ELKE paginawissel, en op
|
|---|
| 671 | // dezelfde DOM zou een kale addEventListener stapelen. Na een htmx-wissel is
|
|---|
| 672 | // het element nieuw en dus de vlag weg -- precies de bedoeling.
|
|---|
| 673 | if (fsBtn && !fsBtn.__fsWired) { fsBtn.__fsWired = true; fsBtn.addEventListener('click', toggleFullscreen); }
|
|---|
| 674 | var fsDoneBtn = document.getElementById('pe-fs-done');
|
|---|
| 675 | if (fsDoneBtn && !fsDoneBtn.__fsWired) { fsDoneBtn.__fsWired = true; fsDoneBtn.addEventListener('click', closeFs); }
|
|---|
| 676 | doc.on(document, 'keydown', (e) => {
|
|---|
| 677 | if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); }
|
|---|
| 678 | });
|
|---|
| 679 |
|
|---|
| 680 | // On mobile/tablet (touch): the content field is NOT editable inline — it is
|
|---|
| 681 | // not a text field there. One tap → fullscreen, where it becomes editable
|
|---|
| 682 | // (toggleFullscreen toggles contenteditable). This prevents inline typing.
|
|---|
| 683 | if (isTouch && editor && !editor.__fsTapWired) {
|
|---|
| 684 | editor.__fsTapWired = true;
|
|---|
| 685 | editor.setAttribute('contenteditable', 'false');
|
|---|
| 686 | editor.classList.add('pe-tap-to-edit');
|
|---|
| 687 | editor.addEventListener('click', function () {
|
|---|
| 688 | if (!isFs()) openFs();
|
|---|
| 689 | });
|
|---|
| 690 | }
|
|---|
| 691 |
|
|---|
| 692 | // Reflect bold/italic/list state on the toolbar buttons
|
|---|
| 693 | function updateToolbarState() {
|
|---|
| 694 | if (!toolbar) return;
|
|---|
| 695 | const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
|
|---|
| 696 | for (const cmd of cmds) {
|
|---|
| 697 | const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
|
|---|
| 698 | if (!btn) continue;
|
|---|
| 699 | try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
|
|---|
| 700 | }
|
|---|
| 701 | // Quote button: active when the caret is inside a <blockquote> (toggle feedback).
|
|---|
| 702 | const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
|
|---|
| 703 | if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
|
|---|
| 704 | }
|
|---|
| 705 | doc.on(document, 'selectionchange', () => {
|
|---|
| 706 | if (document.activeElement === editor) updateToolbarState();
|
|---|
| 707 | });
|
|---|
| 708 |
|
|---|
| 709 | // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
|
|---|
| 710 | editor.addEventListener('keydown', (e) => {
|
|---|
| 711 | const mod = e.ctrlKey || e.metaKey;
|
|---|
| 712 | if (!mod) return;
|
|---|
| 713 | const k = e.key.toLowerCase();
|
|---|
| 714 | if (k === 'b') { e.preventDefault(); execCmd('bold'); }
|
|---|
| 715 | else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
|
|---|
| 716 | else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
|
|---|
| 717 | else if (k === 'k') { e.preventDefault(); linkPrompt(); }
|
|---|
| 718 | });
|
|---|
| 719 |
|
|---|
| 720 | // Paste: keep it simple — strip formatting unless user wants it. Default
|
|---|
| 721 | // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
|
|---|
| 722 | // styles from clipboard only when shift is held — otherwise plain text.
|
|---|
| 723 | editor.addEventListener('paste', (e) => {
|
|---|
| 724 | if (e.shiftKey) return; // user wants formatted paste
|
|---|
| 725 | const text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
|---|
| 726 | if (text == null) return;
|
|---|
| 727 | e.preventDefault();
|
|---|
| 728 | document.execCommand('insertText', false, text);
|
|---|
| 729 | });
|
|---|
| 730 |
|
|---|
| 731 | // ── Image upload (button + drag-drop into the editor)
|
|---|
| 732 | async function uploadAndInsertImage(file) {
|
|---|
| 733 | const edited = await openImageEditor(file);
|
|---|
| 734 | if (!edited) return; // cancelled
|
|---|
| 735 | contentStatus.classList.remove('is-error');
|
|---|
| 736 | contentStatus.textContent = '' + esc(T.js_uploading) + '';
|
|---|
| 737 | try {
|
|---|
| 738 | const j = await uploadImage(edited);
|
|---|
| 739 | const img = '<img src="' + j.url + '" alt="">';
|
|---|
| 740 | editor.focus({ preventScroll: true });
|
|---|
| 741 | document.execCommand('insertHTML', false, img);
|
|---|
| 742 | contentStatus.textContent = '' + esc(T.js_inserted) + ' ✓';
|
|---|
| 743 | setTimeout(() => { contentStatus.textContent = ''; }, 2000);
|
|---|
| 744 | updateCharCount();
|
|---|
| 745 | } catch (e) {
|
|---|
| 746 | contentStatus.classList.add('is-error');
|
|---|
| 747 | contentStatus.textContent = '' + esc(T.js_failed) + ': ' + e.message;
|
|---|
| 748 | }
|
|---|
| 749 | }
|
|---|
| 750 |
|
|---|
| 751 | if (contentBtn && contentField) {
|
|---|
| 752 | contentBtn.addEventListener('click', () => contentField.click());
|
|---|
| 753 | contentField.addEventListener('change', () => {
|
|---|
| 754 | if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
|
|---|
| 755 | contentField.value = '';
|
|---|
| 756 | });
|
|---|
| 757 |
|
|---|
| 758 | editor.addEventListener('dragover', (e) => {
|
|---|
| 759 | if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
|
|---|
| 760 | e.preventDefault();
|
|---|
| 761 | editor.classList.add('is-dragover');
|
|---|
| 762 | }
|
|---|
| 763 | });
|
|---|
| 764 | editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
|
|---|
| 765 | editor.addEventListener('drop', async (e) => {
|
|---|
| 766 | editor.classList.remove('is-dragover');
|
|---|
| 767 | const files = e.dataTransfer && e.dataTransfer.files;
|
|---|
| 768 | if (!files || !files.length) return;
|
|---|
| 769 | e.preventDefault();
|
|---|
| 770 | for (const f of files) {
|
|---|
| 771 | if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
|
|---|
| 772 | }
|
|---|
| 773 | });
|
|---|
| 774 | }
|
|---|
| 775 |
|
|---|
| 776 | // ── Insert chip helpers (track / playlist)
|
|---|
| 777 | function insertChip(kind, value) {
|
|---|
| 778 | editor.focus({ preventScroll: true });
|
|---|
| 779 | const chip = makeChip(kind, value);
|
|---|
| 780 | // Insert at caret using the Selection API (execCommand insertNode)
|
|---|
| 781 | const sel = window.getSelection();
|
|---|
| 782 | if (sel && sel.rangeCount > 0) {
|
|---|
| 783 | const range = sel.getRangeAt(0);
|
|---|
| 784 | range.deleteContents();
|
|---|
| 785 | range.insertNode(chip);
|
|---|
| 786 | // Insert a trailing space so the user can keep typing after the chip
|
|---|
| 787 | const space = document.createTextNode('\u00A0');
|
|---|
| 788 | chip.after(space);
|
|---|
| 789 | range.setStartAfter(space);
|
|---|
| 790 | range.collapse(true);
|
|---|
| 791 | sel.removeAllRanges();
|
|---|
| 792 | sel.addRange(range);
|
|---|
| 793 | } else {
|
|---|
| 794 | editor.appendChild(chip);
|
|---|
| 795 | editor.appendChild(document.createTextNode('\u00A0'));
|
|---|
| 796 | }
|
|---|
| 797 | updateCharCount();
|
|---|
| 798 | volgMuziek();
|
|---|
| 799 | }
|
|---|
| 800 |
|
|---|
| 801 | // Wordt hieronder gevuld door het typeblok. Zolang dat er niet is (of de
|
|---|
| 802 | // gebruiker het type zelf koos) gebeurt er niets -- insertChip mag daar niet
|
|---|
| 803 | // op stuklopen.
|
|---|
| 804 | let volgMuziek = () => {};
|
|---|
| 805 |
|
|---|
| 806 | // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes
|
|---|
| 807 | // an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
|
|---|
| 808 | const embedBtn = document.getElementById('insert-embed-btn');
|
|---|
| 809 | if (embedBtn) {
|
|---|
| 810 | embedBtn.addEventListener('click', () => {
|
|---|
| 811 | const raw = window.prompt('' + esc(T.js_embed_prompt) + '');
|
|---|
| 812 | if (!raw) return;
|
|---|
| 813 | const url = raw.trim();
|
|---|
| 814 | if (!/^https?:\/\//i.test(url)) { alert('' + esc(T.js_embed_invalid) + ''); return; }
|
|---|
| 815 | insertChip('embed', url);
|
|---|
| 816 | });
|
|---|
| 817 | }
|
|---|
| 818 |
|
|---|
| 819 | // ── Track insert: opens the track-picker modal (P59)
|
|---|
| 820 | const trackBtn = document.getElementById('insert-track-btn');
|
|---|
| 821 | const trackPicker = document.getElementById('track-picker');
|
|---|
| 822 | if (trackBtn && trackPicker) {
|
|---|
| 823 | const tpList = document.getElementById('tp-list');
|
|---|
| 824 | const tpEmpty = document.getElementById('tp-empty');
|
|---|
| 825 | const tpSearch = document.getElementById('tp-search');
|
|---|
| 826 | let tpCache = null; // cached track list (fetched once per page load)
|
|---|
| 827 | let tpLastFocus = null; // element to restore focus to on close
|
|---|
| 828 |
|
|---|
| 829 | 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>';
|
|---|
| 830 |
|
|---|
| 831 | function fmtDur(sec) {
|
|---|
| 832 | sec = Math.max(0, Math.floor(sec || 0));
|
|---|
| 833 | const m = Math.floor(sec / 60), s = sec % 60;
|
|---|
| 834 | return m + ':' + String(s).padStart(2, '0');
|
|---|
| 835 | }
|
|---|
| 836 | function escAttr(s) {
|
|---|
| 837 | return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
|
|---|
| 838 | '&':'&','<':'<','>':'>','"':'"',"'":'''
|
|---|
| 839 | }[c]));
|
|---|
| 840 | }
|
|---|
| 841 |
|
|---|
| 842 | function renderList(filter) {
|
|---|
| 843 | if (!Array.isArray(tpCache)) return;
|
|---|
| 844 | const q = (filter || '').trim().toLowerCase();
|
|---|
| 845 | const filtered = q
|
|---|
| 846 | ? tpCache.filter(t =>
|
|---|
| 847 | (t.title || '').toLowerCase().includes(q) ||
|
|---|
| 848 | (t.artist || '').toLowerCase().includes(q))
|
|---|
| 849 | : tpCache;
|
|---|
| 850 |
|
|---|
| 851 | if (!filtered.length) {
|
|---|
| 852 | tpList.innerHTML = '';
|
|---|
| 853 | tpEmpty.textContent = q ? '' + esc(T.js_no_tracks_found) + ' ' + q : '' + esc(T.js_no_tracks_yet) + '';
|
|---|
| 854 | tpList.appendChild(tpEmpty);
|
|---|
| 855 | return;
|
|---|
| 856 | }
|
|---|
| 857 |
|
|---|
| 858 | tpList.innerHTML = filtered.map(t => {
|
|---|
| 859 | const cov = t.cover
|
|---|
| 860 | ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
|
|---|
| 861 | : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
|
|---|
| 862 | const dis = t.playable ? '' : ' aria-disabled="true"';
|
|---|
| 863 | const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
|
|---|
| 864 | return (
|
|---|
| 865 | '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
|
|---|
| 866 | cov +
|
|---|
| 867 | '<span class="tp-meta">' +
|
|---|
| 868 | '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
|
|---|
| 869 | sub +
|
|---|
| 870 | '</span>' +
|
|---|
| 871 | '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
|
|---|
| 872 | '</button>'
|
|---|
| 873 | );
|
|---|
| 874 | }).join('');
|
|---|
| 875 | }
|
|---|
| 876 |
|
|---|
| 877 | async function loadTracks() {
|
|---|
| 878 | if (Array.isArray(tpCache)) return tpCache;
|
|---|
| 879 | tpEmpty.textContent = '' + esc(T.js_tracks_loading) + '';
|
|---|
| 880 | try {
|
|---|
| 881 | const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
|
|---|
| 882 | const j = await r.json();
|
|---|
| 883 | tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
|
|---|
| 884 | } catch (e) {
|
|---|
| 885 | tpCache = [];
|
|---|
| 886 | tpEmpty.textContent = '' + esc(T.js_tracks_load_fail) + ': ' + e.message;
|
|---|
| 887 | }
|
|---|
| 888 | return tpCache;
|
|---|
| 889 | }
|
|---|
| 890 |
|
|---|
| 891 | function openPicker() {
|
|---|
| 892 | tpLastFocus = document.activeElement;
|
|---|
| 893 | trackPicker.hidden = false;
|
|---|
| 894 | trackPicker.setAttribute('aria-hidden', 'false');
|
|---|
| 895 | document.body.classList.add('tp-locked');
|
|---|
| 896 | tpSearch.value = '';
|
|---|
| 897 | renderList('');
|
|---|
| 898 | // Defer focus so the open animation doesn't get jumped
|
|---|
| 899 | setTimeout(() => tpSearch.focus(), 30);
|
|---|
| 900 | }
|
|---|
| 901 | function closePicker() {
|
|---|
| 902 | trackPicker.hidden = true;
|
|---|
| 903 | trackPicker.setAttribute('aria-hidden', 'true');
|
|---|
| 904 | document.body.classList.remove('tp-locked');
|
|---|
| 905 | if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
|
|---|
| 906 | try { tpLastFocus.focus(); } catch(_) {}
|
|---|
| 907 | }
|
|---|
| 908 | }
|
|---|
| 909 |
|
|---|
| 910 | trackBtn.addEventListener('click', async () => {
|
|---|
| 911 | openPicker();
|
|---|
| 912 | await loadTracks();
|
|---|
| 913 | renderList(tpSearch.value);
|
|---|
| 914 | });
|
|---|
| 915 |
|
|---|
| 916 | // Close: backdrop click, [data-tp-close], or Escape
|
|---|
| 917 | trackPicker.addEventListener('click', (e) => {
|
|---|
| 918 | if (e.target.closest('[data-tp-close]')) {
|
|---|
| 919 | closePicker();
|
|---|
| 920 | return;
|
|---|
| 921 | }
|
|---|
| 922 | const row = e.target.closest('.tp-row[data-track-id]');
|
|---|
| 923 | if (row) {
|
|---|
| 924 | if (row.getAttribute('aria-disabled') === 'true') return;
|
|---|
| 925 | const id = row.dataset.trackId;
|
|---|
| 926 | if (id) {
|
|---|
| 927 | insertChip('track', id);
|
|---|
| 928 | closePicker();
|
|---|
| 929 | }
|
|---|
| 930 | }
|
|---|
| 931 | });
|
|---|
| 932 | doc.on(document, 'keydown', (e) => {
|
|---|
| 933 | if (!trackPicker.hidden && e.key === 'Escape') {
|
|---|
| 934 | e.preventDefault();
|
|---|
| 935 | closePicker();
|
|---|
| 936 | }
|
|---|
| 937 | });
|
|---|
| 938 |
|
|---|
| 939 | // Live filter
|
|---|
| 940 | tpSearch.addEventListener('input', () => renderList(tpSearch.value));
|
|---|
| 941 | }
|
|---|
| 942 |
|
|---|
| 943 | // ── Playlist insert (open existing or create new via modal)
|
|---|
| 944 | const playlistBtn = document.getElementById('insert-playlist-btn');
|
|---|
| 945 | if (playlistBtn) {
|
|---|
| 946 | playlistBtn.addEventListener('click', async () => {
|
|---|
| 947 | if (typeof window.openPlaylistEditor !== 'function') {
|
|---|
| 948 | alert('' + esc(T.js_playlist_editor_missing) + '');
|
|---|
| 949 | return;
|
|---|
| 950 | }
|
|---|
| 951 | try {
|
|---|
| 952 | const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
|
|---|
| 953 | const j = await r.json();
|
|---|
| 954 | if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
|
|---|
| 955 | const choice = prompt(
|
|---|
| 956 | '' + esc(T.js_playlist_existing) + '\n\n' +
|
|---|
| 957 | j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
|
|---|
| 958 | '\n\n' + esc(T.js_playlist_choose) + ''
|
|---|
| 959 | );
|
|---|
| 960 | if (choice && /^\d+$/.test(choice.trim())) {
|
|---|
| 961 | const idx = parseInt(choice.trim(), 10) - 1;
|
|---|
| 962 | if (idx >= 0 && idx < j.playlists.length) {
|
|---|
| 963 | insertChip('playlist', j.playlists[idx].id);
|
|---|
| 964 | return;
|
|---|
| 965 | }
|
|---|
| 966 | }
|
|---|
| 967 | if (choice === null) return;
|
|---|
| 968 | }
|
|---|
| 969 | } catch (_) { /* fall through to create */ }
|
|---|
| 970 |
|
|---|
| 971 | window.openPlaylistEditor({
|
|---|
| 972 | mode: 'create',
|
|---|
| 973 | onSaved: ({ id }) => insertChip('playlist', id),
|
|---|
| 974 | });
|
|---|
| 975 | });
|
|---|
| 976 | }
|
|---|
| 977 |
|
|---|
| 978 | // ── Post type: segmented control + type-aware panels ──────────
|
|---|
| 979 | (function () {
|
|---|
| 980 | const typeInput = document.getElementById('pe-type-input');
|
|---|
| 981 | const card = document.querySelector('.pe-type-card');
|
|---|
| 982 | if (!typeInput || !card) return;
|
|---|
| 983 | const seg = card.querySelector('.pe-typeseg');
|
|---|
| 984 | const panels = card.querySelectorAll('.pe-type-panel');
|
|---|
| 985 | let handmatig = false; // heeft de gebruiker het type zelf aangeklikt?
|
|---|
| 986 |
|
|---|
| 987 | function applyType(tt) {
|
|---|
| 988 | typeInput.value = tt;
|
|---|
| 989 | seg.querySelectorAll('.pe-typeseg-btn').forEach(b => {
|
|---|
| 990 | const on = b.dataset.type === tt;
|
|---|
| 991 | b.classList.toggle('is-active', on);
|
|---|
| 992 | b.setAttribute('aria-checked', on ? 'true' : 'false');
|
|---|
| 993 | });
|
|---|
| 994 | // data-panel mag meerdere types noemen: Album en Playlist delen het
|
|---|
| 995 | // muziekpaneel, want het verschil zit in de playlist en niet in de upload.
|
|---|
| 996 | panels.forEach(p => {
|
|---|
| 997 | const voor = String(p.dataset.panel || '').trim().split(/\s+/);
|
|---|
| 998 | p.hidden = !voor.includes(tt);
|
|---|
| 999 | });
|
|---|
| 1000 | }
|
|---|
| 1001 | seg.addEventListener('click', (e) => {
|
|---|
| 1002 | const btn = e.target.closest('.pe-typeseg-btn');
|
|---|
| 1003 | if (!btn) return;
|
|---|
| 1004 | handmatig = true; // jouw klik wint vanaf nu van de afleiding
|
|---|
| 1005 | applyType(btn.dataset.type);
|
|---|
| 1006 | });
|
|---|
| 1007 | applyType(typeInput.value || 'post');
|
|---|
| 1008 |
|
|---|
| 1009 | // ── Het type volgt de muziek (shaer-cyg) ──────────────────────
|
|---|
| 1010 | //
|
|---|
| 1011 | // Robins regel: de post neemt de soort van zijn muziek over als hij precies
|
|---|
| 1012 | // EEN muzikale eenheid bevat. Dus zodra je een playlist invoegt verspringt
|
|---|
| 1013 | // de balk mee, en zie je wat je aan het maken bent in plaats van het zelf te
|
|---|
| 1014 | // moeten bijhouden.
|
|---|
| 1015 | //
|
|---|
| 1016 | // TWEE DINGEN DIE HIJ MET RUST LAAT. Klik je zelf een type aan, dan wint die
|
|---|
| 1017 | // keuze -- daarna wordt er niets meer voor je omgezet. En Foto en Video zijn
|
|---|
| 1018 | // een bewuste andere keuze over dezelfde post, dus die overschrijft hij
|
|---|
| 1019 | // nooit, ook niet als er muziek in staat.
|
|---|
| 1020 | //
|
|---|
| 1021 | // Bij het OPENEN van een post gebeurt er niets: de eerste afleiding wordt
|
|---|
| 1022 | // alleen als ijkpunt onthouden. Anders zou een oude post van type veranderen
|
|---|
| 1023 | // door hem te bekijken.
|
|---|
| 1024 | const VOLGBAAR = new Set(['post', 'album', 'playlist', 'mixtape', 'audio']);
|
|---|
| 1025 | const kindVan = new Map();
|
|---|
| 1026 | let vorigeAfleiding = null;
|
|---|
| 1027 |
|
|---|
| 1028 | const soortenGeladen = fetch('/admin/playlists/api/list', { credentials: 'same-origin' })
|
|---|
| 1029 | .then((r) => r.json())
|
|---|
| 1030 | .then((j) => {
|
|---|
| 1031 | if (j && j.ok && Array.isArray(j.playlists)) {
|
|---|
| 1032 | // De soort ZOALS HIJ IS. Dit was `=== 'playlist' ? 'playlist' : 'album'`,
|
|---|
| 1033 | // en dan ziet de editor een mixtape als album terwijl de server hem
|
|---|
| 1034 | // als mixtape opslaat: het type verspringt onder je handen bij het
|
|---|
| 1035 | // bewaren. SOORTEN is dezelfde lijst die de server gebruikt.
|
|---|
| 1036 | for (const p of j.playlists) kindVan.set(p.id, SOORTEN.includes(p.kind) ? p.kind : 'album');
|
|---|
| 1037 | }
|
|---|
| 1038 | })
|
|---|
| 1039 | // Zonder de soorten leidt de regel niets af (elke playlist is dan
|
|---|
| 1040 | // 'onbekend'), en dat is beter dan een gok die als keuze oogt.
|
|---|
| 1041 | .catch(() => {})
|
|---|
| 1042 | .then(() => { vorigeAfleiding = afleidenNu(); });
|
|---|
| 1043 |
|
|---|
| 1044 | function afleidenNu() {
|
|---|
| 1045 | // Precies de tekst die straks wordt opgeslagen: chips terug naar
|
|---|
| 1046 | // shortcodes. Zo leidt de editor af uit wat de server ook zal zien.
|
|---|
| 1047 | const clone = editor.cloneNode(true);
|
|---|
| 1048 | serializeChips(clone);
|
|---|
| 1049 | const r = afleidenUitInsluitingen(clone.innerHTML, (id) => kindVan.get(id) || null);
|
|---|
| 1050 | return r ? r.type : null;
|
|---|
| 1051 | }
|
|---|
| 1052 |
|
|---|
| 1053 | volgMuziek = async () => {
|
|---|
| 1054 | await soortenGeladen;
|
|---|
| 1055 | const nu = afleidenNu();
|
|---|
| 1056 | if (nu === vorigeAfleiding) return; // de muziek is niet van soort veranderd
|
|---|
| 1057 | vorigeAfleiding = nu;
|
|---|
| 1058 | if (handmatig || !nu) return;
|
|---|
| 1059 | if (!VOLGBAAR.has(typeInput.value)) return;
|
|---|
| 1060 | applyType(nu);
|
|---|
| 1061 | };
|
|---|
| 1062 |
|
|---|
| 1063 | // Ook getypte of geplakte shortcodes tellen, niet alleen de knoppen.
|
|---|
| 1064 | let tik = null;
|
|---|
| 1065 | editor.addEventListener('input', () => {
|
|---|
| 1066 | clearTimeout(tik);
|
|---|
| 1067 | tik = setTimeout(volgMuziek, 300);
|
|---|
| 1068 | });
|
|---|
| 1069 |
|
|---|
| 1070 | // Video URL → [[embed:url]] chip
|
|---|
| 1071 | const vBtn = document.getElementById('pe-video-insert');
|
|---|
| 1072 | const vUrl = document.getElementById('pe-video-url');
|
|---|
| 1073 | if (vBtn && vUrl) {
|
|---|
| 1074 | const doInsert = () => {
|
|---|
| 1075 | const url = (vUrl.value || '').trim();
|
|---|
| 1076 | if (!/^https?:\/\//i.test(url)) { alert('' + esc(T.js_embed_invalid) + ''); return; }
|
|---|
| 1077 | insertChip('embed', url);
|
|---|
| 1078 | vUrl.value = '';
|
|---|
| 1079 | };
|
|---|
| 1080 | vBtn.addEventListener('click', doInsert);
|
|---|
| 1081 | vUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doInsert(); } });
|
|---|
| 1082 | }
|
|---|
| 1083 |
|
|---|
| 1084 | // Audio: inline upload → transcodes server-side → [[track:id]] chip
|
|---|
| 1085 | const drop = document.getElementById('pe-audio-drop');
|
|---|
| 1086 | const fileInput = document.getElementById('pe-audio-file');
|
|---|
| 1087 | const list = document.getElementById('pe-audio-list');
|
|---|
| 1088 | if (drop && fileInput && list) {
|
|---|
| 1089 | const pick = () => fileInput.click();
|
|---|
| 1090 | drop.addEventListener('click', pick);
|
|---|
| 1091 | drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
|
|---|
| 1092 | ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('is-drag'); }));
|
|---|
| 1093 | ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('is-drag'); }));
|
|---|
| 1094 | drop.addEventListener('drop', (e) => { if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); });
|
|---|
| 1095 | fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; });
|
|---|
| 1096 |
|
|---|
| 1097 | function clientDuration(f) {
|
|---|
| 1098 | return new Promise((resolve) => {
|
|---|
| 1099 | try {
|
|---|
| 1100 | const u = URL.createObjectURL(f);
|
|---|
| 1101 | const a = document.createElement('audio');
|
|---|
| 1102 | a.preload = 'metadata';
|
|---|
| 1103 | a.onloadedmetadata = () => { URL.revokeObjectURL(u); resolve(Number.isFinite(a.duration) ? Math.round(a.duration) : null); };
|
|---|
| 1104 | a.onerror = () => { URL.revokeObjectURL(u); resolve(null); };
|
|---|
| 1105 | a.src = u;
|
|---|
| 1106 | } catch (_) { resolve(null); }
|
|---|
| 1107 | });
|
|---|
| 1108 | }
|
|---|
| 1109 | async function handleFiles(files) {
|
|---|
| 1110 | for (const f of Array.from(files || [])) await uploadOne(f);
|
|---|
| 1111 | }
|
|---|
| 1112 | async function uploadOne(f) {
|
|---|
| 1113 | const li = document.createElement('li');
|
|---|
| 1114 | li.className = 'pe-audio-item';
|
|---|
| 1115 | const nameEl = document.createElement('span');
|
|---|
| 1116 | nameEl.className = 'pe-audio-item-name';
|
|---|
| 1117 | nameEl.textContent = f.name;
|
|---|
| 1118 | const stateEl = document.createElement('span');
|
|---|
| 1119 | stateEl.className = 'pe-audio-item-state';
|
|---|
| 1120 | stateEl.textContent = '⏳ ' + esc(T.audio_up_busy) + '';
|
|---|
| 1121 | li.appendChild(nameEl); li.appendChild(stateEl);
|
|---|
| 1122 | list.appendChild(li);
|
|---|
| 1123 | try {
|
|---|
| 1124 | const dur = await clientDuration(f);
|
|---|
| 1125 | const fd = new FormData();
|
|---|
| 1126 | fd.append('audio', f);
|
|---|
| 1127 | if (dur) fd.append('duration', String(dur));
|
|---|
| 1128 | const res = await fetch('/admin/audio/upload', {
|
|---|
| 1129 | method: 'POST', body: fd,
|
|---|
| 1130 | headers: { 'Accept': 'application/json' },
|
|---|
| 1131 | credentials: 'same-origin',
|
|---|
| 1132 | });
|
|---|
| 1133 | const j = await res.json().catch(() => ({}));
|
|---|
| 1134 | if (!res.ok || !j.ok || !j.id) throw new Error(j.error || ('HTTP ' + res.status));
|
|---|
| 1135 | insertChip('track', j.id);
|
|---|
| 1136 | stateEl.textContent = '✓ ' + esc(T.audio_up_done) + '';
|
|---|
| 1137 | li.classList.add('is-done');
|
|---|
| 1138 | } catch (err) {
|
|---|
| 1139 | stateEl.textContent = '✕ ' + esc(T.audio_up_fail) + ': ' + err.message;
|
|---|
| 1140 | li.classList.add('is-fail');
|
|---|
| 1141 | }
|
|---|
| 1142 | }
|
|---|
| 1143 | }
|
|---|
| 1144 | })();
|
|---|
| 1145 |
|
|---|
| 1146 | // ── Submit: serialize editor contents into the hidden field
|
|---|
| 1147 | if (form && hiddenField) {
|
|---|
| 1148 | form.addEventListener('submit', () => {
|
|---|
| 1149 | const clone = editor.cloneNode(true);
|
|---|
| 1150 | serializeChips(clone);
|
|---|
| 1151 | hiddenField.value = clone.innerHTML;
|
|---|
| 1152 | });
|
|---|
| 1153 | }
|
|---|
| 1154 | })();
|
|---|
| 1155 |
|
|---|
| 1156 | // ── volgend blok ──
|
|---|
| 1157 |
|
|---|
| 1158 | (function () {
|
|---|
| 1159 | // Pin: checkbox toggles the hidden rank field (0 = not pinned),
|
|---|
| 1160 | // ▲▼ shifts the position, with a readable description instead of a raw number.
|
|---|
| 1161 | var toggle = document.getElementById('pin-toggle');
|
|---|
| 1162 | var rank = document.getElementById('pin-rank');
|
|---|
| 1163 | var pos = document.getElementById('pin-pos');
|
|---|
| 1164 | var label = document.getElementById('pin-label');
|
|---|
| 1165 | var up = document.getElementById('pin-up'); // higher = lower number (towards 1/top)
|
|---|
| 1166 | var down = document.getElementById('pin-down');
|
|---|
| 1167 | if (!toggle || !rank || !pos) return;
|
|---|
| 1168 |
|
|---|
| 1169 | function descr(n) {
|
|---|
| 1170 | n = Number(n) || 0;
|
|---|
| 1171 | if (n <= 1) return '' + esc(T.pin_top) + '';
|
|---|
| 1172 | return n + '' + esc(T.pin_nth_suffix) + '';
|
|---|
| 1173 | }
|
|---|
| 1174 | function render() {
|
|---|
| 1175 | var on = toggle.checked;
|
|---|
| 1176 | pos.hidden = !on;
|
|---|
| 1177 | if (on && Number(rank.value) < 1) rank.value = 1;
|
|---|
| 1178 | if (!on) rank.value = 0;
|
|---|
| 1179 | if (label) label.textContent = on ? descr(rank.value) : '';
|
|---|
| 1180 | if (up) up.disabled = Number(rank.value) <= 1;
|
|---|
| 1181 | }
|
|---|
| 1182 | toggle.addEventListener('change', render);
|
|---|
| 1183 | if (up) up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
|
|---|
| 1184 | if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
|
|---|
| 1185 | render();
|
|---|
| 1186 | })();
|
|---|
| 1187 |
|
|---|
| 1188 | (function () {
|
|---|
| 1189 | // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible
|
|---|
| 1190 | // obstacles by setting a dynamic bottom offset = the greater of:
|
|---|
| 1191 | // 1) the height of the keyboard area NOT covered by the layout viewport
|
|---|
| 1192 | // (on iOS the visual viewport shifts; on Android the layout viewport shrinks
|
|---|
| 1193 | // due to interactive-widget=resizes-content → offset ≈ 0);
|
|---|
| 1194 | // 2) the height of the playing audio player (fixed, z-index 1000).
|
|---|
| 1195 | // We stick with sticky (no fixed/top tricks → no bar floating in the middle).
|
|---|
| 1196 | var bar = document.querySelector('.pe-actions');
|
|---|
| 1197 | if (!bar) return;
|
|---|
| 1198 | var vv = window.visualViewport;
|
|---|
| 1199 | function position() {
|
|---|
| 1200 | var ap = document.querySelector('.audio-player');
|
|---|
| 1201 | var playing = document.body.classList.contains('has-audio-player') &&
|
|---|
| 1202 | ap && getComputedStyle(ap).display !== 'none';
|
|---|
| 1203 | var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0;
|
|---|
| 1204 | var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
|
|---|
| 1205 | var offset = Math.max(audioOffset, kbCovered);
|
|---|
| 1206 | bar.style.bottom = offset ? offset + 'px' : '';
|
|---|
| 1207 | }
|
|---|
| 1208 | position();
|
|---|
| 1209 | doc.on(window, 'resize', position);
|
|---|
| 1210 | if (vv) { doc.on(vv, 'resize', position); doc.on(vv, 'scroll', position); }
|
|---|
| 1211 | // has-audio-player is toggled via a body class → observe it. De observer
|
|---|
| 1212 | // overleeft de swap net als de listeners; init() disconnect de vorige.
|
|---|
| 1213 | try { _barObserver = new MutationObserver(position); _barObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
|
|---|
| 1214 | })();
|
|---|
| 1215 | }
|
|---|