source: Klonkt/src/views/partials/track-editor.ejs@ 7bc636b

main
Last change on this file since 7bc636b was 7bc636b, checked in by Robin <robin@…>, 4 months ago

Initial commit — PrutFolio v1 source (pulled from Hetzner /srv/prutfolio)

  • Property mode set to 100644
File size: 22.3 KB
Line 
1<%
2// Track editor modal — mobile-first rewrite.
3//
4// Visual model:
5// Mobile (<720px): bottom-sheet that slides up from below, max 92vh,
6// rounded top corners only, drag-handle bar at top.
7// Desktop (≥720px): centered modal max-width 520px, fully-rounded.
8//
9// Self-contained styling under the .te-* namespace — no dependency on
10// the .ax-* admin primitives. Same visual language though: paper bg,
11// rule borders, accent for active states.
12//
13// Exposes window.openTrackEditor({ id, onSaved? }) — called by the
14// "✎ Bewerken" buttons on admin-audio. Fetches /admin/audio/api/:id,
15// renders the form with current values + an inline <audio> preview,
16// and PATCHes via /admin/audio/api/:id on save.
17
18const _csrf = (typeof csrfToken !== 'undefined' && csrfToken) || '';
19%>
20
21<style>
22/* ─────────────────────────────────────────────────────────────────
23 Track editor modal — namespace .te-*
24 ───────────────────────────────────────────────────────────────── */
25
26.te-backdrop {
27 position: fixed; inset: 0;
28 /* Above bottom-tab nav (z=1050) and audio-player sheet (z=1100) so the
29 editor always wins. */
30 z-index: 9000;
31 background: rgba(0, 0, 0, 0.55);
32 -webkit-backdrop-filter: blur(4px);
33 backdrop-filter: blur(4px);
34 display: flex;
35 /* Mobile: dock to bottom; desktop overrides to center via media query */
36 align-items: flex-end;
37 justify-content: center;
38 animation: te-fade 160ms ease-out;
39}
40@keyframes te-fade { from { opacity: 0; } to { opacity: 1; } }
41
42.te-modal {
43 background: var(--paper);
44 width: 100%;
45 max-height: 92vh;
46 /* Bottom-sheet shape on mobile — only top corners rounded */
47 border-radius: 16px 16px 0 0;
48 border: 1px solid var(--rule);
49 border-bottom: 0;
50 box-shadow: 0 -8px 30px rgba(0,0,0,0.4);
51 display: flex; flex-direction: column;
52 overflow: hidden;
53 animation: te-slide-up 220ms cubic-bezier(.2,.9,.3,1);
54}
55@keyframes te-slide-up {
56 from { transform: translateY(100%); }
57 to { transform: translateY(0); }
58}
59
60/* Desktop: center the modal as a card */
61@media (min-width: 720px) {
62 .te-backdrop { align-items: center; padding: 1rem; }
63 .te-modal {
64 max-width: 520px;
65 border-radius: 14px;
66 border: 1px solid var(--rule);
67 max-height: calc(100vh - 2rem);
68 box-shadow: 0 20px 60px rgba(0,0,0,0.4);
69 animation: te-fade 220ms ease-out;
70 }
71}
72
73/* Drag-handle bar at top of mobile sheet */
74.te-handle {
75 width: 100%; padding: 0.6rem 0;
76 display: flex; justify-content: center;
77 flex-shrink: 0;
78}
79.te-handle-bar {
80 width: 36px; height: 4px;
81 background: var(--rule);
82 border-radius: 99px;
83}
84@media (min-width: 720px) { .te-handle { display: none; } }
85
86.te-header {
87 display: flex; align-items: center; justify-content: space-between;
88 padding: 0.5rem 1.25rem 1rem;
89 border-bottom: 1px solid var(--rule);
90 flex-shrink: 0;
91}
92@media (min-width: 720px) { .te-header { padding: 1rem 1.25rem; } }
93.te-header h3 {
94 font-family: var(--font-display, serif);
95 margin: 0; font-size: 1.15rem; font-weight: 600;
96}
97.te-close {
98 width: 36px; height: 36px;
99 display: inline-flex; align-items: center; justify-content: center;
100 border: 0; border-radius: 8px;
101 background: transparent;
102 color: var(--ink-muted, var(--ink-soft));
103 font-size: 1.5rem; line-height: 1;
104 cursor: pointer;
105 transition: background 120ms, color 120ms;
106}
107.te-close:hover { background: var(--paper-2); color: var(--ink); }
108
109.te-body {
110 padding: 1rem 1.25rem;
111 overflow-y: auto;
112 flex: 1;
113 -webkit-overflow-scrolling: touch;
114 display: flex; flex-direction: column; gap: 1rem;
115}
116
117/* ── Inline preview player ─────────────────────────────────── */
118.te-preview {
119 display: flex; align-items: center; gap: 0.75rem;
120 padding: 0.75rem;
121 background: var(--paper-2);
122 border: 1px solid var(--rule);
123 border-radius: 10px;
124}
125.te-preview-cover {
126 width: 56px; height: 56px;
127 border-radius: 8px; overflow: hidden;
128 background: var(--paper);
129 display: inline-flex; align-items: center; justify-content: center;
130 flex-shrink: 0;
131 font-size: 1.4rem; color: var(--accent);
132}
133.te-preview-cover img { width: 100%; height: 100%; object-fit: cover; }
134.te-preview-meta {
135 flex: 1; min-width: 0;
136 display: flex; flex-direction: column; gap: 0.25rem;
137}
138.te-preview-title {
139 font-weight: 600; font-size: 0.95rem;
140 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
141}
142.te-preview-sub {
143 font-size: 0.78rem;
144 color: var(--ink-muted, var(--ink-soft));
145}
146.te-preview-play {
147 width: 44px; height: 44px;
148 border: 1px solid var(--rule);
149 border-radius: 50%;
150 background: var(--paper);
151 color: var(--ink);
152 font-size: 1.1rem;
153 cursor: pointer;
154 display: inline-flex; align-items: center; justify-content: center;
155 flex-shrink: 0;
156 transition: border-color 120ms, color 120ms, background 120ms;
157}
158.te-preview-play:hover { border-color: var(--accent); color: var(--accent); }
159.te-preview-play.is-playing {
160 background: var(--accent); color: white; border-color: var(--accent);
161}
162
163/* ── Form fields ───────────────────────────────────────────── */
164.te-form { display: flex; flex-direction: column; gap: 0.85rem; }
165.te-row { display: grid; gap: 0.85rem; }
166.te-row-2 { grid-template-columns: 1fr 1fr; }
167@media (max-width: 480px) { .te-row-2 { grid-template-columns: 1fr; } }
168
169.te-field { display: flex; flex-direction: column; gap: 0.35rem; min-width: 0; }
170.te-field > span {
171 font-size: 0.8rem; font-weight: 600;
172 color: var(--ink-soft);
173 display: flex; align-items: baseline; gap: 0.4rem;
174}
175.te-field > span small {
176 font-weight: 400; color: var(--ink-muted); font-size: 0.95em;
177}
178.te-required { color: #dc2626; }
179.te-field input[type="text"],
180.te-field input[type="number"],
181.te-field input[type="url"] {
182 width: 100%; box-sizing: border-box;
183 padding: 0.6rem 0.75rem; min-height: 44px;
184 border: 1px solid var(--rule); border-radius: 6px;
185 background: var(--paper-2); color: var(--ink);
186 font-family: var(--font-ui, system-ui), sans-serif;
187 font-size: 0.95rem;
188 -webkit-appearance: none; appearance: none;
189 transition: border-color 120ms;
190}
191.te-field input:focus {
192 outline: 2px solid var(--accent);
193 outline-offset: -1px;
194 border-color: var(--accent);
195}
196
197/* ── Cover field with thumb + button + URL ─────────────────── */
198.te-cover-row {
199 display: grid;
200 grid-template-columns: 80px 1fr;
201 gap: 0.75rem;
202 align-items: start;
203}
204.te-cover-thumb {
205 width: 80px; height: 80px;
206 border-radius: 10px;
207 border: 1px dashed var(--rule);
208 background: var(--paper-2);
209 display: flex; align-items: center; justify-content: center;
210 overflow: hidden;
211 cursor: pointer;
212 transition: border-color 120ms;
213}
214.te-cover-thumb:hover, .te-cover-thumb.is-dragover { border-color: var(--accent); border-style: solid; }
215.te-cover-thumb img { width: 100%; height: 100%; object-fit: cover; }
216.te-cover-empty {
217 font-size: 1.6rem; opacity: 0.5;
218}
219.te-cover-actions {
220 display: flex; flex-direction: column; gap: 0.5rem;
221 min-width: 0;
222}
223.te-cover-btn-row { display: flex; gap: 0.4rem; flex-wrap: wrap; }
224.te-cover-btn {
225 display: inline-flex; align-items: center; gap: 0.35rem;
226 padding: 0.45rem 0.75rem;
227 border: 1px solid var(--rule); border-radius: 6px;
228 background: var(--paper-2); color: var(--ink);
229 font-family: var(--font-ui, system-ui); font-size: 0.85rem;
230 cursor: pointer;
231 transition: border-color 120ms;
232}
233.te-cover-btn:hover { border-color: var(--accent); }
234.te-cover-btn-remove:hover { color: #dc2626; border-color: #dc2626; }
235.te-cover-status {
236 font-size: 0.78rem;
237 color: var(--ink-muted, var(--ink-soft));
238 min-height: 1em;
239}
240.te-cover-status.is-error { color: #dc2626; }
241.te-cover-status.is-ok { color: #2a9d5e; }
242
243/* ── Footer (sticky) ───────────────────────────────────────── */
244.te-footer {
245 display: flex; align-items: center; gap: 0.5rem;
246 padding: 0.85rem 1.25rem;
247 border-top: 1px solid var(--rule);
248 background: var(--paper);
249 flex-shrink: 0;
250}
251.te-footer-spacer { flex: 1; }
252.te-btn {
253 display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem;
254 padding: 0.6rem 1.1rem; min-height: 44px;
255 border: 1px solid var(--rule); border-radius: 8px;
256 background: var(--paper-2); color: var(--ink);
257 font-family: var(--font-ui, system-ui); font-size: 0.9rem; font-weight: 500;
258 cursor: pointer;
259 transition: border-color 120ms, background 120ms;
260 -webkit-tap-highlight-color: transparent;
261}
262.te-btn:hover { border-color: var(--accent); }
263.te-btn:disabled { opacity: 0.5; cursor: not-allowed; }
264.te-btn-primary {
265 background: var(--accent); color: white; border-color: var(--accent);
266}
267.te-btn-primary:hover { opacity: 0.92; }
268
269/* Lock body scroll while modal is open */
270body.te-modal-open { overflow: hidden; }
271</style>
272
273<script>
274(function() {
275
276 function esc(s) {
277 return String(s == null ? '' : s)
278 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
279 .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
280 }
281
282 async function api(method, url, body) {
283 const opts = { method, credentials: 'same-origin', headers: {} };
284 if (body !== undefined) {
285 if (body instanceof FormData) {
286 opts.body = body;
287 } else {
288 opts.headers['Content-Type'] = 'application/json';
289 opts.body = JSON.stringify(body);
290 }
291 }
292 const res = await fetch(url, opts);
293 let data;
294 try { data = await res.json(); }
295 catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); }
296 if (!res.ok) data.ok = false;
297 return data;
298 }
299
300 /**
301 * Open the track editor.
302 * @param {object} opts
303 * @param {string} opts.id — track id to edit
304 * @param {function?} opts.onSaved — called with updated track on success
305 */
306 window.openTrackEditor = async function openTrackEditor({ id, onSaved }) {
307 if (!id) return;
308 let track, albumSuggestions = [];
309 try {
310 const [trackJson, listJson] = await Promise.all([
311 api('GET', '/admin/audio/api/' + encodeURIComponent(id)),
312 api('GET', '/admin/audio/api/albums'),
313 ]);
314 if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden');
315 track = trackJson.track;
316 if (listJson.ok && Array.isArray(listJson.albums)) {
317 albumSuggestions = listJson.albums.filter(Boolean);
318 }
319 } catch (err) {
320 alert('Track ophalen mislukt: ' + err.message);
321 return;
322 }
323
324 const backdrop = document.createElement('div');
325 backdrop.className = 'te-backdrop';
326 backdrop.innerHTML = `
327 <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken">
328 <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div>
329
330 <div class="te-header">
331 <h3>✎ Track bewerken</h3>
332 <button type="button" class="te-close" aria-label="Sluiten">×</button>
333 </div>
334
335 <div class="te-body">
336
337 ${track.stream_url ? `
338 <div class="te-preview">
339 <div class="te-preview-cover">
340 ${track.cover_url
341 ? `<img src="${esc(track.cover_url)}" alt="">`
342 : `🎵`}
343 </div>
344 <div class="te-preview-meta">
345 <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div>
346 <div class="te-preview-sub">
347 ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''}
348 </div>
349 </div>
350 <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button>
351 </div>
352 ` : ''}
353
354 <div class="te-form">
355
356 <label class="te-field">
357 <span>Titel <span class="te-required" aria-hidden="true">*</span></span>
358 <input type="text" id="te-title" maxlength="200" required
359 autocomplete="off" autocapitalize="words" spellcheck="false"
360 value="${esc(track.title || '')}">
361 </label>
362
363 <div class="te-row te-row-2">
364 <label class="te-field">
365 <span>Artiest</span>
366 <input type="text" id="te-artist" maxlength="200"
367 autocomplete="off" autocapitalize="words" spellcheck="false"
368 value="${esc(track.artist || '')}">
369 </label>
370 <label class="te-field">
371 <span>Album</span>
372 <input type="text" id="te-album" maxlength="200"
373 autocomplete="off" autocapitalize="words" spellcheck="false"
374 list="te-album-list" value="${esc(track.album || '')}">
375 <datalist id="te-album-list">
376 ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')}
377 </datalist>
378 </label>
379 </div>
380
381 <label class="te-field">
382 <span>Duur <small>(seconden — bv. 142 voor 2:22)</small></span>
383 <input type="number" id="te-duration" min="0" step="1"
384 inputmode="numeric" pattern="[0-9]*"
385 value="${track.duration || ''}" placeholder="0">
386 </label>
387
388 <div class="te-field">
389 <span>Cover</span>
390 <div class="te-cover-row">
391 <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button"
392 aria-label="Klik om cover te kiezen">
393 ${track.cover_url
394 ? `<img src="${esc(track.cover_url)}" alt="">`
395 : `<span class="te-cover-empty">🎨</span>`}
396 </div>
397 <input type="file" id="te-cover-file"
398 accept="image/jpeg,image/png,image/webp,image/gif" hidden>
399 <div class="te-cover-actions">
400 <div class="te-cover-btn-row">
401 <button type="button" class="te-cover-btn" id="te-cover-pick">
402 📷 Foto kiezen
403 </button>
404 ${track.cover_url ? `
405 <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove">
406 × Verwijder
407 </button>` : ''}
408 </div>
409 <input type="url" id="te-cover-url"
410 placeholder="of plak URL: https://…"
411 autocomplete="off" autocapitalize="none" spellcheck="false"
412 value="${esc(track.cover_url || '')}">
413 <div class="te-cover-status" id="te-cover-status"></div>
414 </div>
415 </div>
416 </div>
417
418 </div>
419 </div>
420
421 <div class="te-footer">
422 <button type="button" class="te-btn" id="te-cancel">Annuleren</button>
423 <div class="te-footer-spacer"></div>
424 <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button>
425 </div>
426 </div>
427 `;
428
429 document.body.appendChild(backdrop);
430 document.body.classList.add('te-modal-open');
431
432 const $ = sel => backdrop.querySelector(sel);
433
434 // ── Inline preview player (routed through global mini-player) ──
435 // We don't build our own <audio>; instead we tell the global
436 // window.pcmsAudioPlayer to load this single track. Visual state
437 // syncs against the global audio element so toggle works correctly
438 // even if the user pauses from the mini-bar.
439 const previewBtn = $('#te-preview-play');
440 if (previewBtn) {
441 const setPreviewState = (playing) => {
442 previewBtn.textContent = playing ? '⏸' : '▶';
443 previewBtn.classList.toggle('is-playing', playing);
444 previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
445 };
446 const isOurTrack = () => {
447 const audio = document.getElementById('audio-element');
448 return audio && track.stream_url && audio.src.endsWith(track.stream_url);
449 };
450 const resync = () => {
451 const audio = document.getElementById('audio-element');
452 const playing = audio && !audio.paused && !audio.ended && isOurTrack();
453 setPreviewState(!!playing);
454 };
455
456 previewBtn.addEventListener('click', () => {
457 const player = window.pcmsAudioPlayer;
458 if (!player || !track.stream_url) {
459 console.warn('preview: miniplayer or url missing');
460 return;
461 }
462 const audio = document.getElementById('audio-element');
463 if (audio && isOurTrack()) {
464 // Same track loaded — toggle
465 if (audio.paused) player.play(); else player.pause();
466 } else {
467 player.setQueue([{
468 url: track.stream_url,
469 title: track.title || '(zonder titel)',
470 artist: track.artist || '',
471 album: track.album || '',
472 cover: track.cover_url || '',
473 }], 0);
474 }
475 });
476
477 const audio = document.getElementById('audio-element');
478 if (audio) {
479 const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied'];
480 evs.forEach(ev => audio.addEventListener(ev, resync));
481 // Detach listeners on close so we don't leak them
482 backdrop._previewCleanup = () => {
483 evs.forEach(ev => audio.removeEventListener(ev, resync));
484 };
485 resync(); // initial state
486 }
487 }
488
489 function close() {
490 // Detach our resync listeners (mini-player stays running)
491 if (backdrop._previewCleanup) backdrop._previewCleanup();
492 document.body.classList.remove('te-modal-open');
493 document.removeEventListener('keydown', onEsc);
494 backdrop.remove();
495 }
496 function onEsc(e) { if (e.key === 'Escape') close(); }
497 document.addEventListener('keydown', onEsc);
498
499 backdrop.addEventListener('click', e => {
500 // Click on the backdrop itself (not modal content) closes
501 if (e.target === backdrop) close();
502 });
503 $('.te-close').addEventListener('click', close);
504 $('#te-cancel').addEventListener('click', close);
505
506 // ── Cover picking + URL paste + drag-drop ────────────────
507 const thumb = $('#te-cover-thumb');
508 const fileInput = $('#te-cover-file');
509 const urlInput = $('#te-cover-url');
510 const status = $('#te-cover-status');
511 const pickBtn = $('#te-cover-pick');
512 const removeBtn = $('#te-cover-remove');
513
514 function setStatus(text, kind) {
515 status.textContent = text || '';
516 status.className = 'te-cover-status' + (kind ? ' is-' + kind : '');
517 }
518 function setThumb(url) {
519 if (url) {
520 thumb.innerHTML = `<img src="${esc(url)}" alt="">`;
521 } else {
522 thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`;
523 }
524 }
525
526 pickBtn.addEventListener('click', () => fileInput.click());
527 thumb.addEventListener('click', () => fileInput.click());
528 thumb.addEventListener('keydown', e => {
529 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
530 });
531
532 async function uploadCoverFile(file) {
533 if (!file) return;
534 if (!/^image\//.test(file.type)) {
535 setStatus('Alleen afbeeldingen toegestaan', 'error'); return;
536 }
537 setStatus('Uploaden…', null);
538 const fd = new FormData();
539 fd.append('cover', file);
540 try {
541 const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', {
542 method: 'POST', body: fd, credentials: 'same-origin',
543 });
544 const j = await r.json();
545 if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
546 urlInput.value = j.cover_url || '';
547 setThumb(j.cover_url);
548 setStatus('✓ Geüpload', 'ok');
549 } catch (err) {
550 setStatus('Mislukt: ' + err.message, 'error');
551 }
552 }
553
554 fileInput.addEventListener('change', e => {
555 const f = e.target.files && e.target.files[0];
556 if (f) uploadCoverFile(f);
557 fileInput.value = '';
558 });
559
560 // Drag-drop on thumb (desktop nicety)
561 ['dragenter', 'dragover'].forEach(ev =>
562 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); }));
563 ['dragleave', 'drop'].forEach(ev =>
564 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); }));
565 thumb.addEventListener('drop', e => {
566 const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
567 if (f) uploadCoverFile(f);
568 });
569
570 // URL paste auto-preview
571 urlInput.addEventListener('input', () => {
572 const v = urlInput.value.trim();
573 setThumb(v);
574 });
575
576 if (removeBtn) {
577 removeBtn.addEventListener('click', () => {
578 urlInput.value = '';
579 setThumb('');
580 removeBtn.remove();
581 });
582 }
583
584 // ── Save ────────────────────────────────────────────────
585 $('#te-save').addEventListener('click', async () => {
586 const titleEl = $('#te-title');
587 const title = titleEl.value.trim();
588 if (!title) {
589 titleEl.focus();
590 alert('Titel is verplicht');
591 return;
592 }
593 const saveBtn = $('#te-save');
594 saveBtn.disabled = true;
595 saveBtn.textContent = '⏳ Opslaan…';
596
597 try {
598 const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), {
599 title,
600 artist: $('#te-artist').value.trim() || null,
601 album: $('#te-album').value.trim() || null,
602 duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
603 cover_url: urlInput.value.trim() || null,
604 });
605 if (!j.ok) throw new Error(j.error || 'Opslaan mislukt');
606 if (typeof onSaved === 'function') onSaved(j.track || { id, title,
607 artist: $('#te-artist').value.trim() || null,
608 album: $('#te-album').value.trim() || null,
609 cover_url: urlInput.value.trim() || null });
610 close();
611 } catch (err) {
612 alert('Opslaan mislukt: ' + err.message);
613 saveBtn.disabled = false;
614 saveBtn.textContent = '💾 Opslaan';
615 }
616 });
617
618 // Focus title for fast typing
619 setTimeout(() => $('#te-title').focus(), 60);
620 };
621})();
622</script>
Note: See TracBrowser for help on using the repository browser.