Ignore:
File:
1 edited

Legend:

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

    r54ee51c r98a1990  
    284284</style>
    285285
    286 <%# Het script van deze pagina staat in assets/js/mod/track-editor.js (shaer-bqr). %>
     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>
Note: See TracChangeset for help on using the changeset viewer.