Ignore:
File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/views/pages/post-edit.ejs

    re685f55 rfb9a8ad  
    272272        <% } else { %>
    273273          <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). %>
    280275        <% } %>
    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       
    290277        <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen.
    291278           var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; }
     
    335322          </select>
    336323        </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 = '&times;';
    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       
    364325        <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
    365326          <label class="pe-checkbox">
     
    377338            </label>
    378339          </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         
    383341          <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
    384342          <label class="pe-checkbox" style="margin-top:8px">
     
    392350            <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
    393351          </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         
    452353        <% } %>
    453354      </div>
     
    12811182</style>
    12821183
    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         '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
    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
    22001185
    22011186<%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
     
    22291214  <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    22301215<% } %>
    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.