| 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 | /* `display: flex` wint van het hidden-attribuut, dus zonder deze regel doet
|
|---|
| 102 | [hidden] op een .pl-field NIETS -- het veld blijft gewoon staan. De
|
|---|
| 103 | uitgavevelden schakelen daarop (album versus afspeellijst). */
|
|---|
| 104 | .pl-field[hidden] { display: none; }
|
|---|
| 105 | .pl-field-full { grid-column: 1 / -1; }
|
|---|
| 106 | .pl-field > span {
|
|---|
| 107 | font-size: .8rem; color: var(--ink-soft); font-weight: 500;
|
|---|
| 108 | }
|
|---|
| 109 | .pl-field input,
|
|---|
| 110 | .pl-field select {
|
|---|
| 111 | width: 100%;
|
|---|
| 112 | padding: .5rem .75rem;
|
|---|
| 113 | border: 1px solid var(--rule); border-radius: 8px;
|
|---|
| 114 | background: var(--paper); color: var(--ink);
|
|---|
| 115 | font-family: var(--font-ui); font-size: .95rem;
|
|---|
| 116 | }
|
|---|
| 117 | .pl-field input:focus,
|
|---|
| 118 | .pl-field select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|---|
| 119 |
|
|---|
| 120 | .pl-cover-row {
|
|---|
| 121 | display: flex; align-items: center; gap: .5rem;
|
|---|
| 122 | }
|
|---|
| 123 | .pl-cover-thumb {
|
|---|
| 124 | width: 48px; height: 48px; border-radius: 8px;
|
|---|
| 125 | background: var(--paper-2);
|
|---|
| 126 | border: 1px solid var(--rule);
|
|---|
| 127 | display: flex; align-items: center; justify-content: center;
|
|---|
| 128 | cursor: pointer; padding: 0; overflow: hidden;
|
|---|
| 129 | }
|
|---|
| 130 | .pl-cover-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|---|
| 131 | .pl-cover-empty { font-size: 1.25rem; color: var(--ink-muted); }
|
|---|
| 132 |
|
|---|
| 133 | /* File upload row + status — only renders in edit mode */
|
|---|
| 134 | .pl-cover-upload-row {
|
|---|
| 135 | display: flex; align-items: center; gap: .5rem;
|
|---|
| 136 | margin-top: .35rem; flex-wrap: wrap;
|
|---|
| 137 | }
|
|---|
| 138 | .pl-btn-small {
|
|---|
| 139 | background: var(--paper-2); color: var(--ink);
|
|---|
| 140 | border: 1px solid var(--rule); border-radius: 999px;
|
|---|
| 141 | padding: .35rem .75rem; font-size: .8rem;
|
|---|
| 142 | cursor: pointer; transition: border-color 120ms;
|
|---|
| 143 | -webkit-tap-highlight-color: transparent;
|
|---|
| 144 | }
|
|---|
| 145 | .pl-btn-small:hover { border-color: var(--accent); }
|
|---|
| 146 | .pl-cover-status { font-size: .8rem; color: var(--ink-muted); }
|
|---|
| 147 | .pl-cover-status.is-error { color: #dc2626; }
|
|---|
| 148 | .pl-cover-status.is-ok { color: #2a9d5e; }
|
|---|
| 149 | .pl-cover-note { font-size: .75rem; color: var(--ink-muted); margin-top: .25rem; display: block; }
|
|---|
| 150 |
|
|---|
| 151 | /* Section titles */
|
|---|
| 152 | .pl-section-title {
|
|---|
| 153 | display: flex; align-items: baseline; gap: .5rem;
|
|---|
| 154 | font-size: .8rem; font-weight: 600;
|
|---|
| 155 | text-transform: uppercase; letter-spacing: .05em;
|
|---|
| 156 | color: var(--ink-soft);
|
|---|
| 157 | padding: .5rem 0;
|
|---|
| 158 | margin-bottom: .5rem;
|
|---|
| 159 | border-bottom: 1px solid var(--rule);
|
|---|
| 160 | flex-shrink: 0;
|
|---|
| 161 | }
|
|---|
| 162 | .pl-section-title small {
|
|---|
| 163 | font-size: .8em; font-weight: 400; text-transform: none; letter-spacing: 0;
|
|---|
| 164 | color: var(--ink-muted);
|
|---|
| 165 | }
|
|---|
| 166 | .pl-track-count {
|
|---|
| 167 | background: var(--accent); color: #fff;
|
|---|
| 168 | padding: .1em .55em; border-radius: 999px; font-size: .75rem;
|
|---|
| 169 | font-weight: 600; margin-left: .25rem;
|
|---|
| 170 | }
|
|---|
| 171 |
|
|---|
| 172 | /* Selected + available lists */
|
|---|
| 173 | .pl-selected-list,
|
|---|
| 174 | .pl-available-list {
|
|---|
| 175 | flex: 1; overflow-y: auto; min-height: 0;
|
|---|
| 176 | padding-right: .25rem;
|
|---|
| 177 | }
|
|---|
| 178 | .pl-empty {
|
|---|
| 179 | color: var(--ink-muted); font-style: italic;
|
|---|
| 180 | padding: 1.5rem; text-align: center; font-size: .9rem;
|
|---|
| 181 | }
|
|---|
| 182 |
|
|---|
| 183 | /* Search input on right pane */
|
|---|
| 184 | .pl-search-input {
|
|---|
| 185 | width: 100%;
|
|---|
| 186 | padding: .5rem .75rem;
|
|---|
| 187 | border: 1px solid var(--rule); border-radius: 8px;
|
|---|
| 188 | background: var(--paper); color: var(--ink);
|
|---|
| 189 | font-family: var(--font-ui); font-size: .9rem;
|
|---|
| 190 | margin-bottom: .5rem;
|
|---|
| 191 | flex-shrink: 0;
|
|---|
| 192 | }
|
|---|
| 193 |
|
|---|
| 194 | /* Selected row (sortable) */
|
|---|
| 195 | .pl-row {
|
|---|
| 196 | display: flex; align-items: center; gap: .5rem;
|
|---|
| 197 | padding: .5rem;
|
|---|
| 198 | border: 1px solid transparent;
|
|---|
| 199 | border-radius: 8px;
|
|---|
| 200 | margin-bottom: .25rem;
|
|---|
| 201 | background: var(--paper-2);
|
|---|
| 202 | user-select: none;
|
|---|
| 203 | }
|
|---|
| 204 | .pl-row.is-dragging { opacity: 0.5; }
|
|---|
| 205 | .pl-row.drop-above { box-shadow: 0 -2px 0 var(--accent); }
|
|---|
| 206 | .pl-row.drop-below { box-shadow: 0 2px 0 var(--accent); }
|
|---|
| 207 | .pl-row-handle {
|
|---|
| 208 | cursor: grab; padding: .25rem .5rem;
|
|---|
| 209 | color: var(--ink-muted);
|
|---|
| 210 | touch-action: none;
|
|---|
| 211 | }
|
|---|
| 212 | .pl-row-handle:active { cursor: grabbing; }
|
|---|
| 213 | .pl-row-num {
|
|---|
| 214 | width: 22px; text-align: right;
|
|---|
| 215 | font-family: var(--font-mono, monospace);
|
|---|
| 216 | font-size: .8rem; color: var(--ink-muted);
|
|---|
| 217 | }
|
|---|
| 218 | .pl-row-cover {
|
|---|
| 219 | width: 32px; height: 32px; border-radius: 4px;
|
|---|
| 220 | flex-shrink: 0;
|
|---|
| 221 | background: var(--paper); border: 1px solid var(--rule);
|
|---|
| 222 | display: inline-flex; align-items: center; justify-content: center;
|
|---|
| 223 | overflow: hidden;
|
|---|
| 224 | }
|
|---|
| 225 | .pl-row-cover img { width: 100%; height: 100%; object-fit: cover; }
|
|---|
| 226 | .pl-row-cover-empty { color: var(--ink-muted); font-size: .8rem; }
|
|---|
| 227 | .pl-row-info {
|
|---|
| 228 | flex: 1; min-width: 0;
|
|---|
| 229 | display: flex; flex-direction: column;
|
|---|
| 230 | font-size: .85rem;
|
|---|
| 231 | }
|
|---|
| 232 | .pl-row-title {
|
|---|
| 233 | font-weight: 500;
|
|---|
| 234 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|---|
| 235 | }
|
|---|
| 236 | .pl-row-artist {
|
|---|
| 237 | font-size: .8em; color: var(--ink-soft);
|
|---|
| 238 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|---|
| 239 | }
|
|---|
| 240 | .pl-row-x {
|
|---|
| 241 | background: transparent; border: 0;
|
|---|
| 242 | width: 28px; height: 28px;
|
|---|
| 243 | border-radius: 6px;
|
|---|
| 244 | cursor: pointer;
|
|---|
| 245 | color: var(--ink-muted);
|
|---|
| 246 | font-size: 1.1rem;
|
|---|
| 247 | }
|
|---|
| 248 | .pl-row-x:hover { background: var(--paper); color: #dc2626; }
|
|---|
| 249 |
|
|---|
| 250 | /* Available row (clickable) */
|
|---|
| 251 | .pl-avail-row {
|
|---|
| 252 | display: flex; align-items: center; gap: .5rem;
|
|---|
| 253 | padding: .5rem; border-radius: 8px;
|
|---|
| 254 | cursor: pointer; margin-bottom: .25rem;
|
|---|
| 255 | transition: background 120ms ease;
|
|---|
| 256 | }
|
|---|
| 257 | .pl-avail-row:hover { background: var(--paper-2); }
|
|---|
| 258 | .pl-avail-row.is-added { opacity: .55; }
|
|---|
| 259 | .pl-avail-row.is-unplayable { opacity: .4; cursor: not-allowed; }
|
|---|
| 260 | .pl-avail-action {
|
|---|
| 261 | width: 22px; height: 22px;
|
|---|
| 262 | border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
|---|
| 263 | font-size: .9rem; font-weight: 600;
|
|---|
| 264 | background: var(--paper-2); color: var(--ink-soft);
|
|---|
| 265 | flex-shrink: 0;
|
|---|
| 266 | }
|
|---|
| 267 | .pl-avail-row.is-added .pl-avail-action {
|
|---|
| 268 | background: var(--accent); color: #fff;
|
|---|
| 269 | }
|
|---|
| 270 |
|
|---|
| 271 | /* Cover-picker grid (sub-modal) */
|
|---|
| 272 | .pl-cover-picker-grid {
|
|---|
| 273 | display: grid;
|
|---|
| 274 | grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
|---|
| 275 | gap: .5rem; max-height: 60vh; overflow-y: auto;
|
|---|
| 276 | }
|
|---|
| 277 | .pl-cover-picker-grid button {
|
|---|
| 278 | border: 1px solid var(--rule); border-radius: 8px;
|
|---|
| 279 | background: var(--paper-2); padding: 0; cursor: pointer;
|
|---|
| 280 | aspect-ratio: 1; overflow: hidden;
|
|---|
| 281 | }
|
|---|
| 282 | .pl-cover-picker-grid button:hover { border-color: var(--accent); }
|
|---|
| 283 | .pl-cover-picker-grid img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|---|
| 284 | </style>
|
|---|
| 285 |
|
|---|
| 286 | <%# Het script staat in assets/js/mod/playlist-editor.js.
|
|---|
| 287 |
|
|---|
| 288 | EIGEN GEGEVENSBLOK, niet dat van partials/page-data.ejs. pageData() pakt met
|
|---|
| 289 | querySelector EEN blok, en deze modal wordt ingesloten op pagina's die er al
|
|---|
| 290 | zelf een hebben (admin-playlists, post-edit) -- een tweede blok zou daar
|
|---|
| 291 | stilzwijgend genegeerd worden en dan stond de hele modal weer in het
|
|---|
| 292 | Nederlands. Eigen kenmerk, eigen lezer.
|
|---|
| 293 |
|
|---|
| 294 | De teksten stonden hier tot 16-8 hard in het script. Dat viel niet op omdat
|
|---|
| 295 | het toevallig de brontaal was: een Engelse of Duitse beheerder kreeg een
|
|---|
| 296 | Nederlandse modal midden in een vertaalde pagina. %>
|
|---|
| 297 | <script type="application/json" data-playlist-editor-i18n><%- JSON.stringify({
|
|---|
| 298 | titel: t('ple.titel'), artiest: t('ple.artiest'), jaar: t('ple.jaar'), type: t('ple.type'),
|
|---|
| 299 | k_album: t('ple.k_album'), k_playlist: t('ple.k_playlist'),
|
|---|
| 300 | uitgave: t('ple.uitgave'), mb_release: t('ple.mb_release'),
|
|---|
| 301 | cover: t('ple.cover'), cover_kies: t('ple.cover_kies'), cover_url: t('ple.cover_url'),
|
|---|
| 302 | tracks_in: t('ple.tracks_in'), sleep_hint: t('ple.sleep_hint'),
|
|---|
| 303 | beschikbaar: t('ple.beschikbaar'), zoek: t('ple.zoek'), geen_res: t('ple.geen_res'),
|
|---|
| 304 | leeg_sel: t('ple.leeg_sel'),
|
|---|
| 305 | t_edit: t('ple.t_edit'), t_new: t('ple.t_new'), dialoog: t('ple.dialoog'),
|
|---|
| 306 | sluiten: t('ple.sluiten'), annuleren: t('ple.annuleren'),
|
|---|
| 307 | opslaan: t('ple.opslaan'), aanmaken: t('ple.aanmaken'),
|
|---|
| 308 | bezig_opslaan: t('ple.bezig_opslaan'),
|
|---|
| 309 | versleep: t('ple.versleep'), verwijder: t('ple.verwijder'),
|
|---|
| 310 | geen_audio: t('ple.geen_audio'), e_geen_tracks: t('ple.e_geen_tracks'),
|
|---|
| 311 | e_tracks: t('ple.e_tracks'), e_opslaan: t('ple.e_opslaan'), e_mislukt: t('ple.e_mislukt'),
|
|---|
| 312 | e_alleen_afb: t('ple.e_alleen_afb'), bezig: t('ple.bezig'), e_upload: t('ple.e_upload'),
|
|---|
| 313 | }).replace(/</g, '\\u003c') %></script>
|
|---|