source: Klonkt/src/assets/js/mod/post-edit.js@ 369aa60

main
Last change on this file since 369aa60 was 369aa60, checked in by Robin <roboburr@…>, 4 days ago

Cache-busting reikt nu ook tot wat de modules zelf ophalen (shaer-724)

De bootstrap hing al ?v= aan elke module die hij zelf laadt. Twee soorten
paden ontsnapten daaraan, en /assets wordt buiten ontwikkeling een jaar
gecachet:

een import BINNEN een module is relatief, en zo\x27n specifier erft de
query niet: ./lib.js naast post.js?v=63 wordt gewoon
/assets/js/mod/lib.js. Elf modules importeren lib.js zo, en juist dat
bestand is gedeeld -- een fout erin overleefde elke MOD_V-bump.

een vendorbestand dat een module zelf ophaalt. read.js deed het goed met
?v=VENDOR_V; lib.js (mijn eigen import van gisteren) en de twee
cropper-verwijzingen in post-edit deden het niet.

Een importmap in de head lost het eerste op zonder die elf imports aan te
raken: hij vertaalt de opgeloste URL naar zijn geversioneerde vorm. MOD_V
staat daarvoor nu als EJS-variabele bovenaan de shell, zodat de importmap
en de bootstrap niet twee nummers kunnen worden. VENDOR_V staat in lib.js
en wordt door read.js en post-edit gedeeld, om dezelfde reden.

Drie toetsen die de REGEL bewaken en niet deze ene plek: elke relatieve
import tussen modules heeft een ingang in de importmap, elk vendorpad
draagt een versie, en de twee nummers zijn er een. Tegenbewijs: haal de
ingang weg of de versie eraf en precies die toets valt.

Volle suite 1248 groen.

  • Property mode set to 100644
File size: 54.2 KB
Line 
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
7import { 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.
10import { 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).
17const doc = makeSweeper();
18let T = {};
19let _barObserver = null;
20
21export function init() {
22 doc.sweep();
23 if (_barObserver) { _barObserver.disconnect(); _barObserver = null; }
24 T = pageData();
25 run();
26}
27
28function 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 = '&times;';
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 '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
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}
Note: See TracBrowser for help on using the repository browser.