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

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

Modules herstarten per paginawissel: de editor leeft weer na een terugkeer (shaer-5s1)

Drie klachten, een wortel. De oude inline scripts draaiden bij ELKE render;
een ES-module draait zijn top-level EEN keer per sessie, en de bootstrap
importeert een geladen module nooit opnieuw. Dus:

  • /posts/new na een htmx-terugkeer: geen toolbar, geen serialisatie -- het verborgen veld kreeg de chips nooit terug (shortcodes 'opgegeten')
  • /admin/audio na een terugkeer: de eigen kopieerhandler dood, en de klik viel door naar de GEDELEGEERDE handler van admin-media, die voor mediapaden terecht location.origin voorplakt -- vandaar https://site[[track:uuid]] op het klembord

De afspraak is nu: een module die per render moet draaien exporteert init(),
en de bootstrap roept die aan bij elke wissel waarop de module actief is.
Modules zonder init houden hun oude gedrag. Listeners op document/window
overleven de swap met closures naar dode elementen; makeSweeper in lib.js
veegt bij elke init de vorige lichting weg. Gedelegeerde handlers die
bewust blijven leven (media/videos) krijgen een paginawacht, want data-copy
betekent daar een PAD en elders een shortcode.

Omgezet: post-edit, admin-audio, admin-playlists. Bewaakt: admin-media,
admin-videos. playlist-editor en track-editor waren al swap-bestendig
(globale functie, gedelegeerd met eigen guards).

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

  • Property mode set to 100644
