Ignore:
File:
1 edited

Legend:

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

    rfb9a8ad re685f55  
    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           <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     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>
    275280        <% } %>
    276        
     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>
    277290        <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen.
    278291           var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; }
     
    322335          </select>
    323336        </div>
    324        
     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>
    325364        <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
    326365          <label class="pe-checkbox">
     
    338377            </label>
    339378          </div>
    340          
     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>
    341383          <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
    342384          <label class="pe-checkbox" style="margin-top:8px">
     
    350392            <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
    351393          </div>
    352          
     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>
    353452        <% } %>
    354453      </div>
     
    11821281</style>
    11831282
    1184 
     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>
    11852200
    11862201<%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
     
    12142229  <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    12152230<% } %>
    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') } }) %>
     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>
Note: See TracChangeset for help on using the changeset viewer.