Changeset 952baf3 in Klonkt for src/views/partials
- Timestamp:
- 08/07/2026 05:15:52 PM (5 weeks ago)
- 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. - Location:
- src/views/partials
- Files:
-
- 1 added
- 7 edited
-
bottom-tab.ejs (modified) (1 diff)
-
msg-item.ejs (modified) (7 diffs)
-
page-data.ejs (added)
-
playlist-editor.ejs (modified) (1 diff)
-
profile-header.ejs (modified) (1 diff)
-
profile-sheet.ejs (modified) (2 diffs)
-
topnav.ejs (modified) (2 diffs)
-
track-editor.ejs (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/views/partials/bottom-tab.ejs
rf85b2c3 r952baf3 302 302 </style> 303 303 304 <script> 305 // Bottom-tab search button → opens #search-overlay. Using event delegation + looking 306 // up the overlay on EVERY click: the overlay lives in the chrome that is OOB-replaced 307 // during htmx navigation, so a once-captured reference goes stale 308 // (button did nothing afterwards). Guard against double-wiring. 309 (function() { 310 if (window.__btSearchWired) return; 311 window.__btSearchWired = true; 312 document.addEventListener('click', function(e) { 313 if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return; 314 var o = document.getElementById('search-overlay'); 315 if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } 316 }); 317 })(); 318 </script> 304 <%# De zoekknop zit nu in de chrome-module (assets/js/mod/chrome.js). 305 Inline script hier wordt door de CSP geweigerd zodra deze partial via htmx 306 binnenkomt, en dat is bij elke navigatie -- zie shaer-0i6. %> -
src/views/partials/msg-item.ejs
rf85b2c3 r952baf3 1 <% if (n.type === 'thread') { %> 2 <% 3 var _tseen = (typeof seen !== 'undefined' && seen) ? seen : 0; 4 // Een draad is nieuw zodra EEN bericht erin nieuw is. 5 var _tnew = !!_tseen && (n.messages || []).some(function (m) { 6 return m.created_at && Date.parse(m.created_at) > _tseen; 7 }); 8 var _tpeople = (n.people || []).map(function (p) { return p.name || p.handle || '?'; }); 9 // Alleen jij aan het woord: dan is de tegenpartij het adres waaraan 10 // je schreef, niet een lege naam. 11 var _tto = ((n.messages || []).find(function (m) { return m.to_handle; }) || {}).to_handle; 12 var _tlabel = _tpeople.length ? _tpeople.join(', ') : (_tto || t('msg.you')); 13 %> 14 <li class="msg-item msg-thread<%= _tnew ? ' is-new' : '' %>" data-kind="conv" data-who="<%= (_tpeople.join(' ') + ' ' + (_tto || '')).toLowerCase() %>"> 15 <div class="msg-thread-head"> 16 <%# Tegenpartij en aantal in EEN uitsparing: het is een label bij het 17 gesprek, geen kop erboven. Zo houdt de inhoud de ruimte. 18 19 Deze uitsparing is ook de knop die het gesprek in- en uitklapt 20 (Barts aanwijzing): tikken op de tegenpartij is waar je van 21 nature naar wijst als je "dit gesprek" bedoelt. Een BUTTON en 22 geen span met een klikhandler, want dan werkt hij ook met het 23 toetsenbord en leest een schermlezer de stand voor. 24 25 Zonder JavaScript doet hij niets, en dan staat het gesprek 26 gewoon open -- dat is ook de stand waar we mee beginnen. %> 27 <button type="button" class="msg-thread-who" aria-expanded="true"> 28 <span class="msg-thread-name"><%= _tlabel %></span> 29 <% if (n.count > 1) { %><span class="msg-thread-count"><%= n.count %></span><% } %> 30 <svg class="msg-thread-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg> 31 </button> 32 <% if (_tnew) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %> 33 <%# De context bij de draad: waar gaat dit gesprek over? Zonder deze 34 link is een antwoord in een lijst niet te plaatsen. %> 35 <% if (n.post) { %> 36 <a class="msg-thread-post" href="/<%= n.post.slug %>"> 37 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> 38 <%= n.post.title || n.post.slug %> 39 </a> 40 <% } %> 41 </div> 42 <ul class="msg-thread-msgs"> 43 <% (n.messages || []).forEach(function (m) { %><%- include('msg-item', { n: m, seen: _tseen, inThread: true }) %><% }); %> 44 </ul> 45 <%# Antwoorden vanuit de draad zelf. Ingeklapt, want een open editor 46 onder elk gesprek maakt de lijst onleesbaar -- juist het probleem 47 dat deze weergave moest oplossen. De route kiest het pad: een 48 draad aan een post krijgt een reply, een draad aan een persoon een 49 direct bericht. %> 50 <% var _rt = n.replyTo || {}; var _kan = canMutate && ((_rt.interactionId && _rt.postSlug) || _rt.actorUri); %> 51 <% if (_kan) { %> 52 <div class="msg-thread-foot"> 53 <details class="msg-reply"> 54 <summary><%= t('fedi.reply') %></summary> 55 <div class="msg-reply-form"> 56 <%- include('../partials/reply-editor', { 57 action: (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') + '/messages/reply', 58 hiddenFields: [ 59 { name: 'interaction_id', value: _rt.interactionId || '' }, 60 { name: 'post_slug', value: _rt.postSlug || '' }, 61 { name: 'to', value: _rt.actorUri || '' }, 62 ], 63 placeholder: t('fedi.reply_ph'), 64 submitLabel: t('fedi.reply'), 65 rows: 2, 66 }) %> 67 </div> 68 </details> 69 <%# De zwaai blijft een eigen knop: het is geen antwoord maar een 70 seintje (FEP-633c), en die twee horen niet op een hoop. %> 71 <% if (_rt.actorUri) { %> 72 <form method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" class="msg-wave"> 73 <input type="hidden" name="to" value="<%= _rt.actorUri %>"> 74 <button class="btn small" name="text" value="👋" title="<%= t('msg.wave_back') %>">👋</button> 75 </form> 76 <% } %> 77 </div> 78 <% } %> 79 </li> 80 <% } else { %> 1 81 <% 2 82 var _seen = (typeof seen !== 'undefined' && seen) ? seen : 0; … … 5 85 var _who = n.name || n.handle || ''; 6 86 var _priv0 = (n.visibility === 'followers' || n.visibility === 'direct'); 7 // Buckets (klonkt-demo-3jf): Messages = @mentions + private (DM) replies; 8 // Activity = likes/boosts/follows; Moderation = reports; Sent = your 9 // replies; Conversations = every other (public) reply. 10 var _kind = _t === 'sent' ? 'sent' 11 : (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act' 87 // Emmers: Activiteit = likes/boosts/follows/polls, Moderatie = 88 // reports, en al het overige is gesprek. De oude emmers 'msgs' en 89 // 'sent' bestaan niet meer sinds Berichten, Gesprekken en Verzonden 90 // samenvielen -- en dat is geen cosmetische hernoeming: een 91 // gespreksregel die om wat voor reden dan ook geen draad werd (geen 92 // post, geen herkenbare tegenpartij) hield anders een data-kind 93 // waarvoor geen chip meer bestond, en was daarmee onder ELKE chip 94 // behalve Alles onzichtbaar. 95 var _kind = (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act' 12 96 : _t === 'report' ? 'mod' 13 : (_t === 'mention' || _priv0) ? 'msgs'14 97 : 'conv'; 98 // Binnen een draad is dit geen zelfstandige regel: de chip filtert op 99 // de draad, niet op de losse zinnen erin. 100 var _sub = (typeof inThread !== 'undefined' && inThread); 15 101 var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase(); 16 102 var _priv = (n.visibility === 'followers' || n.visibility === 'direct'); 17 103 %> 18 <li class=" msg-item msg-<%= _t %><%= _new ? ' is-new' : '' %>" data-kind="<%= _kind %>"data-who="<%= String(_who).toLowerCase() %>">104 <li class="<%= _sub ? 'msg-sub' : 'msg-item' %> msg-<%= _t %><%= _new ? ' is-new' : '' %>"<% if (!_sub) { %> data-kind="<%= _kind %>"<% } %> data-who="<%= String(_who).toLowerCase() %>"> 19 105 <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true"> 20 106 <% if (_t === 'sent') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> … … 38 124 <% if (_t === 'sent') { %> 39 125 <span class="msg-who"><%= t('msg.you') %></span> 40 <% if (n.to_handle ) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>126 <% if (n.to_handle && !_sub) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %> 41 127 <% } else { %> 42 128 <a class="msg-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%- emojiName(_who, n.actor_emoji_json) %></a> 43 <% if (n.handle && n.name) { %><span class="msg-handle"><%= n.handle %></span><% } %> 129 <% /* De naam blijft: binnen een draad moet je zien WIE wat zei. 130 De handle erachter niet -- die staat in de kop. */ %> 131 <% if (n.handle && n.name && !_sub) { %><span class="msg-handle"><%= n.handle %></span><% } %> 44 132 <% } %> 45 133 <% if (_new) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %> … … 50 138 </div> 51 139 140 <% var _hasWhat = !_sub || _priv 141 || (_t === 'mention' && (n.help_request || n.wave || n.note_url)) 142 || (_t === 'sent' && n.in_reply_to); %> 143 <% if (_hasWhat) { %> 52 144 <div class="msg-what"> 53 145 <% if (n.count && n.count > 1) { %> … … 60 152 <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %> 61 153 <% } else if (_t === 'mention' && n.wave) { %>👋 <%= t('msg.waved_at_you') %> 154 <% } else if (_sub) { %><%# binnen een draad zegt de kop al dat dit een gesprek is %> 62 155 <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %> 63 156 <% } else if (_t === 'sent') { %><%= t('msg.sent_reply') %> … … 65 158 <% } else { %><%= t('notif.replied') %><% } %> 66 159 <% if (_priv) { %><span class="msg-priv" title="<%= t('msg.private_hint') %>">🔒 <%= t('msg.private') %></span><% } %> 67 <% if (n.post_slug ) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>160 <% if (n.post_slug && !_sub) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %> 68 161 <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %> 69 162 <% if (_t === 'sent' && n.in_reply_to) { %><a class="msg-post" href="<%= n.in_reply_to %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %> 70 163 </div> 164 <% } %> 71 165 72 166 <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div> … … 116 210 </div> 117 211 </li> 212 <% } %> -
src/views/partials/playlist-editor.ejs
rf85b2c3 r952baf3 280 280 </style> 281 281 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 '&':'&','<':'<','>':'>','"':'"',"'":''' 293 }[c])); 294 } 295 function fmtDur(sec) { 296 if (!sec) return ''; 297 const m = Math.floor(sec / 60), s = sec % 60; 298 return `${m}:${String(s).padStart(2, '0')}`; 299 } 300 301 async function api(method, url, body) { 302 const opts = { 303 method, credentials: 'same-origin', 304 headers: { 'X-CSRF-Token': CSRF }, 305 }; 306 if (body !== undefined) { 307 opts.headers['Content-Type'] = 'application/json'; 308 opts.body = JSON.stringify(body); 309 } 310 const r = await fetch(url, opts); 311 return r.json(); 312 } 313 314 /** 315 * Public entry point: open the editor. 316 * opts: { mode: 'create'|'edit', id?, onSaved? } 317 * onSaved is called with { id, playlist } after a successful save. 318 */ 319 window.openPlaylistEditor = async function(opts) { 320 opts = opts || {}; 321 const mode = opts.mode === 'edit' ? 'edit' : 'create'; 322 const isEdit = mode === 'edit'; 323 324 // Load all audio tracks for the picker 325 let tracks = []; 326 try { 327 const j = await api('GET', '/admin/playlists/api/tracks'); 328 if (Array.isArray(j.tracks)) tracks = j.tracks; 329 } catch (e) { 330 alert('Tracks ophalen mislukt'); 331 return; 332 } 333 if (!tracks.length) { 334 alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.'); 335 return; 336 } 337 338 // Existing playlist data when editing 339 let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] }; 340 if (isEdit && opts.id) { 341 try { 342 const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id)); 343 if (j.ok) initial = { ...initial, ...j.playlist }; 344 } catch (e) {} 345 } 346 347 // ── DOM ──────────────────────────────────────────────────────── 348 const backdrop = document.createElement('div'); 349 backdrop.className = 'pl-modal-backdrop'; 350 backdrop.innerHTML = ` 351 <div class="pl-modal" role="dialog" aria-label="Playlist editor"> 352 <div class="pl-modal-header"> 353 <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3> 354 <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button> 355 </div> 356 <div class="pl-modal-body"> 357 <div class="pl-editor-cols"> 358 <div class="pl-editor-left"> 359 <div class="pl-meta-grid"> 360 <label class="pl-field pl-field-full"> 361 <span>Titel *</span> 362 <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}"> 363 </label> 364 <label class="pl-field"> 365 <span>Artiest</span> 366 <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}"> 367 </label> 368 <label class="pl-field"> 369 <span>Jaar</span> 370 <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}"> 371 </label> 372 <label class="pl-field"> 373 <span>Type</span> 374 <select id="pli-kind"> 375 <option value="album" ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option> 376 <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option> 377 </select> 378 </label> 379 <div class="pl-field pl-field-full"> 380 <span>Cover</span> 381 <div class="pl-cover-row"> 382 <span class="pl-cover-thumb" id="pli-cover-thumb"> 383 ${initial.cover 384 ? `<img src="${esc(initial.cover)}" alt="">` 385 : `<span class="pl-cover-empty">🎨</span>`} 386 </span> 387 <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1"> 388 </div> 389 <div class="pl-cover-upload-row"> 390 <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden> 391 <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button> 392 <span class="pl-cover-status" id="pli-cover-status"></span> 393 </div> 394 </div> 395 </div> 396 <div class="pl-section-title"> 397 Tracks in playlist <span class="pl-track-count" id="pli-count">0</span> 398 <small>(sleep ⠿ om te ordenen)</small> 399 </div> 400 <div id="pli-selected" class="pl-selected-list"></div> 401 </div> 402 <div class="pl-editor-right"> 403 <div class="pl-section-title">Beschikbare tracks</div> 404 <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input"> 405 <div id="pli-available" class="pl-available-list"></div> 406 </div> 407 </div> 408 </div> 409 <div class="pl-modal-footer"> 410 <button type="button" class="btn" id="pli-cancel">Annuleren</button> 411 <button type="button" class="btn btn-primary" id="pli-save" disabled> 412 ${isEdit ? 'Opslaan' : 'Aanmaken'} 413 </button> 414 </div> 415 </div> 416 `; 417 document.body.appendChild(backdrop); 418 419 const $ = sel => backdrop.querySelector(sel); 420 const close = () => backdrop.remove(); 421 backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }); 422 $('.pl-modal-close').addEventListener('click', close); 423 $('#pli-cancel').addEventListener('click', close); 424 425 // Index tracks by id for fast lookup 426 const trackById = new Map(tracks.map(t => [t.id, t])); 427 let selected = (initial.track_ids || []).filter(id => trackById.has(id)); 428 429 const titleEl = $('#pli-title'); 430 const artistEl = $('#pli-artist'); 431 const yearEl = $('#pli-year'); 432 const kindEl = $('#pli-kind'); 433 const coverEl = $('#pli-cover'); 434 const coverThumb = $('#pli-cover-thumb'); 435 const saveBtn = $('#pli-save'); 436 const selectedEl = $('#pli-selected'); 437 const availEl = $('#pli-available'); 438 const searchEl = $('#pli-search'); 439 const countEl = $('#pli-count'); 440 441 coverEl.addEventListener('input', () => { 442 const u = coverEl.value.trim(); 443 coverThumb.innerHTML = u 444 ? `<img src="${esc(u)}" alt="" data-fallback>` 445 : `<span class="pl-cover-empty">🎨</span>`; 446 }); 447 448 // ── Cover file upload (werkt in create ÉN edit) ─────────── 449 // We uploaden naar het generieke image-endpoint (/posts/upload-image, 450 // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id 451 // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het 452 // cover-veld en wordt bij 'Aanmaken'/'Opslaan' met de playlist meegestuurd. 453 const coverFileInput = $('#pli-cover-file'); 454 const coverPickBtn = $('#pli-cover-pick'); 455 const coverStatus = $('#pli-cover-status'); 456 if (coverFileInput && coverPickBtn) { 457 coverPickBtn.addEventListener('click', () => coverFileInput.click()); 458 coverFileInput.addEventListener('change', async (e) => { 459 const file = e.target.files && e.target.files[0]; 460 coverFileInput.value = ''; 461 if (!file) return; 462 if (!/^image\//.test(file.type)) { 463 coverStatus.textContent = 'Alleen afbeeldingen'; 464 coverStatus.className = 'pl-cover-status is-error'; 465 return; 466 } 467 coverStatus.textContent = 'Uploaden…'; 468 coverStatus.className = 'pl-cover-status'; 469 const fd = new FormData(); 470 fd.append('image', file); 471 try { 472 const r = await fetch('/posts/upload-image', 473 { method: 'POST', body: fd, credentials: 'same-origin' } 474 ); 475 const j = await r.json(); 476 if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt'); 477 const url = j.url || ''; 478 coverEl.value = url; 479 coverThumb.innerHTML = url 480 ? `<img src="${esc(url)}" alt="">` 481 : `<span class="pl-cover-empty">🎨</span>`; 482 coverStatus.textContent = '✓ Geüpload'; 483 coverStatus.className = 'pl-cover-status is-ok'; 484 } catch (err) { 485 coverStatus.textContent = 'Mislukt: ' + err.message; 486 coverStatus.className = 'pl-cover-status is-error'; 487 } 488 }); 489 } 490 491 function updateSaveBtn() { 492 saveBtn.disabled = !titleEl.value.trim() || selected.length === 0; 493 } 494 495 function renderSelected() { 496 countEl.textContent = selected.length; 497 if (selected.length === 0) { 498 selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>'; 499 updateSaveBtn(); 500 return; 501 } 502 selectedEl.innerHTML = selected.map((id, i) => { 503 const t = trackById.get(id); 504 if (!t) return ''; 505 const cover = t.cover 506 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>` 507 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`; 508 return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}"> 509 <span class="pl-row-handle" aria-label="Verslepen">⠿</span> 510 <span class="pl-row-num">${i + 1}</span> 511 ${cover} 512 <span class="pl-row-info"> 513 <span class="pl-row-title">${esc(t.title)}</span> 514 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''} 515 </span> 516 <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button> 517 </div>`; 518 }).join(''); 519 520 selectedEl.querySelectorAll('.pl-row-x').forEach(b => { 521 b.addEventListener('click', () => { 522 selected = selected.filter(x => x !== b.dataset.id); 523 renderSelected(); 524 renderAvailable(); 525 }); 526 }); 527 bindDrag(); 528 updateSaveBtn(); 529 } 530 531 function renderAvailable() { 532 const q = searchEl.value.trim().toLowerCase(); 533 const matches = tracks.filter(t => { 534 if (!q) return true; 535 return (t.title || '').toLowerCase().includes(q) 536 || (t.artist || '').toLowerCase().includes(q); 537 }); 538 if (matches.length === 0) { 539 availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>'; 540 return; 541 } 542 availEl.innerHTML = matches.map(t => { 543 const isAdded = selected.includes(t.id); 544 const cls = ['pl-avail-row']; 545 if (isAdded) cls.push('is-added'); 546 if (!t.playable) cls.push('is-unplayable'); 547 const cover = t.cover 548 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>` 549 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`; 550 return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}> 551 ${cover} 552 <span class="pl-row-info"> 553 <span class="pl-row-title">${esc(t.title)}</span> 554 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''} 555 </span> 556 <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span> 557 </div>`; 558 }).join(''); 559 560 availEl.querySelectorAll('.pl-avail-row').forEach(row => { 561 if (row.classList.contains('is-unplayable')) return; 562 row.addEventListener('click', () => { 563 const id = row.dataset.id; 564 if (selected.includes(id)) selected = selected.filter(x => x !== id); 565 else selected.push(id); 566 renderSelected(); 567 renderAvailable(); 568 }); 569 }); 570 } 571 572 // Pointer-based drag-to-reorder. Same pattern as v9's admin.js. 573 function bindDrag() { 574 selectedEl.querySelectorAll('.pl-row').forEach(row => { 575 const handle = row.querySelector('.pl-row-handle'); 576 if (!handle) return; 577 let dragging = false, originalIdx = -1; 578 579 handle.addEventListener('pointerdown', e => { 580 e.preventDefault(); 581 handle.setPointerCapture(e.pointerId); 582 dragging = true; 583 originalIdx = parseInt(row.dataset.pos, 10); 584 row.classList.add('is-dragging'); 585 }); 586 handle.addEventListener('pointermove', e => { 587 if (!dragging) return; 588 e.preventDefault(); 589 const rows = Array.from(selectedEl.querySelectorAll('.pl-row')); 590 rows.forEach(r => r.classList.remove('drop-above', 'drop-below')); 591 let targetIdx = -1, above = false; 592 for (let i = 0; i < rows.length; i++) { 593 const r = rows[i]; if (r === row) continue; 594 const rect = r.getBoundingClientRect(); 595 const mid = rect.top + rect.height / 2; 596 if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; } 597 if (e.clientY >= rect.top && e.clientY <= rect.bottom) { 598 targetIdx = i; above = e.clientY < mid; break; 599 } 600 } 601 if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below'); 602 }); 603 const finish = e => { 604 if (!dragging) return; 605 dragging = false; 606 try { handle.releasePointerCapture(e.pointerId); } catch (_) {} 607 row.classList.remove('is-dragging'); 608 const rows = Array.from(selectedEl.querySelectorAll('.pl-row')); 609 let targetIdx = -1, above = false; 610 for (let i = 0; i < rows.length; i++) { 611 if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; } 612 if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; } 613 } 614 rows.forEach(r => r.classList.remove('drop-above', 'drop-below')); 615 if (targetIdx === -1 || targetIdx === originalIdx) return; 616 const moved = selected[originalIdx]; 617 selected.splice(originalIdx, 1); 618 let newIdx = targetIdx; 619 if (originalIdx < targetIdx) newIdx--; 620 if (!above) newIdx++; 621 newIdx = Math.max(0, Math.min(selected.length, newIdx)); 622 selected.splice(newIdx, 0, moved); 623 renderSelected(); 624 }; 625 handle.addEventListener('pointerup', finish); 626 handle.addEventListener('pointercancel', () => { 627 dragging = false; 628 row.classList.remove('is-dragging'); 629 selectedEl.querySelectorAll('.drop-above, .drop-below') 630 .forEach(r => r.classList.remove('drop-above', 'drop-below')); 631 }); 632 }); 633 } 634 635 titleEl.addEventListener('input', updateSaveBtn); 636 searchEl.addEventListener('input', renderAvailable); 637 renderSelected(); 638 renderAvailable(); 639 640 saveBtn.addEventListener('click', async () => { 641 if (saveBtn.disabled) return; 642 const orig = saveBtn.textContent; 643 saveBtn.disabled = true; 644 saveBtn.textContent = 'Opslaan...'; 645 646 const payload = { 647 title: titleEl.value.trim(), 648 artist: artistEl.value.trim(), 649 year: parseInt(yearEl.value, 10) || 0, 650 cover: coverEl.value.trim(), 651 kind: kindEl.value === 'playlist' ? 'playlist' : 'album', 652 tracks: selected.slice(), 653 }; 654 655 try { 656 const url = isEdit 657 ? '/admin/playlists/api/' + encodeURIComponent(initial.id) 658 : '/admin/playlists/api'; 659 const j = await api('POST', url, payload); 660 if (!j.ok) { 661 alert('Opslaan mislukt: ' + (j.error || 'onbekend')); 662 saveBtn.disabled = false; 663 saveBtn.textContent = orig; 664 return; 665 } 666 const savedId = isEdit ? initial.id : j.id; 667 close(); 668 if (typeof opts.onSaved === 'function') { 669 opts.onSaved({ id: savedId, playlist: payload }); 670 } 671 } catch (err) { 672 alert('Opslaan mislukt: ' + err.message); 673 saveBtn.disabled = false; 674 saveBtn.textContent = orig; 675 } 676 }); 677 678 // ESC to close 679 document.addEventListener('keydown', function onEsc(e) { 680 if (e.key === 'Escape' && document.body.contains(backdrop)) { 681 close(); 682 document.removeEventListener('keydown', onEsc); 683 } 684 }); 685 }; 686 })(); 687 </script> 282 <%# Het script staat in assets/js/mod/playlist-editor.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> -
src/views/partials/profile-header.ejs
rf85b2c3 r952baf3 160 160 </div> 161 161 <% } %> 162 <script> 163 (function () { 164 if (window.__pcmsFediFollowWired) return; 165 window.__pcmsFediFollowWired = true; 166 function modal() { return document.querySelector('.pf-follow'); } 167 function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); } 168 function go() { 169 var m = modal(); if (!m) return; 170 var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : ''; 171 // Strip a full @user@host handle (and any scheme/path) down to the server host. 172 var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim(); 173 if (!server) { if (inp) inp.focus(); return; } 174 try { localStorage.setItem('pcmsFediServer', server); } catch (e) {} 175 var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || ''); 176 location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor); 177 } 178 document.addEventListener('click', function (e) { 179 var b = e.target.closest && e.target.closest('.profile-fedi-link'); 180 if (b) { e.preventDefault(); 181 var m = modal(); if (!m) return; 182 m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || ''); 183 var inp = m.querySelector('.pf-follow-input'); 184 try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {} 185 m.classList.add('is-open'); 186 setTimeout(function () { if (inp) inp.focus(); }, 30); 187 return; 188 } 189 if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; } 190 if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; } 191 var open = document.querySelector('.pf-follow.is-open'); 192 if (open && e.target === open) closeM(); /* click on backdrop */ 193 }); 194 document.addEventListener('keydown', function (e) { 195 var m = document.querySelector('.pf-follow.is-open'); if (!m) return; 196 if (e.key === 'Escape') { closeM(); } 197 else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); } 198 }); 199 })(); 200 201 /* Profile summary: click the avatar to open a modal with photo + details. */ 202 (function () { 203 if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true; 204 function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); } 205 document.addEventListener('click', function (e) { 206 if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; } 207 var open = document.querySelector('.pf-summary.is-open'); 208 if (open && e.target === open) { close(); return; } /* click on backdrop */ 209 var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]'); 210 if (!t) return; 211 e.preventDefault(); 212 var root = (t.closest && t.closest('#pcms-chrome')) || document; 213 var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary'); 214 if (modal) modal.classList.add('is-open'); 215 }); 216 document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); }); 217 })(); 218 </script> 162 <%# Het script van de profielkop staat in assets/js/mod/chrome.js: deze partial 163 zit in de OOB-chrome en wordt bij elke htmx-navigatie opnieuw ingevoegd 164 (shaer-bqr). %> 219 165 <% } %> 220 166 -
src/views/partials/profile-sheet.ejs
rf85b2c3 r952baf3 105 105 <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg> 106 106 <span class="psi-label"><%= t('nav.theme_label') %></span> 107 <span class="psi-meta" id="profile-sheet-theme-state" ><%= t('nav.dark') %></span>107 <span class="psi-meta" id="profile-sheet-theme-state" data-dark="<%= t('nav.dark') %>" data-light="<%= t('nav.light') %>"><%= t('nav.dark') %></span> 108 108 </button> 109 109 </li> … … 326 326 </style> 327 327 328 <script> 329 (function() { 330 const sheet = document.getElementById('profile-sheet'); 331 if (!sheet) return; 332 333 const backdrop = document.getElementById('profile-sheet-backdrop'); 334 const panel = sheet.querySelector('.profile-sheet-panel'); 335 const closeBtn = document.getElementById('profile-sheet-close'); 336 const dragZone = document.getElementById('profile-sheet-drag-zone'); 337 const themeBtn = document.getElementById('profile-sheet-theme'); 338 const themeLbl = document.getElementById('profile-sheet-theme-state'); 339 340 function openSheet() { 341 sheet.classList.add('is-open'); 342 sheet.setAttribute('aria-hidden', 'false'); 343 document.body.classList.add('profile-sheet-locked'); 344 syncTheme(); 345 } 346 function closeSheet() { 347 sheet.classList.remove('is-open'); 348 sheet.setAttribute('aria-hidden', 'true'); 349 document.body.classList.remove('profile-sheet-locked'); 350 panel.style.removeProperty('--pcms-drag-y'); 351 } 352 353 // Open: any element with [data-profile-sheet-toggle] 354 document.addEventListener('click', function(e) { 355 const trigger = e.target.closest('[data-profile-sheet-toggle]'); 356 if (trigger) { 357 e.preventDefault(); 358 openSheet(); 359 } 360 }); 361 362 // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item 363 if (backdrop) backdrop.addEventListener('click', closeSheet); 364 if (closeBtn) closeBtn.addEventListener('click', closeSheet); 365 document.addEventListener('keydown', (e) => { 366 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet(); 367 }); 368 369 // Menu items that navigate: close synchronously before nav fires 370 sheet.querySelectorAll('[data-close-sheet]').forEach((el) => { 371 el.addEventListener('click', closeSheet); 372 }); 373 374 // Drag-down-to-close on touch. 375 // Uses --pcms-drag-y custom property rather than overwriting the panel's 376 // transform string. This composes correctly with any horizontal centering 377 // (currently none here, but the pattern matches audio-sheet for safety). 378 let startY = 0, lastY = 0, dragging = false; 379 function onDown(e) { 380 if (e.pointerType !== 'touch') return; 381 if (panel.scrollTop > 0) return; 382 startY = lastY = e.clientY; 383 dragging = true; 384 panel.classList.add('is-dragging'); 385 } 386 function onMove(e) { 387 if (!dragging) return; 388 lastY = e.clientY; 389 const dy = Math.max(0, lastY - startY); 390 panel.style.setProperty('--pcms-drag-y', dy + 'px'); 391 } 392 function onUp() { 393 if (!dragging) return; 394 dragging = false; 395 panel.classList.remove('is-dragging'); 396 const dy = lastY - startY; 397 if (dy > 80) closeSheet(); 398 else panel.style.removeProperty('--pcms-drag-y'); 399 } 400 if (window.PointerEvent && dragZone) { 401 dragZone.addEventListener('pointerdown', onDown); 402 document.addEventListener('pointermove', onMove); 403 document.addEventListener('pointerup', onUp); 404 document.addEventListener('pointercancel', onUp); 405 } 406 407 // Theme toggle inside sheet — syncs label 408 function syncTheme() { 409 if (!themeLbl) return; 410 const t = document.documentElement.getAttribute('data-theme') || 'dark'; 411 themeLbl.textContent = t === 'dark' ? '<%= t('nav.dark') %>' : '<%= t('nav.light') %>'; 412 } 413 if (themeBtn) { 414 themeBtn.addEventListener('click', function() { 415 const cur = document.documentElement.getAttribute('data-theme') || 'dark'; 416 const next = cur === 'dark' ? 'light' : 'dark'; 417 document.documentElement.setAttribute('data-theme', next); 418 try { localStorage.setItem('pcms-theme', next); } catch (e) {} 419 syncTheme(); 420 }); 421 } 422 })(); 423 </script> 328 <%# Het script van het profielblad staat in assets/js/mod/chrome.js (shaer-bqr). %> -
src/views/partials/topnav.ejs
rf85b2c3 r952baf3 176 176 177 177 <!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) --> 178 <div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search"> 178 <%# De teksten staan op het element dat ze gebruikt, niet in het script: een 179 module is een statisch bestand en kan geen vertaling interpoleren. %> 180 <div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search" 181 data-i18n="<%= JSON.stringify({ posts: t('search.section_posts'), tracks: t('search.section_tracks'), events: t('search.section_events'), pages: t('search.section_pages'), all: t('search.suggest_all'), none: t('search.suggest_empty') }) %>"> 179 182 <div class="container"> 180 183 <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search"> … … 191 194 </div> 192 195 193 <script> 194 (function() { 195 // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation 196 // → without this guard the script would stack EXTRA listeners on every navigation, 197 // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working"). 198 // Everything below uses event delegation on body/document, so it also works for 199 // buttons that appear after this run (OOB). 200 if (window.__pcmsChromeWired) return; 201 window.__pcmsChromeWired = true; 202 203 function toggleTheme() { 204 var cur = document.documentElement.getAttribute('data-theme') || 'dark'; 205 var next = cur === 'dark' ? 'light' : 'dark'; 206 document.documentElement.setAttribute('data-theme', next); 207 try { localStorage.setItem('pcms-theme', next); } catch (e) {} 208 } 209 function overlay() { return document.getElementById('search-overlay'); } 210 function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } } 211 function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } } 212 213 document.body.addEventListener('click', function(e) { 214 if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; } 215 if (e.target.closest('#search-toggle')) { openSearch(); return; } 216 if (e.target.closest('#search-close')) { closeSearch(); return; } 217 // Close open dropdowns (user menu + language picker) on click outside. 218 document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) { 219 if (!d.contains(e.target)) d.removeAttribute('open'); 220 }); 221 }); 222 223 document.addEventListener('keydown', function(e) { 224 if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); } 225 }); 226 227 // ── Live results while typing ─────────────────────────────────── 228 var _ST = { 229 posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>', 230 events: '<%= t('search.section_events') %>', pages: '<%= t('search.section_pages') %>', 231 all: '<%= t('search.suggest_all') %>', none: '<%= t('search.suggest_empty') %>' 232 }; 233 function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); } 234 function renderSuggest(box, d, q, ov) { 235 var html = ''; 236 function grp(title, items, fmt) { 237 if (!items || !items.length) return; 238 html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>'; 239 } 240 grp(_ST.posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; }); 241 grp(_ST.tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; }); 242 grp(_ST.events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; }); 243 grp(_ST.pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; }); 244 var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length); 245 if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_ST.none) + '</div>'; return; } 246 var action = (ov && ov.getAttribute('data-action')) || '/search'; 247 html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_ST.all) + '</a>'; 248 box.innerHTML = html; 249 } 250 var _sTimer; 251 document.addEventListener('input', function(e) { 252 var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]'); 253 if (!inp) return; 254 var box = document.getElementById('search-suggest'); 255 if (!box) return; 256 var q = inp.value.trim(); 257 clearTimeout(_sTimer); 258 if (q.length < 2) { box.innerHTML = ''; return; } 259 _sTimer = setTimeout(function() { 260 var ov = document.getElementById('search-overlay'); 261 var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest'; 262 fetch(url + '?q=' + encodeURIComponent(q)) 263 .then(function(r){ return r.ok ? r.json() : null; }) 264 .then(function(d){ if (d) renderSuggest(box, d, q, ov); }) 265 .catch(function(){}); 266 }, 200); 267 }); 268 // Click on a suggestion → close the overlay (the link/boost handles navigation). 269 document.addEventListener('click', function(e) { 270 if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; } 271 }); 272 })(); 273 </script> 196 <%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %> 274 197 275 198 <style> -
src/views/partials/track-editor.ejs
rf85b2c3 r952baf3 284 284 </style> 285 285 286 <script> 287 (function() { 288 289 function esc(s) { 290 return String(s == null ? '' : s) 291 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') 292 .replace(/"/g, '"').replace(/'/g, '''); 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.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)