Changeset 952baf3 in Klonkt for src/views/pages/post-edit.ejs


Ignore:
Timestamp:
08/07/2026 05:15:52 PM (5 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
ba76bf5
Parents:
f85b2c3 (diff), 0d5bd2c (diff)
Note: this is a merge changeset, the changes displayed below correspond to the merge itself.
Use the (diff) links above to see all the changes relative to each parent.
Message:

Merge GitHub-main (1.7.0) met de VPS-lijn

De twee mains waren een dag gedivergeerd en bevatten elk echt werk. GitHub had 66
commits die nooit langs prutfolio.git zijn gekomen, omdat een parallelle sessie
rechtstreeks naar GitHub pushte vanaf een kloon in /tmp op de VPS. De VPS had twee
commits die GitHub niet had. Geen van beide bevatte de ander, en stable had geen van
de twee.

Bewust een merge en geen rebase: dan blijft beide historie intact en wordt er niets
herschreven waar iemand anders al op voortbouwt.

Drie bestanden raakten beide kanten. Alle drie zijn nagekeken, want dat een merge
automatisch slaagt zegt niets over of hij inhoudelijk klopt:

src/services/ActivityPubService.js

  • de sleutelbinding staat nu boven de nieuwe asSlug-aanroep van 1.7.0, dus de controle komt nog steeds voor de handtekeningcontrole

scripts/klonkt-refresh-updater.sh

  • alleen de opzij-aanpak overleefde; systemctl mask staat nergens meer als code

deploy/MULTI-INSTANCE.md

  • spreekt zichzelf niet tegen: beschrijft opzij zetten, met de reden waarom mask weigert

remarks: het gat dat in de review naar boven kwam staat hiermee ook op de 1.7.0-lijn.
De andere bevindingen uit die review staan nog open en zijn niet in deze merge
opgelost; die horen als beads. Ook nog te doen: dezelfde sleutelbinding op stable
als 1.6.1, want daar is het gat nog open bij self-hosters.

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

File:
1 edited

Legend:

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

    rf85b2c3 r952baf3  
    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.