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

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

Audio is geen keuze meer: Album en Playlist staan in de balk (shaer-cyg)

De posttype-balk had post/foto/video/audio. Audio verdwijnt als keuze -- alles
wat muziek is landt voortaan op album of playlist, en dat maakt de keuze
betekenisvol in plaats van cosmetisch. Album en Playlist delen het muziekpaneel,
want het verschil zit in de playlist en niet in wat je uploadt.

De knop Audio blijft alleen staan zolang een post nog type=audio IS. Anders zou
het openen van een oude post hem stilzwijgend van type veranderen, en dat is
precies het soort verlies dat niemand ziet gebeuren.

De lijst van geldige types stond op DRIE plaatsen los van elkaar: twee keer in
routes/posts.js en een keer in routes/types.js. Dat viel niet op zolang ze
gelijk waren, maar het faalt stil: kent het opslaan 'playlist' niet, dan wordt
de post zonder melding een gewone post en is de keuze verdwenen in plaats van
geweigerd. Nu een lijst, in config/post-types.js, die ook de editor voedt.

En de migratie: scripts/backfill-post-types.mjs zet bestaande audio-posts om via
postMusicType. Proefdraai is de standaard, --doen schrijft. Wat de afleiding
niet tot album of playlist maakt blijft met rust en wordt apart gemeld -- die
verdienen een blik en geen gok.

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

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