| [7bc636b] | 1 | <div class="container ax-page">
|
|---|
| 2 |
|
|---|
| [69b509d] | 3 | <p><a href="/admin" class="btn">← <%= t('nav.admin') %></a></p>
|
|---|
| [7bc636b] | 4 | <header class="ax-header">
|
|---|
| 5 | <div>
|
|---|
| [bbf9d1a] | 6 | <h1><%= t('aaud.title') %></h1>
|
|---|
| 7 | <p class="ax-tagline"><%= t('aaud.tagline_pre') %> <code>[[track:<id>]]</code> <%= t('aaud.tagline_post') %></p>
|
|---|
| [7bc636b] | 8 | </div>
|
|---|
| 9 | </header>
|
|---|
| 10 |
|
|---|
| 11 | <% if (success) { %><div class="ax-flash ax-flash-ok"><%= success %></div><% } %>
|
|---|
| 12 | <% if (error) { %><div class="ax-flash ax-flash-err"><%= error %></div><% } %>
|
|---|
| 13 |
|
|---|
| 14 | <%# ── UPLOAD ──────────────────────────────────────────────── %>
|
|---|
| 15 | <section class="ax-card">
|
|---|
| [bbf9d1a] | 16 | <div class="ax-card-title"><%= t('aaud.upload') %></div>
|
|---|
| [7bc636b] | 17 |
|
|---|
| 18 | <%# Bulk drag-drop zone. Multiple files at once, transcoded sequentially.
|
|---|
| 19 | Title is auto-derived from each filename. Artist/album/cover are
|
|---|
| 20 | SHARED across the batch (set once, applied to every track) — same
|
|---|
| 21 | pattern as iTunes/Plex bulk-add. %>
|
|---|
| 22 | <div class="ax-form">
|
|---|
| 23 |
|
|---|
| 24 | <%# Shared metadata fields (apply to ALL files in the batch). %>
|
|---|
| 25 | <div class="ax-row ax-row-2">
|
|---|
| 26 | <label class="ax-field">
|
|---|
| [bbf9d1a] | 27 | <span><%= t('aaud.artist') %> <small><%= t('aaud.applied_all') %></small></span>
|
|---|
| 28 | <input type="text" id="batch-artist" placeholder="<%= t('aaud.optional') %>">
|
|---|
| [7bc636b] | 29 | </label>
|
|---|
| 30 | <label class="ax-field">
|
|---|
| [bbf9d1a] | 31 | <span><%= t('aaud.album') %> <small><%= t('aaud.applied_all') %></small></span>
|
|---|
| 32 | <input type="text" id="batch-album" placeholder="<%= t('aaud.optional') %>">
|
|---|
| [7bc636b] | 33 | </label>
|
|---|
| 34 | </div>
|
|---|
| 35 | <label class="ax-field ax-file">
|
|---|
| [bbf9d1a] | 36 | <span><%= t('aaud.cover') %> <small><%= t('aaud.cover_hint') %></small></span>
|
|---|
| [7bc636b] | 37 | <span class="ax-file-control">
|
|---|
| [bbf9d1a] | 38 | <span class="ax-btn ax-btn-secondary ax-file-btn">🖼 <%= t('aaud.choose_cover') %></span>
|
|---|
| 39 | <span class="ax-file-name" data-empty><%= t('aaud.no_file') %></span>
|
|---|
| [7bc636b] | 40 | <input type="file" id="batch-cover" accept="image/jpeg,image/png,image/webp,image/gif">
|
|---|
| 41 | </span>
|
|---|
| 42 | </label>
|
|---|
| 43 |
|
|---|
| 44 | <%# The drop-zone itself. Click anywhere to open file picker. %>
|
|---|
| 45 | <label class="ax-dropzone" id="audio-dropzone" tabindex="0">
|
|---|
| 46 | <span class="ax-dropzone-icon">🎵</span>
|
|---|
| [bbf9d1a] | 47 | <strong class="ax-dropzone-title"><%= t('aaud.drag_here') %></strong>
|
|---|
| 48 | <span class="ax-dropzone-sub"><%= t('aaud.or_click') %><br><small>mp3, m4a, ogg, opus, flac · max <%= maxBytesMb %> MB · wav max <%= maxWavMb %> MB</small></span>
|
|---|
| [7bc636b] | 49 | <input type="file" id="audio-files" accept="audio/*" multiple>
|
|---|
| 50 | </label>
|
|---|
| 51 |
|
|---|
| 52 | <%# Per-file progress list. Populated by JS as files are queued. %>
|
|---|
| 53 | <ul class="ax-upload-queue" id="upload-queue" hidden></ul>
|
|---|
| 54 |
|
|---|
| 55 | <div class="ax-form-actions" id="upload-actions" hidden>
|
|---|
| [bbf9d1a] | 56 | <button type="button" class="ax-btn ax-btn-primary" id="start-upload-btn">⬆ <%= t('aaud.start_upload') %></button>
|
|---|
| 57 | <button type="button" class="ax-btn" id="clear-queue-btn"><%= t('aaud.clear_list') %></button>
|
|---|
| [7bc636b] | 58 | </div>
|
|---|
| 59 | </div>
|
|---|
| 60 | </section>
|
|---|
| 61 |
|
|---|
| 62 | <%# ── TRACK LIST ─────────────────────────────────────────── %>
|
|---|
| 63 | <section class="ax-card">
|
|---|
| [d727e92] | 64 | <div class="ax-card-title" style="display:flex;align-items:center;gap:.5rem">
|
|---|
| [bbf9d1a] | 65 | <%= t('aaud.tracks') %> <span class="ax-count"><%= tracks.length %></span>
|
|---|
| [d727e92] | 66 | <button type="button" class="ax-btn ax-btn-secondary" id="add-link-track-btn"
|
|---|
| [bbf9d1a] | 67 | style="margin-left:auto;font-size:.85rem" title="<%= t('aaud.add_link_track_title') %>">
|
|---|
| 68 | + <%= t('aaud.add_link_track') %>
|
|---|
| [d727e92] | 69 | </button>
|
|---|
| [7bc636b] | 70 | </div>
|
|---|
| 71 |
|
|---|
| 72 | <% if (!tracks.length) { %>
|
|---|
| 73 | <div class="ax-empty">
|
|---|
| 74 | <div class="ax-empty-icon">♫</div>
|
|---|
| [bbf9d1a] | 75 | <p><%= t('aaud.no_tracks') %></p>
|
|---|
| [7bc636b] | 76 | </div>
|
|---|
| 77 | <% } else { %>
|
|---|
| [bbf9d1a] | 78 | <%
|
|---|
| 79 | // Pre-fetch loop labels — `t` is shadowed by the loop var below.
|
|---|
| 80 | var _aaudUntitled = t('aaud.untitled');
|
|---|
| 81 | var _aaudPlay = t('aaud.play');
|
|---|
| 82 | var _aaudEdit = t('aaud.edit');
|
|---|
| 83 | var _aaudDlOn = t('aaud.dl_on');
|
|---|
| 84 | var _aaudDlOff = t('aaud.dl_off');
|
|---|
| 85 | var _aaudDelete = t('aaud.delete');
|
|---|
| 86 | var _aaudCopy = t('aaud.copy_click');
|
|---|
| 87 | var _aaudDelConfirm = t('aaud.delete_confirm');
|
|---|
| 88 | %>
|
|---|
| [7bc636b] | 89 | <ul class="ax-list">
|
|---|
| 90 | <% tracks.forEach(function(t) { %>
|
|---|
| 91 | <li class="ax-track" data-track-id="<%= t.id %>">
|
|---|
| 92 | <% if (t.cover_url) { %>
|
|---|
| 93 | <img class="ax-track-cover" src="<%= t.cover_url %>" alt="" data-cover-thumb>
|
|---|
| 94 | <% } else { %>
|
|---|
| 95 | <span class="ax-track-cover ax-track-cover-empty" data-cover-thumb>♫</span>
|
|---|
| 96 | <% } %>
|
|---|
| 97 |
|
|---|
| 98 | <div class="ax-track-meta">
|
|---|
| [bbf9d1a] | 99 | <div class="ax-track-title" data-cell="title"><%= t.title || _aaudUntitled %></div>
|
|---|
| [7bc636b] | 100 | <div class="ax-track-sub">
|
|---|
| 101 | <span data-cell="artist"><%= t.artist || '—' %></span>
|
|---|
| 102 | <% if (t.album) { %><span class="ax-track-sep">·</span><span data-cell="album"><%= t.album %></span><% } else { %><span data-cell="album" hidden></span><% } %>
|
|---|
| 103 | <span class="ax-track-sep">·</span><span><%= t.size ? Math.round(t.size/1024) + ' KB' : '—' %></span>
|
|---|
| 104 | </div>
|
|---|
| [bbf9d1a] | 105 | <code class="ax-embed" data-copy="[[track:<%= t.id %>]]" title="<%= _aaudCopy %>">[[track:<%= t.id %>]]</code>
|
|---|
| [7bc636b] | 106 | </div>
|
|---|
| 107 |
|
|---|
| 108 | <div class="ax-track-actions">
|
|---|
| 109 | <% if (t.stream_url) { %>
|
|---|
| 110 | <button type="button" class="ax-icon-btn ax-track-play"
|
|---|
| 111 | data-stream-url="<%= t.stream_url %>"
|
|---|
| 112 | data-track-id="<%= t.id %>"
|
|---|
| [bbf9d1a] | 113 | aria-label="<%= _aaudPlay %>" title="<%= _aaudPlay %>">
|
|---|
| [7bc636b] | 114 | <span class="ax-track-play-icon" aria-hidden="true">▶</span>
|
|---|
| 115 | </button>
|
|---|
| 116 | <% } %>
|
|---|
| [bbf9d1a] | 117 | <button type="button" class="ax-icon-btn" data-track-edit data-id="<%= t.id %>" aria-label="<%= _aaudEdit %>" title="<%= _aaudEdit %>">✎</button>
|
|---|
| [91094a4] | 118 | <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
|
|---|
| [70679063] | 119 | <button type="button" class="ax-icon-btn" data-track-dl data-id="<%= t.id %>" data-on="<%= t.downloadable ? '1' : '0' %>"
|
|---|
| [bbf9d1a] | 120 | aria-label="<%= t.downloadable ? _aaudDlOn : _aaudDlOff %>" title="<%= t.downloadable ? _aaudDlOn : _aaudDlOff %>"
|
|---|
| [70679063] | 121 | style="<%= t.downloadable ? 'color:var(--accent,#6b8f71)' : 'opacity:.5' %>">⬇</button>
|
|---|
| [91094a4] | 122 | <% } %>
|
|---|
| [f7d142f] | 123 | <form action="/admin/audio/<%= t.id %>/delete" method="post" data-confirm="<%= _aaudDelConfirm %>" class="ax-track-delete">
|
|---|
| [bbf9d1a] | 124 | <button type="submit" class="ax-icon-btn ax-icon-btn-danger" aria-label="<%= _aaudDelete %>" title="<%= _aaudDelete %>">🗑</button>
|
|---|
| [7bc636b] | 125 | </form>
|
|---|
| 126 | </div>
|
|---|
| 127 | </li>
|
|---|
| 128 | <% }); %>
|
|---|
| 129 | </ul>
|
|---|
| 130 | <% } %>
|
|---|
| 131 | </section>
|
|---|
| 132 |
|
|---|
| [6be57b4] | 133 | <% if ((typeof premiumUnlocked === 'undefined' || premiumUnlocked) && typeof embedUrl !== 'undefined') { %>
|
|---|
| 134 | <section class="ax-section" style="margin-top:1.5rem">
|
|---|
| [bbf9d1a] | 135 | <h2 style="margin:0 0 .4rem">🔊 <%= t('aaud.embed_player') %></h2>
|
|---|
| 136 | <p style="opacity:.75;font-size:.9rem;margin:0 0 .6rem"><%= t('aaud.embed_hint') %></p>
|
|---|
| [f7d142f] | 137 | <textarea readonly rows="3" data-selectall style="width:100%;font-family:monospace;font-size:12px;padding:10px;border-radius:8px;border:1px solid var(--rule);background:var(--paper-2);color:inherit;"><iframe src="<%= embedUrl %>" width="100%" height="420" frameborder="0" loading="lazy" style="border-radius:12px"></iframe></textarea>
|
|---|
| [bbf9d1a] | 138 | <p style="margin:.6rem 0 0"><a class="btn" href="<%= embedUrl %>" target="_blank">▶ <%= t('aaud.preview_player') %></a></p>
|
|---|
| [6be57b4] | 139 | </section>
|
|---|
| 140 | <% } %>
|
|---|
| 141 |
|
|---|
| [7bc636b] | 142 | </div>
|
|---|
| 143 |
|
|---|
| 144 | <style>
|
|---|
| 145 | /* ─── Page wrapper ──────────────────────────────────────────────── */
|
|---|
| 146 | .ax-page {
|
|---|
| 147 | max-width: 880px;
|
|---|
| 148 | margin: 1.5rem auto 4rem;
|
|---|
| 149 | padding: 0 1rem;
|
|---|
| 150 | }
|
|---|
| 151 | .ax-header {
|
|---|
| 152 | display: flex; align-items: flex-start; gap: 0.75rem;
|
|---|
| 153 | margin-bottom: 1.5rem;
|
|---|
| 154 | }
|
|---|
| 155 | .ax-header h1 {
|
|---|
| 156 | font-family: var(--font-display, serif);
|
|---|
| 157 | font-size: 1.75rem;
|
|---|
| 158 | margin: 0 0 0.25rem;
|
|---|
| 159 | }
|
|---|
| 160 | .ax-tagline {
|
|---|
| 161 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 162 | margin: 0;
|
|---|
| 163 | font-size: 0.9rem;
|
|---|
| 164 | line-height: 1.5;
|
|---|
| 165 | }
|
|---|
| 166 | .ax-tagline code {
|
|---|
| 167 | background: var(--paper-2);
|
|---|
| 168 | padding: 0.1em 0.4em;
|
|---|
| 169 | border-radius: 4px;
|
|---|
| 170 | font-size: 0.9em;
|
|---|
| 171 | }
|
|---|
| 172 | .ax-back {
|
|---|
| 173 | display: inline-flex; align-items: center; justify-content: center;
|
|---|
| 174 | width: 40px; height: 40px;
|
|---|
| 175 | border: 1px solid var(--rule);
|
|---|
| 176 | border-radius: 8px;
|
|---|
| 177 | background: var(--paper);
|
|---|
| 178 | color: var(--ink);
|
|---|
| 179 | text-decoration: none;
|
|---|
| 180 | font-size: 1.1rem;
|
|---|
| 181 | flex-shrink: 0;
|
|---|
| 182 | transition: border-color 120ms;
|
|---|
| 183 | }
|
|---|
| 184 | .ax-back:hover { border-color: var(--accent); }
|
|---|
| 185 |
|
|---|
| 186 | /* ─── Flash messages ───────────────────────────────────────────── */
|
|---|
| 187 | .ax-flash {
|
|---|
| 188 | padding: 0.75rem 1rem;
|
|---|
| 189 | border-radius: 8px;
|
|---|
| 190 | margin-bottom: 1rem;
|
|---|
| 191 | font-size: 0.9rem;
|
|---|
| 192 | }
|
|---|
| 193 | .ax-flash-ok { background: rgba(40,160,90,.15); color: #2a9d5e; border: 1px solid rgba(40,160,90,.3); }
|
|---|
| 194 | .ax-flash-err { background: rgba(200,60,60,.15); color: #c33; border: 1px solid rgba(200,60,60,.3); }
|
|---|
| 195 |
|
|---|
| 196 | /* ─── Card sections ────────────────────────────────────────────── */
|
|---|
| 197 | .ax-card {
|
|---|
| 198 | background: var(--paper);
|
|---|
| 199 | border: 1px solid var(--rule);
|
|---|
| 200 | border-radius: 12px;
|
|---|
| 201 | padding: 1.25rem;
|
|---|
| 202 | margin-bottom: 1rem;
|
|---|
| 203 | }
|
|---|
| 204 | .ax-card-title {
|
|---|
| 205 | font-family: var(--font-display, serif);
|
|---|
| 206 | font-size: 1.15rem;
|
|---|
| 207 | font-weight: 600;
|
|---|
| 208 | margin-bottom: 1rem;
|
|---|
| 209 | display: flex; align-items: baseline; gap: 0.5rem;
|
|---|
| 210 | }
|
|---|
| 211 | .ax-count {
|
|---|
| 212 | font-family: var(--font-ui, system-ui);
|
|---|
| 213 | font-size: 0.8rem;
|
|---|
| 214 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 215 | background: var(--paper-2);
|
|---|
| 216 | padding: 0.1em 0.55em;
|
|---|
| 217 | border-radius: 999px;
|
|---|
| 218 | font-weight: 500;
|
|---|
| 219 | }
|
|---|
| 220 |
|
|---|
| 221 | /* ─── Form fields ──────────────────────────────────────────────── */
|
|---|
| 222 | .ax-form {
|
|---|
| 223 | display: flex; flex-direction: column;
|
|---|
| 224 | gap: 0.85rem;
|
|---|
| 225 | }
|
|---|
| 226 | .ax-row {
|
|---|
| 227 | display: grid; gap: 0.85rem;
|
|---|
| 228 | }
|
|---|
| 229 | .ax-row-2 { grid-template-columns: 1fr 1fr; }
|
|---|
| 230 | @media (max-width: 600px) {
|
|---|
| 231 | .ax-row-2 { grid-template-columns: 1fr; }
|
|---|
| 232 | }
|
|---|
| 233 | .ax-field {
|
|---|
| 234 | display: flex; flex-direction: column;
|
|---|
| 235 | gap: 0.35rem;
|
|---|
| 236 | min-width: 0;
|
|---|
| 237 | }
|
|---|
| 238 | .ax-field > span {
|
|---|
| 239 | font-size: 0.8rem;
|
|---|
| 240 | font-weight: 600;
|
|---|
| 241 | color: var(--ink-soft);
|
|---|
| 242 | display: flex; align-items: baseline; gap: 0.4rem;
|
|---|
| 243 | flex-wrap: wrap;
|
|---|
| 244 | }
|
|---|
| 245 | .ax-field > span small {
|
|---|
| 246 | font-weight: 400; color: var(--ink-muted);
|
|---|
| 247 | font-size: 0.95em;
|
|---|
| 248 | }
|
|---|
| 249 | .ax-field input[type="text"],
|
|---|
| 250 | .ax-field input[type="url"],
|
|---|
| 251 | .ax-field input[type="email"],
|
|---|
| 252 | .ax-field select,
|
|---|
| 253 | .ax-field textarea {
|
|---|
| 254 | width: 100%;
|
|---|
| 255 | box-sizing: border-box;
|
|---|
| 256 | padding: 0.6rem 0.75rem;
|
|---|
| 257 | border: 1px solid var(--rule);
|
|---|
| 258 | border-radius: 6px;
|
|---|
| 259 | background: var(--paper-2);
|
|---|
| 260 | color: var(--ink);
|
|---|
| 261 | font-family: var(--font-ui, system-ui), sans-serif;
|
|---|
| 262 | font-size: 0.95rem;
|
|---|
| 263 | -webkit-appearance: none; appearance: none;
|
|---|
| 264 | transition: border-color 120ms;
|
|---|
| 265 | }
|
|---|
| 266 | .ax-field input:focus,
|
|---|
| 267 | .ax-field select:focus,
|
|---|
| 268 | .ax-field textarea:focus {
|
|---|
| 269 | outline: 2px solid var(--accent);
|
|---|
| 270 | outline-offset: -1px;
|
|---|
| 271 | border-color: var(--accent);
|
|---|
| 272 | }
|
|---|
| 273 |
|
|---|
| 274 | /* ─── File inputs (custom-styled wrapper) ─────────────────────── */
|
|---|
| 275 | .ax-file-control {
|
|---|
| 276 | display: flex; align-items: center; gap: 0.5rem;
|
|---|
| 277 | flex-wrap: wrap;
|
|---|
| 278 | position: relative;
|
|---|
| 279 | min-height: 40px;
|
|---|
| 280 | }
|
|---|
| 281 | .ax-file-control input[type="file"] {
|
|---|
| 282 | position: absolute; inset: 0;
|
|---|
| 283 | opacity: 0; cursor: pointer;
|
|---|
| 284 | z-index: 2;
|
|---|
| 285 | }
|
|---|
| 286 | .ax-file-btn {
|
|---|
| 287 | pointer-events: none; /* let clicks pass through to the file input */
|
|---|
| 288 | }
|
|---|
| 289 | .ax-file-name {
|
|---|
| 290 | font-size: 0.85rem;
|
|---|
| 291 | color: var(--ink);
|
|---|
| 292 | word-break: break-all;
|
|---|
| 293 | flex: 1;
|
|---|
| 294 | min-width: 0;
|
|---|
| 295 | }
|
|---|
| 296 | .ax-file-name[data-empty] { color: var(--ink-muted, var(--ink-soft)); }
|
|---|
| 297 |
|
|---|
| 298 | .ax-form-actions {
|
|---|
| 299 | display: flex; gap: 0.5rem;
|
|---|
| 300 | margin-top: 0.25rem;
|
|---|
| 301 | }
|
|---|
| 302 | .ax-form-actions[hidden] { display: none; }
|
|---|
| 303 |
|
|---|
| 304 | /* ─── Buttons ──────────────────────────────────────────────────── */
|
|---|
| 305 | .ax-btn {
|
|---|
| 306 | display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
|
|---|
| 307 | padding: 0.55rem 1rem;
|
|---|
| 308 | border: 1px solid var(--rule);
|
|---|
| 309 | background: var(--paper-2);
|
|---|
| 310 | color: var(--ink);
|
|---|
| 311 | font-family: var(--font-ui, system-ui), sans-serif;
|
|---|
| 312 | font-size: 0.9rem; font-weight: 500;
|
|---|
| 313 | text-decoration: none;
|
|---|
| 314 | border-radius: 6px;
|
|---|
| 315 | cursor: pointer;
|
|---|
| 316 | min-height: 40px;
|
|---|
| 317 | transition: background 120ms, border-color 120ms;
|
|---|
| 318 | -webkit-tap-highlight-color: transparent;
|
|---|
| 319 | }
|
|---|
| 320 | .ax-btn:hover { border-color: var(--accent); }
|
|---|
| 321 | .ax-btn-secondary { background: var(--paper-2); }
|
|---|
| 322 | .ax-btn-primary {
|
|---|
| 323 | background: var(--accent); color: white;
|
|---|
| 324 | border-color: var(--accent);
|
|---|
| 325 | }
|
|---|
| 326 | .ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }
|
|---|
| 327 |
|
|---|
| 328 | /* Tiny icon-only button for row actions */
|
|---|
| 329 | .ax-icon-btn {
|
|---|
| 330 | display: inline-flex; align-items: center; justify-content: center;
|
|---|
| 331 | width: 40px; height: 40px;
|
|---|
| 332 | border: 1px solid var(--rule);
|
|---|
| 333 | background: var(--paper-2);
|
|---|
| 334 | color: var(--ink);
|
|---|
| 335 | border-radius: 8px;
|
|---|
| 336 | cursor: pointer;
|
|---|
| 337 | font-size: 1rem;
|
|---|
| 338 | transition: background 120ms, border-color 120ms, color 120ms;
|
|---|
| 339 | }
|
|---|
| 340 | .ax-icon-btn:hover { border-color: var(--accent); }
|
|---|
| 341 | .ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }
|
|---|
| 342 |
|
|---|
| 343 | .ax-track-play.is-playing {
|
|---|
| 344 | border-color: var(--accent);
|
|---|
| 345 | color: var(--accent);
|
|---|
| 346 | background: color-mix(in srgb, var(--accent) 12%, var(--paper-2));
|
|---|
| 347 | }
|
|---|
| 348 | .ax-track-play-icon { font-size: 0.85rem; line-height: 1; }
|
|---|
| 349 |
|
|---|
| 350 | /* ─── Empty state ──────────────────────────────────────────────── */
|
|---|
| 351 | .ax-empty {
|
|---|
| 352 | text-align: center;
|
|---|
| 353 | padding: 2rem 1rem;
|
|---|
| 354 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 355 | }
|
|---|
| 356 | .ax-empty-icon {
|
|---|
| 357 | font-size: 2.5rem;
|
|---|
| 358 | font-family: var(--font-display, serif);
|
|---|
| 359 | color: var(--ink-soft);
|
|---|
| 360 | opacity: 0.4;
|
|---|
| 361 | margin-bottom: 0.5rem;
|
|---|
| 362 | }
|
|---|
| 363 | .ax-empty p { margin: 0; }
|
|---|
| 364 |
|
|---|
| 365 | /* ─── Track list (card per row) ────────────────────────────────── */
|
|---|
| 366 | .ax-list {
|
|---|
| 367 | list-style: none; padding: 0; margin: 0;
|
|---|
| 368 | display: flex; flex-direction: column;
|
|---|
| 369 | gap: 0.6rem;
|
|---|
| 370 | }
|
|---|
| 371 | .ax-track {
|
|---|
| 372 | display: grid;
|
|---|
| 373 | grid-template-columns: 56px 1fr auto;
|
|---|
| 374 | gap: 0.85rem;
|
|---|
| 375 | align-items: center;
|
|---|
| 376 | padding: 0.75rem;
|
|---|
| 377 | background: var(--paper-2);
|
|---|
| 378 | border: 1px solid var(--rule);
|
|---|
| 379 | border-radius: 10px;
|
|---|
| 380 | transition: border-color 120ms;
|
|---|
| 381 | }
|
|---|
| 382 | .ax-track:hover { border-color: var(--accent); }
|
|---|
| 383 | .ax-track-cover {
|
|---|
| 384 | width: 56px; height: 56px;
|
|---|
| 385 | border-radius: 6px;
|
|---|
| 386 | object-fit: cover;
|
|---|
| 387 | background: var(--paper);
|
|---|
| 388 | display: flex; align-items: center; justify-content: center;
|
|---|
| 389 | font-family: var(--font-display, serif);
|
|---|
| 390 | font-size: 1.5rem;
|
|---|
| 391 | color: var(--accent);
|
|---|
| 392 | flex-shrink: 0;
|
|---|
| 393 | }
|
|---|
| 394 | .ax-track-cover-empty { /* span variant */ }
|
|---|
| 395 | .ax-track-meta {
|
|---|
| 396 | min-width: 0; /* allow text-overflow on long titles */
|
|---|
| 397 | display: flex; flex-direction: column; gap: 0.2rem;
|
|---|
| 398 | }
|
|---|
| 399 | .ax-track-title {
|
|---|
| 400 | font-weight: 600;
|
|---|
| 401 | font-size: 0.95rem;
|
|---|
| 402 | color: var(--ink);
|
|---|
| 403 | overflow: hidden;
|
|---|
| 404 | text-overflow: ellipsis;
|
|---|
| 405 | white-space: nowrap;
|
|---|
| 406 | }
|
|---|
| 407 | .ax-track-sub {
|
|---|
| 408 | font-size: 0.8rem;
|
|---|
| 409 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 410 | display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;
|
|---|
| 411 | }
|
|---|
| 412 | .ax-track-sep { opacity: 0.5; }
|
|---|
| 413 | .ax-embed {
|
|---|
| 414 | display: inline-block;
|
|---|
| 415 | background: var(--paper);
|
|---|
| 416 | padding: 0.15rem 0.5rem;
|
|---|
| 417 | border-radius: 4px;
|
|---|
| 418 | font-family: var(--font-mono, ui-monospace, monospace);
|
|---|
| 419 | font-size: 0.75rem;
|
|---|
| 420 | color: var(--ink-soft);
|
|---|
| 421 | cursor: pointer;
|
|---|
| 422 | user-select: all;
|
|---|
| 423 | margin-top: 0.15rem;
|
|---|
| 424 | word-break: break-all;
|
|---|
| 425 | align-self: flex-start;
|
|---|
| 426 | transition: background 120ms;
|
|---|
| 427 | }
|
|---|
| 428 | .ax-embed:hover { background: var(--rule); }
|
|---|
| 429 | .ax-embed.is-copied { background: rgba(40,160,90,0.2); color: #2a9d5e; }
|
|---|
| 430 |
|
|---|
| 431 | .ax-track-actions {
|
|---|
| 432 | display: flex; gap: 0.4rem;
|
|---|
| 433 | flex-shrink: 0;
|
|---|
| 434 | }
|
|---|
| 435 | .ax-track-delete { display: inline; }
|
|---|
| 436 |
|
|---|
| 437 | /* ─── Mobile tweaks ───────────────────────────────────────────── */
|
|---|
| 438 | @media (max-width: 480px) {
|
|---|
| 439 | .ax-track {
|
|---|
| 440 | grid-template-columns: 48px 1fr;
|
|---|
| 441 | grid-template-areas: "cover meta" "actions actions";
|
|---|
| 442 | gap: 0.6rem;
|
|---|
| 443 | }
|
|---|
| 444 | .ax-track-cover { grid-area: cover; width: 48px; height: 48px; }
|
|---|
| 445 | .ax-track-meta { grid-area: meta; }
|
|---|
| 446 | .ax-track-actions {
|
|---|
| 447 | grid-area: actions;
|
|---|
| 448 | justify-content: flex-end;
|
|---|
| 449 | border-top: 1px solid var(--rule);
|
|---|
| 450 | padding-top: 0.5rem;
|
|---|
| 451 | margin-top: 0.25rem;
|
|---|
| 452 | }
|
|---|
| 453 | }
|
|---|
| 454 |
|
|---|
| 455 | /* ─── Bulk drop-zone ──────────────────────────────────────────── */
|
|---|
| 456 | .ax-dropzone {
|
|---|
| 457 | display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|---|
| 458 | gap: 0.4rem;
|
|---|
| 459 | padding: 2rem 1rem;
|
|---|
| 460 | border: 2px dashed var(--rule);
|
|---|
| 461 | border-radius: 12px;
|
|---|
| 462 | background: var(--paper-2);
|
|---|
| 463 | cursor: pointer;
|
|---|
| 464 | text-align: center;
|
|---|
| 465 | transition: border-color 150ms, background 150ms;
|
|---|
| 466 | position: relative;
|
|---|
| 467 | min-height: 160px;
|
|---|
| 468 | }
|
|---|
| 469 | .ax-dropzone:hover,
|
|---|
| 470 | .ax-dropzone:focus-within {
|
|---|
| 471 | border-color: var(--accent);
|
|---|
| 472 | }
|
|---|
| 473 | .ax-dropzone.is-dragover {
|
|---|
| 474 | border-color: var(--accent);
|
|---|
| 475 | background: color-mix(in srgb, var(--accent) 8%, var(--paper-2));
|
|---|
| 476 | }
|
|---|
| 477 | .ax-dropzone input[type="file"] {
|
|---|
| 478 | /* Cover the whole dropzone but invisible — clicks bubble through to it. */
|
|---|
| 479 | position: absolute; inset: 0;
|
|---|
| 480 | opacity: 0;
|
|---|
| 481 | cursor: pointer;
|
|---|
| 482 | }
|
|---|
| 483 | .ax-dropzone-icon {
|
|---|
| 484 | font-size: 2rem;
|
|---|
| 485 | opacity: 0.5;
|
|---|
| 486 | line-height: 1;
|
|---|
| 487 | }
|
|---|
| 488 | .ax-dropzone-title {
|
|---|
| 489 | font-size: 1rem;
|
|---|
| 490 | font-weight: 600;
|
|---|
| 491 | color: var(--ink);
|
|---|
| 492 | }
|
|---|
| 493 | .ax-dropzone-sub {
|
|---|
| 494 | font-size: 0.85rem;
|
|---|
| 495 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 496 | line-height: 1.4;
|
|---|
| 497 | }
|
|---|
| 498 | .ax-dropzone-sub small {
|
|---|
| 499 | font-size: 0.75rem;
|
|---|
| 500 | opacity: 0.8;
|
|---|
| 501 | }
|
|---|
| 502 |
|
|---|
| 503 | /* ─── Upload queue ────────────────────────────────────────────── */
|
|---|
| 504 | .ax-upload-queue {
|
|---|
| 505 | list-style: none;
|
|---|
| 506 | padding: 0; margin: 0.75rem 0 0;
|
|---|
| 507 | display: flex; flex-direction: column;
|
|---|
| 508 | gap: 0.4rem;
|
|---|
| 509 | }
|
|---|
| 510 | .ax-queue-item {
|
|---|
| 511 | display: grid;
|
|---|
| 512 | grid-template-columns: 1fr auto;
|
|---|
| 513 | gap: 0.75rem;
|
|---|
| 514 | align-items: center;
|
|---|
| 515 | padding: 0.6rem 0.85rem;
|
|---|
| 516 | background: var(--paper);
|
|---|
| 517 | border: 1px solid var(--rule);
|
|---|
| 518 | border-radius: 8px;
|
|---|
| 519 | font-size: 0.875rem;
|
|---|
| 520 | transition: border-color 150ms, opacity 150ms;
|
|---|
| 521 | }
|
|---|
| 522 | .ax-queue-item--active {
|
|---|
| 523 | border-color: var(--accent);
|
|---|
| 524 | }
|
|---|
| 525 | .ax-queue-item--done {
|
|---|
| 526 | border-color: rgba(40,160,90,0.4);
|
|---|
| 527 | opacity: 0.85;
|
|---|
| 528 | }
|
|---|
| 529 | .ax-queue-item--error {
|
|---|
| 530 | border-color: rgba(220,60,60,0.5);
|
|---|
| 531 | }
|
|---|
| 532 | .ax-queue-name {
|
|---|
| 533 | font-weight: 500;
|
|---|
| 534 | color: var(--ink);
|
|---|
| 535 | overflow: hidden;
|
|---|
| 536 | text-overflow: ellipsis;
|
|---|
| 537 | white-space: nowrap;
|
|---|
| 538 | min-width: 0;
|
|---|
| 539 | }
|
|---|
| 540 | .ax-queue-size {
|
|---|
| 541 | font-size: 0.75rem;
|
|---|
| 542 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 543 | margin-left: 0.5rem;
|
|---|
| 544 | }
|
|---|
| 545 | .ax-queue-status {
|
|---|
| 546 | font-size: 0.75rem;
|
|---|
| 547 | font-weight: 500;
|
|---|
| 548 | padding: 0.15em 0.6em;
|
|---|
| 549 | border-radius: 999px;
|
|---|
| 550 | background: var(--paper-2);
|
|---|
| 551 | color: var(--ink-soft);
|
|---|
| 552 | white-space: nowrap;
|
|---|
| 553 | flex-shrink: 0;
|
|---|
| 554 | }
|
|---|
| 555 | .ax-queue-status--queued { color: var(--ink-muted, var(--ink-soft)); }
|
|---|
| 556 | .ax-queue-status--uploading,
|
|---|
| 557 | .ax-queue-status--transcoding {
|
|---|
| 558 | color: var(--accent);
|
|---|
| 559 | background: color-mix(in srgb, var(--accent) 10%, var(--paper-2));
|
|---|
| 560 | }
|
|---|
| 561 | .ax-queue-status--done {
|
|---|
| 562 | color: #2a9d5e;
|
|---|
| 563 | background: rgba(40,160,90,0.12);
|
|---|
| 564 | }
|
|---|
| 565 | .ax-queue-status--error {
|
|---|
| 566 | color: #dc2626;
|
|---|
| 567 | background: rgba(220,60,60,0.12);
|
|---|
| 568 | }
|
|---|
| 569 | </style>
|
|---|
| 570 |
|
|---|
| 571 | <%# Track editor modal (exposes window.openTrackEditor) %>
|
|---|
| 572 | <%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
|
|---|
| 573 |
|
|---|
| 574 | <script>
|
|---|
| 575 | (function() {
|
|---|
| 576 |
|
|---|
| 577 | // ── Live filename display for custom file inputs ──────────────
|
|---|
| 578 | document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
|
|---|
| 579 | const nameEl = input.parentElement.querySelector('.ax-file-name');
|
|---|
| 580 | if (!nameEl) return;
|
|---|
| 581 | input.addEventListener('change', () => {
|
|---|
| 582 | if (input.files && input.files[0]) {
|
|---|
| 583 | nameEl.textContent = input.files[0].name;
|
|---|
| 584 | nameEl.removeAttribute('data-empty');
|
|---|
| 585 | } else {
|
|---|
| [bbf9d1a] | 586 | nameEl.textContent = '<%= t('aaud.no_file') %>';
|
|---|
| [7bc636b] | 587 | nameEl.setAttribute('data-empty', '');
|
|---|
| 588 | }
|
|---|
| 589 | });
|
|---|
| 590 | });
|
|---|
| 591 |
|
|---|
| 592 | // ── Inline track preview (routed through the global mini-player) ──
|
|---|
| 593 | // Each .ax-track-play button is a thin wrapper around the global
|
|---|
| 594 | // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
|
|---|
| 595 | // .is-playing) is synced from the player's own audio element so it
|
|---|
| 596 | // stays accurate even when the user uses the mini-player's controls.
|
|---|
| 597 | (function setupTrackPreview() {
|
|---|
| 598 | const buttons = document.querySelectorAll('.ax-track-play');
|
|---|
| 599 | if (!buttons.length) return;
|
|---|
| 600 |
|
|---|
| 601 | function setIcon(btn, playing) {
|
|---|
| 602 | const icon = btn.querySelector('.ax-track-play-icon');
|
|---|
| 603 | if (icon) icon.textContent = playing ? '⏸' : '▶';
|
|---|
| 604 | btn.classList.toggle('is-playing', playing);
|
|---|
| [bbf9d1a] | 605 | btn.setAttribute('aria-label', playing ? '<%= t('aaud.pause') %>' : '<%= t('aaud.play') %>');
|
|---|
| [7bc636b] | 606 | }
|
|---|
| 607 |
|
|---|
| 608 | // Resync ALL preview buttons against the current audio element state.
|
|---|
| 609 | // Called on every play/pause/ended event so admins always see the right
|
|---|
| 610 | // icon — including the case where they hit pause on the mini-player
|
|---|
| 611 | // bar instead of the row's button.
|
|---|
| 612 | function resyncAll() {
|
|---|
| 613 | const audio = document.getElementById('audio-element');
|
|---|
| [21522ae] | 614 | const player = window.pcmsAudioPlayer;
|
|---|
| [7bc636b] | 615 | const playing = audio && !audio.paused && !audio.ended;
|
|---|
| [21522ae] | 616 | // audio.src is now a blob: URL (Spotify-style playback), so compare
|
|---|
| 617 | // against the player's logical track URL, not the element src.
|
|---|
| 618 | const cur = player && player.currentTrack();
|
|---|
| 619 | const curUrl = cur ? cur.url : '';
|
|---|
| [7bc636b] | 620 | buttons.forEach(b => {
|
|---|
| [21522ae] | 621 | const isThisOne = playing && curUrl === b.dataset.streamUrl;
|
|---|
| [7bc636b] | 622 | setIcon(b, isThisOne);
|
|---|
| 623 | });
|
|---|
| 624 | }
|
|---|
| 625 |
|
|---|
| 626 | buttons.forEach(btn => {
|
|---|
| 627 | btn.addEventListener('click', e => {
|
|---|
| 628 | e.preventDefault();
|
|---|
| 629 | const player = window.pcmsAudioPlayer;
|
|---|
| 630 | if (!player) {
|
|---|
| 631 | console.warn('[admin-audio] miniplayer not available');
|
|---|
| 632 | return;
|
|---|
| 633 | }
|
|---|
| 634 | const url = btn.dataset.streamUrl;
|
|---|
| 635 | if (!url) return;
|
|---|
| 636 |
|
|---|
| 637 | // Build track metadata from the row's DOM so the mini-player shows
|
|---|
| 638 | // useful info (title/artist/album/cover) without an extra API call.
|
|---|
| 639 | const row = btn.closest('.ax-track');
|
|---|
| 640 | const titleEl = row && row.querySelector('[data-cell="title"]');
|
|---|
| 641 | const artistEl = row && row.querySelector('[data-cell="artist"]');
|
|---|
| 642 | const albumEl = row && row.querySelector('[data-cell="album"]');
|
|---|
| 643 | const coverImg = row && row.querySelector('img[data-cover-thumb]');
|
|---|
| 644 | const track = {
|
|---|
| 645 | url,
|
|---|
| 646 | title: titleEl ? titleEl.textContent.trim() : 'Track',
|
|---|
| 647 | artist: artistEl ? artistEl.textContent.trim() : '',
|
|---|
| 648 | album: albumEl ? albumEl.textContent.trim() : '',
|
|---|
| 649 | cover: coverImg ? coverImg.src : '',
|
|---|
| 650 | };
|
|---|
| 651 |
|
|---|
| [21522ae] | 652 | // If this exact track is already current, toggle pause/play instead
|
|---|
| 653 | // of restarting from zero. Compare logical URLs (audio.src is a blob:).
|
|---|
| 654 | const cur = player.currentTrack();
|
|---|
| 655 | if (cur && cur.url === url) {
|
|---|
| 656 | if (player.isPlaying()) player.pause();
|
|---|
| 657 | else player.play();
|
|---|
| [7bc636b] | 658 | return;
|
|---|
| 659 | }
|
|---|
| 660 |
|
|---|
| 661 | player.setQueue([track], 0);
|
|---|
| 662 | });
|
|---|
| 663 | });
|
|---|
| 664 |
|
|---|
| 665 | // Hook the global audio element's events to keep the row buttons synced.
|
|---|
| 666 | // We attach lazily after the player has built its DOM. The script in
|
|---|
| 667 | // shell.ejs runs at page-load so #audio-element exists by the time
|
|---|
| 668 | // this IIFE fires (script tag is below the body content).
|
|---|
| 669 | const audio = document.getElementById('audio-element');
|
|---|
| 670 | if (audio) {
|
|---|
| 671 | ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
|
|---|
| 672 | audio.addEventListener(ev, resyncAll);
|
|---|
| 673 | });
|
|---|
| 674 | // Initial state on page load (e.g. user navigated back while a track
|
|---|
| 675 | // was already playing — buttons should reflect that).
|
|---|
| 676 | resyncAll();
|
|---|
| 677 | }
|
|---|
| 678 | })();
|
|---|
| 679 |
|
|---|
| 680 |
|
|---|
| 681 | // ── Bulk upload (drag-drop + sequential transcoding) ─────────
|
|---|
| 682 | // Files dropped or picked are queued (not uploaded immediately) so the
|
|---|
| 683 | // user can review the list, set shared metadata, then hit "Start upload".
|
|---|
| 684 | // The loop POSTs one file at a time to /admin/audio/upload with
|
|---|
| 685 | // Accept: application/json so the server returns structured per-file
|
|---|
| 686 | // results instead of redirecting.
|
|---|
| 687 | const dropzone = document.getElementById('audio-dropzone');
|
|---|
| 688 | const fileInput = document.getElementById('audio-files');
|
|---|
| 689 | const queueEl = document.getElementById('upload-queue');
|
|---|
| 690 | const actionsEl = document.getElementById('upload-actions');
|
|---|
| 691 | const startBtn = document.getElementById('start-upload-btn');
|
|---|
| 692 | const clearBtn = document.getElementById('clear-queue-btn');
|
|---|
| 693 | const artistInput= document.getElementById('batch-artist');
|
|---|
| 694 | const albumInput = document.getElementById('batch-album');
|
|---|
| 695 | const coverInput = document.getElementById('batch-cover');
|
|---|
| 696 |
|
|---|
| 697 | /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
|
|---|
| 698 | const queue = [];
|
|---|
| 699 |
|
|---|
| 700 | function fmtBytes(n) {
|
|---|
| 701 | if (n < 1024) return n + ' B';
|
|---|
| 702 | if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
|
|---|
| 703 | return (n / 1024 / 1024).toFixed(1) + ' MB';
|
|---|
| 704 | }
|
|---|
| 705 |
|
|---|
| 706 | function setItemStatus(item, status, label) {
|
|---|
| 707 | const labels = {
|
|---|
| [bbf9d1a] | 708 | queued: '<%= t('aaud.st_queued') %>',
|
|---|
| 709 | uploading: '<%= t('aaud.st_uploading') %>',
|
|---|
| 710 | transcoding: '<%= t('aaud.st_transcoding') %>',
|
|---|
| 711 | done: '✓ <%= t('aaud.st_done') %>',
|
|---|
| 712 | error: '✗ <%= t('aaud.st_error') %>',
|
|---|
| [7bc636b] | 713 | };
|
|---|
| 714 | item.status = status;
|
|---|
| 715 | const badge = item.el.querySelector('.ax-queue-status');
|
|---|
| 716 | badge.className = 'ax-queue-status ax-queue-status--' + status;
|
|---|
| 717 | badge.textContent = label || labels[status] || status;
|
|---|
| 718 | // Restyle the row
|
|---|
| 719 | item.el.classList.remove(
|
|---|
| 720 | 'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
|
|---|
| 721 | );
|
|---|
| 722 | if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
|
|---|
| 723 | else if (status === 'done') item.el.classList.add('ax-queue-item--done');
|
|---|
| 724 | else if (status === 'error') item.el.classList.add('ax-queue-item--error');
|
|---|
| 725 | }
|
|---|
| 726 |
|
|---|
| 727 | function addFiles(files) {
|
|---|
| 728 | let added = 0;
|
|---|
| 729 | for (const file of files) {
|
|---|
| 730 | if (!file.type.startsWith('audio/') &&
|
|---|
| 731 | !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
|
|---|
| 732 | // Silently skip non-audio drops; keeps the UX uncluttered.
|
|---|
| 733 | continue;
|
|---|
| 734 | }
|
|---|
| 735 | const li = document.createElement('li');
|
|---|
| 736 | li.className = 'ax-queue-item';
|
|---|
| 737 | li.innerHTML =
|
|---|
| 738 | '<div class="ax-queue-name"></div>' +
|
|---|
| [bbf9d1a] | 739 | '<span class="ax-queue-status ax-queue-status--queued"><%= t('aaud.st_queued') %></span>';
|
|---|
| [7bc636b] | 740 | // Use textContent to avoid HTML-injection if a filename contains markup.
|
|---|
| 741 | li.querySelector('.ax-queue-name').textContent = file.name;
|
|---|
| 742 | // Append size hint inline
|
|---|
| 743 | const size = document.createElement('span');
|
|---|
| 744 | size.className = 'ax-queue-size';
|
|---|
| 745 | size.textContent = fmtBytes(file.size);
|
|---|
| 746 | li.querySelector('.ax-queue-name').appendChild(size);
|
|---|
| 747 | queueEl.appendChild(li);
|
|---|
| 748 | queue.push({ file, el: li, status: 'queued' });
|
|---|
| 749 | added++;
|
|---|
| 750 | }
|
|---|
| 751 | if (added) {
|
|---|
| 752 | queueEl.hidden = false;
|
|---|
| 753 | actionsEl.hidden = false;
|
|---|
| 754 | }
|
|---|
| 755 | }
|
|---|
| 756 |
|
|---|
| 757 | // ── Drop-zone events ─────────────────────────────────────────
|
|---|
| 758 | // Page-level guard: a dropped file outside the zone would otherwise
|
|---|
| 759 | // make the browser navigate to it (e.g. opening the audio inline), which
|
|---|
| 760 | // discards typed metadata. We swallow drops anywhere unless the dropzone
|
|---|
| 761 | // explicitly handles them.
|
|---|
| 762 | ['dragover', 'drop'].forEach(ev => {
|
|---|
| 763 | window.addEventListener(ev, e => {
|
|---|
| 764 | // Allow drops INSIDE the dropzone — its own listener handles those.
|
|---|
| 765 | if (dropzone.contains(e.target)) return;
|
|---|
| 766 | e.preventDefault();
|
|---|
| 767 | });
|
|---|
| 768 | });
|
|---|
| 769 |
|
|---|
| 770 | ['dragenter', 'dragover'].forEach(ev => {
|
|---|
| 771 | dropzone.addEventListener(ev, e => {
|
|---|
| 772 | e.preventDefault();
|
|---|
| 773 | dropzone.classList.add('is-dragover');
|
|---|
| 774 | });
|
|---|
| 775 | });
|
|---|
| 776 | ['dragleave', 'drop'].forEach(ev => {
|
|---|
| 777 | dropzone.addEventListener(ev, e => {
|
|---|
| 778 | e.preventDefault();
|
|---|
| 779 | dropzone.classList.remove('is-dragover');
|
|---|
| 780 | });
|
|---|
| 781 | });
|
|---|
| 782 | dropzone.addEventListener('drop', e => {
|
|---|
| 783 | if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
|
|---|
| 784 | });
|
|---|
| 785 | fileInput.addEventListener('change', () => {
|
|---|
| 786 | addFiles(fileInput.files);
|
|---|
| 787 | // Reset so the same file can be picked again later if user wants
|
|---|
| 788 | fileInput.value = '';
|
|---|
| 789 | });
|
|---|
| 790 |
|
|---|
| 791 | // ── Clear queue button ───────────────────────────────────────
|
|---|
| 792 | clearBtn.addEventListener('click', () => {
|
|---|
| 793 | // Only remove items that aren't currently uploading (anything queued
|
|---|
| 794 | // or already finished). An in-progress upload finishes, then its row
|
|---|
| 795 | // would also disappear once we re-render — but we keep it simple and
|
|---|
| 796 | // just refuse to clear during an active run.
|
|---|
| 797 | if (startBtn.disabled) return;
|
|---|
| 798 | queue.length = 0;
|
|---|
| 799 | queueEl.innerHTML = '';
|
|---|
| 800 | queueEl.hidden = true;
|
|---|
| 801 | actionsEl.hidden = true;
|
|---|
| 802 | });
|
|---|
| 803 |
|
|---|
| 804 | // ── Sequential upload loop ───────────────────────────────────
|
|---|
| 805 | startBtn.addEventListener('click', async () => {
|
|---|
| 806 | if (startBtn.disabled) return;
|
|---|
| 807 | startBtn.disabled = true;
|
|---|
| 808 | clearBtn.disabled = true;
|
|---|
| 809 | dropzone.style.pointerEvents = 'none';
|
|---|
| 810 | dropzone.style.opacity = '0.5';
|
|---|
| 811 |
|
|---|
| 812 | const sharedArtist = artistInput.value.trim();
|
|---|
| 813 | const sharedAlbum = albumInput.value.trim();
|
|---|
| 814 | const sharedCover = coverInput.files && coverInput.files[0];
|
|---|
| 815 |
|
|---|
| 816 | // Process queued items one at a time. We iterate via index so that
|
|---|
| 817 | // if more files get dropped during the run they ALSO get processed
|
|---|
| 818 | // (queue.push above mutates the same array we're iterating).
|
|---|
| 819 | for (let i = 0; i < queue.length; i++) {
|
|---|
| 820 | const item = queue[i];
|
|---|
| 821 | if (item.status !== 'queued') continue;
|
|---|
| 822 | try {
|
|---|
| 823 | await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
|
|---|
| 824 | } catch (err) {
|
|---|
| 825 | console.error('upload failed for', item.file.name, err);
|
|---|
| [bbf9d1a] | 826 | setItemStatus(item, 'error', '✗ ' + (err.message || '<%= t('aaud.failed') %>'));
|
|---|
| [7bc636b] | 827 | }
|
|---|
| 828 | }
|
|---|
| 829 |
|
|---|
| 830 | startBtn.disabled = false;
|
|---|
| 831 | clearBtn.disabled = false;
|
|---|
| 832 | dropzone.style.pointerEvents = '';
|
|---|
| 833 | dropzone.style.opacity = '';
|
|---|
| 834 |
|
|---|
| 835 | // Reload the page so the new tracks appear in the list below.
|
|---|
| 836 | // Could also fetch them and inject, but a full reload is simpler and
|
|---|
| 837 | // ensures position indexes / album-grouping are correct.
|
|---|
| 838 | const anyDone = queue.some(q => q.status === 'done');
|
|---|
| 839 | if (anyDone) {
|
|---|
| 840 | setTimeout(() => location.reload(), 700);
|
|---|
| 841 | }
|
|---|
| 842 | });
|
|---|
| 843 |
|
|---|
| 844 | async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
|
|---|
| 845 | setItemStatus(item, 'uploading');
|
|---|
| 846 |
|
|---|
| 847 | const fd = new FormData();
|
|---|
| 848 | fd.append('audio', item.file);
|
|---|
| 849 | if (sharedArtist) fd.append('artist', sharedArtist);
|
|---|
| 850 | if (sharedAlbum) fd.append('album', sharedAlbum);
|
|---|
| 851 | if (sharedCover) fd.append('cover', sharedCover);
|
|---|
| 852 | // Title is intentionally omitted — server uses filename fallback.
|
|---|
| 853 |
|
|---|
| 854 | // We can't reliably distinguish "still uploading bytes" from
|
|---|
| 855 | // "uploading done, ffmpeg running" without progress events, but the
|
|---|
| 856 | // status flips to "Converteren…" once the request is past upload phase.
|
|---|
| 857 | // We approximate this by waiting until the response arrives — by then
|
|---|
| 858 | // both phases are complete on the server side. For a smoother feel we
|
|---|
| 859 | // briefly show "transcoding" near the end of the request lifecycle.
|
|---|
| 860 | const transcodeHint = setTimeout(() => {
|
|---|
| 861 | if (item.status === 'uploading') setItemStatus(item, 'transcoding');
|
|---|
| 862 | }, 1500);
|
|---|
| 863 |
|
|---|
| 864 | try {
|
|---|
| 865 | const res = await fetch('/admin/audio/upload', {
|
|---|
| 866 | method: 'POST',
|
|---|
| 867 | headers: { 'Accept': 'application/json' },
|
|---|
| 868 | body: fd,
|
|---|
| 869 | credentials: 'same-origin',
|
|---|
| 870 | });
|
|---|
| 871 | clearTimeout(transcodeHint);
|
|---|
| 872 |
|
|---|
| 873 | // Server responds with JSON for our Accept header. If it didn't
|
|---|
| 874 | // (e.g. session expired and got an HTML login page), surface that.
|
|---|
| 875 | let data;
|
|---|
| 876 | try { data = await res.json(); }
|
|---|
| [bbf9d1a] | 877 | catch (_) { throw new Error('<%= t('aaud.err_unexpected') %> (' + res.status + ')'); }
|
|---|
| [7bc636b] | 878 |
|
|---|
| 879 | if (!res.ok || !data.ok) {
|
|---|
| 880 | throw new Error(data.error || ('HTTP ' + res.status));
|
|---|
| 881 | }
|
|---|
| 882 |
|
|---|
| [bbf9d1a] | 883 | setItemStatus(item, 'done', '✓ ' + (data.title || '<%= t('aaud.st_done') %>'));
|
|---|
| [7bc636b] | 884 | } catch (err) {
|
|---|
| 885 | clearTimeout(transcodeHint);
|
|---|
| 886 | throw err;
|
|---|
| 887 | }
|
|---|
| 888 | }
|
|---|
| 889 |
|
|---|
| 890 | // ── Click-to-copy embed codes ─────────────────────────────────
|
|---|
| 891 | document.querySelectorAll('[data-copy]').forEach(el => {
|
|---|
| 892 | el.addEventListener('click', async () => {
|
|---|
| 893 | const text = el.dataset.copy;
|
|---|
| 894 | try {
|
|---|
| 895 | await navigator.clipboard.writeText(text);
|
|---|
| 896 | el.classList.add('is-copied');
|
|---|
| 897 | const original = el.textContent;
|
|---|
| [bbf9d1a] | 898 | el.textContent = '✓ <%= t('aaud.copied') %>';
|
|---|
| [7bc636b] | 899 | setTimeout(() => {
|
|---|
| 900 | el.classList.remove('is-copied');
|
|---|
| 901 | el.textContent = original;
|
|---|
| 902 | }, 1200);
|
|---|
| 903 | } catch (_) { /* fall through — selection still works */ }
|
|---|
| 904 | });
|
|---|
| 905 | });
|
|---|
| 906 |
|
|---|
| [d727e92] | 907 | // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
|
|---|
| 908 | const addLinkBtn = document.getElementById('add-link-track-btn');
|
|---|
| 909 | if (addLinkBtn) {
|
|---|
| 910 | addLinkBtn.addEventListener('click', async () => {
|
|---|
| 911 | addLinkBtn.disabled = true;
|
|---|
| 912 | try {
|
|---|
| 913 | const r = await fetch('/admin/audio/create-link', {
|
|---|
| 914 | method: 'POST', credentials: 'same-origin',
|
|---|
| 915 | headers: { 'Content-Type': 'application/json' },
|
|---|
| [bbf9d1a] | 916 | body: JSON.stringify({ title: '<%= t('aaud.new_track') %>' }),
|
|---|
| [d727e92] | 917 | });
|
|---|
| 918 | const j = await r.json();
|
|---|
| [bbf9d1a] | 919 | if (!r.ok || !j.ok) throw new Error(j.error || '<%= t('aaud.create_failed') %>');
|
|---|
| [d727e92] | 920 | if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
|
|---|
| 921 | window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
|
|---|
| 922 | } catch (err) {
|
|---|
| [bbf9d1a] | 923 | alert('<%= t('aaud.create_failed') %>: ' + err.message);
|
|---|
| [d727e92] | 924 | } finally {
|
|---|
| 925 | addLinkBtn.disabled = false;
|
|---|
| 926 | }
|
|---|
| 927 | });
|
|---|
| 928 | }
|
|---|
| 929 |
|
|---|
| [7bc636b] | 930 | // ── Wire all "Edit" buttons to the track-editor modal ─────────
|
|---|
| 931 | // After save we patch the row in-place rather than reloading,
|
|---|
| 932 | // so the user keeps their scroll position on long lists.
|
|---|
| 933 | document.querySelectorAll('[data-track-edit]').forEach(btn => {
|
|---|
| 934 | btn.addEventListener('click', () => {
|
|---|
| 935 | if (typeof window.openTrackEditor !== 'function') {
|
|---|
| [bbf9d1a] | 936 | alert('<%= t('aaud.editor_not_loaded') %>');
|
|---|
| [7bc636b] | 937 | return;
|
|---|
| 938 | }
|
|---|
| 939 | const id = btn.dataset.id;
|
|---|
| 940 | window.openTrackEditor({
|
|---|
| 941 | id,
|
|---|
| 942 | onSaved: (track) => {
|
|---|
| 943 | const row = document.querySelector('li[data-track-id="' + id + '"]');
|
|---|
| 944 | if (!row) return;
|
|---|
| 945 | // Update visible cells
|
|---|
| 946 | const titleEl = row.querySelector('[data-cell="title"]');
|
|---|
| 947 | const artistEl = row.querySelector('[data-cell="artist"]');
|
|---|
| 948 | const albumEl = row.querySelector('[data-cell="album"]');
|
|---|
| [bbf9d1a] | 949 | if (titleEl) titleEl.textContent = track.title || '<%= t('aaud.untitled') %>';
|
|---|
| [7bc636b] | 950 | if (artistEl) artistEl.textContent = track.artist || '—';
|
|---|
| 951 | if (albumEl) {
|
|---|
| 952 | albumEl.textContent = track.album || '';
|
|---|
| 953 | if (track.album) albumEl.removeAttribute('hidden');
|
|---|
| 954 | else albumEl.setAttribute('hidden', '');
|
|---|
| 955 | }
|
|---|
| 956 | // Update cover thumb (replace element if type changed)
|
|---|
| 957 | const oldThumb = row.querySelector('[data-cover-thumb]');
|
|---|
| 958 | if (oldThumb) {
|
|---|
| 959 | const parent = oldThumb.parentElement;
|
|---|
| 960 | if (track.cover_url) {
|
|---|
| 961 | const img = document.createElement('img');
|
|---|
| 962 | img.className = 'ax-track-cover';
|
|---|
| 963 | img.src = track.cover_url;
|
|---|
| 964 | img.alt = '';
|
|---|
| 965 | img.dataset.coverThumb = '';
|
|---|
| 966 | parent.replaceChild(img, oldThumb);
|
|---|
| 967 | } else {
|
|---|
| 968 | const sp = document.createElement('span');
|
|---|
| 969 | sp.className = 'ax-track-cover ax-track-cover-empty';
|
|---|
| 970 | sp.textContent = '♫';
|
|---|
| 971 | sp.dataset.coverThumb = '';
|
|---|
| 972 | parent.replaceChild(sp, oldThumb);
|
|---|
| 973 | }
|
|---|
| 974 | }
|
|---|
| 975 | },
|
|---|
| 976 | });
|
|---|
| 977 | });
|
|---|
| 978 | });
|
|---|
| [70679063] | 979 |
|
|---|
| 980 | // Download-voor-email toggle — AJAX (geen pagina-reload meer).
|
|---|
| 981 | document.querySelectorAll('[data-track-dl]').forEach(btn => {
|
|---|
| 982 | btn.addEventListener('click', async () => {
|
|---|
| 983 | if (btn.disabled) return;
|
|---|
| 984 | const want = btn.dataset.on === '1' ? 0 : 1;
|
|---|
| 985 | btn.disabled = true;
|
|---|
| 986 | try {
|
|---|
| 987 | const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
|
|---|
| 988 | method: 'POST',
|
|---|
| 989 | headers: { 'Content-Type': 'application/json' },
|
|---|
| 990 | body: JSON.stringify({ downloadable: !!want }),
|
|---|
| 991 | });
|
|---|
| 992 | if (!res.ok) throw new Error('HTTP ' + res.status);
|
|---|
| 993 | btn.dataset.on = String(want);
|
|---|
| 994 | btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
|
|---|
| 995 | btn.style.opacity = want ? '1' : '.5';
|
|---|
| 996 | btn.title = want
|
|---|
| [bbf9d1a] | 997 | ? '<%= t('aaud.dl_on') %>'
|
|---|
| 998 | : '<%= t('aaud.dl_off') %>';
|
|---|
| [70679063] | 999 | } catch (e) {
|
|---|
| [bbf9d1a] | 1000 | alert('<%= t('aaud.change_failed') %>: ' + (e.message || e));
|
|---|
| [70679063] | 1001 | } finally {
|
|---|
| 1002 | btn.disabled = false;
|
|---|
| 1003 | }
|
|---|
| 1004 | });
|
|---|
| 1005 | });
|
|---|
| [7bc636b] | 1006 | })();
|
|---|
| 1007 | </script>
|
|---|