Ignore:
Timestamp:
08/07/2026 01:38:51 PM (5 weeks ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
fb9a8ad
Parents:
156baa3
git-author:
Robin <roboburr@…> (08/07/2026 01:33:49 PM)
git-committer:
roboburr <roboburr@…> (08/07/2026 01:38:51 PM)
Message:

De audio- en afspeellijst-editors (shaer-bqr, stap 3f)

admin-audio (435 regels, 18 vertaalsleutels) en playlist-editor (406 regels, het
csrf-token). Daarmee zijn 23 van de 24 bestanden om; alleen post-edit rest.

De achttien vertalingen van admin-audio stonden verspreid door de code, midden in
stringconcatenaties. Ze gaan nu als een tabel via pageData en worden opgezocht in
plaats van geinterpoleerd. Steekproefsgewijs nagelopen op de plekken waar de tekst
de HELE string was: daar bleef anders een leeg begin of eind over.

admin-playlists neemt de playlist-editor op, dus die route vraagt nu om
'admin-playlists playlist-editor' -- een module hoort bij het onderdeel, niet bij
de pagina die het toevallig opneemt.

Templates compileren, 22 modules schoon, suite 565/565.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/views/partials/playlist-editor.ejs

    r156baa3 r6ee289a  
    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). %>
Note: See TracChangeset for help on using the changeset viewer.