| 1 | <%
|
|---|
| 2 | // Playlist editor modal — included once per page (in admin-playlists, and in
|
|---|
| 3 | // post-edit when present). Exposes `window.openPlaylistEditor({ mode, id?, onSaved? })`.
|
|---|
| 4 | //
|
|---|
| 5 | // mode: 'create' or 'edit'
|
|---|
| 6 | // id : (edit only) playlist id to load
|
|---|
| 7 | // onSaved: callback receiving { id, playlist } — used by post-editor
|
|---|
| 8 | // to insert [[playlist:id]] at the cursor.
|
|---|
| 9 | //
|
|---|
| 10 | // The modal itself is appended on first call; subsequent calls reuse the
|
|---|
| 11 | // same DOM. CSS is included once. JS lives below.
|
|---|
| 12 |
|
|---|
| 13 | const _csrf = (typeof csrfToken !== 'undefined' && csrfToken) || '';
|
|---|
| 14 | %>
|
|---|
| 15 |
|
|---|
| 16 | <style>
|
|---|
| 17 | /* ─── Editor modal ─────────────────────────────────────────────── */
|
|---|
| 18 | .pl-modal-backdrop {
|
|---|
| 19 | position: fixed; inset: 0; z-index: 9000;
|
|---|
| 20 | background: color-mix(in srgb, #000 50%, transparent);
|
|---|
| 21 | display: flex; align-items: center; justify-content: center;
|
|---|
| 22 | padding: 1rem;
|
|---|
| 23 | -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
|
|---|
| 24 | }
|
|---|
| 25 | .pl-modal {
|
|---|
| 26 | background: var(--paper);
|
|---|
| 27 | border: 1px solid var(--rule); border-radius: 16px;
|
|---|
| 28 | width: 100%; max-width: 960px;
|
|---|
| 29 | max-height: 92vh;
|
|---|
| 30 | display: flex; flex-direction: column;
|
|---|
| 31 | box-shadow: 0 24px 60px rgba(0, 0, 0, .35);
|
|---|
| 32 | overflow: hidden;
|
|---|
| 33 | }
|
|---|
| 34 | .pl-modal-header {
|
|---|
| 35 | padding: 1rem 1.25rem;
|
|---|
| 36 | border-bottom: 1px solid var(--rule);
|
|---|
| 37 | display: flex; align-items: center; justify-content: space-between;
|
|---|
| 38 | }
|
|---|
| 39 | .pl-modal-header h3 { margin: 0; font-size: 1.15rem; }
|
|---|
| 40 | .pl-modal-close {
|
|---|
| 41 | background: transparent; border: 0;
|
|---|
| 42 | font-size: 1.5rem; line-height: 1; cursor: pointer;
|
|---|
| 43 | width: 36px; height: 36px; border-radius: 8px;
|
|---|
| 44 | display: inline-flex; align-items: center; justify-content: center;
|
|---|
| 45 | color: var(--ink-soft);
|
|---|
| 46 | }
|
|---|
| 47 | .pl-modal-close:hover { background: var(--paper-2); color: var(--ink); }
|
|---|
| 48 | .pl-modal-body {
|
|---|
| 49 | flex: 1 1 auto; min-height: 0;
|
|---|
| 50 | overflow: hidden;
|
|---|
| 51 | padding: 1.25rem;
|
|---|
| 52 | /* Become a flex container so .pl-editor-cols (grid) can flex into the
|
|---|
| 53 | full available height. Without this, .pl-editor-cols had no bounded
|
|---|
| 54 | parent height and inner overflow:auto never fired. */
|
|---|
| 55 | display: flex; flex-direction: column;
|
|---|
| 56 | }
|
|---|
| 57 | .pl-modal-footer {
|
|---|
| 58 | padding: .75rem 1.25rem;
|
|---|
| 59 | border-top: 1px solid var(--rule);
|
|---|
| 60 | display: flex; justify-content: flex-end; gap: .5rem;
|
|---|
| 61 | }
|
|---|
| 62 |
|
|---|
| 63 | .pl-editor-cols {
|
|---|
| 64 | flex: 1 1 auto; min-height: 0;
|
|---|
| 65 | display: grid;
|
|---|
| 66 | grid-template-columns: 1fr 1fr;
|
|---|
| 67 | gap: 1.5rem;
|
|---|
| 68 | overflow: hidden;
|
|---|
| 69 | }
|
|---|
| 70 | @media (max-width: 720px) {
|
|---|
| 71 | /* On mobile, abandon the inner-scroll-container model entirely. Two
|
|---|
| 72 | scrolling boxes side-by-side don't fit, and stacked scroll boxes inside
|
|---|
| 73 | a fixed modal create a scroll-trap (touch scroll easily lands on the
|
|---|
| 74 | wrong layer). Instead: let the modal-body scroll the whole content,
|
|---|
| 75 | and let lists flow at their natural height. */
|
|---|
| 76 | .pl-editor-cols {
|
|---|
| 77 | grid-template-columns: 1fr;
|
|---|
| 78 | gap: 1rem;
|
|---|
| 79 | height: auto;
|
|---|
| 80 | overflow: visible;
|
|---|
| 81 | }
|
|---|
| 82 | .pl-modal-body { overflow: auto; -webkit-overflow-scrolling: touch; }
|
|---|
| 83 | .pl-editor-left, .pl-editor-right { overflow: visible; min-height: 0; }
|
|---|
| 84 | .pl-selected-list, .pl-available-list { overflow: visible; flex: 0 0 auto; }
|
|---|
| 85 | }
|
|---|
| 86 |
|
|---|
| 87 | .pl-editor-left,
|
|---|
| 88 | .pl-editor-right {
|
|---|
| 89 | display: flex; flex-direction: column;
|
|---|
| 90 | min-height: 0;
|
|---|
| 91 | overflow: hidden;
|
|---|
| 92 | }
|
|---|
| 93 |
|
|---|
| 94 | /* Meta grid (title/artist/year/kind/cover) */
|
|---|
| 95 | .pl-meta-grid {
|
|---|
| 96 | display: grid; gap: .5rem .75rem;
|
|---|
| 97 | grid-template-columns: 1fr 1fr;
|
|---|
| 98 | margin-bottom: 1rem;
|
|---|
| 99 | }
|
|---|
| 100 | .pl-field { display: flex; flex-direction: column; gap: .25rem; }
|
|---|
| 101 | .pl-field-full { grid-column: 1 / -1; }
|
|---|
| 102 | .pl-field > span {
|
|---|
| 103 | font-size: .8rem; color: var(--ink-soft); font-weight: 500;
|
|---|
| 104 | }
|
|---|
| 105 | .pl-field input,
|
|---|
| 106 | .pl-field select {
|
|---|
| 107 | width: 100%;
|
|---|
| 108 | padding: .5rem .75rem;
|
|---|
| 109 | border: 1px solid var(--rule); border-radius: 8px;
|
|---|
| 110 | background: var(--paper); color: var(--ink);
|
|---|
| 111 | font-family: var(--font-ui); font-size: .95rem;
|
|---|
| 112 | }
|
|---|
| 113 | .pl-field input:focus,
|
|---|
| 114 | .pl-field select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|---|
| 115 |
|
|---|
| 116 | .pl-cover-row {
|
|---|
| 117 | display: flex; align-items: center; gap: .5rem;
|
|---|
| 118 | }
|
|---|
| 119 | .pl-cover-thumb {
|
|---|
| 120 | width: 48px; height: 48px; border-radius: 8px;
|
|---|
| 121 | background: var(--paper-2);
|
|---|
| 122 | border: 1px solid var(--rule);
|
|---|
| 123 | display: flex; align-items: center; justify-content: center;
|
|---|
| 124 | cursor: pointer; padding: 0; overflow: hidden;
|
|---|
| 125 | }
|
|---|
| 126 | .pl-cover-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|---|
| 127 | .pl-cover-empty { font-size: 1.25rem; color: var(--ink-muted); }
|
|---|
| 128 |
|
|---|
| 129 | /* File upload row + status — only renders in edit mode */
|
|---|
| 130 | .pl-cover-upload-row {
|
|---|
| 131 | display: flex; align-items: center; gap: .5rem;
|
|---|
| 132 | margin-top: .35rem; flex-wrap: wrap;
|
|---|
| 133 | }
|
|---|
| 134 | .pl-btn-small {
|
|---|
| 135 | background: var(--paper-2); color: var(--ink);
|
|---|
| 136 | border: 1px solid var(--rule); border-radius: 999px;
|
|---|
| 137 | padding: .35rem .75rem; font-size: .8rem;
|
|---|
| 138 | cursor: pointer; transition: border-color 120ms;
|
|---|
| 139 | -webkit-tap-highlight-color: transparent;
|
|---|
| 140 | }
|
|---|
| 141 | .pl-btn-small:hover { border-color: var(--accent); }
|
|---|
| 142 | .pl-cover-status { font-size: .8rem; color: var(--ink-muted); }
|
|---|
| 143 | .pl-cover-status.is-error { color: #dc2626; }
|
|---|
| 144 | .pl-cover-status.is-ok { color: #2a9d5e; }
|
|---|
| 145 | .pl-cover-note { font-size: .75rem; color: var(--ink-muted); margin-top: .25rem; display: block; }
|
|---|
| 146 |
|
|---|
| 147 | /* Section titles */
|
|---|
| 148 | .pl-section-title {
|
|---|
| 149 | display: flex; align-items: baseline; gap: .5rem;
|
|---|
| 150 | font-size: .8rem; font-weight: 600;
|
|---|
| 151 | text-transform: uppercase; letter-spacing: .05em;
|
|---|
| 152 | color: var(--ink-soft);
|
|---|
| 153 | padding: .5rem 0;
|
|---|
| 154 | margin-bottom: .5rem;
|
|---|
| 155 | border-bottom: 1px solid var(--rule);
|
|---|
| 156 | flex-shrink: 0;
|
|---|
| 157 | }
|
|---|
| 158 | .pl-section-title small {
|
|---|
| 159 | font-size: .8em; font-weight: 400; text-transform: none; letter-spacing: 0;
|
|---|
| 160 | color: var(--ink-muted);
|
|---|
| 161 | }
|
|---|
| 162 | .pl-track-count {
|
|---|
| 163 | background: var(--accent); color: #fff;
|
|---|
| 164 | padding: .1em .55em; border-radius: 999px; font-size: .75rem;
|
|---|
| 165 | font-weight: 600; margin-left: .25rem;
|
|---|
| 166 | }
|
|---|
| 167 |
|
|---|
| 168 | /* Selected + available lists */
|
|---|
| 169 | .pl-selected-list,
|
|---|
| 170 | .pl-available-list {
|
|---|
| 171 | flex: 1; overflow-y: auto; min-height: 0;
|
|---|
| 172 | padding-right: .25rem;
|
|---|
| 173 | }
|
|---|
| 174 | .pl-empty {
|
|---|
| 175 | color: var(--ink-muted); font-style: italic;
|
|---|
| 176 | padding: 1.5rem; text-align: center; font-size: .9rem;
|
|---|
| 177 | }
|
|---|
| 178 |
|
|---|
| 179 | /* Search input on right pane */
|
|---|
| 180 | .pl-search-input {
|
|---|
| 181 | width: 100%;
|
|---|
| 182 | padding: .5rem .75rem;
|
|---|
| 183 | border: 1px solid var(--rule); border-radius: 8px;
|
|---|
| 184 | background: var(--paper); color: var(--ink);
|
|---|
| 185 | font-family: var(--font-ui); font-size: .9rem;
|
|---|
| 186 | margin-bottom: .5rem;
|
|---|
| 187 | flex-shrink: 0;
|
|---|
| 188 | }
|
|---|
| 189 |
|
|---|
| 190 | /* Selected row (sortable) */
|
|---|
| 191 | .pl-row {
|
|---|
| 192 | display: flex; align-items: center; gap: .5rem;
|
|---|
| 193 | padding: .5rem;
|
|---|
| 194 | border: 1px solid transparent;
|
|---|
| 195 | border-radius: 8px;
|
|---|
| 196 | margin-bottom: .25rem;
|
|---|
| 197 | background: var(--paper-2);
|
|---|
| 198 | user-select: none;
|
|---|
| 199 | }
|
|---|
| 200 | .pl-row.is-dragging { opacity: 0.5; }
|
|---|
| 201 | .pl-row.drop-above { box-shadow: 0 -2px 0 var(--accent); }
|
|---|
| 202 | .pl-row.drop-below { box-shadow: 0 2px 0 var(--accent); }
|
|---|
| 203 | .pl-row-handle {
|
|---|
| 204 | cursor: grab; padding: .25rem .5rem;
|
|---|
| 205 | color: var(--ink-muted);
|
|---|
| 206 | touch-action: none;
|
|---|
| 207 | }
|
|---|
| 208 | .pl-row-handle:active { cursor: grabbing; }
|
|---|
| 209 | .pl-row-num {
|
|---|
| 210 | width: 22px; text-align: right;
|
|---|
| 211 | font-family: var(--font-mono, monospace);
|
|---|
| 212 | font-size: .8rem; color: var(--ink-muted);
|
|---|
| 213 | }
|
|---|
| 214 | .pl-row-cover {
|
|---|
| 215 | width: 32px; height: 32px; border-radius: 4px;
|
|---|
| 216 | flex-shrink: 0;
|
|---|
| 217 | background: var(--paper); border: 1px solid var(--rule);
|
|---|
| 218 | display: inline-flex; align-items: center; justify-content: center;
|
|---|
| 219 | overflow: hidden;
|
|---|
| 220 | }
|
|---|
| 221 | .pl-row-cover img { width: 100%; height: 100%; object-fit: cover; }
|
|---|
| 222 | .pl-row-cover-empty { color: var(--ink-muted); font-size: .8rem; }
|
|---|
| 223 | .pl-row-info {
|
|---|
| 224 | flex: 1; min-width: 0;
|
|---|
| 225 | display: flex; flex-direction: column;
|
|---|
| 226 | font-size: .85rem;
|
|---|
| 227 | }
|
|---|
| 228 | .pl-row-title {
|
|---|
| 229 | font-weight: 500;
|
|---|
| 230 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|---|
| 231 | }
|
|---|
| 232 | .pl-row-artist {
|
|---|
| 233 | font-size: .8em; color: var(--ink-soft);
|
|---|
| 234 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|---|
| 235 | }
|
|---|
| 236 | .pl-row-x {
|
|---|
| 237 | background: transparent; border: 0;
|
|---|
| 238 | width: 28px; height: 28px;
|
|---|
| 239 | border-radius: 6px;
|
|---|
| 240 | cursor: pointer;
|
|---|
| 241 | color: var(--ink-muted);
|
|---|
| 242 | font-size: 1.1rem;
|
|---|
| 243 | }
|
|---|
| 244 | .pl-row-x:hover { background: var(--paper); color: #dc2626; }
|
|---|
| 245 |
|
|---|
| 246 | /* Available row (clickable) */
|
|---|
| 247 | .pl-avail-row {
|
|---|
| 248 | display: flex; align-items: center; gap: .5rem;
|
|---|
| 249 | padding: .5rem; border-radius: 8px;
|
|---|
| 250 | cursor: pointer; margin-bottom: .25rem;
|
|---|
| 251 | transition: background 120ms ease;
|
|---|
| 252 | }
|
|---|
| 253 | .pl-avail-row:hover { background: var(--paper-2); }
|
|---|
| 254 | .pl-avail-row.is-added { opacity: .55; }
|
|---|
| 255 | .pl-avail-row.is-unplayable { opacity: .4; cursor: not-allowed; }
|
|---|
| 256 | .pl-avail-action {
|
|---|
| 257 | width: 22px; height: 22px;
|
|---|
| 258 | border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
|---|
| 259 | font-size: .9rem; font-weight: 600;
|
|---|
| 260 | background: var(--paper-2); color: var(--ink-soft);
|
|---|
| 261 | flex-shrink: 0;
|
|---|
| 262 | }
|
|---|
| 263 | .pl-avail-row.is-added .pl-avail-action {
|
|---|
| 264 | background: var(--accent); color: #fff;
|
|---|
| 265 | }
|
|---|
| 266 |
|
|---|
| 267 | /* Cover-picker grid (sub-modal) */
|
|---|
| 268 | .pl-cover-picker-grid {
|
|---|
| 269 | display: grid;
|
|---|
| 270 | grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
|---|
| 271 | gap: .5rem; max-height: 60vh; overflow-y: auto;
|
|---|
| 272 | }
|
|---|
| 273 | .pl-cover-picker-grid button {
|
|---|
| 274 | border: 1px solid var(--rule); border-radius: 8px;
|
|---|
| 275 | background: var(--paper-2); padding: 0; cursor: pointer;
|
|---|
| 276 | aspect-ratio: 1; overflow: hidden;
|
|---|
| 277 | }
|
|---|
| 278 | .pl-cover-picker-grid button:hover { border-color: var(--accent); }
|
|---|
| 279 | .pl-cover-picker-grid img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|---|
| 280 | </style>
|
|---|
| 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>
|
|---|