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

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

Het type volgt de muziek, en de regel staat nog maar op een plek (shaer-cyg)

Robins regel zegt dat de post de soort van zijn muziek overneemt bij een enkele
insluiting. Dat deed hij nog niet: je koos het type zelf en de afleiding draaide
alleen bij de migratie. Nu verspringt de balk mee zodra je een playlist invoegt,
en zie je wat je aan het maken bent in plaats van het zelf bij te houden.

DE REGEL VERHUIST NAAR assets/js/shared/post-music-type.js. Hij wordt nu op twee
plaatsen gebruikt -- de server leidt het type van bestaande posts ermee af, de
editor laat het live volgen -- en dat MOET dezelfde regel zijn. Twee kopieen
zouden niet luidruchtig kapotgaan maar stil uit elkaar lopen: de editor zegt
album, de server zegt playlist, en niemand merkt het. De browser kan alleen bij
/assets en de server kan overal bij, dus staat hij daar en importeert
services/music hem. De module is puur: geen db, geen DOM, geen fetch -- wie hem
gebruikt levert zelf een opzoeker voor de soort van een playlist.

DRIE DINGEN DIE HIJ MET RUST LAAT. Klik je zelf een type aan, dan wint die
keuze en wordt er daarna niets meer voor je omgezet. Foto en Video zijn een
bewuste andere keuze over dezelfde post en worden nooit overschreven. En bij het
OPENEN van een post gebeurt er niets: de eerste afleiding wordt alleen als
ijkpunt onthouden, anders zou een oude post van type veranderen door hem te
bekijken.

De editor leidt af uit precies de tekst die straks wordt opgeslagen (chips terug
naar shortcodes via serializeChips), zodat hij niet iets anders ziet dan de
server. Zonder de playlist-soorten leidt de regel niets af in plaats van te
gokken.

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

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