Changeset 952baf3 in Klonkt for src/views/partials


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@…>

Location:
src/views/partials
Files:
1 added
7 edited

Legend:

Unmodified
Added
Removed
  • src/views/partials/bottom-tab.ejs

    rf85b2c3 r952baf3  
    302302</style>
    303303
    304 <script>
    305 // Bottom-tab search button → opens #search-overlay. Using event delegation + looking
    306 // up the overlay on EVERY click: the overlay lives in the chrome that is OOB-replaced
    307 // during htmx navigation, so a once-captured reference goes stale
    308 // (button did nothing afterwards). Guard against double-wiring.
    309 (function() {
    310   if (window.__btSearchWired) return;
    311   window.__btSearchWired = true;
    312   document.addEventListener('click', function(e) {
    313     if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return;
    314     var o = document.getElementById('search-overlay');
    315     if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); }
    316   });
    317 })();
    318 </script>
     304<%# De zoekknop zit nu in de chrome-module (assets/js/mod/chrome.js).
     305    Inline script hier wordt door de CSP geweigerd zodra deze partial via htmx
     306    binnenkomt, en dat is bij elke navigatie -- zie shaer-0i6. %>
  • src/views/partials/msg-item.ejs

    rf85b2c3 r952baf3  
     1<% if (n.type === 'thread') { %>
     2<%
     3           var _tseen = (typeof seen !== 'undefined' && seen) ? seen : 0;
     4           // Een draad is nieuw zodra EEN bericht erin nieuw is.
     5           var _tnew = !!_tseen && (n.messages || []).some(function (m) {
     6             return m.created_at && Date.parse(m.created_at) > _tseen;
     7           });
     8           var _tpeople = (n.people || []).map(function (p) { return p.name || p.handle || '?'; });
     9           // Alleen jij aan het woord: dan is de tegenpartij het adres waaraan
     10           // je schreef, niet een lege naam.
     11           var _tto = ((n.messages || []).find(function (m) { return m.to_handle; }) || {}).to_handle;
     12           var _tlabel = _tpeople.length ? _tpeople.join(', ') : (_tto || t('msg.you'));
     13      %>
     14        <li class="msg-item msg-thread<%= _tnew ? ' is-new' : '' %>" data-kind="conv" data-who="<%= (_tpeople.join(' ') + ' ' + (_tto || '')).toLowerCase() %>">
     15          <div class="msg-thread-head">
     16            <%# Tegenpartij en aantal in EEN uitsparing: het is een label bij het
     17                gesprek, geen kop erboven. Zo houdt de inhoud de ruimte.
     18
     19                Deze uitsparing is ook de knop die het gesprek in- en uitklapt
     20                (Barts aanwijzing): tikken op de tegenpartij is waar je van
     21                nature naar wijst als je "dit gesprek" bedoelt. Een BUTTON en
     22                geen span met een klikhandler, want dan werkt hij ook met het
     23                toetsenbord en leest een schermlezer de stand voor.
     24
     25                Zonder JavaScript doet hij niets, en dan staat het gesprek
     26                gewoon open -- dat is ook de stand waar we mee beginnen. %>
     27            <button type="button" class="msg-thread-who" aria-expanded="true">
     28              <span class="msg-thread-name"><%= _tlabel %></span>
     29              <% if (n.count > 1) { %><span class="msg-thread-count"><%= n.count %></span><% } %>
     30              <svg class="msg-thread-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
     31            </button>
     32            <% if (_tnew) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
     33            <%# De context bij de draad: waar gaat dit gesprek over? Zonder deze
     34                link is een antwoord in een lijst niet te plaatsen. %>
     35            <% if (n.post) { %>
     36              <a class="msg-thread-post" href="/<%= n.post.slug %>">
     37                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
     38                <%= n.post.title || n.post.slug %>
     39              </a>
     40            <% } %>
     41          </div>
     42          <ul class="msg-thread-msgs">
     43            <% (n.messages || []).forEach(function (m) { %><%- include('msg-item', { n: m, seen: _tseen, inThread: true }) %><% }); %>
     44          </ul>
     45          <%# Antwoorden vanuit de draad zelf. Ingeklapt, want een open editor
     46              onder elk gesprek maakt de lijst onleesbaar -- juist het probleem
     47              dat deze weergave moest oplossen. De route kiest het pad: een
     48              draad aan een post krijgt een reply, een draad aan een persoon een
     49              direct bericht. %>
     50          <% var _rt = n.replyTo || {}; var _kan = canMutate && ((_rt.interactionId && _rt.postSlug) || _rt.actorUri); %>
     51          <% if (_kan) { %>
     52            <div class="msg-thread-foot">
     53              <details class="msg-reply">
     54                <summary><%= t('fedi.reply') %></summary>
     55                <div class="msg-reply-form">
     56                  <%- include('../partials/reply-editor', {
     57                    action: (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') + '/messages/reply',
     58                    hiddenFields: [
     59                      { name: 'interaction_id', value: _rt.interactionId || '' },
     60                      { name: 'post_slug', value: _rt.postSlug || '' },
     61                      { name: 'to', value: _rt.actorUri || '' },
     62                    ],
     63                    placeholder: t('fedi.reply_ph'),
     64                    submitLabel: t('fedi.reply'),
     65                    rows: 2,
     66                  }) %>
     67                </div>
     68              </details>
     69              <%# De zwaai blijft een eigen knop: het is geen antwoord maar een
     70                  seintje (FEP-633c), en die twee horen niet op een hoop. %>
     71              <% if (_rt.actorUri) { %>
     72                <form method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" class="msg-wave">
     73                  <input type="hidden" name="to" value="<%= _rt.actorUri %>">
     74                  <button class="btn small" name="text" value="&#128075;" title="<%= t('msg.wave_back') %>">&#128075;</button>
     75                </form>
     76              <% } %>
     77            </div>
     78          <% } %>
     79        </li>
     80<% } else { %>
    181<%
    282           var _seen = (typeof seen !== 'undefined' && seen) ? seen : 0;
     
    585           var _who = n.name || n.handle || '';
    686           var _priv0 = (n.visibility === 'followers' || n.visibility === 'direct');
    7            // Buckets (klonkt-demo-3jf): Messages = @mentions + private (DM) replies;
    8            // Activity = likes/boosts/follows; Moderation = reports; Sent = your
    9            // replies; Conversations = every other (public) reply.
    10            var _kind = _t === 'sent' ? 'sent'
    11              : (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act'
     87           // Emmers: Activiteit = likes/boosts/follows/polls, Moderatie =
     88           // reports, en al het overige is gesprek. De oude emmers 'msgs' en
     89           // 'sent' bestaan niet meer sinds Berichten, Gesprekken en Verzonden
     90           // samenvielen -- en dat is geen cosmetische hernoeming: een
     91           // gespreksregel die om wat voor reden dan ook geen draad werd (geen
     92           // post, geen herkenbare tegenpartij) hield anders een data-kind
     93           // waarvoor geen chip meer bestond, en was daarmee onder ELKE chip
     94           // behalve Alles onzichtbaar.
     95           var _kind = (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act'
    1296             : _t === 'report' ? 'mod'
    13              : (_t === 'mention' || _priv0) ? 'msgs'
    1497             : 'conv';
     98           // Binnen een draad is dit geen zelfstandige regel: de chip filtert op
     99           // de draad, niet op de losse zinnen erin.
     100           var _sub = (typeof inThread !== 'undefined' && inThread);
    15101           var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase();
    16102           var _priv = (n.visibility === 'followers' || n.visibility === 'direct');
    17103      %>
    18         <li class="msg-item msg-<%= _t %><%= _new ? ' is-new' : '' %>" data-kind="<%= _kind %>" data-who="<%= String(_who).toLowerCase() %>">
     104        <li class="<%= _sub ? 'msg-sub' : 'msg-item' %> msg-<%= _t %><%= _new ? ' is-new' : '' %>"<% if (!_sub) { %> data-kind="<%= _kind %>"<% } %> data-who="<%= String(_who).toLowerCase() %>">
    19105          <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true">
    20106            <% if (_t === 'sent') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
     
    38124              <% if (_t === 'sent') { %>
    39125                <span class="msg-who"><%= t('msg.you') %></span>
    40                 <% if (n.to_handle) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>
     126                <% if (n.to_handle && !_sub) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>
    41127              <% } else { %>
    42128                <a class="msg-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%- emojiName(_who, n.actor_emoji_json) %></a>
    43                 <% if (n.handle && n.name) { %><span class="msg-handle"><%= n.handle %></span><% } %>
     129                <% /* De naam blijft: binnen een draad moet je zien WIE wat zei.
     130                      De handle erachter niet -- die staat in de kop. */ %>
     131                <% if (n.handle && n.name && !_sub) { %><span class="msg-handle"><%= n.handle %></span><% } %>
    44132              <% } %>
    45133              <% if (_new) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
     
    50138            </div>
    51139
     140            <% var _hasWhat = !_sub || _priv
     141                 || (_t === 'mention' && (n.help_request || n.wave || n.note_url))
     142                 || (_t === 'sent' && n.in_reply_to); %>
     143            <% if (_hasWhat) { %>
    52144            <div class="msg-what">
    53145              <% if (n.count && n.count > 1) { %>
     
    60152              <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %>
    61153              <% } else if (_t === 'mention' && n.wave) { %>👋 <%= t('msg.waved_at_you') %>
     154              <% } else if (_sub) { %><%# binnen een draad zegt de kop al dat dit een gesprek is %>
    62155              <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %>
    63156              <% } else if (_t === 'sent') { %><%= t('msg.sent_reply') %>
     
    65158              <% } else { %><%= t('notif.replied') %><% } %>
    66159              <% if (_priv) { %><span class="msg-priv" title="<%= t('msg.private_hint') %>">🔒 <%= t('msg.private') %></span><% } %>
    67               <% if (n.post_slug) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
     160              <% if (n.post_slug && !_sub) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
    68161              <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
    69162              <% if (_t === 'sent' && n.in_reply_to) { %><a class="msg-post" href="<%= n.in_reply_to %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
    70163            </div>
     164            <% } %>
    71165
    72166            <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div>
     
    116210          </div>
    117211        </li>
     212<% } %>
  • src/views/partials/playlist-editor.ejs

    rf85b2c3 r952baf3  
    280280</style>
    281281
    282 <script>
    283 (function() {
    284   // Idempotency: if window.openPlaylistEditor already defined (multiple
    285   // partial includes on a single page), skip re-binding.
    286   if (typeof window.openPlaylistEditor === 'function') return;
    287 
    288   const CSRF = '<%= _csrf %>';
    289 
    290   function esc(s) {
    291     return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
    292       '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
    293     }[c]));
    294   }
    295   function fmtDur(sec) {
    296     if (!sec) return '';
    297     const m = Math.floor(sec / 60), s = sec % 60;
    298     return `${m}:${String(s).padStart(2, '0')}`;
    299   }
    300 
    301   async function api(method, url, body) {
    302     const opts = {
    303       method, credentials: 'same-origin',
    304       headers: { 'X-CSRF-Token': CSRF },
    305     };
    306     if (body !== undefined) {
    307       opts.headers['Content-Type'] = 'application/json';
    308       opts.body = JSON.stringify(body);
    309     }
    310     const r = await fetch(url, opts);
    311     return r.json();
    312   }
    313 
    314   /**
    315    * Public entry point: open the editor.
    316    *  opts: { mode: 'create'|'edit', id?, onSaved? }
    317    *  onSaved is called with { id, playlist } after a successful save.
    318    */
    319   window.openPlaylistEditor = async function(opts) {
    320     opts = opts || {};
    321     const mode = opts.mode === 'edit' ? 'edit' : 'create';
    322     const isEdit = mode === 'edit';
    323 
    324     // Load all audio tracks for the picker
    325     let tracks = [];
    326     try {
    327       const j = await api('GET', '/admin/playlists/api/tracks');
    328       if (Array.isArray(j.tracks)) tracks = j.tracks;
    329     } catch (e) {
    330       alert('Tracks ophalen mislukt');
    331       return;
    332     }
    333     if (!tracks.length) {
    334       alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');
    335       return;
    336     }
    337 
    338     // Existing playlist data when editing
    339     let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] };
    340     if (isEdit && opts.id) {
    341       try {
    342         const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
    343         if (j.ok) initial = { ...initial, ...j.playlist };
    344       } catch (e) {}
    345     }
    346 
    347     // ── DOM ────────────────────────────────────────────────────────
    348     const backdrop = document.createElement('div');
    349     backdrop.className = 'pl-modal-backdrop';
    350     backdrop.innerHTML = `
    351       <div class="pl-modal" role="dialog" aria-label="Playlist editor">
    352         <div class="pl-modal-header">
    353           <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3>
    354           <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button>
    355         </div>
    356         <div class="pl-modal-body">
    357           <div class="pl-editor-cols">
    358             <div class="pl-editor-left">
    359               <div class="pl-meta-grid">
    360                 <label class="pl-field pl-field-full">
    361                   <span>Titel *</span>
    362                   <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
    363                 </label>
    364                 <label class="pl-field">
    365                   <span>Artiest</span>
    366                   <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
    367                 </label>
    368                 <label class="pl-field">
    369                   <span>Jaar</span>
    370                   <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
    371                 </label>
    372                 <label class="pl-field">
    373                   <span>Type</span>
    374                   <select id="pli-kind">
    375                     <option value="album"    ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option>
    376                     <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option>
    377                   </select>
    378                 </label>
    379                 <div class="pl-field pl-field-full">
    380                   <span>Cover</span>
    381                   <div class="pl-cover-row">
    382                     <span class="pl-cover-thumb" id="pli-cover-thumb">
    383                       ${initial.cover
    384                         ? `<img src="${esc(initial.cover)}" alt="">`
    385                         : `<span class="pl-cover-empty">🎨</span>`}
    386                     </span>
    387                     <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1">
    388                   </div>
    389                   <div class="pl-cover-upload-row">
    390                     <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
    391                     <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
    392                     <span class="pl-cover-status" id="pli-cover-status"></span>
    393                   </div>
    394                 </div>
    395               </div>
    396               <div class="pl-section-title">
    397                 Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
    398                 <small>(sleep ⠿ om te ordenen)</small>
    399               </div>
    400               <div id="pli-selected" class="pl-selected-list"></div>
    401             </div>
    402             <div class="pl-editor-right">
    403               <div class="pl-section-title">Beschikbare tracks</div>
    404               <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
    405               <div id="pli-available" class="pl-available-list"></div>
    406             </div>
    407           </div>
    408         </div>
    409         <div class="pl-modal-footer">
    410           <button type="button" class="btn" id="pli-cancel">Annuleren</button>
    411           <button type="button" class="btn btn-primary" id="pli-save" disabled>
    412             ${isEdit ? 'Opslaan' : 'Aanmaken'}
    413           </button>
    414         </div>
    415       </div>
    416     `;
    417     document.body.appendChild(backdrop);
    418 
    419     const $ = sel => backdrop.querySelector(sel);
    420     const close = () => backdrop.remove();
    421     backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
    422     $('.pl-modal-close').addEventListener('click', close);
    423     $('#pli-cancel').addEventListener('click', close);
    424 
    425     // Index tracks by id for fast lookup
    426     const trackById = new Map(tracks.map(t => [t.id, t]));
    427     let selected = (initial.track_ids || []).filter(id => trackById.has(id));
    428 
    429     const titleEl = $('#pli-title');
    430     const artistEl = $('#pli-artist');
    431     const yearEl = $('#pli-year');
    432     const kindEl = $('#pli-kind');
    433     const coverEl = $('#pli-cover');
    434     const coverThumb = $('#pli-cover-thumb');
    435     const saveBtn = $('#pli-save');
    436     const selectedEl = $('#pli-selected');
    437     const availEl = $('#pli-available');
    438     const searchEl = $('#pli-search');
    439     const countEl = $('#pli-count');
    440 
    441     coverEl.addEventListener('input', () => {
    442       const u = coverEl.value.trim();
    443       coverThumb.innerHTML = u
    444         ? `<img src="${esc(u)}" alt="" data-fallback>`
    445         : `<span class="pl-cover-empty">🎨</span>`;
    446     });
    447 
    448     // ── Cover file upload (werkt in create ÉN edit) ───────────
    449     // We uploaden naar het generieke image-endpoint (/posts/upload-image,
    450     // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
    451     // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het
    452     // cover-veld en wordt bij 'Aanmaken'/'Opslaan' met de playlist meegestuurd.
    453     const coverFileInput = $('#pli-cover-file');
    454     const coverPickBtn   = $('#pli-cover-pick');
    455     const coverStatus    = $('#pli-cover-status');
    456     if (coverFileInput && coverPickBtn) {
    457       coverPickBtn.addEventListener('click', () => coverFileInput.click());
    458       coverFileInput.addEventListener('change', async (e) => {
    459         const file = e.target.files && e.target.files[0];
    460         coverFileInput.value = '';
    461         if (!file) return;
    462         if (!/^image\//.test(file.type)) {
    463           coverStatus.textContent = 'Alleen afbeeldingen';
    464           coverStatus.className = 'pl-cover-status is-error';
    465           return;
    466         }
    467         coverStatus.textContent = 'Uploaden…';
    468         coverStatus.className = 'pl-cover-status';
    469         const fd = new FormData();
    470         fd.append('image', file);
    471         try {
    472           const r = await fetch('/posts/upload-image',
    473             { method: 'POST', body: fd, credentials: 'same-origin' }
    474           );
    475           const j = await r.json();
    476           if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt');
    477           const url = j.url || '';
    478           coverEl.value = url;
    479           coverThumb.innerHTML = url
    480             ? `<img src="${esc(url)}" alt="">`
    481             : `<span class="pl-cover-empty">🎨</span>`;
    482           coverStatus.textContent = '✓ Geüpload';
    483           coverStatus.className = 'pl-cover-status is-ok';
    484         } catch (err) {
    485           coverStatus.textContent = 'Mislukt: ' + err.message;
    486           coverStatus.className = 'pl-cover-status is-error';
    487         }
    488       });
    489     }
    490 
    491     function updateSaveBtn() {
    492       saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
    493     }
    494 
    495     function renderSelected() {
    496       countEl.textContent = selected.length;
    497       if (selected.length === 0) {
    498         selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
    499         updateSaveBtn();
    500         return;
    501       }
    502       selectedEl.innerHTML = selected.map((id, i) => {
    503         const t = trackById.get(id);
    504         if (!t) return '';
    505         const cover = t.cover
    506           ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
    507           : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
    508         return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
    509           <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
    510           <span class="pl-row-num">${i + 1}</span>
    511           ${cover}
    512           <span class="pl-row-info">
    513             <span class="pl-row-title">${esc(t.title)}</span>
    514             ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
    515           </span>
    516           <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
    517         </div>`;
    518       }).join('');
    519 
    520       selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
    521         b.addEventListener('click', () => {
    522           selected = selected.filter(x => x !== b.dataset.id);
    523           renderSelected();
    524           renderAvailable();
    525         });
    526       });
    527       bindDrag();
    528       updateSaveBtn();
    529     }
    530 
    531     function renderAvailable() {
    532       const q = searchEl.value.trim().toLowerCase();
    533       const matches = tracks.filter(t => {
    534         if (!q) return true;
    535         return (t.title || '').toLowerCase().includes(q)
    536             || (t.artist || '').toLowerCase().includes(q);
    537       });
    538       if (matches.length === 0) {
    539         availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
    540         return;
    541       }
    542       availEl.innerHTML = matches.map(t => {
    543         const isAdded = selected.includes(t.id);
    544         const cls = ['pl-avail-row'];
    545         if (isAdded) cls.push('is-added');
    546         if (!t.playable) cls.push('is-unplayable');
    547         const cover = t.cover
    548           ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
    549           : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
    550         return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
    551           ${cover}
    552           <span class="pl-row-info">
    553             <span class="pl-row-title">${esc(t.title)}</span>
    554             ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
    555           </span>
    556           <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
    557         </div>`;
    558       }).join('');
    559 
    560       availEl.querySelectorAll('.pl-avail-row').forEach(row => {
    561         if (row.classList.contains('is-unplayable')) return;
    562         row.addEventListener('click', () => {
    563           const id = row.dataset.id;
    564           if (selected.includes(id)) selected = selected.filter(x => x !== id);
    565           else selected.push(id);
    566           renderSelected();
    567           renderAvailable();
    568         });
    569       });
    570     }
    571 
    572     // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
    573     function bindDrag() {
    574       selectedEl.querySelectorAll('.pl-row').forEach(row => {
    575         const handle = row.querySelector('.pl-row-handle');
    576         if (!handle) return;
    577         let dragging = false, originalIdx = -1;
    578 
    579         handle.addEventListener('pointerdown', e => {
    580           e.preventDefault();
    581           handle.setPointerCapture(e.pointerId);
    582           dragging = true;
    583           originalIdx = parseInt(row.dataset.pos, 10);
    584           row.classList.add('is-dragging');
    585         });
    586         handle.addEventListener('pointermove', e => {
    587           if (!dragging) return;
    588           e.preventDefault();
    589           const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
    590           rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
    591           let targetIdx = -1, above = false;
    592           for (let i = 0; i < rows.length; i++) {
    593             const r = rows[i]; if (r === row) continue;
    594             const rect = r.getBoundingClientRect();
    595             const mid = rect.top + rect.height / 2;
    596             if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
    597             if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
    598               targetIdx = i; above = e.clientY < mid; break;
    599             }
    600           }
    601           if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
    602         });
    603         const finish = e => {
    604           if (!dragging) return;
    605           dragging = false;
    606           try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
    607           row.classList.remove('is-dragging');
    608           const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
    609           let targetIdx = -1, above = false;
    610           for (let i = 0; i < rows.length; i++) {
    611             if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
    612             if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
    613           }
    614           rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
    615           if (targetIdx === -1 || targetIdx === originalIdx) return;
    616           const moved = selected[originalIdx];
    617           selected.splice(originalIdx, 1);
    618           let newIdx = targetIdx;
    619           if (originalIdx < targetIdx) newIdx--;
    620           if (!above) newIdx++;
    621           newIdx = Math.max(0, Math.min(selected.length, newIdx));
    622           selected.splice(newIdx, 0, moved);
    623           renderSelected();
    624         };
    625         handle.addEventListener('pointerup', finish);
    626         handle.addEventListener('pointercancel', () => {
    627           dragging = false;
    628           row.classList.remove('is-dragging');
    629           selectedEl.querySelectorAll('.drop-above, .drop-below')
    630             .forEach(r => r.classList.remove('drop-above', 'drop-below'));
    631         });
    632       });
    633     }
    634 
    635     titleEl.addEventListener('input', updateSaveBtn);
    636     searchEl.addEventListener('input', renderAvailable);
    637     renderSelected();
    638     renderAvailable();
    639 
    640     saveBtn.addEventListener('click', async () => {
    641       if (saveBtn.disabled) return;
    642       const orig = saveBtn.textContent;
    643       saveBtn.disabled = true;
    644       saveBtn.textContent = 'Opslaan...';
    645 
    646       const payload = {
    647         title:  titleEl.value.trim(),
    648         artist: artistEl.value.trim(),
    649         year:   parseInt(yearEl.value, 10) || 0,
    650         cover:  coverEl.value.trim(),
    651         kind:   kindEl.value === 'playlist' ? 'playlist' : 'album',
    652         tracks: selected.slice(),
    653       };
    654 
    655       try {
    656         const url = isEdit
    657           ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
    658           : '/admin/playlists/api';
    659         const j = await api('POST', url, payload);
    660         if (!j.ok) {
    661           alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
    662           saveBtn.disabled = false;
    663           saveBtn.textContent = orig;
    664           return;
    665         }
    666         const savedId = isEdit ? initial.id : j.id;
    667         close();
    668         if (typeof opts.onSaved === 'function') {
    669           opts.onSaved({ id: savedId, playlist: payload });
    670         }
    671       } catch (err) {
    672         alert('Opslaan mislukt: ' + err.message);
    673         saveBtn.disabled = false;
    674         saveBtn.textContent = orig;
    675       }
    676     });
    677 
    678     // ESC to close
    679     document.addEventListener('keydown', function onEsc(e) {
    680       if (e.key === 'Escape' && document.body.contains(backdrop)) {
    681         close();
    682         document.removeEventListener('keydown', onEsc);
    683       }
    684     });
    685   };
    686 })();
    687 </script>
     282<%# Het script staat in assets/js/mod/playlist-editor.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
  • src/views/partials/profile-header.ejs

    rf85b2c3 r952baf3  
    160160</div>
    161161<% } %>
    162 <script>
    163 (function () {
    164   if (window.__pcmsFediFollowWired) return;
    165   window.__pcmsFediFollowWired = true;
    166   function modal() { return document.querySelector('.pf-follow'); }
    167   function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
    168   function go() {
    169     var m = modal(); if (!m) return;
    170     var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : '';
    171     // Strip a full @user@host handle (and any scheme/path) down to the server host.
    172     var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
    173     if (!server) { if (inp) inp.focus(); return; }
    174     try { localStorage.setItem('pcmsFediServer', server); } catch (e) {}
    175     var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || '');
    176     location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor);
    177   }
    178   document.addEventListener('click', function (e) {
    179     var b = e.target.closest && e.target.closest('.profile-fedi-link');
    180     if (b) { e.preventDefault();
    181       var m = modal(); if (!m) return;
    182       m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || '');
    183       var inp = m.querySelector('.pf-follow-input');
    184       try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {}
    185       m.classList.add('is-open');
    186       setTimeout(function () { if (inp) inp.focus(); }, 30);
    187       return;
    188     }
    189     if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; }
    190     if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; }
    191     var open = document.querySelector('.pf-follow.is-open');
    192     if (open && e.target === open) closeM(); /* click on backdrop */
    193   });
    194   document.addEventListener('keydown', function (e) {
    195     var m = document.querySelector('.pf-follow.is-open'); if (!m) return;
    196     if (e.key === 'Escape') { closeM(); }
    197     else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); }
    198   });
    199 })();
    200 
    201 /* Profile summary: click the avatar to open a modal with photo + details. */
    202 (function () {
    203   if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true;
    204   function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); }
    205   document.addEventListener('click', function (e) {
    206     if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; }
    207     var open = document.querySelector('.pf-summary.is-open');
    208     if (open && e.target === open) { close(); return; } /* click on backdrop */
    209     var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]');
    210     if (!t) return;
    211     e.preventDefault();
    212     var root = (t.closest && t.closest('#pcms-chrome')) || document;
    213     var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary');
    214     if (modal) modal.classList.add('is-open');
    215   });
    216   document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
    217 })();
    218 </script>
     162<%# Het script van de profielkop staat in assets/js/mod/chrome.js: deze partial
     163    zit in de OOB-chrome en wordt bij elke htmx-navigatie opnieuw ingevoegd
     164    (shaer-bqr). %>
    219165<% } %>
    220166
  • src/views/partials/profile-sheet.ejs

    rf85b2c3 r952baf3  
    105105          <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
    106106          <span class="psi-label"><%= t('nav.theme_label') %></span>
    107           <span class="psi-meta" id="profile-sheet-theme-state"><%= t('nav.dark') %></span>
     107          <span class="psi-meta" id="profile-sheet-theme-state" data-dark="<%= t('nav.dark') %>" data-light="<%= t('nav.light') %>"><%= t('nav.dark') %></span>
    108108        </button>
    109109      </li>
     
    326326</style>
    327327
    328 <script>
    329 (function() {
    330   const sheet = document.getElementById('profile-sheet');
    331   if (!sheet) return;
    332 
    333   const backdrop  = document.getElementById('profile-sheet-backdrop');
    334   const panel     = sheet.querySelector('.profile-sheet-panel');
    335   const closeBtn  = document.getElementById('profile-sheet-close');
    336   const dragZone  = document.getElementById('profile-sheet-drag-zone');
    337   const themeBtn  = document.getElementById('profile-sheet-theme');
    338   const themeLbl  = document.getElementById('profile-sheet-theme-state');
    339 
    340   function openSheet() {
    341     sheet.classList.add('is-open');
    342     sheet.setAttribute('aria-hidden', 'false');
    343     document.body.classList.add('profile-sheet-locked');
    344     syncTheme();
    345   }
    346   function closeSheet() {
    347     sheet.classList.remove('is-open');
    348     sheet.setAttribute('aria-hidden', 'true');
    349     document.body.classList.remove('profile-sheet-locked');
    350     panel.style.removeProperty('--pcms-drag-y');
    351   }
    352 
    353   // Open: any element with [data-profile-sheet-toggle]
    354   document.addEventListener('click', function(e) {
    355     const trigger = e.target.closest('[data-profile-sheet-toggle]');
    356     if (trigger) {
    357       e.preventDefault();
    358       openSheet();
    359     }
    360   });
    361 
    362   // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item
    363   if (backdrop) backdrop.addEventListener('click', closeSheet);
    364   if (closeBtn) closeBtn.addEventListener('click', closeSheet);
    365   document.addEventListener('keydown', (e) => {
    366     if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
    367   });
    368 
    369   // Menu items that navigate: close synchronously before nav fires
    370   sheet.querySelectorAll('[data-close-sheet]').forEach((el) => {
    371     el.addEventListener('click', closeSheet);
    372   });
    373 
    374   // Drag-down-to-close on touch.
    375   // Uses --pcms-drag-y custom property rather than overwriting the panel's
    376   // transform string. This composes correctly with any horizontal centering
    377   // (currently none here, but the pattern matches audio-sheet for safety).
    378   let startY = 0, lastY = 0, dragging = false;
    379   function onDown(e) {
    380     if (e.pointerType !== 'touch') return;
    381     if (panel.scrollTop > 0) return;
    382     startY = lastY = e.clientY;
    383     dragging = true;
    384     panel.classList.add('is-dragging');
    385   }
    386   function onMove(e) {
    387     if (!dragging) return;
    388     lastY = e.clientY;
    389     const dy = Math.max(0, lastY - startY);
    390     panel.style.setProperty('--pcms-drag-y', dy + 'px');
    391   }
    392   function onUp() {
    393     if (!dragging) return;
    394     dragging = false;
    395     panel.classList.remove('is-dragging');
    396     const dy = lastY - startY;
    397     if (dy > 80) closeSheet();
    398     else panel.style.removeProperty('--pcms-drag-y');
    399   }
    400   if (window.PointerEvent && dragZone) {
    401     dragZone.addEventListener('pointerdown', onDown);
    402     document.addEventListener('pointermove', onMove);
    403     document.addEventListener('pointerup', onUp);
    404     document.addEventListener('pointercancel', onUp);
    405   }
    406 
    407   // Theme toggle inside sheet — syncs label
    408   function syncTheme() {
    409     if (!themeLbl) return;
    410     const t = document.documentElement.getAttribute('data-theme') || 'dark';
    411     themeLbl.textContent = t === 'dark' ? '<%= t('nav.dark') %>' : '<%= t('nav.light') %>';
    412   }
    413   if (themeBtn) {
    414     themeBtn.addEventListener('click', function() {
    415       const cur = document.documentElement.getAttribute('data-theme') || 'dark';
    416       const next = cur === 'dark' ? 'light' : 'dark';
    417       document.documentElement.setAttribute('data-theme', next);
    418       try { localStorage.setItem('pcms-theme', next); } catch (e) {}
    419       syncTheme();
    420     });
    421   }
    422 })();
    423 </script>
     328<%# Het script van het profielblad staat in assets/js/mod/chrome.js (shaer-bqr). %>
  • src/views/partials/topnav.ejs

    rf85b2c3 r952baf3  
    176176
    177177<!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) -->
    178 <div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search">
     178<%# De teksten staan op het element dat ze gebruikt, niet in het script: een
     179    module is een statisch bestand en kan geen vertaling interpoleren. %>
     180<div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search"
     181     data-i18n="<%= JSON.stringify({ posts: t('search.section_posts'), tracks: t('search.section_tracks'), events: t('search.section_events'), pages: t('search.section_pages'), all: t('search.suggest_all'), none: t('search.suggest_empty') }) %>">
    179182  <div class="container">
    180183    <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search">
     
    191194</div>
    192195
    193 <script>
    194 (function() {
    195   // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation
    196   // → without this guard the script would stack EXTRA listeners on every navigation,
    197   // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working").
    198   // Everything below uses event delegation on body/document, so it also works for
    199   // buttons that appear after this run (OOB).
    200   if (window.__pcmsChromeWired) return;
    201   window.__pcmsChromeWired = true;
    202 
    203   function toggleTheme() {
    204     var cur = document.documentElement.getAttribute('data-theme') || 'dark';
    205     var next = cur === 'dark' ? 'light' : 'dark';
    206     document.documentElement.setAttribute('data-theme', next);
    207     try { localStorage.setItem('pcms-theme', next); } catch (e) {}
    208   }
    209   function overlay() { return document.getElementById('search-overlay'); }
    210   function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } }
    211   function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } }
    212 
    213   document.body.addEventListener('click', function(e) {
    214     if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; }
    215     if (e.target.closest('#search-toggle')) { openSearch(); return; }
    216     if (e.target.closest('#search-close')) { closeSearch(); return; }
    217     // Close open dropdowns (user menu + language picker) on click outside.
    218     document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) {
    219       if (!d.contains(e.target)) d.removeAttribute('open');
    220     });
    221   });
    222 
    223   document.addEventListener('keydown', function(e) {
    224     if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); }
    225   });
    226 
    227   // ── Live results while typing ───────────────────────────────────
    228   var _ST = {
    229     posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>',
    230     events: '<%= t('search.section_events') %>', pages: '<%= t('search.section_pages') %>',
    231     all: '<%= t('search.suggest_all') %>', none: '<%= t('search.suggest_empty') %>'
    232   };
    233   function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
    234   function renderSuggest(box, d, q, ov) {
    235     var html = '';
    236     function grp(title, items, fmt) {
    237       if (!items || !items.length) return;
    238       html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>';
    239     }
    240     grp(_ST.posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; });
    241     grp(_ST.tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; });
    242     grp(_ST.events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; });
    243     grp(_ST.pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; });
    244     var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length);
    245     if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_ST.none) + '</div>'; return; }
    246     var action = (ov && ov.getAttribute('data-action')) || '/search';
    247     html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_ST.all) + '</a>';
    248     box.innerHTML = html;
    249   }
    250   var _sTimer;
    251   document.addEventListener('input', function(e) {
    252     var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]');
    253     if (!inp) return;
    254     var box = document.getElementById('search-suggest');
    255     if (!box) return;
    256     var q = inp.value.trim();
    257     clearTimeout(_sTimer);
    258     if (q.length < 2) { box.innerHTML = ''; return; }
    259     _sTimer = setTimeout(function() {
    260       var ov = document.getElementById('search-overlay');
    261       var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest';
    262       fetch(url + '?q=' + encodeURIComponent(q))
    263         .then(function(r){ return r.ok ? r.json() : null; })
    264         .then(function(d){ if (d) renderSuggest(box, d, q, ov); })
    265         .catch(function(){});
    266     }, 200);
    267   });
    268   // Click on a suggestion → close the overlay (the link/boost handles navigation).
    269   document.addEventListener('click', function(e) {
    270     if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; }
    271   });
    272 })();
    273 </script>
     196<%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %>
    274197
    275198<style>
  • src/views/partials/track-editor.ejs

    rf85b2c3 r952baf3  
    284284</style>
    285285
    286 <script>
    287 (function() {
    288 
    289   function esc(s) {
    290     return String(s == null ? '' : s)
    291       .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    292       .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
    293   }
    294 
    295   async function api(method, url, body) {
    296     const opts = { method, credentials: 'same-origin', headers: {} };
    297     if (body !== undefined) {
    298       if (body instanceof FormData) {
    299         opts.body = body;
    300       } else {
    301         opts.headers['Content-Type'] = 'application/json';
    302         opts.body = JSON.stringify(body);
    303       }
    304     }
    305     const res = await fetch(url, opts);
    306     let data;
    307     try { data = await res.json(); }
    308     catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); }
    309     if (!res.ok) data.ok = false;
    310     return data;
    311   }
    312 
    313   /**
    314    * Open the track editor.
    315    * @param {object} opts
    316    * @param {string} opts.id        — track id to edit
    317    * @param {function?} opts.onSaved — called with updated track on success
    318    */
    319   window.openTrackEditor = async function openTrackEditor({ id, onSaved }) {
    320     if (!id) return;
    321     let track, albumSuggestions = [];
    322     try {
    323       const [trackJson, listJson] = await Promise.all([
    324         api('GET', '/admin/audio/api/' + encodeURIComponent(id)),
    325         api('GET', '/admin/audio/api/albums'),
    326       ]);
    327       if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden');
    328       track = trackJson.track;
    329       if (listJson.ok && Array.isArray(listJson.albums)) {
    330         albumSuggestions = listJson.albums.filter(Boolean);
    331       }
    332     } catch (err) {
    333       alert('Track ophalen mislukt: ' + err.message);
    334       return;
    335     }
    336 
    337     const backdrop = document.createElement('div');
    338     backdrop.className = 'te-backdrop';
    339     backdrop.innerHTML = `
    340       <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken">
    341         <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div>
    342 
    343         <div class="te-header">
    344           <h3>✎ Track bewerken</h3>
    345           <button type="button" class="te-close" aria-label="Sluiten">×</button>
    346         </div>
    347 
    348         <div class="te-body">
    349 
    350           ${track.stream_url ? `
    351           <div class="te-preview">
    352             <div class="te-preview-cover">
    353               ${track.cover_url
    354                 ? `<img src="${esc(track.cover_url)}" alt="">`
    355                 : `🎵`}
    356             </div>
    357             <div class="te-preview-meta">
    358               <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div>
    359               <div class="te-preview-sub">
    360                 ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''}
    361               </div>
    362             </div>
    363             <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button>
    364           </div>
    365           ` : ''}
    366 
    367           <div class="te-form">
    368 
    369             <label class="te-field">
    370               <span>Titel <span class="te-required" aria-hidden="true">*</span></span>
    371               <input type="text" id="te-title" maxlength="200" required
    372                      autocomplete="off" autocapitalize="words" spellcheck="false"
    373                      value="${esc(track.title || '')}">
    374             </label>
    375 
    376             <div class="te-row te-row-2">
    377               <label class="te-field">
    378                 <span>Artiest</span>
    379                 <input type="text" id="te-artist" maxlength="200"
    380                        autocomplete="off" autocapitalize="words" spellcheck="false"
    381                        value="${esc(track.artist || '')}">
    382               </label>
    383               <label class="te-field">
    384                 <span>Album</span>
    385                 <input type="text" id="te-album" maxlength="200"
    386                        autocomplete="off" autocapitalize="words" spellcheck="false"
    387                        list="te-album-list" value="${esc(track.album || '')}">
    388                 <datalist id="te-album-list">
    389                   ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')}
    390                 </datalist>
    391               </label>
    392             </div>
    393 
    394             <label class="te-field">
    395               <span>Duur <small>(seconden — automatisch bepaald, hier te overschrijven)</small></span>
    396               <input type="number" id="te-duration" min="0" step="1"
    397                      inputmode="numeric" pattern="[0-9]*"
    398                      value="${track.duration || ''}" placeholder="auto">
    399             </label>
    400 
    401             <div class="te-row te-row-2">
    402               <label class="te-field">
    403                 <span>Eigenaar / credit <small>(copyright-houder)</small></span>
    404                 <div class="te-credit-row">
    405                   <input type="text" id="te-credit" maxlength="200"
    406                          autocomplete="off" spellcheck="false"
    407                          placeholder="bv. © 2025 Mara Vos"
    408                          value="${esc(track.credit || '')}">
    409                   <button type="button" class="te-sym-btn" id="te-credit-copyr"
    410                           title="© invoegen" aria-label="Copyright-teken invoegen">©</button>
    411                 </div>
    412               </label>
    413               <label class="te-field">
    414                 <span>Licentie</span>
    415                 <input type="text" id="te-license" maxlength="120"
    416                        autocomplete="off" spellcheck="false" list="te-license-list"
    417                        placeholder="Alle rechten voorbehouden"
    418                        value="${esc(track.license || '')}">
    419                 <datalist id="te-license-list">
    420                   <option value="Alle rechten voorbehouden"></option>
    421                   <option value="CC BY 4.0"></option>
    422                   <option value="CC BY-SA 4.0"></option>
    423                   <option value="CC BY-NC 4.0"></option>
    424                   <option value="CC BY-NC-SA 4.0"></option>
    425                   <option value="CC BY-ND 4.0"></option>
    426                   <option value="CC0 1.0 (publiek domein)"></option>
    427                 </datalist>
    428               </label>
    429             </div>
    430 
    431             <div class="te-field">
    432               <span>Open in <small>(links naar dezelfde track elders)</small></span>
    433               <input type="url" id="te-link-spotify" inputmode="url" autocomplete="off" spellcheck="false"
    434                      placeholder="Spotify-URL (https://open.spotify.com/…)" value="${esc(track.link_spotify || '')}">
    435               <input type="url" id="te-link-youtube" inputmode="url" autocomplete="off" spellcheck="false"
    436                      placeholder="YouTube-URL (https://youtu.be/…)" value="${esc(track.link_youtube || '')}">
    437               <input type="url" id="te-link-soundcloud" inputmode="url" autocomplete="off" spellcheck="false"
    438                      placeholder="SoundCloud-URL (https://soundcloud.com/…)" value="${esc(track.link_soundcloud || '')}">
    439             </div>
    440 
    441             <div class="te-field">
    442               <span>Audiobestand</span>
    443               <div class="te-cover-btn-row">
    444                 <input type="file" id="te-audio-file" accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg,.aac" hidden>
    445                 <button type="button" class="te-cover-btn" id="te-audio-pick">${track.stream_url ? '🔁 Vervang audiobestand' : '🎵 Audiobestand kiezen'}</button>
    446                 <span class="te-cover-status" id="te-audio-status"></span>
    447               </div>
    448             </div>
    449 
    450             <div class="te-field">
    451               <span>Cover</span>
    452               <div class="te-cover-row">
    453                 <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button"
    454                      aria-label="Klik om cover te kiezen">
    455                   ${track.cover_url
    456                     ? `<img src="${esc(track.cover_url)}" alt="">`
    457                     : `<span class="te-cover-empty">🎨</span>`}
    458                 </div>
    459                 <input type="file" id="te-cover-file"
    460                        accept="image/jpeg,image/png,image/webp,image/gif" hidden>
    461                 <div class="te-cover-actions">
    462                   <div class="te-cover-btn-row">
    463                     <button type="button" class="te-cover-btn" id="te-cover-pick">
    464                       📷 Foto kiezen
    465                     </button>
    466                     ${track.cover_url ? `
    467                     <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove">
    468                       × Verwijder
    469                     </button>` : ''}
    470                   </div>
    471                   <input type="text" id="te-cover-url" inputmode="url"
    472                          placeholder="/media/… of https://…"
    473                          autocomplete="off" autocapitalize="none" spellcheck="false"
    474                          value="${esc(track.cover_url || '')}">
    475                   <div class="te-cover-status" id="te-cover-status"></div>
    476                 </div>
    477               </div>
    478             </div>
    479 
    480           </div>
    481         </div>
    482 
    483         <div class="te-footer">
    484           <button type="button" class="te-btn" id="te-cancel">Annuleren</button>
    485           <div class="te-footer-spacer"></div>
    486           <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button>
    487         </div>
    488       </div>
    489     `;
    490 
    491     document.body.appendChild(backdrop);
    492     document.body.classList.add('te-modal-open');
    493 
    494     const $ = sel => backdrop.querySelector(sel);
    495 
    496     // ── Inline preview player (routed through global mini-player) ──
    497     // We don't build our own <audio>; instead we tell the global
    498     // window.pcmsAudioPlayer to load this single track. Visual state
    499     // syncs against the global audio element so toggle works correctly
    500     // even if the user pauses from the mini-bar.
    501     const previewBtn = $('#te-preview-play');
    502     if (previewBtn) {
    503       const setPreviewState = (playing) => {
    504         previewBtn.textContent = playing ? '⏸' : '▶';
    505         previewBtn.classList.toggle('is-playing', playing);
    506         previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
    507       };
    508       const isOurTrack = () => {
    509         // audio.src is a blob: URL (Spotify-style playback) — compare against
    510         // the player's logical current-track URL instead.
    511         const player = window.pcmsAudioPlayer;
    512         const cur = player && player.currentTrack();
    513         return !!(cur && track.stream_url && cur.url === track.stream_url);
    514       };
    515       const resync = () => {
    516         const audio = document.getElementById('audio-element');
    517         const playing = audio && !audio.paused && !audio.ended && isOurTrack();
    518         setPreviewState(!!playing);
    519       };
    520 
    521       previewBtn.addEventListener('click', () => {
    522         const player = window.pcmsAudioPlayer;
    523         if (!player || !track.stream_url) {
    524           console.warn('preview: miniplayer or url missing');
    525           return;
    526         }
    527         const audio = document.getElementById('audio-element');
    528         if (audio && isOurTrack()) {
    529           // Same track loaded — toggle
    530           if (audio.paused) player.play(); else player.pause();
    531         } else {
    532           player.setQueue([{
    533             url:    track.stream_url,
    534             title:  track.title  || '(zonder titel)',
    535             artist: track.artist || '',
    536             album:  track.album  || '',
    537             cover:  track.cover_url || '',
    538           }], 0);
    539         }
    540       });
    541 
    542       const audio = document.getElementById('audio-element');
    543       if (audio) {
    544         const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied'];
    545         evs.forEach(ev => audio.addEventListener(ev, resync));
    546         // Detach listeners on close so we don't leak them
    547         backdrop._previewCleanup = () => {
    548           evs.forEach(ev => audio.removeEventListener(ev, resync));
    549         };
    550         resync();   // initial state
    551       }
    552     }
    553 
    554     function close() {
    555       // Detach our resync listeners (mini-player stays running)
    556       if (backdrop._previewCleanup) backdrop._previewCleanup();
    557       document.body.classList.remove('te-modal-open');
    558       document.removeEventListener('keydown', onEsc);
    559       backdrop.remove();
    560     }
    561     function onEsc(e) { if (e.key === 'Escape') close(); }
    562     document.addEventListener('keydown', onEsc);
    563 
    564     // A click on the backdrop does NOT close the editor — too easy to lose edits by
    565     // mis-clicking outside. Close deliberately via ×, Cancel or Esc.
    566     $('.te-close').addEventListener('click', close);
    567     $('#te-cancel').addEventListener('click', close);
    568 
    569     // ── Cover picking + URL paste + drag-drop ────────────────
    570     const thumb     = $('#te-cover-thumb');
    571     const fileInput = $('#te-cover-file');
    572     const urlInput  = $('#te-cover-url');
    573     const status    = $('#te-cover-status');
    574     const pickBtn   = $('#te-cover-pick');
    575     const removeBtn = $('#te-cover-remove');
    576 
    577     function setStatus(text, kind) {
    578       status.textContent = text || '';
    579       status.className = 'te-cover-status' + (kind ? ' is-' + kind : '');
    580     }
    581     function setThumb(url) {
    582       if (url) {
    583         thumb.innerHTML = `<img src="${esc(url)}" alt="">`;
    584       } else {
    585         thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`;
    586       }
    587     }
    588 
    589     pickBtn.addEventListener('click', () => fileInput.click());
    590     thumb.addEventListener('click', () => fileInput.click());
    591     thumb.addEventListener('keydown', e => {
    592       if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
    593     });
    594 
    595     async function uploadCoverFile(file) {
    596       if (!file) return;
    597       if (!/^image\//.test(file.type)) {
    598         setStatus('Alleen afbeeldingen toegestaan', 'error'); return;
    599       }
    600       setStatus('Uploaden…', null);
    601       const fd = new FormData();
    602       fd.append('cover', file);
    603       try {
    604         const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', {
    605           method: 'POST', body: fd, credentials: 'same-origin',
    606         });
    607         const j = await r.json();
    608         if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
    609         urlInput.value = j.cover_url || '';
    610         setThumb(j.cover_url);
    611         setStatus('✓ Geüpload', 'ok');
    612       } catch (err) {
    613         setStatus('Mislukt: ' + err.message, 'error');
    614       }
    615     }
    616 
    617     fileInput.addEventListener('change', e => {
    618       const f = e.target.files && e.target.files[0];
    619       if (f) uploadCoverFile(f);
    620       fileInput.value = '';
    621     });
    622 
    623     // Drag-drop on thumb (desktop nicety)
    624     ['dragenter', 'dragover'].forEach(ev =>
    625       thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); }));
    626     ['dragleave', 'drop'].forEach(ev =>
    627       thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); }));
    628     thumb.addEventListener('drop', e => {
    629       const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
    630       if (f) uploadCoverFile(f);
    631     });
    632 
    633     // URL paste auto-preview
    634     urlInput.addEventListener('input', () => {
    635       const v = urlInput.value.trim();
    636       setThumb(v);
    637     });
    638 
    639     if (removeBtn) {
    640       removeBtn.addEventListener('click', () => {
    641         urlInput.value = '';
    642         setThumb('');
    643         removeBtn.remove();
    644       });
    645     }
    646 
    647     // ── Replace the audio file of this track ──────────────────
    648     const aPick = $('#te-audio-pick');
    649     const aFile = $('#te-audio-file');
    650     const aStatus = $('#te-audio-status');
    651     if (aPick && aFile) {
    652       aPick.addEventListener('click', () => aFile.click());
    653       aFile.addEventListener('change', async (e) => {
    654         const f = e.target.files && e.target.files[0];
    655         aFile.value = '';
    656         if (!f) return;
    657         aStatus.textContent = '⏳ Converteren… (kan even duren)'; aStatus.className = 'te-cover-status';
    658         aPick.disabled = true;
    659         try {
    660           const fd = new FormData();
    661           fd.append('audio', f);
    662           const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id) + '/replace-audio', fd);
    663           if (!j.ok) throw new Error(j.error || 'mislukt');
    664           aStatus.textContent = '✓ Vervangen'; aStatus.className = 'te-cover-status is-ok';
    665           track.stream_url = j.stream_url || track.stream_url;
    666           if (j.duration) { const d = $('#te-duration'); if (d) d.value = j.duration; }
    667         } catch (err) {
    668           aStatus.textContent = 'Mislukt: ' + err.message; aStatus.className = 'te-cover-status is-error';
    669         } finally { aPick.disabled = false; }
    670       });
    671     }
    672 
    673     // ── Insert © symbol into the credit field ─────────────────
    674     const copyrBtn = $('#te-credit-copyr');
    675     if (copyrBtn) {
    676       copyrBtn.addEventListener('click', () => {
    677         const inp = $('#te-credit');
    678         if (!inp) return;
    679         const sym = '© ';
    680         const start = inp.selectionStart != null ? inp.selectionStart : inp.value.length;
    681         const end = inp.selectionEnd != null ? inp.selectionEnd : inp.value.length;
    682         inp.value = inp.value.slice(0, start) + sym + inp.value.slice(end);
    683         inp.focus();
    684         const pos = start + sym.length;
    685         try { inp.setSelectionRange(pos, pos); } catch (e) {}
    686       });
    687     }
    688 
    689     // ── Save ────────────────────────────────────────────────
    690     $('#te-save').addEventListener('click', async () => {
    691       const titleEl = $('#te-title');
    692       const title = titleEl.value.trim();
    693       if (!title) {
    694         titleEl.focus();
    695         alert('Titel is verplicht');
    696         return;
    697       }
    698       const saveBtn = $('#te-save');
    699       saveBtn.disabled = true;
    700       saveBtn.textContent = '⏳ Opslaan…';
    701 
    702       try {
    703         const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), {
    704           title,
    705           artist: $('#te-artist').value.trim() || null,
    706           album:  $('#te-album').value.trim() || null,
    707           credit:  $('#te-credit').value.trim() || null,
    708           license: $('#te-license').value.trim() || null,
    709           link_spotify:    $('#te-link-spotify').value.trim() || null,
    710           link_youtube:    $('#te-link-youtube').value.trim() || null,
    711           link_soundcloud: $('#te-link-soundcloud').value.trim() || null,
    712           duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
    713           cover_url: urlInput.value.trim() || null,
    714         });
    715         if (!j.ok) throw new Error(j.error || 'Opslaan mislukt');
    716         if (typeof onSaved === 'function') onSaved(j.track || { id, title,
    717           artist: $('#te-artist').value.trim() || null,
    718           album:  $('#te-album').value.trim() || null,
    719           cover_url: urlInput.value.trim() || null });
    720         close();
    721       } catch (err) {
    722         alert('Opslaan mislukt: ' + err.message);
    723         saveBtn.disabled = false;
    724         saveBtn.textContent = '💾 Opslaan';
    725       }
    726     });
    727 
    728     // ── Auto-duration ───────────────────────────────────────────
    729     // The server already determines duration automatically on upload. This is the
    730     // fallback/UX layer: if an admin opens an existing track without a duration,
    731     // we read it from the audio metadata and fill the field — so you never need
    732     // to type seconds manually. An existing value is never overwritten. We fetch
    733     // the bytes via the same header gate as the player (X-Audio-Player).
    734     (async function autoDuration() {
    735       const durEl = $('#te-duration');
    736       if (!durEl || !track.stream_url) return;
    737       if (durEl.value && Number(durEl.value) > 0) return;  // already filled → leave it alone
    738       let objUrl = null;
    739       try {
    740         const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } });
    741         if (!r.ok) return;
    742         objUrl = URL.createObjectURL(await r.blob());
    743         const probe = new Audio();
    744         probe.preload = 'metadata';
    745         probe.addEventListener('loadedmetadata', () => {
    746           if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) {
    747             durEl.value = Math.round(probe.duration);
    748           }
    749           if (objUrl) URL.revokeObjectURL(objUrl);
    750         });
    751         probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); });
    752         probe.src = objUrl;
    753       } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); }
    754     })();
    755 
    756     // Focus title for fast typing
    757     setTimeout(() => $('#te-title').focus(), 60);
    758   };
    759 })();
    760 </script>
     286<%# Het script van deze pagina staat in assets/js/mod/track-editor.js (shaer-bqr). %>
Note: See TracChangeset for help on using the changeset viewer.