Changeset fb9a8ad in Klonkt for src/views/pages/post-edit.ejs
- Timestamp:
- 08/07/2026 01:38:51 PM (5 weeks ago)
- Branches:
- main
- Children:
- 7cfe35a
- Parents:
- 6ee289a
- git-author:
- Robin <roboburr@…> (08/07/2026 01:38:49 PM)
- git-committer:
- roboburr <roboburr@…> (08/07/2026 01:38:51 PM)
- File:
-
- 1 edited
-
src/views/pages/post-edit.ejs (modified) (6 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/views/pages/post-edit.ejs
r6ee289a rfb9a8ad 272 272 <% } else { %> 273 273 <p id="pe-fedi-audio-warn" style="font-size:12px;color:#c0392b;margin:2px 0 0 26px" hidden>⚠️ <%= t('pedit.fedi_audio_oneway') %></p> 274 <script> 275 (function () { 276 var cb = document.getElementById('pe-fedi-audio'), w = document.getElementById('pe-fedi-audio-warn'); 277 if (cb && w && !cb.__wired) { cb.__wired = true; cb.addEventListener('change', function () { w.hidden = !cb.checked; }); } 278 })(); 279 </script> 274 <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 280 275 <% } %> 281 <script> 282 (function () { 283 var cw = document.getElementById('pe-cw'), nsfw = document.getElementById('pe-nsfw'); 284 // Typing a warning text implies the post is sensitive → auto-tick NSFW. 285 if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true; 286 cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; }); 287 } 288 })(); 289 </script> 276 290 277 <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen. 291 278 var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; } … … 335 322 </select> 336 323 </div> 337 <script> 338 (function () { 339 var box = document.getElementById('pe-poll-fields'); 340 var tog = document.getElementById('pe-poll-toggle'); 341 var opts = document.getElementById('pe-poll-opts'); 342 var add = document.getElementById('pe-poll-add'); 343 if (!box || !opts) return; 344 if (tog && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); } 345 var PH = opts.getAttribute('data-ph') || '', DEL = opts.getAttribute('data-del') || ''; 346 function rows() { return opts.querySelectorAll('.pe-poll-row'); } 347 // A poll needs at least 2 options: hide the ✕ at the minimum, and cap adding at 8. 348 function refresh() { 349 var n = rows().length; 350 opts.querySelectorAll('.pe-poll-del').forEach(function (b) { b.hidden = n <= 2; }); 351 if (add) add.disabled = n >= 8; 352 } 353 function makeRow() { 354 var row = document.createElement('div'); row.className = 'pe-poll-row'; 355 var i = document.createElement('input'); i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100; i.placeholder = PH; 356 var d = document.createElement('button'); d.type = 'button'; d.className = 'pe-poll-del'; d.setAttribute('aria-label', DEL); d.title = DEL; d.innerHTML = '×'; 357 row.appendChild(i); row.appendChild(d); return row; 358 } 359 if (add && !add.__wired) { add.__wired = true; add.addEventListener('click', function () { if (rows().length >= 8) return; opts.appendChild(makeRow()); refresh(); }); } 360 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(); }); } 361 refresh(); 362 })(); 363 </script> 324 364 325 <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %> 365 326 <label class="pe-checkbox"> … … 377 338 </label> 378 339 </div> 379 <script> 380 (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price'); 381 if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })(); 382 </script> 340 383 341 <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %> 384 342 <label class="pe-checkbox" style="margin-top:8px"> … … 392 350 <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div> 393 351 </div> 394 <script> 395 (function () { 396 var cb = document.getElementById('pe-sched-toggle'); 397 var box = document.getElementById('pe-sched-fields'); 398 if (!cb || !box) return; 399 var inp = document.getElementById('pe-publish-at'); 400 var SITE_TZ = '<%= timezone || '' %>'; // configured site timezone; empty = browser local 401 var pad = function (n) { return String(n).padStart(2, '0'); }; 402 // Offset (ms) between a timezone and UTC at a given moment. 403 function tzOffset(date, tz) { 404 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' }); 405 var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; }); 406 return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime(); 407 } 408 // datetime-local "wall time" (in the site zone) → UTC Date. 409 function wallToUtc(wall) { 410 if (!SITE_TZ) return new Date(wall); 411 var guess = new Date(wall + ':00Z').getTime(); 412 return new Date(guess - tzOffset(new Date(guess), SITE_TZ)); 413 } 414 // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone. 415 function utcToWall(iso) { 416 var d = new Date(iso); if (isNaN(d)) return ''; 417 if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()); 418 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' }); 419 var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; }); 420 return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute; 421 } 422 // Prefill: stored UTC → wall time in the site zone. 423 if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso); 424 // "Scheduled for" in human-readable time in the site zone. 425 var when = document.getElementById('pe-sched-when'); 426 if (when && when.dataset.iso) { 427 var dw = new Date(when.dataset.iso); 428 if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined); 429 } 430 function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; } 431 cb.addEventListener('change', sync); sync(); 432 // On save: wall time in the site zone → UTC-ISO via a hidden field. 433 var form = cb.closest('form'); 434 if (form) { 435 form.addEventListener('submit', function () { 436 if (inp) inp.removeAttribute('name'); 437 var old = form.querySelector('input[data-pa-utc]'); 438 if (old) old.remove(); 439 if (cb.checked && inp && inp.value) { 440 var d2 = wallToUtc(inp.value); 441 if (!isNaN(d2)) { 442 var h = document.createElement('input'); 443 h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', ''); 444 h.value = d2.toISOString(); 445 form.appendChild(h); 446 } 447 } 448 }); 449 } 450 })(); 451 </script> 352 452 353 <% } %> 453 354 </div> … … 1281 1182 </style> 1282 1183 1283 <script> 1284 (function() { 1285 1286 // ── Cover upload ──────────────────────────────────────────────── 1287 const coverField = document.getElementById('cover-upload-field'); 1288 const coverTrigger = document.getElementById('cover-upload-trigger'); 1289 const coverUrl = document.getElementById('cover-url-field'); 1290 const coverVideo = document.getElementById('cover-video-field'); 1291 const coverStatus = document.getElementById('cover-upload-status'); 1292 const coverWrap = document.getElementById('cover-preview-wrap'); 1293 const coverImg = document.getElementById('cover-preview-img'); 1294 1295 async function uploadImage(file) { 1296 const fd = new FormData(); 1297 fd.append('image', file); 1298 const res = await fetch('/posts/upload-image', { method: 'POST', body: fd }); 1299 if (!res.ok) { 1300 const j = await res.json().catch(() => ({})); 1301 throw new Error(j.error || ('Upload failed (' + res.status + ')')); 1302 } 1303 return await res.json(); // {url, size, mime} 1304 } 1305 1306 // ── Image editor (rotate / crop / mirror) ────────── 1307 // Lazy-load Cropper.js (locally vendored) on first use. 1308 let _cropperReady = null; 1309 function ensureCropper() { 1310 if (window.Cropper) return Promise.resolve(); 1311 if (_cropperReady) return _cropperReady; 1312 _cropperReady = new Promise((resolve, reject) => { 1313 if (!document.querySelector('link[data-cropper-css]')) { 1314 const l = document.createElement('link'); 1315 l.rel = 'stylesheet'; l.href = '/assets/vendor/cropper.min.css'; l.setAttribute('data-cropper-css', ''); 1316 document.head.appendChild(l); 1317 } 1318 const s = document.createElement('script'); 1319 s.src = '/assets/vendor/cropper.min.js'; 1320 s.onload = () => resolve(); 1321 s.onerror = () => reject(new Error('cropper load failed')); 1322 document.head.appendChild(s); 1323 }); 1324 return _cropperReady; 1325 } 1326 1327 // True for an animated WebP (VP8X chunk with the animation flag set) — like a GIF it must skip 1328 // the canvas editor, otherwise it'd be flattened to a single static frame. 1329 async function isAnimatedWebpFile(file) { 1330 if (!file || file.type !== 'image/webp') return false; 1331 try { 1332 const b = new Uint8Array(await file.slice(0, 40).arrayBuffer()); 1333 return b.length >= 21 && String.fromCharCode(b[12], b[13], b[14], b[15]) === 'VP8X' && (b[20] & 0x02) !== 0; 1334 } catch (_) { return false; } 1335 } 1336 1337 // Opens the editor for a chosen file; resolves with an edited File, 1338 // or null if the user cancels. Animated images (GIF / animated WebP) are NOT sent through the 1339 // canvas editor (they would become static) — those upload directly. 1340 async function openImageEditor(file) { 1341 if (!file || !file.type || !file.type.startsWith('image/')) return file; 1342 if (file.type === 'image/gif') return file; // preserve animation 1343 if (await isAnimatedWebpFile(file)) return file; // animated WebP → preserve animation 1344 try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly 1345 1346 return new Promise((resolve) => { 1347 const back = document.createElement('div'); 1348 back.className = 'imed-backdrop'; 1349 back.innerHTML = 1350 '<div class="imed-modal" role="dialog" aria-modal="true" aria-label="<%= t('imed.title') %>">' + 1351 '<div class="imed-stage"><img alt=""></div>' + 1352 '<div class="imed-tools">' + 1353 '<button type="button" data-act="rl" title="<%= t('imed.rotate_left') %>">⟲</button>' + 1354 '<button type="button" data-act="rr" title="<%= t('imed.rotate_right') %>">⟳</button>' + 1355 '<button type="button" data-act="fh" title="<%= t('imed.flip_h') %>">⇆</button>' + 1356 '<button type="button" data-act="fv" title="<%= t('imed.flip_v') %>">⇅</button>' + 1357 '<button type="button" data-act="zi" title="<%= t('imed.zoom_in') %>">+</button>' + 1358 '<button type="button" data-act="zo" title="<%= t('imed.zoom_out') %>">-</button>' + 1359 '<button type="button" data-act="reset" title="<%= t('imed.reset') %>">↺</button>' + 1360 '</div>' + 1361 '<div class="imed-actions">' + 1362 '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary"><%= t('imed.cancel') %></button>' + 1363 '<button type="button" data-act="apply" class="pe-btn pe-btn-primary"><%= t('imed.apply') %></button>' + 1364 '</div>' + 1365 '</div>'; 1366 document.body.appendChild(back); 1367 const img = back.querySelector('img'); 1368 const url = URL.createObjectURL(file); 1369 let cropper = null, sx = 1, sy = 1; 1370 1371 function cleanup() { 1372 try { if (cropper) cropper.destroy(); } catch (_) {} 1373 URL.revokeObjectURL(url); 1374 back.remove(); 1375 document.removeEventListener('keydown', onKey); 1376 } 1377 function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } } 1378 document.addEventListener('keydown', onKey); 1379 1380 img.onload = () => { 1381 cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true }); 1382 }; 1383 img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original 1384 img.src = url; 1385 1386 back.addEventListener('click', (e) => { 1387 const btn = e.target.closest('[data-act]'); 1388 if (e.target === back) { cleanup(); resolve(null); return; } 1389 if (!btn || !cropper) return; 1390 const a = btn.getAttribute('data-act'); 1391 if (a === 'rl') cropper.rotate(-90); 1392 else if (a === 'rr') cropper.rotate(90); 1393 else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); } 1394 else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); } 1395 else if (a === 'zi') cropper.zoom(0.1); 1396 else if (a === 'zo') cropper.zoom(-0.1); 1397 else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); } 1398 else if (a === 'cancel') { cleanup(); resolve(null); } 1399 else if (a === 'apply') { 1400 const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' }); 1401 const png = (file.type === 'image/png' || file.type === 'image/webp'); 1402 const mime = png ? 'image/png' : 'image/jpeg'; 1403 const ext = png ? '.png' : '.jpg'; 1404 canvas.toBlob((blob) => { 1405 cleanup(); 1406 if (!blob) { resolve(file); return; } 1407 const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, ''); 1408 resolve(new File([blob], base + ext, { type: mime })); 1409 }, mime, 0.92); 1410 } 1411 }); 1412 }); 1413 } 1414 1415 function showCoverPreview(url) { 1416 if (!coverWrap || !coverImg) return; 1417 if (url) { 1418 coverImg.src = url; 1419 coverImg.hidden = false; 1420 coverWrap.removeAttribute('data-empty'); 1421 const emptyIcon = coverWrap.querySelector('.pe-cover-empty'); 1422 if (emptyIcon) emptyIcon.remove(); 1423 } else { 1424 coverImg.hidden = true; 1425 coverImg.src = ''; 1426 coverWrap.setAttribute('data-empty', ''); 1427 if (!coverWrap.querySelector('.pe-cover-empty')) { 1428 const span = document.createElement('span'); 1429 span.className = 'pe-cover-empty'; 1430 span.textContent = '🖼'; 1431 coverWrap.appendChild(span); 1432 } 1433 } 1434 } 1435 1436 if (coverTrigger && coverField) { 1437 coverTrigger.addEventListener('click', () => coverField.click()); 1438 } 1439 if (coverField) { 1440 coverField.addEventListener('change', async () => { 1441 if (!coverField.files[0]) return; 1442 const edited = await openImageEditor(coverField.files[0]); 1443 coverField.value = ''; 1444 if (!edited) return; // cancelled 1445 coverStatus.classList.remove('is-error'); 1446 coverStatus.textContent = '<%= t('pedit.js_uploading') %>'; 1447 try { 1448 const j = await uploadImage(edited); 1449 coverUrl.value = j.url; 1450 if (coverVideo) coverVideo.value = j.video || ''; // muted loop MP4 for an animated cover 1451 showCoverPreview(j.url); 1452 coverStatus.textContent = (j.video ? '🎬 ' : '') + '<%= t('pedit.js_uploaded') %> ✓'; 1453 setTimeout(() => { coverStatus.textContent = ''; }, 2000); 1454 } catch (e) { 1455 coverStatus.classList.add('is-error'); 1456 coverStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message; 1457 } 1458 }); 1459 } 1460 // Live-update preview when user pastes a URL manually 1461 if (coverUrl) { 1462 coverUrl.addEventListener('input', () => { 1463 const v = coverUrl.value.trim(); 1464 if (v) showCoverPreview(v); else showCoverPreview(''); 1465 }); 1466 } 1467 1468 // ── WYSIWYG editor (P58) ──────────────────────────────────────── 1469 // Architecture: 1470 // - Visible <div contenteditable> (`#content-editor`) is what the user 1471 // types in; it shows real HTML (formatted, not raw markup). 1472 // - Hidden <input name="content"> (`#content-hidden`) is what submits. 1473 // On submit we serialize the editor's HTML into it, with shortcode 1474 // chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text. 1475 // - Initial content comes from a <script type="application/json"> tag 1476 // to avoid HTML-escape-into-DOM issues; we set innerHTML once on load 1477 // and walk text nodes to render shortcode tokens as chips. 1478 const contentField = document.getElementById('content-upload-field'); 1479 const contentBtn = document.getElementById('insert-image-btn'); 1480 const contentStatus = document.getElementById('content-upload-status'); 1481 const editor = document.getElementById('content-editor'); 1482 const hiddenField = document.getElementById('content-hidden'); 1483 const charCountEl = document.getElementById('char-count'); 1484 const initialEl = document.getElementById('initial-content'); 1485 const toolbar = document.getElementById('pe-toolbar'); 1486 const form = editor && editor.closest('form'); 1487 1488 if (!editor) return; 1489 1490 // Auto-focus the title only on desktop (mouse/trackpad). On touch this would 1491 // immediately open the keyboard when the editor opens — not desired. 1492 try { 1493 const titleInput = form && form.querySelector('input[name="title"]'); 1494 if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) { 1495 titleInput.focus({ preventScroll: true }); 1496 } 1497 } catch (_) {} 1498 1499 // ── Shortcode chip rendering / serialization ──────────────────── 1500 // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]] 1501 // — but we DON'T want to chipify text the user is mid-typing inside an 1502 // HTML attribute; since chipify only walks text nodes (never attribute 1503 // values) that's already safe. 1504 const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g; 1505 1506 const SC_ICONS = { 1507 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>', 1508 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>', 1509 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>', 1510 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>', 1511 }; 1512 1513 function chipLabel(kind, value) { 1514 if (kind === 'track') { 1515 // UUIDs are noisy — show a 6-char prefix for visual hint 1516 const v = String(value || ''); 1517 return '<%= t('pedit.chip_track') %> ' + (v.length > 8 ? v.slice(0, 6) + '…' : v); 1518 } 1519 if (kind === 'album') return '<%= t('pedit.chip_album') %> ' + value; 1520 if (kind === 'playlist') return '<%= t('pedit.chip_playlist') %> ' + value; 1521 if (kind === 'embed') { 1522 const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, ''); 1523 return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean); 1524 } 1525 return value; 1526 } 1527 1528 function makeChip(kind, value) { 1529 const span = document.createElement('span'); 1530 span.className = 'sc-chip'; 1531 span.contentEditable = 'false'; 1532 span.setAttribute('data-sc', kind + ':' + value); 1533 span.innerHTML = 1534 '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' + 1535 '<span class="sc-chip-label"></span>'; 1536 span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value); 1537 return span; 1538 } 1539 1540 // Walk text nodes inside `root` and replace [[type:value]] tokens with chips. 1541 function chipifyShortcodes(root) { 1542 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); 1543 const targets = []; 1544 while (walker.nextNode()) { 1545 const n = walker.currentNode; 1546 // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears) 1547 if (n.parentElement && n.parentElement.closest('.sc-chip')) continue; 1548 if (SC_RE.test(n.nodeValue)) targets.push(n); 1549 SC_RE.lastIndex = 0; 1550 } 1551 for (const node of targets) { 1552 const txt = node.nodeValue; 1553 const frag = document.createDocumentFragment(); 1554 let last = 0; 1555 let m; 1556 SC_RE.lastIndex = 0; 1557 while ((m = SC_RE.exec(txt)) !== null) { 1558 if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index))); 1559 frag.appendChild(makeChip(m[1], m[2].trim())); 1560 last = m.index + m[0].length; 1561 } 1562 if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last))); 1563 node.parentNode.replaceChild(frag, node); 1564 } 1565 } 1566 1567 // Inverse of chipify: clone the editor, replace every chip with its text. 1568 function serializeChips(rootClone) { 1569 const chips = rootClone.querySelectorAll('.sc-chip[data-sc]'); 1570 for (const c of chips) { 1571 const txt = '[[' + c.getAttribute('data-sc') + ']]'; 1572 c.replaceWith(document.createTextNode(txt)); 1573 } 1574 } 1575 1576 // ── Boot: load initial content as HTML, then render shortcodes as chips 1577 try { 1578 const initial = JSON.parse(initialEl.textContent || '""'); 1579 editor.innerHTML = initial || ''; 1580 chipifyShortcodes(editor); 1581 } catch (e) { 1582 console.error('[editor] could not parse initial content', e); 1583 editor.innerHTML = ''; 1584 } 1585 1586 // ── Char counter 1587 function updateCharCount() { 1588 const text = (editor.innerText || '').replace(/\s+/g, ' ').trim(); 1589 if (charCountEl) charCountEl.textContent = String(text.length); 1590 } 1591 updateCharCount(); 1592 editor.addEventListener('input', updateCharCount); 1593 1594 // ── Toolbar wiring 1595 // Lock the scroll position around an edit command. execCommand/insert scrolls 1596 // the caret into view by default → the view "jumps" when clicking a formatting 1597 // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …) 1598 // + the page and restore them — sync and over a few frames, because Chrome 1599 // sometimes scrolls a frame later. The user scrolls themselves. 1600 function scrollableAncestors(el) { 1601 const list = []; 1602 let node = el; 1603 while (node && node !== document.body && node !== document.documentElement) { 1604 const oy = getComputedStyle(node).overflowY; 1605 if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node); 1606 node = node.parentElement; 1607 } 1608 return list; 1609 } 1610 function keepScroll(fn) { 1611 // In fullscreen the page is locked (body overflow:hidden) and the field may 1612 // scroll to the caret freely — no page jump possible, so nothing to fix. 1613 const frame = document.querySelector('.pe-editor-frame'); 1614 if (frame && frame.classList.contains('pe-fs')) { fn(); return; } 1615 const wx = window.scrollX, wy = window.scrollY; 1616 const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; }); 1617 const restore = function () { 1618 window.scrollTo(wx, wy); 1619 anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; }); 1620 }; 1621 fn(); 1622 restore(); 1623 requestAnimationFrame(restore); 1624 } 1625 function execCmd(cmd, arg) { 1626 keepScroll(function () { 1627 editor.focus({ preventScroll: true }); 1628 document.execCommand(cmd, false, arg); 1629 }); 1630 updateToolbarState(); 1631 updateCharCount(); 1632 } 1633 function wrapCode() { 1634 const sel = window.getSelection(); 1635 if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return; 1636 keepScroll(function () { 1637 const range = sel.getRangeAt(0); 1638 const code = document.createElement('code'); 1639 code.textContent = sel.toString(); 1640 range.deleteContents(); 1641 range.insertNode(code); 1642 // Move caret after the new node 1643 range.setStartAfter(code); 1644 range.collapse(true); 1645 sel.removeAllRanges(); 1646 sel.addRange(range); 1647 editor.focus({ preventScroll: true }); 1648 }); 1649 } 1650 function linkPrompt() { 1651 const url = window.prompt('<%= t('pedit.js_link_prompt') %>'); 1652 if (!url) return; 1653 execCmd('createLink', url); 1654 } 1655 // Is the current selection inside a <blockquote> within the editor? Return it. 1656 function blockquoteAncestor() { 1657 const sel = window.getSelection(); 1658 if (!sel || sel.rangeCount === 0) return null; 1659 let node = sel.anchorNode; 1660 while (node && node !== editor) { 1661 if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node; 1662 node = node.parentNode; 1663 } 1664 return null; 1665 } 1666 // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but 1667 // can never turn it OFF (browser quirk). If the caret is already in a quote → 1668 // unwrap it; otherwise apply blockquote. 1669 function toggleBlockquote() { 1670 keepScroll(function () { 1671 editor.focus({ preventScroll: true }); 1672 const bq = blockquoteAncestor(); 1673 if (bq) { 1674 const parent = bq.parentNode; 1675 // Extract content from the quote in place, then remove the empty wrapper. 1676 const ref = bq; 1677 let firstMoved = null; 1678 while (bq.firstChild) { 1679 const child = bq.firstChild; 1680 if (!firstMoved) firstMoved = child; 1681 parent.insertBefore(child, ref); 1682 } 1683 parent.removeChild(bq); 1684 // Restore the caret inside the unwrapped content. 1685 if (firstMoved) { 1686 const sel = window.getSelection(); 1687 const range = document.createRange(); 1688 range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent); 1689 range.collapse(false); 1690 sel.removeAllRanges(); 1691 sel.addRange(range); 1692 } 1693 } else { 1694 document.execCommand('formatBlock', false, 'blockquote'); 1695 } 1696 }); 1697 updateToolbarState(); 1698 updateCharCount(); 1699 } 1700 1701 if (toolbar) { 1702 // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection 1703 // from the editor field. Without this the selection is lost on tap 1704 // → execCommand operates on an empty selection (bold can no longer be toggled OFF) 1705 // and the browser scrolls the caret back into view (the "jump down"). preventDefault 1706 // on mousedown keeps focus in the editor; the click still fires normally. 1707 toolbar.addEventListener('mousedown', (e) => { 1708 if (e.target.closest('button')) e.preventDefault(); 1709 }); 1710 toolbar.addEventListener('click', (e) => { 1711 const btn = e.target.closest('button[data-cmd]'); 1712 if (!btn) return; 1713 e.preventDefault(); 1714 const cmd = btn.dataset.cmd; 1715 const arg = btn.dataset.arg || null; 1716 if (cmd === 'link-prompt') linkPrompt(); 1717 else if (cmd === 'code-wrap') wrapCode(); 1718 else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote(); 1719 else execCmd(cmd, arg); 1720 }); 1721 } 1722 1723 // ── Full-screen writing mode: the writing field fills the whole page. 1724 const fsBtn = document.getElementById('pe-fullscreen-btn'); 1725 const editorFrame = document.querySelector('.pe-editor-frame'); 1726 const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches); 1727 1728 // On mobile the keyboard pushes the visible (visual) viewport up while 1729 // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar 1730 // slides out of view. Keep the fullscreen frame aligned to the visual 1731 // viewport (top + height) so the toolbar stays visible at the top. 1732 function syncFsViewport() { 1733 if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return; 1734 const vv = window.visualViewport; 1735 if (!vv) return; 1736 editorFrame.style.top = vv.offsetTop + 'px'; 1737 editorFrame.style.height = vv.height + 'px'; 1738 } 1739 function clearFsViewport() { 1740 if (!editorFrame) return; 1741 editorFrame.style.top = ''; 1742 editorFrame.style.height = ''; 1743 } 1744 function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); } 1745 function applyFs(on) { 1746 if (!editorFrame) return; 1747 editorFrame.classList.toggle('pe-fs', on); 1748 document.body.classList.toggle('pe-fs-open', on); 1749 document.documentElement.classList.toggle('pe-fs-open', on); 1750 if (fsBtn) { 1751 fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); 1752 fsBtn.title = on ? '<%= t('pedit.tb_done') %>' : '<%= t('pedit.tb_fullscreen') %>'; 1753 } 1754 if (window.visualViewport) { 1755 if (on) { 1756 window.visualViewport.addEventListener('resize', syncFsViewport); 1757 window.visualViewport.addEventListener('scroll', syncFsViewport); 1758 syncFsViewport(); 1759 } else { 1760 window.visualViewport.removeEventListener('resize', syncFsViewport); 1761 window.visualViewport.removeEventListener('scroll', syncFsViewport); 1762 clearFsViewport(); 1763 } 1764 } 1765 // On touch the field is NOT editable inline; only in fullscreen. 1766 if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false'); 1767 if (on) { 1768 editor.focus({ preventScroll: true }); 1769 } else { 1770 if (isTouch) editor.blur(); 1771 // On close: scroll to the TOP of the content instead of staying 1772 // somewhere at the bottom (footer). 1773 requestAnimationFrame(function () { 1774 try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {} 1775 }); 1776 } 1777 } 1778 // The fullscreen writing "page": opening pushes a history state so the browser 1779 // back button (and the Done button) closes it and returns you to the form — feels 1780 // like a separate page, but all form fields remain intact (same DOM). 1781 function openFs() { 1782 if (isFs()) return; 1783 try { history.pushState({ peFs: true }, ''); } catch (_) {} 1784 applyFs(true); 1785 } 1786 function closeFs() { 1787 if (!isFs()) return; 1788 if (history.state && history.state.peFs) history.back(); // → popstate closes it 1789 else applyFs(false); 1790 } 1791 function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); } 1792 window.addEventListener('popstate', function () { if (isFs()) applyFs(false); }); 1793 if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen); 1794 var fsDoneBtn = document.getElementById('pe-fs-done'); 1795 if (fsDoneBtn) fsDoneBtn.addEventListener('click', closeFs); 1796 document.addEventListener('keydown', (e) => { 1797 if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); } 1798 }); 1799 1800 // On mobile/tablet (touch): the content field is NOT editable inline — it is 1801 // not a text field there. One tap → fullscreen, where it becomes editable 1802 // (toggleFullscreen toggles contenteditable). This prevents inline typing. 1803 if (isTouch) { 1804 editor.setAttribute('contenteditable', 'false'); 1805 editor.classList.add('pe-tap-to-edit'); 1806 editor.addEventListener('click', function () { 1807 if (!isFs()) openFs(); 1808 }); 1809 } 1810 1811 // Reflect bold/italic/list state on the toolbar buttons 1812 function updateToolbarState() { 1813 if (!toolbar) return; 1814 const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList']; 1815 for (const cmd of cmds) { 1816 const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]'); 1817 if (!btn) continue; 1818 try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {} 1819 } 1820 // Quote button: active when the caret is inside a <blockquote> (toggle feedback). 1821 const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]'); 1822 if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor()); 1823 } 1824 document.addEventListener('selectionchange', () => { 1825 if (document.activeElement === editor) updateToolbarState(); 1826 }); 1827 1828 // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K 1829 editor.addEventListener('keydown', (e) => { 1830 const mod = e.ctrlKey || e.metaKey; 1831 if (!mod) return; 1832 const k = e.key.toLowerCase(); 1833 if (k === 'b') { e.preventDefault(); execCmd('bold'); } 1834 else if (k === 'i') { e.preventDefault(); execCmd('italic'); } 1835 else if (k === 'u') { e.preventDefault(); execCmd('underline'); } 1836 else if (k === 'k') { e.preventDefault(); linkPrompt(); } 1837 }); 1838 1839 // Paste: keep it simple — strip formatting unless user wants it. Default 1840 // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline 1841 // styles from clipboard only when shift is held — otherwise plain text. 1842 editor.addEventListener('paste', (e) => { 1843 if (e.shiftKey) return; // user wants formatted paste 1844 const text = (e.clipboardData || window.clipboardData).getData('text/plain'); 1845 if (text == null) return; 1846 e.preventDefault(); 1847 document.execCommand('insertText', false, text); 1848 }); 1849 1850 // ── Image upload (button + drag-drop into the editor) 1851 async function uploadAndInsertImage(file) { 1852 const edited = await openImageEditor(file); 1853 if (!edited) return; // cancelled 1854 contentStatus.classList.remove('is-error'); 1855 contentStatus.textContent = '<%= t('pedit.js_uploading') %>'; 1856 try { 1857 const j = await uploadImage(edited); 1858 const img = '<img src="' + j.url + '" alt="">'; 1859 editor.focus({ preventScroll: true }); 1860 document.execCommand('insertHTML', false, img); 1861 contentStatus.textContent = '<%= t('pedit.js_inserted') %> ✓'; 1862 setTimeout(() => { contentStatus.textContent = ''; }, 2000); 1863 updateCharCount(); 1864 } catch (e) { 1865 contentStatus.classList.add('is-error'); 1866 contentStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message; 1867 } 1868 } 1869 1870 if (contentBtn && contentField) { 1871 contentBtn.addEventListener('click', () => contentField.click()); 1872 contentField.addEventListener('change', () => { 1873 if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]); 1874 contentField.value = ''; 1875 }); 1876 1877 editor.addEventListener('dragover', (e) => { 1878 if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { 1879 e.preventDefault(); 1880 editor.classList.add('is-dragover'); 1881 } 1882 }); 1883 editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover')); 1884 editor.addEventListener('drop', async (e) => { 1885 editor.classList.remove('is-dragover'); 1886 const files = e.dataTransfer && e.dataTransfer.files; 1887 if (!files || !files.length) return; 1888 e.preventDefault(); 1889 for (const f of files) { 1890 if (f.type.startsWith('image/')) await uploadAndInsertImage(f); 1891 } 1892 }); 1893 } 1894 1895 // ── Insert chip helpers (track / playlist) 1896 function insertChip(kind, value) { 1897 editor.focus({ preventScroll: true }); 1898 const chip = makeChip(kind, value); 1899 // Insert at caret using the Selection API (execCommand insertNode) 1900 const sel = window.getSelection(); 1901 if (sel && sel.rangeCount > 0) { 1902 const range = sel.getRangeAt(0); 1903 range.deleteContents(); 1904 range.insertNode(chip); 1905 // Insert a trailing space so the user can keep typing after the chip 1906 const space = document.createTextNode('\u00A0'); 1907 chip.after(space); 1908 range.setStartAfter(space); 1909 range.collapse(true); 1910 sel.removeAllRanges(); 1911 sel.addRange(range); 1912 } else { 1913 editor.appendChild(chip); 1914 editor.appendChild(document.createTextNode('\u00A0')); 1915 } 1916 updateCharCount(); 1917 } 1918 1919 // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes 1920 // an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp). 1921 const embedBtn = document.getElementById('insert-embed-btn'); 1922 if (embedBtn) { 1923 embedBtn.addEventListener('click', () => { 1924 const raw = window.prompt('<%= t('pedit.js_embed_prompt') %>'); 1925 if (!raw) return; 1926 const url = raw.trim(); 1927 if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; } 1928 insertChip('embed', url); 1929 }); 1930 } 1931 1932 // ── Track insert: opens the track-picker modal (P59) 1933 const trackBtn = document.getElementById('insert-track-btn'); 1934 const trackPicker = document.getElementById('track-picker'); 1935 if (trackBtn && trackPicker) { 1936 const tpList = document.getElementById('tp-list'); 1937 const tpEmpty = document.getElementById('tp-empty'); 1938 const tpSearch = document.getElementById('tp-search'); 1939 let tpCache = null; // cached track list (fetched once per page load) 1940 let tpLastFocus = null; // element to restore focus to on close 1941 1942 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>'; 1943 1944 function fmtDur(sec) { 1945 sec = Math.max(0, Math.floor(sec || 0)); 1946 const m = Math.floor(sec / 60), s = sec % 60; 1947 return m + ':' + String(s).padStart(2, '0'); 1948 } 1949 function escAttr(s) { 1950 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ 1951 '&':'&','<':'<','>':'>','"':'"',"'":''' 1952 }[c])); 1953 } 1954 1955 function renderList(filter) { 1956 if (!Array.isArray(tpCache)) return; 1957 const q = (filter || '').trim().toLowerCase(); 1958 const filtered = q 1959 ? tpCache.filter(t => 1960 (t.title || '').toLowerCase().includes(q) || 1961 (t.artist || '').toLowerCase().includes(q)) 1962 : tpCache; 1963 1964 if (!filtered.length) { 1965 tpList.innerHTML = ''; 1966 tpEmpty.textContent = q ? '<%= t('pedit.js_no_tracks_found') %> ' + q : '<%= t('pedit.js_no_tracks_yet') %>'; 1967 tpList.appendChild(tpEmpty); 1968 return; 1969 } 1970 1971 tpList.innerHTML = filtered.map(t => { 1972 const cov = t.cover 1973 ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>' 1974 : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>'; 1975 const dis = t.playable ? '' : ' aria-disabled="true"'; 1976 const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : ''; 1977 return ( 1978 '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' + 1979 cov + 1980 '<span class="tp-meta">' + 1981 '<span class="tp-row-title">' + escAttr(t.title) + '</span>' + 1982 sub + 1983 '</span>' + 1984 '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' + 1985 '</button>' 1986 ); 1987 }).join(''); 1988 } 1989 1990 async function loadTracks() { 1991 if (Array.isArray(tpCache)) return tpCache; 1992 tpEmpty.textContent = '<%= t('pedit.js_tracks_loading') %>'; 1993 try { 1994 const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' }); 1995 const j = await r.json(); 1996 tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : []; 1997 } catch (e) { 1998 tpCache = []; 1999 tpEmpty.textContent = '<%= t('pedit.js_tracks_load_fail') %>: ' + e.message; 2000 } 2001 return tpCache; 2002 } 2003 2004 function openPicker() { 2005 tpLastFocus = document.activeElement; 2006 trackPicker.hidden = false; 2007 trackPicker.setAttribute('aria-hidden', 'false'); 2008 document.body.classList.add('tp-locked'); 2009 tpSearch.value = ''; 2010 renderList(''); 2011 // Defer focus so the open animation doesn't get jumped 2012 setTimeout(() => tpSearch.focus(), 30); 2013 } 2014 function closePicker() { 2015 trackPicker.hidden = true; 2016 trackPicker.setAttribute('aria-hidden', 'true'); 2017 document.body.classList.remove('tp-locked'); 2018 if (tpLastFocus && typeof tpLastFocus.focus === 'function') { 2019 try { tpLastFocus.focus(); } catch(_) {} 2020 } 2021 } 2022 2023 trackBtn.addEventListener('click', async () => { 2024 openPicker(); 2025 await loadTracks(); 2026 renderList(tpSearch.value); 2027 }); 2028 2029 // Close: backdrop click, [data-tp-close], or Escape 2030 trackPicker.addEventListener('click', (e) => { 2031 if (e.target.closest('[data-tp-close]')) { 2032 closePicker(); 2033 return; 2034 } 2035 const row = e.target.closest('.tp-row[data-track-id]'); 2036 if (row) { 2037 if (row.getAttribute('aria-disabled') === 'true') return; 2038 const id = row.dataset.trackId; 2039 if (id) { 2040 insertChip('track', id); 2041 closePicker(); 2042 } 2043 } 2044 }); 2045 document.addEventListener('keydown', (e) => { 2046 if (!trackPicker.hidden && e.key === 'Escape') { 2047 e.preventDefault(); 2048 closePicker(); 2049 } 2050 }); 2051 2052 // Live filter 2053 tpSearch.addEventListener('input', () => renderList(tpSearch.value)); 2054 } 2055 2056 // ── Playlist insert (open existing or create new via modal) 2057 const playlistBtn = document.getElementById('insert-playlist-btn'); 2058 if (playlistBtn) { 2059 playlistBtn.addEventListener('click', async () => { 2060 if (typeof window.openPlaylistEditor !== 'function') { 2061 alert('<%= t('pedit.js_playlist_editor_missing') %>'); 2062 return; 2063 } 2064 try { 2065 const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' }); 2066 const j = await r.json(); 2067 if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) { 2068 const choice = prompt( 2069 '<%= t('pedit.js_playlist_existing') %>\n\n' + 2070 j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') + 2071 '\n\n<%= t('pedit.js_playlist_choose') %>' 2072 ); 2073 if (choice && /^\d+$/.test(choice.trim())) { 2074 const idx = parseInt(choice.trim(), 10) - 1; 2075 if (idx >= 0 && idx < j.playlists.length) { 2076 insertChip('playlist', j.playlists[idx].id); 2077 return; 2078 } 2079 } 2080 if (choice === null) return; 2081 } 2082 } catch (_) { /* fall through to create */ } 2083 2084 window.openPlaylistEditor({ 2085 mode: 'create', 2086 onSaved: ({ id }) => insertChip('playlist', id), 2087 }); 2088 }); 2089 } 2090 2091 // ── Post type: segmented control + type-aware panels ────────── 2092 (function () { 2093 const typeInput = document.getElementById('pe-type-input'); 2094 const card = document.querySelector('.pe-type-card'); 2095 if (!typeInput || !card) return; 2096 const seg = card.querySelector('.pe-typeseg'); 2097 const panels = card.querySelectorAll('.pe-type-panel'); 2098 2099 function applyType(tt) { 2100 typeInput.value = tt; 2101 seg.querySelectorAll('.pe-typeseg-btn').forEach(b => { 2102 const on = b.dataset.type === tt; 2103 b.classList.toggle('is-active', on); 2104 b.setAttribute('aria-checked', on ? 'true' : 'false'); 2105 }); 2106 panels.forEach(p => { p.hidden = (p.dataset.panel !== tt); }); 2107 } 2108 seg.addEventListener('click', (e) => { 2109 const btn = e.target.closest('.pe-typeseg-btn'); 2110 if (btn) applyType(btn.dataset.type); 2111 }); 2112 applyType(typeInput.value || 'post'); 2113 2114 // Video URL → [[embed:url]] chip 2115 const vBtn = document.getElementById('pe-video-insert'); 2116 const vUrl = document.getElementById('pe-video-url'); 2117 if (vBtn && vUrl) { 2118 const doInsert = () => { 2119 const url = (vUrl.value || '').trim(); 2120 if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; } 2121 insertChip('embed', url); 2122 vUrl.value = ''; 2123 }; 2124 vBtn.addEventListener('click', doInsert); 2125 vUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doInsert(); } }); 2126 } 2127 2128 // Audio: inline upload → transcodes server-side → [[track:id]] chip 2129 const drop = document.getElementById('pe-audio-drop'); 2130 const fileInput = document.getElementById('pe-audio-file'); 2131 const list = document.getElementById('pe-audio-list'); 2132 if (drop && fileInput && list) { 2133 const pick = () => fileInput.click(); 2134 drop.addEventListener('click', pick); 2135 drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } }); 2136 ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('is-drag'); })); 2137 ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('is-drag'); })); 2138 drop.addEventListener('drop', (e) => { if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); }); 2139 fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; }); 2140 2141 function clientDuration(f) { 2142 return new Promise((resolve) => { 2143 try { 2144 const u = URL.createObjectURL(f); 2145 const a = document.createElement('audio'); 2146 a.preload = 'metadata'; 2147 a.onloadedmetadata = () => { URL.revokeObjectURL(u); resolve(Number.isFinite(a.duration) ? Math.round(a.duration) : null); }; 2148 a.onerror = () => { URL.revokeObjectURL(u); resolve(null); }; 2149 a.src = u; 2150 } catch (_) { resolve(null); } 2151 }); 2152 } 2153 async function handleFiles(files) { 2154 for (const f of Array.from(files || [])) await uploadOne(f); 2155 } 2156 async function uploadOne(f) { 2157 const li = document.createElement('li'); 2158 li.className = 'pe-audio-item'; 2159 const nameEl = document.createElement('span'); 2160 nameEl.className = 'pe-audio-item-name'; 2161 nameEl.textContent = f.name; 2162 const stateEl = document.createElement('span'); 2163 stateEl.className = 'pe-audio-item-state'; 2164 stateEl.textContent = '⏳ <%= t('pedit.audio_up_busy') %>'; 2165 li.appendChild(nameEl); li.appendChild(stateEl); 2166 list.appendChild(li); 2167 try { 2168 const dur = await clientDuration(f); 2169 const fd = new FormData(); 2170 fd.append('audio', f); 2171 if (dur) fd.append('duration', String(dur)); 2172 const res = await fetch('/admin/audio/upload', { 2173 method: 'POST', body: fd, 2174 headers: { 'Accept': 'application/json' }, 2175 credentials: 'same-origin', 2176 }); 2177 const j = await res.json().catch(() => ({})); 2178 if (!res.ok || !j.ok || !j.id) throw new Error(j.error || ('HTTP ' + res.status)); 2179 insertChip('track', j.id); 2180 stateEl.textContent = '✓ <%= t('pedit.audio_up_done') %>'; 2181 li.classList.add('is-done'); 2182 } catch (err) { 2183 stateEl.textContent = '✕ <%= t('pedit.audio_up_fail') %>: ' + err.message; 2184 li.classList.add('is-fail'); 2185 } 2186 } 2187 } 2188 })(); 2189 2190 // ── Submit: serialize editor contents into the hidden field 2191 if (form && hiddenField) { 2192 form.addEventListener('submit', () => { 2193 const clone = editor.cloneNode(true); 2194 serializeChips(clone); 2195 hiddenField.value = clone.innerHTML; 2196 }); 2197 } 2198 })(); 2199 </script> 1184 2200 1185 2201 1186 <%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small … … 2229 1214 <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %> 2230 1215 <% } %> 2231 2232 <script> 2233 (function () { 2234 // Pin: checkbox toggles the hidden rank field (0 = not pinned), 2235 // ▲▼ shifts the position, with a readable description instead of a raw number. 2236 var toggle = document.getElementById('pin-toggle'); 2237 var rank = document.getElementById('pin-rank'); 2238 var pos = document.getElementById('pin-pos'); 2239 var label = document.getElementById('pin-label'); 2240 var up = document.getElementById('pin-up'); // higher = lower number (towards 1/top) 2241 var down = document.getElementById('pin-down'); 2242 if (!toggle || !rank || !pos) return; 2243 2244 function descr(n) { 2245 n = Number(n) || 0; 2246 if (n <= 1) return '<%= t('pedit.pin_top') %>'; 2247 return n + '<%= t('pedit.pin_nth_suffix') %>'; 2248 } 2249 function render() { 2250 var on = toggle.checked; 2251 pos.hidden = !on; 2252 if (on && Number(rank.value) < 1) rank.value = 1; 2253 if (!on) rank.value = 0; 2254 if (label) label.textContent = on ? descr(rank.value) : ''; 2255 if (up) up.disabled = Number(rank.value) <= 1; 2256 } 2257 toggle.addEventListener('change', render); 2258 if (up) up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); }); 2259 if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); }); 2260 render(); 2261 })(); 2262 2263 (function () { 2264 // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible 2265 // obstacles by setting a dynamic bottom offset = the greater of: 2266 // 1) the height of the keyboard area NOT covered by the layout viewport 2267 // (on iOS the visual viewport shifts; on Android the layout viewport shrinks 2268 // due to interactive-widget=resizes-content → offset ≈ 0); 2269 // 2) the height of the playing audio player (fixed, z-index 1000). 2270 // We stick with sticky (no fixed/top tricks → no bar floating in the middle). 2271 var bar = document.querySelector('.pe-actions'); 2272 if (!bar) return; 2273 var vv = window.visualViewport; 2274 function position() { 2275 var ap = document.querySelector('.audio-player'); 2276 var playing = document.body.classList.contains('has-audio-player') && 2277 ap && getComputedStyle(ap).display !== 'none'; 2278 var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0; 2279 var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0; 2280 var offset = Math.max(audioOffset, kbCovered); 2281 bar.style.bottom = offset ? offset + 'px' : ''; 2282 } 2283 position(); 2284 window.addEventListener('resize', position); 2285 if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); } 2286 // has-audio-player is toggled via a body class → observe it. 2287 try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {} 2288 })(); 2289 </script> 1216 <%- include('../partials/page-data', { pageData: { _timezone: (typeof timezone !== 'undefined' ? timezone : ''), apply: t('imed.apply'), audio_up_busy: t('pedit.audio_up_busy'), audio_up_done: t('pedit.audio_up_done'), audio_up_fail: t('pedit.audio_up_fail'), cancel: t('imed.cancel'), chip_album: t('pedit.chip_album'), chip_playlist: t('pedit.chip_playlist'), chip_track: t('pedit.chip_track'), flip_h: t('imed.flip_h'), flip_v: t('imed.flip_v'), js_embed_invalid: t('pedit.js_embed_invalid'), js_embed_prompt: t('pedit.js_embed_prompt'), js_failed: t('pedit.js_failed'), js_inserted: t('pedit.js_inserted'), js_link_prompt: t('pedit.js_link_prompt'), js_no_tracks_found: t('pedit.js_no_tracks_found'), js_no_tracks_yet: t('pedit.js_no_tracks_yet'), js_playlist_choose: t('pedit.js_playlist_choose'), js_playlist_editor_missing: t('pedit.js_playlist_editor_missing'), js_playlist_existing: t('pedit.js_playlist_existing'), js_tracks_load_fail: t('pedit.js_tracks_load_fail'), js_tracks_loading: t('pedit.js_tracks_loading'), js_uploaded: t('pedit.js_uploaded'), js_uploading: t('pedit.js_uploading'), pin_nth_suffix: t('pedit.pin_nth_suffix'), pin_top: t('pedit.pin_top'), reset: t('imed.reset'), rotate_left: t('imed.rotate_left'), rotate_right: t('imed.rotate_right'), tb_done: t('pedit.tb_done'), tb_fullscreen: t('pedit.tb_fullscreen'), title: t('imed.title'), zoom_in: t('imed.zoom_in'), zoom_out: t('imed.zoom_out') } }) %>
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)