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/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.