File size: 49.2 KB
Line 
1// De posteditor -- verplaatst uit inline script, shaer-bqr.
2//
3// Zeven blokken, ruim 1000 regels. Alle servertekst komt uit pageData() en gaat
4// door esc(): de EJS-tag ontsnapte die vroeger ook, dus dit is gedragsgelijk en
5// het houdt een apostrof in een vertaling uit de HTML die hier geplakt wordt.
6
7import { pageData, esc, makeSweeper } 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
585 // On mobile the keyboard pushes the visible (visual) viewport up while
586 // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar
587 // slides out of view. Keep the fullscreen frame aligned to the visual
588 // viewport (top + height) so the toolbar stays visible at the top.
589 function syncFsViewport() {
590 if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
591 const vv = window.visualViewport;
592 if (!vv) return;
593 editorFrame.style.top = vv.offsetTop + 'px';
594 editorFrame.style.height = vv.height + 'px';
595 }
596 function clearFsViewport() {
597 if (!editorFrame) return;
598 editorFrame.style.top = '';
599 editorFrame.style.height = '';
600 }
601 function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); }
602 function applyFs(on) {
603 if (!editorFrame) return;
604 editorFrame.classList.toggle('pe-fs', on);
605 document.body.classList.toggle('pe-fs-open', on);
606 document.documentElement.classList.toggle('pe-fs-open', on);
607 if (fsBtn) {
608 fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
609 fsBtn.title = on ? '' + esc(T.tb_done) + '' : '' + esc(T.tb_fullscreen) + '';
610 }
611 if (window.visualViewport) {
612 if (on) {
613 window.visualViewport.addEventListener('resize', syncFsViewport);
614 window.visualViewport.addEventListener('scroll', syncFsViewport);
615 syncFsViewport();
616 } else {
617 window.visualViewport.removeEventListener('resize', syncFsViewport);
618 window.visualViewport.removeEventListener('scroll', syncFsViewport);
619 clearFsViewport();
620 }
621 }
622 // On touch the field is NOT editable inline; only in fullscreen.
623 if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false');
624 if (on) {
625 editor.focus({ preventScroll: true });
626 } else {
627 if (isTouch) editor.blur();
628 // On close: scroll to the TOP of the content instead of staying
629 // somewhere at the bottom (footer).
630 requestAnimationFrame(function () {
631 try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {}
632 });
633 }
634 }
635 // The fullscreen writing "page": opening pushes a history state so the browser
636 // back button (and the Done button) closes it and returns you to the form — feels
637 // like a separate page, but all form fields remain intact (same DOM).
638 function openFs() {
639 if (isFs()) return;
640 try { history.pushState({ peFs: true }, ''); } catch (_) {}
641 applyFs(true);
642 }
643 function closeFs() {
644 if (!isFs()) return;
645 if (history.state && history.state.peFs) history.back(); // → popstate closes it
646 else applyFs(false);
647 }
648 function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); }
649 doc.on(window, 'popstate', function () { if (isFs()) applyFs(false); });
650 if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
651 var fsDoneBtn = document.getElementById('pe-fs-done');
652 if (fsDoneBtn) fsDoneBtn.addEventListener('click', closeFs);
653 doc.on(document, 'keydown', (e) => {
654 if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); }
655 });
656
657 // On mobile/tablet (touch): the content field is NOT editable inline — it is
658 // not a text field there. One tap → fullscreen, where it becomes editable
659 // (toggleFullscreen toggles contenteditable). This prevents inline typing.
660 if (isTouch) {
661 editor.setAttribute('contenteditable', 'false');
662 editor.classList.add('pe-tap-to-edit');
663 editor.addEventListener('click', function () {
664 if (!isFs()) openFs();
665 });
666 }
667
668 // Reflect bold/italic/list state on the toolbar buttons
669 function updateToolbarState() {
670 if (!toolbar) return;
671 const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
672 for (const cmd of cmds) {
673 const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
674 if (!btn) continue;
675 try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
676 }
677 // Quote button: active when the caret is inside a <blockquote> (toggle feedback).
678 const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
679 if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
680 }
681 doc.on(document, 'selectionchange', () => {
682 if (document.activeElement === editor) updateToolbarState();
683 });
684
685 // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
686 editor.addEventListener('keydown', (e) => {
687 const mod = e.ctrlKey || e.metaKey;
688 if (!mod) return;
689 const k = e.key.toLowerCase();
690 if (k === 'b') { e.preventDefault(); execCmd('bold'); }
691 else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
692 else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
693 else if (k === 'k') { e.preventDefault(); linkPrompt(); }
694 });
695
696 // Paste: keep it simple — strip formatting unless user wants it. Default
697 // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
698 // styles from clipboard only when shift is held — otherwise plain text.
699 editor.addEventListener('paste', (e) => {
700 if (e.shiftKey) return; // user wants formatted paste
701 const text = (e.clipboardData || window.clipboardData).getData('text/plain');
702 if (text == null) return;
703 e.preventDefault();
704 document.execCommand('insertText', false, text);
705 });
706
707 // ── Image upload (button + drag-drop into the editor)
708 async function uploadAndInsertImage(file) {
709 const edited = await openImageEditor(file);
710 if (!edited) return; // cancelled
711 contentStatus.classList.remove('is-error');
712 contentStatus.textContent = '' + esc(T.js_uploading) + '';
713 try {
714 const j = await uploadImage(edited);
715 const img = '<img src="' + j.url + '" alt="">';
716 editor.focus({ preventScroll: true });
717 document.execCommand('insertHTML', false, img);
718 contentStatus.textContent = '' + esc(T.js_inserted) + ' ✓';
719 setTimeout(() => { contentStatus.textContent = ''; }, 2000);
720 updateCharCount();
721 } catch (e) {
722 contentStatus.classList.add('is-error');
723 contentStatus.textContent = '' + esc(T.js_failed) + ': ' + e.message;
724 }
725 }
726
727 if (contentBtn && contentField) {
728 contentBtn.addEventListener('click', () => contentField.click());
729 contentField.addEventListener('change', () => {
730 if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
731 contentField.value = '';
732 });
733
734 editor.addEventListener('dragover', (e) => {
735 if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
736 e.preventDefault();
737 editor.classList.add('is-dragover');
738 }
739 });
740 editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
741 editor.addEventListener('drop', async (e) => {
742 editor.classList.remove('is-dragover');
743 const files = e.dataTransfer && e.dataTransfer.files;
744 if (!files || !files.length) return;
745 e.preventDefault();
746 for (const f of files) {
747 if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
748 }
749 });
750 }
751
752 // ── Insert chip helpers (track / playlist)
753 function insertChip(kind, value) {
754 editor.focus({ preventScroll: true });
755 const chip = makeChip(kind, value);
756 // Insert at caret using the Selection API (execCommand insertNode)
757 const sel = window.getSelection();
758 if (sel && sel.rangeCount > 0) {
759 const range = sel.getRangeAt(0);
760 range.deleteContents();
761 range.insertNode(chip);
762 // Insert a trailing space so the user can keep typing after the chip
763 const space = document.createTextNode('\u00A0');
764 chip.after(space);
765 range.setStartAfter(space);
766 range.collapse(true);
767 sel.removeAllRanges();
768 sel.addRange(range);
769 } else {
770 editor.appendChild(chip);
771 editor.appendChild(document.createTextNode('\u00A0'));
772 }
773 updateCharCount();
774 }
775
776 // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes
777 // an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
778 const embedBtn = document.getElementById('insert-embed-btn');
779 if (embedBtn) {
780 embedBtn.addEventListener('click', () => {
781 const raw = window.prompt('' + esc(T.js_embed_prompt) + '');
782 if (!raw) return;
783 const url = raw.trim();
784 if (!/^https?:\/\//i.test(url)) { alert('' + esc(T.js_embed_invalid) + ''); return; }
785 insertChip('embed', url);
786 });
787 }
788
789 // ── Track insert: opens the track-picker modal (P59)
790 const trackBtn = document.getElementById('insert-track-btn');
791 const trackPicker = document.getElementById('track-picker');
792 if (trackBtn && trackPicker) {
793 const tpList = document.getElementById('tp-list');
794 const tpEmpty = document.getElementById('tp-empty');
795 const tpSearch = document.getElementById('tp-search');
796 let tpCache = null; // cached track list (fetched once per page load)
797 let tpLastFocus = null; // element to restore focus to on close
798
799 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>';
800
801 function fmtDur(sec) {
802 sec = Math.max(0, Math.floor(sec || 0));
803 const m = Math.floor(sec / 60), s = sec % 60;
804 return m + ':' + String(s).padStart(2, '0');
805 }
806 function escAttr(s) {
807 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
808 '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
809 }[c]));
810 }
811
812 function renderList(filter) {
813 if (!Array.isArray(tpCache)) return;
814 const q = (filter || '').trim().toLowerCase();
815 const filtered = q
816 ? tpCache.filter(t =>
817 (t.title || '').toLowerCase().includes(q) ||
818 (t.artist || '').toLowerCase().includes(q))
819 : tpCache;
820
821 if (!filtered.length) {
822 tpList.innerHTML = '';
823 tpEmpty.textContent = q ? '' + esc(T.js_no_tracks_found) + ' ' + q : '' + esc(T.js_no_tracks_yet) + '';
824 tpList.appendChild(tpEmpty);
825 return;
826 }
827
828 tpList.innerHTML = filtered.map(t => {
829 const cov = t.cover
830 ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
831 : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
832 const dis = t.playable ? '' : ' aria-disabled="true"';
833 const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
834 return (
835 '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
836 cov +
837 '<span class="tp-meta">' +
838 '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
839 sub +
840 '</span>' +
841 '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
842 '</button>'
843 );
844 }).join('');
845 }
846
847 async function loadTracks() {
848 if (Array.isArray(tpCache)) return tpCache;
849 tpEmpty.textContent = '' + esc(T.js_tracks_loading) + '';
850 try {
851 const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
852 const j = await r.json();
853 tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
854 } catch (e) {
855 tpCache = [];
856 tpEmpty.textContent = '' + esc(T.js_tracks_load_fail) + ': ' + e.message;
857 }
858 return tpCache;
859 }
860
861 function openPicker() {
862 tpLastFocus = document.activeElement;
863 trackPicker.hidden = false;
864 trackPicker.setAttribute('aria-hidden', 'false');
865 document.body.classList.add('tp-locked');
866 tpSearch.value = '';
867 renderList('');
868 // Defer focus so the open animation doesn't get jumped
869 setTimeout(() => tpSearch.focus(), 30);
870 }
871 function closePicker() {
872 trackPicker.hidden = true;
873 trackPicker.setAttribute('aria-hidden', 'true');
874 document.body.classList.remove('tp-locked');
875 if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
876 try { tpLastFocus.focus(); } catch(_) {}
877 }
878 }
879
880 trackBtn.addEventListener('click', async () => {
881 openPicker();
882 await loadTracks();
883 renderList(tpSearch.value);
884 });
885
886 // Close: backdrop click, [data-tp-close], or Escape
887 trackPicker.addEventListener('click', (e) => {
888 if (e.target.closest('[data-tp-close]')) {
889 closePicker();
890 return;
891 }
892 const row = e.target.closest('.tp-row[data-track-id]');
893 if (row) {
894 if (row.getAttribute('aria-disabled') === 'true') return;
895 const id = row.dataset.trackId;
896 if (id) {
897 insertChip('track', id);
898 closePicker();
899 }
900 }
901 });
902 doc.on(document, 'keydown', (e) => {
903 if (!trackPicker.hidden && e.key === 'Escape') {
904 e.preventDefault();
905 closePicker();
906 }
907 });
908
909 // Live filter
910 tpSearch.addEventListener('input', () => renderList(tpSearch.value));
911 }
912
913 // ── Playlist insert (open existing or create new via modal)
914 const playlistBtn = document.getElementById('insert-playlist-btn');
915 if (playlistBtn) {
916 playlistBtn.addEventListener('click', async () => {
917 if (typeof window.openPlaylistEditor !== 'function') {
918 alert('' + esc(T.js_playlist_editor_missing) + '');
919 return;
920 }
921 try {
922 const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
923 const j = await r.json();
924 if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
925 const choice = prompt(
926 '' + esc(T.js_playlist_existing) + '\n\n' +
927 j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
928 '\n\n' + esc(T.js_playlist_choose) + ''
929 );
930 if (choice && /^\d+$/.test(choice.trim())) {
931 const idx = parseInt(choice.trim(), 10) - 1;
932 if (idx >= 0 && idx < j.playlists.length) {
933 insertChip('playlist', j.playlists[idx].id);
934 return;
935 }
936 }
937 if (choice === null) return;
938 }
939 } catch (_) { /* fall through to create */ }
940
941 window.openPlaylistEditor({
942 mode: 'create',
943 onSaved: ({ id }) => insertChip('playlist', id),
944 });
945 });
946 }
947
948 // ── Post type: segmented control + type-aware panels ──────────
949 (function () {
950 const typeInput = document.getElementById('pe-type-input');
951 const card = document.querySelector('.pe-type-card');
952 if (!typeInput || !card) return;
953 const seg = card.querySelector('.pe-typeseg');
954 const panels = card.querySelectorAll('.pe-type-panel');
955
956 function applyType(tt) {
957 typeInput.value = tt;
958 seg.querySelectorAll('.pe-typeseg-btn').forEach(b => {
959 const on = b.dataset.type === tt;
960 b.classList.toggle('is-active', on);
961 b.setAttribute('aria-checked', on ? 'true' : 'false');
962 });
963 panels.forEach(p => { p.hidden = (p.dataset.panel !== tt); });
964 }
965 seg.addEventListener('click', (e) => {
966 const btn = e.target.closest('.pe-typeseg-btn');
967 if (btn) applyType(btn.dataset.type);
968 });
969 applyType(typeInput.value || 'post');
970
971 // Video URL → [[embed:url]] chip
972 const vBtn = document.getElementById('pe-video-insert');
973 const vUrl = document.getElementById('pe-video-url');
974 if (vBtn && vUrl) {
975 const doInsert = () => {
976 const url = (vUrl.value || '').trim();
977 if (!/^https?:\/\//i.test(url)) { alert('' + esc(T.js_embed_invalid) + ''); return; }
978 insertChip('embed', url);
979 vUrl.value = '';
980 };
981 vBtn.addEventListener('click', doInsert);
982 vUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doInsert(); } });
983 }
984
985 // Audio: inline upload → transcodes server-side → [[track:id]] chip
986 const drop = document.getElementById('pe-audio-drop');
987 const fileInput = document.getElementById('pe-audio-file');
988 const list = document.getElementById('pe-audio-list');
989 if (drop && fileInput && list) {
990 const pick = () => fileInput.click();
991 drop.addEventListener('click', pick);
992 drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
993 ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('is-drag'); }));
994 ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('is-drag'); }));
995 drop.addEventListener('drop', (e) => { if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); });
996 fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; });
997
998 function clientDuration(f) {
999 return new Promise((resolve) => {
1000 try {
1001 const u = URL.createObjectURL(f);
1002 const a = document.createElement('audio');
1003 a.preload = 'metadata';
1004 a.onloadedmetadata = () => { URL.revokeObjectURL(u); resolve(Number.isFinite(a.duration) ? Math.round(a.duration) : null); };
1005 a.onerror = () => { URL.revokeObjectURL(u); resolve(null); };
1006 a.src = u;
1007 } catch (_) { resolve(null); }
1008 });
1009 }
1010 async function handleFiles(files) {
1011 for (const f of Array.from(files || [])) await uploadOne(f);
1012 }
1013 async function uploadOne(f) {
1014 const li = document.createElement('li');
1015 li.className = 'pe-audio-item';
1016 const nameEl = document.createElement('span');
1017 nameEl.className = 'pe-audio-item-name';
1018 nameEl.textContent = f.name;
1019 const stateEl = document.createElement('span');
1020 stateEl.className = 'pe-audio-item-state';
1021 stateEl.textContent = '⏳ ' + esc(T.audio_up_busy) + '';
1022 li.appendChild(nameEl); li.appendChild(stateEl);
1023 list.appendChild(li);
1024 try {
1025 const dur = await clientDuration(f);
1026 const fd = new FormData();
1027 fd.append('audio', f);
1028 if (dur) fd.append('duration', String(dur));
1029 const res = await fetch('/admin/audio/upload', {
1030 method: 'POST', body: fd,
1031 headers: { 'Accept': 'application/json' },
1032 credentials: 'same-origin',
1033 });
1034 const j = await res.json().catch(() => ({}));
1035 if (!res.ok || !j.ok || !j.id) throw new Error(j.error || ('HTTP ' + res.status));
1036 insertChip('track', j.id);
1037 stateEl.textContent = '✓ ' + esc(T.audio_up_done) + '';
1038 li.classList.add('is-done');
1039 } catch (err) {
1040 stateEl.textContent = '✕ ' + esc(T.audio_up_fail) + ': ' + err.message;
1041 li.classList.add('is-fail');
1042 }
1043 }
1044 }
1045 })();
1046
1047 // ── Submit: serialize editor contents into the hidden field
1048 if (form && hiddenField) {
1049 form.addEventListener('submit', () => {
1050 const clone = editor.cloneNode(true);
1051 serializeChips(clone);
1052 hiddenField.value = clone.innerHTML;
1053 });
1054 }
1055})();
1056
1057// ── volgend blok ──
1058
1059(function () {
1060 // Pin: checkbox toggles the hidden rank field (0 = not pinned),
1061 // ▲▼ shifts the position, with a readable description instead of a raw number.
1062 var toggle = document.getElementById('pin-toggle');
1063 var rank = document.getElementById('pin-rank');
1064 var pos = document.getElementById('pin-pos');
1065 var label = document.getElementById('pin-label');
1066 var up = document.getElementById('pin-up'); // higher = lower number (towards 1/top)
1067 var down = document.getElementById('pin-down');
1068 if (!toggle || !rank || !pos) return;
1069
1070 function descr(n) {
1071 n = Number(n) || 0;
1072 if (n <= 1) return '' + esc(T.pin_top) + '';
1073 return n + '' + esc(T.pin_nth_suffix) + '';
1074 }
1075 function render() {
1076 var on = toggle.checked;
1077 pos.hidden = !on;
1078 if (on && Number(rank.value) < 1) rank.value = 1;
1079 if (!on) rank.value = 0;
1080 if (label) label.textContent = on ? descr(rank.value) : '';
1081 if (up) up.disabled = Number(rank.value) <= 1;
1082 }
1083 toggle.addEventListener('change', render);
1084 if (up) up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
1085 if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
1086 render();
1087})();
1088
1089(function () {
1090 // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible
1091 // obstacles by setting a dynamic bottom offset = the greater of:
1092 // 1) the height of the keyboard area NOT covered by the layout viewport
1093 // (on iOS the visual viewport shifts; on Android the layout viewport shrinks
1094 // due to interactive-widget=resizes-content → offset ≈ 0);
1095 // 2) the height of the playing audio player (fixed, z-index 1000).
1096 // We stick with sticky (no fixed/top tricks → no bar floating in the middle).
1097 var bar = document.querySelector('.pe-actions');
1098 if (!bar) return;
1099 var vv = window.visualViewport;
1100 function position() {
1101 var ap = document.querySelector('.audio-player');
1102 var playing = document.body.classList.contains('has-audio-player') &&
1103 ap && getComputedStyle(ap).display !== 'none';
1104 var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0;
1105 var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
1106 var offset = Math.max(audioOffset, kbCovered);
1107 bar.style.bottom = offset ? offset + 'px' : '';
1108 }
1109 position();
1110 doc.on(window, 'resize', position);
1111 if (vv) { doc.on(vv, 'resize', position); doc.on(vv, 'scroll', position); }
1112 // has-audio-player is toggled via a body class → observe it. De observer
1113 // overleeft de swap net als de listeners; init() disconnect de vorige.
1114 try { _barObserver = new MutationObserver(position); _barObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
1115})();
1116}
Note: See TracBrowser for help on using the repository browser.