audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

add multi-track album upload to /upload page (#1237)

Adds an album upload mode alongside the existing single-track upload.
Users set album-level metadata once (title, cover art), then add per-track
details in collapsible sections. Tracks upload sequentially via the existing
POST /tracks/ endpoint — no backend changes needed.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4.6
and committed by
GitHub
ed9b9876 fa7748da

+1410 -3
+742
frontend/src/lib/components/AlbumUploadForm.svelte
··· 1 + <script lang="ts"> 2 + import type { AlbumSummary, Artist } from '$lib/types'; 3 + import type { TrackEntry } from '$lib/components/TrackEntryCard.svelte'; 4 + import TrackEntryCard from '$lib/components/TrackEntryCard.svelte'; 5 + import PdsTooltip from '$lib/components/PdsTooltip.svelte'; 6 + import { uploader } from '$lib/uploader.svelte'; 7 + import { toast } from '$lib/toast.svelte'; 8 + import { getServerConfig } from '$lib/config'; 9 + 10 + const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.m4a', '.aiff', '.aif', '.flac']; 11 + const FILE_INPUT_ACCEPT = 12 + '.mp3,.wav,.m4a,.aiff,.aif,.flac,audio/mpeg,audio/wav,audio/mp4,audio/aiff,audio/x-aiff,audio/flac'; 13 + const UPLOAD_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes per track 14 + 15 + interface Props { 16 + albums: AlbumSummary[]; 17 + artistProfile: Artist | null; 18 + onAlbumsReload: () => Promise<void>; 19 + } 20 + 21 + let { albums, artistProfile, onAlbumsReload }: Props = $props(); 22 + 23 + let albumTitle = $state(''); 24 + let coverArtFile = $state<File | null>(null); 25 + const initialTrack = createEmptyTrack(); 26 + let tracks = $state<TrackEntry[]>([initialTrack]); 27 + let attestedRights = $state(false); 28 + let uploading = $state(false); 29 + let expandedTrackId = $state<string | null>(initialTrack.id); 30 + 31 + let bulkFileInputEl = $state<HTMLInputElement | null>(null); 32 + 33 + let albumTitleConflict = $derived( 34 + albumTitle.trim().length > 0 && 35 + albums.some((a) => a.title.toLowerCase() === albumTitle.trim().toLowerCase()), 36 + ); 37 + 38 + let completedCount = $derived(tracks.filter((t) => t.status === 'completed').length); 39 + let currentUploadIndex = $derived(tracks.findIndex((t) => t.status === 'uploading' || t.status === 'processing')); 40 + let hasUnresolvedFeatures = $derived(tracks.some((t) => t.hasUnresolvedFeaturesInput)); 41 + 42 + let canSubmit = $derived( 43 + albumTitle.trim().length > 0 && 44 + tracks.every((t) => t.file !== null && t.title.trim().length > 0) && 45 + attestedRights && 46 + !hasUnresolvedFeatures && 47 + !uploading, 48 + ); 49 + 50 + function createEmptyTrack(): TrackEntry { 51 + return { 52 + id: crypto.randomUUID(), 53 + file: null, 54 + title: '', 55 + description: '', 56 + tags: [], 57 + featuredArtists: [], 58 + hasUnresolvedFeaturesInput: false, 59 + autoTag: false, 60 + supportGated: false, 61 + status: 'pending', 62 + error: null, 63 + }; 64 + } 65 + 66 + function titleFromFilename(name: string): string { 67 + // strip extension 68 + const dotIndex = name.lastIndexOf('.'); 69 + let base = dotIndex !== -1 ? name.slice(0, dotIndex) : name; 70 + 71 + // strip leading track numbers (01 - , 01. , 01_, 1 - , 1. , etc.) 72 + base = base.replace(/^\d+\s*[-._]\s*/, ''); 73 + 74 + // replace underscores and hyphens with spaces 75 + base = base.replace(/[_-]/g, ' '); 76 + 77 + // title-case each word 78 + return base 79 + .split(/\s+/) 80 + .filter((w) => w.length > 0) 81 + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) 82 + .join(' '); 83 + } 84 + 85 + function isSupportedAudioFile(name: string): boolean { 86 + const dotIndex = name.lastIndexOf('.'); 87 + if (dotIndex === -1) return false; 88 + const ext = name.slice(dotIndex).toLowerCase(); 89 + return AUDIO_EXTENSIONS.includes(ext); 90 + } 91 + 92 + function addTrack() { 93 + const entry = createEmptyTrack(); 94 + tracks = [...tracks, entry]; 95 + expandedTrackId = entry.id; 96 + } 97 + 98 + async function addTracksFromFiles(files: FileList) { 99 + let config; 100 + try { 101 + config = await getServerConfig(); 102 + } catch (_e) { 103 + console.error('failed to fetch server config:', _e); 104 + toast.error('failed to validate file sizes'); 105 + return; 106 + } 107 + 108 + const skipped: string[] = []; 109 + const added: TrackEntry[] = []; 110 + 111 + for (const file of files) { 112 + if (!isSupportedAudioFile(file.name)) { 113 + skipped.push(`${file.name} (unsupported format)`); 114 + continue; 115 + } 116 + 117 + const sizeMB = file.size / (1024 * 1024); 118 + if (sizeMB > config.max_upload_size_mb) { 119 + skipped.push(`${file.name} (${sizeMB.toFixed(1)}MB exceeds ${config.max_upload_size_mb}MB limit)`); 120 + continue; 121 + } 122 + 123 + const entry: TrackEntry = { 124 + id: crypto.randomUUID(), 125 + file, 126 + title: titleFromFilename(file.name), 127 + description: '', 128 + tags: [], 129 + featuredArtists: [], 130 + hasUnresolvedFeaturesInput: false, 131 + autoTag: false, 132 + supportGated: false, 133 + status: 'pending', 134 + error: null, 135 + }; 136 + added.push(entry); 137 + } 138 + 139 + if (added.length > 0) { 140 + // replace the initial empty track if it has no file 141 + if (tracks.length === 1 && tracks[0].file === null && tracks[0].title === '') { 142 + tracks = added; 143 + } else { 144 + tracks = [...tracks, ...added]; 145 + } 146 + expandedTrackId = added[0].id; 147 + } 148 + 149 + if (skipped.length > 0) { 150 + toast.warning(`skipped ${skipped.length} file${skipped.length > 1 ? 's' : ''}: ${skipped.join('; ')}`); 151 + } 152 + } 153 + 154 + function removeTrack(id: string) { 155 + if (tracks.length <= 1) return; 156 + 157 + const idx = tracks.findIndex((t) => t.id === id); 158 + tracks = tracks.filter((t) => t.id !== id); 159 + 160 + if (expandedTrackId === id) { 161 + // expand the next track, or the last one if we removed the last 162 + const nextIdx = Math.min(idx, tracks.length - 1); 163 + expandedTrackId = tracks[nextIdx].id; 164 + } 165 + } 166 + 167 + function updateTrack(id: string, field: string, value: any) { 168 + const idx = tracks.findIndex((t) => t.id === id); 169 + if (idx !== -1) { 170 + tracks[idx] = { ...tracks[idx], [field]: value }; 171 + } 172 + } 173 + 174 + async function handleFileChange(id: string, file: File) { 175 + if (!isSupportedAudioFile(file.name)) { 176 + toast.error(`unsupported file type. supported: ${AUDIO_EXTENSIONS.join(', ')}`); 177 + return; 178 + } 179 + 180 + try { 181 + const config = await getServerConfig(); 182 + const sizeMB = file.size / (1024 * 1024); 183 + if (sizeMB > config.max_upload_size_mb) { 184 + toast.error(`audio file too large (${sizeMB.toFixed(1)}MB). max: ${config.max_upload_size_mb}MB`); 185 + return; 186 + } 187 + } catch (_e) { 188 + console.error('failed to validate file size:', _e); 189 + } 190 + 191 + const idx = tracks.findIndex((t) => t.id === id); 192 + if (idx !== -1) { 193 + const track = tracks[idx]; 194 + const updates: Partial<TrackEntry> = { file }; 195 + if (!track.title) { 196 + updates.title = titleFromFilename(file.name); 197 + } 198 + tracks[idx] = { ...track, ...updates }; 199 + } 200 + } 201 + 202 + async function handleCoverArtChange(e: Event) { 203 + const target = e.target as HTMLInputElement; 204 + if (target.files && target.files[0]) { 205 + const selected = target.files[0]; 206 + 207 + try { 208 + const config = await getServerConfig(); 209 + const sizeMB = selected.size / (1024 * 1024); 210 + if (sizeMB > config.max_image_size_mb) { 211 + toast.error(`image too large (${sizeMB.toFixed(1)}MB). max: ${config.max_image_size_mb}MB`); 212 + target.value = ''; 213 + coverArtFile = null; 214 + return; 215 + } 216 + } catch (_e) { 217 + console.error('failed to validate image size:', _e); 218 + } 219 + 220 + coverArtFile = selected; 221 + } 222 + } 223 + 224 + function handleBulkFileInput(e: Event) { 225 + const target = e.target as HTMLInputElement; 226 + if (target.files && target.files.length > 0) { 227 + addTracksFromFiles(target.files); 228 + target.value = ''; 229 + } 230 + } 231 + 232 + async function handleUploadAlbum(e: SubmitEvent) { 233 + e.preventDefault(); 234 + 235 + if (!canSubmit) return; 236 + 237 + uploading = true; 238 + let completed = 0; 239 + let failed = 0; 240 + 241 + for (let i = 0; i < tracks.length; i++) { 242 + const track = tracks[i]; 243 + tracks[i] = { ...tracks[i], status: 'uploading' }; 244 + 245 + await new Promise<void>((resolve) => { 246 + let resolved = false; 247 + const safeResolve = () => { 248 + if (!resolved) { 249 + resolved = true; 250 + resolve(); 251 + } 252 + }; 253 + 254 + const timeout = setTimeout(() => { 255 + if (!resolved) { 256 + tracks[i] = { ...tracks[i], status: 'failed', error: 'upload timed out' }; 257 + failed++; 258 + safeResolve(); 259 + } 260 + }, UPLOAD_TIMEOUT_MS); 261 + 262 + uploader.upload( 263 + track.file!, 264 + track.title, 265 + albumTitle.trim(), 266 + [...track.featuredArtists], 267 + i === 0 ? coverArtFile : null, 268 + [...track.tags], 269 + track.supportGated, 270 + track.autoTag, 271 + track.description, 272 + () => { 273 + // SSE completed 274 + clearTimeout(timeout); 275 + tracks[i] = { ...tracks[i], status: 'completed' }; 276 + completed++; 277 + onAlbumsReload(); 278 + safeResolve(); 279 + }, 280 + { 281 + onSuccess: (_uploadId: string) => { 282 + // XHR upload succeeded, now processing via SSE 283 + tracks[i] = { ...tracks[i], status: 'processing' }; 284 + }, 285 + onError: (error: string) => { 286 + clearTimeout(timeout); 287 + tracks[i] = { ...tracks[i], status: 'failed', error }; 288 + failed++; 289 + safeResolve(); 290 + }, 291 + }, 292 + ); 293 + }); 294 + } 295 + 296 + // refresh albums so we can find the slug for the "view album" link 297 + await onAlbumsReload(); 298 + 299 + if (completed > 0) { 300 + const albumSlug = albums.find( 301 + (a) => a.title.toLowerCase() === albumTitle.trim().toLowerCase(), 302 + )?.slug; 303 + 304 + toast.success( 305 + `${completed} of ${tracks.length} track${tracks.length > 1 ? 's' : ''} uploaded`, 306 + 5000, 307 + albumSlug 308 + ? { 309 + label: 'view album', 310 + href: `/album/${albumSlug}`, 311 + } 312 + : undefined, 313 + ); 314 + } 315 + 316 + if (failed > 0 && completed === 0) { 317 + toast.error(`all ${failed} track${failed > 1 ? 's' : ''} failed to upload`); 318 + } else if (failed > 0) { 319 + toast.warning(`${failed} track${failed > 1 ? 's' : ''} failed to upload`); 320 + } 321 + 322 + uploading = false; 323 + } 324 + </script> 325 + 326 + <form onsubmit={handleUploadAlbum}> 327 + <div class="form-group"> 328 + <label for="album-title">album title</label> 329 + <input 330 + id="album-title" 331 + type="text" 332 + bind:value={albumTitle} 333 + required 334 + maxlength="256" 335 + placeholder="album title" 336 + disabled={uploading} 337 + /> 338 + {#if albumTitleConflict} 339 + <p class="title-warning">an album with this title already exists — tracks will be added to it</p> 340 + {/if} 341 + </div> 342 + 343 + <div class="form-group"> 344 + <label for="cover-art" class="label-with-tooltip"> 345 + cover art (optional) 346 + <PdsTooltip /> 347 + </label> 348 + <input 349 + id="cover-art" 350 + type="file" 351 + accept="image/*" 352 + onchange={handleCoverArtChange} 353 + disabled={uploading} 354 + /> 355 + <p class="format-hint">supported: jpg, png, webp, gif</p> 356 + {#if coverArtFile} 357 + <p class="file-info"> 358 + {coverArtFile.name} ({(coverArtFile.size / 1024 / 1024).toFixed(2)} MB) 359 + </p> 360 + {/if} 361 + </div> 362 + 363 + <div class="tracks-section"> 364 + <span class="tracks-label">tracks</span> 365 + <div class="track-list"> 366 + {#each tracks as track, i (track.id)} 367 + <TrackEntryCard 368 + entry={track} 369 + index={i} 370 + expanded={expandedTrackId === track.id} 371 + {artistProfile} 372 + disabled={uploading} 373 + onUpdate={(field, value) => updateTrack(track.id, field, value)} 374 + onRemove={() => removeTrack(track.id)} 375 + onToggle={() => (expandedTrackId = expandedTrackId === track.id ? null : track.id)} 376 + onFileChange={(file) => handleFileChange(track.id, file)} 377 + /> 378 + {/each} 379 + </div> 380 + 381 + <div class="button-bar"> 382 + <button 383 + type="button" 384 + class="secondary-btn" 385 + disabled={uploading} 386 + onclick={() => bulkFileInputEl?.click()} 387 + > 388 + choose files 389 + </button> 390 + <button type="button" class="secondary-btn" disabled={uploading} onclick={addTrack}> 391 + + add track 392 + </button> 393 + </div> 394 + 395 + <input 396 + bind:this={bulkFileInputEl} 397 + type="file" 398 + accept={FILE_INPUT_ACCEPT} 399 + multiple 400 + class="hidden-input" 401 + onchange={handleBulkFileInput} 402 + /> 403 + </div> 404 + 405 + {#if uploading} 406 + <div class="upload-progress"> 407 + {#if currentUploadIndex >= 0} 408 + <p class="progress-text"> 409 + uploading track {currentUploadIndex + 1} of {tracks.length}... 410 + </p> 411 + {:else} 412 + <p class="progress-text"> 413 + {completedCount} of {tracks.length} track{tracks.length > 1 ? 's' : ''} completed 414 + </p> 415 + {/if} 416 + <div class="progress-bar-bg"> 417 + <div 418 + class="progress-bar-fill" 419 + style="width: {(completedCount / tracks.length) * 100}%" 420 + ></div> 421 + </div> 422 + </div> 423 + {/if} 424 + 425 + <div class="form-group attestation"> 426 + <label class="checkbox-label"> 427 + <input 428 + type="checkbox" 429 + bind:checked={attestedRights} 430 + required 431 + disabled={uploading} 432 + /> 433 + <span class="checkbox-text"> 434 + I have the right to distribute this content, I am not 435 + knowingly infringing on copyright or otherwise stealing 436 + from artists. 437 + </span> 438 + </label> 439 + <p class="attestation-note"> 440 + Content appearing on other platforms (YouTube, SoundCloud, 441 + Internet Archive, etc.) does not mean it's licensed for 442 + redistribution. You should own the rights or have explicit 443 + permission, or the content may be removed to keep plyr.fm in 444 + compliance. For any questions or concerns, please DM 445 + <a 446 + href="https://bsky.app/profile/zzstoatzz.io" 447 + target="_blank" 448 + rel="noopener">@zzstoatzz.io</a 449 + > :) have a nice day! 450 + </p> 451 + </div> 452 + 453 + <button 454 + type="submit" 455 + disabled={!canSubmit} 456 + class="upload-btn" 457 + title={!albumTitle.trim() 458 + ? 'please enter an album title' 459 + : !tracks.every((t) => t.file && t.title.trim()) 460 + ? 'every track needs a file and title' 461 + : hasUnresolvedFeatures 462 + ? 'please select or clear featured artists' 463 + : !attestedRights 464 + ? 'please confirm you have distribution rights' 465 + : ''} 466 + > 467 + {#if uploading} 468 + uploading... 469 + {:else} 470 + upload album ({tracks.length} track{tracks.length > 1 ? 's' : ''}) 471 + {/if} 472 + </button> 473 + </form> 474 + 475 + <style> 476 + form { 477 + background: var(--bg-tertiary); 478 + padding: 2rem; 479 + border-radius: var(--radius-md); 480 + border: 1px solid var(--border-subtle); 481 + } 482 + 483 + .form-group { 484 + margin-bottom: 1.5rem; 485 + } 486 + 487 + label { 488 + display: block; 489 + color: var(--text-secondary); 490 + margin-bottom: 0.5rem; 491 + font-size: var(--text-base); 492 + } 493 + 494 + .label-with-tooltip { 495 + display: inline-flex; 496 + align-items: center; 497 + gap: 0.4rem; 498 + } 499 + 500 + input[type='text'] { 501 + width: 100%; 502 + padding: 0.75rem; 503 + background: var(--bg-primary); 504 + border: 1px solid var(--border-default); 505 + border-radius: var(--radius-sm); 506 + color: var(--text-primary); 507 + font-size: var(--text-lg); 508 + font-family: inherit; 509 + transition: all 0.2s; 510 + } 511 + 512 + input[type='text']:focus { 513 + outline: none; 514 + border-color: var(--accent); 515 + } 516 + 517 + input[type='text']:disabled { 518 + opacity: 0.5; 519 + cursor: not-allowed; 520 + } 521 + 522 + input[type='file'] { 523 + width: 100%; 524 + padding: 0.75rem; 525 + background: var(--bg-primary); 526 + border: 1px solid var(--border-default); 527 + border-radius: var(--radius-sm); 528 + color: var(--text-primary); 529 + font-size: var(--text-base); 530 + font-family: inherit; 531 + cursor: pointer; 532 + } 533 + 534 + input[type='file']:disabled { 535 + opacity: 0.5; 536 + cursor: not-allowed; 537 + } 538 + 539 + .format-hint { 540 + margin-top: 0.25rem; 541 + font-size: var(--text-sm); 542 + color: var(--text-tertiary); 543 + } 544 + 545 + .file-info { 546 + margin-top: 0.5rem; 547 + font-size: var(--text-sm); 548 + color: var(--text-muted); 549 + } 550 + 551 + .title-warning { 552 + margin-top: 0.375rem; 553 + font-size: var(--text-sm); 554 + color: var(--warning, #e6a817); 555 + } 556 + 557 + .tracks-section { 558 + margin-bottom: 1.5rem; 559 + } 560 + 561 + .tracks-label { 562 + display: block; 563 + color: var(--text-secondary); 564 + margin-bottom: 0.75rem; 565 + font-size: var(--text-base); 566 + } 567 + 568 + .track-list { 569 + display: flex; 570 + flex-direction: column; 571 + gap: 0.75rem; 572 + } 573 + 574 + .button-bar { 575 + display: flex; 576 + gap: 0.75rem; 577 + margin-top: 0.75rem; 578 + } 579 + 580 + .secondary-btn { 581 + padding: 0.625rem 1.25rem; 582 + background: var(--bg-primary); 583 + border: 1px solid var(--border-default); 584 + border-radius: var(--radius-sm); 585 + color: var(--text-primary); 586 + font-size: var(--text-base); 587 + font-family: inherit; 588 + font-weight: 500; 589 + cursor: pointer; 590 + transition: all 0.2s; 591 + } 592 + 593 + .secondary-btn:hover:not(:disabled) { 594 + border-color: var(--text-tertiary); 595 + background: var(--bg-hover); 596 + } 597 + 598 + .secondary-btn:disabled { 599 + opacity: 0.5; 600 + cursor: not-allowed; 601 + } 602 + 603 + .hidden-input { 604 + position: absolute; 605 + width: 1px; 606 + height: 1px; 607 + padding: 0; 608 + margin: -1px; 609 + overflow: hidden; 610 + clip: rect(0, 0, 0, 0); 611 + white-space: nowrap; 612 + border: 0; 613 + } 614 + 615 + .upload-progress { 616 + margin-bottom: 1.5rem; 617 + padding: 1rem; 618 + background: var(--bg-primary); 619 + border-radius: var(--radius-sm); 620 + border: 1px solid var(--border-default); 621 + } 622 + 623 + .progress-text { 624 + font-size: var(--text-sm); 625 + color: var(--text-secondary); 626 + margin-bottom: 0.5rem; 627 + } 628 + 629 + .progress-bar-bg { 630 + width: 100%; 631 + height: 4px; 632 + background: var(--border-subtle); 633 + border-radius: 2px; 634 + overflow: hidden; 635 + } 636 + 637 + .progress-bar-fill { 638 + height: 100%; 639 + background: var(--accent); 640 + border-radius: 2px; 641 + transition: width 0.3s ease; 642 + } 643 + 644 + .attestation { 645 + background: var(--bg-primary); 646 + padding: 1rem; 647 + border-radius: var(--radius-sm); 648 + border: 1px solid var(--border-default); 649 + } 650 + 651 + .checkbox-label { 652 + display: flex; 653 + align-items: flex-start; 654 + gap: 0.75rem; 655 + cursor: pointer; 656 + margin-bottom: 0; 657 + } 658 + 659 + .checkbox-label input[type='checkbox'] { 660 + width: 1.25rem; 661 + height: 1.25rem; 662 + margin-top: 0.1rem; 663 + flex-shrink: 0; 664 + accent-color: var(--accent); 665 + cursor: pointer; 666 + } 667 + 668 + .checkbox-label input[type='checkbox']:disabled { 669 + cursor: not-allowed; 670 + } 671 + 672 + .checkbox-text { 673 + font-size: var(--text-base); 674 + color: var(--text-primary); 675 + line-height: 1.4; 676 + } 677 + 678 + .attestation-note { 679 + margin-top: 0.75rem; 680 + margin-left: 2rem; 681 + font-size: var(--text-sm); 682 + color: var(--text-tertiary); 683 + line-height: 1.4; 684 + } 685 + 686 + .attestation-note a { 687 + color: var(--accent); 688 + text-decoration: none; 689 + } 690 + 691 + .attestation-note a:hover { 692 + text-decoration: underline; 693 + } 694 + 695 + .upload-btn { 696 + width: 100%; 697 + padding: 0.75rem; 698 + background: var(--accent); 699 + color: var(--text-primary); 700 + border: none; 701 + border-radius: var(--radius-sm); 702 + font-size: var(--text-lg); 703 + font-weight: 600; 704 + font-family: inherit; 705 + cursor: pointer; 706 + transition: all 0.2s; 707 + display: flex; 708 + align-items: center; 709 + justify-content: center; 710 + gap: 0.5rem; 711 + } 712 + 713 + .upload-btn:hover:not(:disabled) { 714 + background: var(--accent-hover); 715 + transform: translateY(-1px); 716 + box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent); 717 + } 718 + 719 + .upload-btn:disabled { 720 + opacity: 0.5; 721 + cursor: not-allowed; 722 + transform: none; 723 + } 724 + 725 + .upload-btn:active:not(:disabled) { 726 + transform: translateY(0); 727 + } 728 + 729 + @media (max-width: 768px) { 730 + form { 731 + padding: 1.25rem; 732 + } 733 + 734 + .button-bar { 735 + flex-direction: column; 736 + } 737 + 738 + input[type='text'] { 739 + font-size: 16px; /* prevents zoom on iOS */ 740 + } 741 + } 742 + </style>
+571
frontend/src/lib/components/TrackEntryCard.svelte
··· 1 + <script lang="ts" module> 2 + import type { FeaturedArtist, Artist } from '$lib/types'; 3 + 4 + export interface TrackEntry { 5 + id: string; 6 + file: File | null; 7 + title: string; 8 + description: string; 9 + tags: string[]; 10 + featuredArtists: FeaturedArtist[]; 11 + hasUnresolvedFeaturesInput: boolean; 12 + autoTag: boolean; 13 + supportGated: boolean; 14 + status: 'pending' | 'uploading' | 'processing' | 'completed' | 'failed'; 15 + error: string | null; 16 + } 17 + </script> 18 + 19 + <script lang="ts"> 20 + import TagInput from '$lib/components/TagInput.svelte'; 21 + import HandleSearch from '$lib/components/HandleSearch.svelte'; 22 + import InfoTooltip from '$lib/components/InfoTooltip.svelte'; 23 + 24 + const FILE_INPUT_ACCEPT = 25 + '.mp3,.wav,.m4a,.aiff,.aif,.flac,audio/mpeg,audio/wav,audio/mp4,audio/aiff,audio/x-aiff,audio/flac'; 26 + 27 + interface Props { 28 + entry: TrackEntry; 29 + index: number; 30 + expanded: boolean; 31 + artistProfile: Artist | null; 32 + disabled?: boolean; 33 + onUpdate: (field: string, value: any) => void; 34 + onRemove: () => void; 35 + onToggle: () => void; 36 + onFileChange: (file: File) => void; 37 + } 38 + 39 + let { 40 + entry, 41 + index, 42 + expanded, 43 + artistProfile, 44 + disabled = false, 45 + onUpdate, 46 + onRemove, 47 + onToggle, 48 + onFileChange, 49 + }: Props = $props(); 50 + 51 + function handleFileInput(e: Event) { 52 + const target = e.target as HTMLInputElement; 53 + if (target.files && target.files[0]) { 54 + onFileChange(target.files[0]); 55 + } 56 + } 57 + 58 + let displayName = $derived( 59 + entry.file?.name ?? 'no file selected' 60 + ); 61 + 62 + let statusIcon = $derived.by(() => { 63 + switch (entry.status) { 64 + case 'uploading': 65 + case 'processing': 66 + return 'spinner'; 67 + case 'completed': 68 + return 'check'; 69 + case 'failed': 70 + return 'error'; 71 + default: 72 + return null; 73 + } 74 + }); 75 + </script> 76 + 77 + <div class="track-card" class:expanded class:has-error={entry.status === 'failed'}> 78 + <button 79 + type="button" 80 + class="track-header" 81 + onclick={onToggle} 82 + aria-expanded={expanded} 83 + > 84 + <span class="chevron" class:rotated={expanded}> 85 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 86 + <path d="M9 18l6-6-6-6" /> 87 + </svg> 88 + </span> 89 + 90 + <span class="track-summary"> 91 + <span class="track-number">#{index + 1}</span> 92 + <span class="separator">&middot;</span> 93 + <span class="track-filename">{displayName}</span> 94 + {#if entry.title} 95 + <span class="separator">&middot;</span> 96 + <span class="track-title">{entry.title}</span> 97 + {/if} 98 + </span> 99 + 100 + {#if statusIcon === 'spinner'} 101 + <span class="status-indicator uploading"> 102 + <svg class="spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 103 + <path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" /> 104 + </svg> 105 + {entry.status} 106 + </span> 107 + {:else if statusIcon === 'check'} 108 + <span class="status-indicator completed"> 109 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 110 + <path d="M20 6L9 17l-5-5" /> 111 + </svg> 112 + completed 113 + </span> 114 + {:else if statusIcon === 'error'} 115 + <span class="status-indicator failed"> 116 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 117 + <path d="M18 6L6 18M6 6l12 12" /> 118 + </svg> 119 + failed 120 + </span> 121 + {/if} 122 + </button> 123 + 124 + {#if entry.status === 'failed' && entry.error && !expanded} 125 + <div class="error-banner">{entry.error}</div> 126 + {/if} 127 + 128 + {#if expanded} 129 + <div class="track-body"> 130 + <div class="form-group"> 131 + <label for="file-{entry.id}">audio file</label> 132 + <input 133 + id="file-{entry.id}" 134 + type="file" 135 + accept={FILE_INPUT_ACCEPT} 136 + onchange={handleFileInput} 137 + {disabled} 138 + /> 139 + {#if entry.file} 140 + <p class="file-info"> 141 + {entry.file.name} ({(entry.file.size / 1024 / 1024).toFixed(2)} MB) 142 + </p> 143 + {/if} 144 + </div> 145 + 146 + <div class="form-group"> 147 + <label for="title-{entry.id}">title</label> 148 + <input 149 + id="title-{entry.id}" 150 + type="text" 151 + value={entry.title} 152 + oninput={(e) => onUpdate('title', (e.target as HTMLInputElement).value)} 153 + required 154 + maxlength="256" 155 + placeholder="track title" 156 + {disabled} 157 + /> 158 + </div> 159 + 160 + <div class="form-group"> 161 + <label for="description-{entry.id}">description (optional)</label> 162 + <textarea 163 + id="description-{entry.id}" 164 + value={entry.description} 165 + oninput={(e) => onUpdate('description', (e.target as HTMLTextAreaElement).value)} 166 + placeholder="liner notes, show notes, credits..." 167 + rows="3" 168 + maxlength="5000" 169 + {disabled} 170 + ></textarea> 171 + {#if entry.description.length > 0} 172 + <div class="char-count">{entry.description.length} / 5000</div> 173 + {/if} 174 + </div> 175 + 176 + <div class="form-group"> 177 + <label for="tags-{entry.id}">tags (optional)</label> 178 + <TagInput 179 + tags={entry.tags} 180 + onAdd={(tag) => onUpdate('tags', [...entry.tags, tag])} 181 + onRemove={(tag) => onUpdate('tags', entry.tags.filter((t) => t !== tag))} 182 + placeholder="type to search tags..." 183 + {disabled} 184 + /> 185 + <label class="checkbox-label" style="margin-top: 0.75rem;"> 186 + <input 187 + type="checkbox" 188 + checked={entry.autoTag} 189 + onchange={(e) => onUpdate('autoTag', (e.target as HTMLInputElement).checked)} 190 + {disabled} 191 + /> 192 + <span class="checkbox-text">auto-tag with recommended genres</span> 193 + <InfoTooltip label="auto-tagging info"> 194 + ML genre classification suggests tags from your audio. 195 + <a href="https://docs.plyr.fm/artists/#auto-tagging" target="_blank" rel="noopener">learn more</a> 196 + </InfoTooltip> 197 + </label> 198 + </div> 199 + 200 + <div class="form-group"> 201 + <label for="features-{entry.id}">featured artists (optional)</label> 202 + <HandleSearch 203 + selected={entry.featuredArtists} 204 + hasUnresolvedInput={entry.hasUnresolvedFeaturesInput} 205 + onAdd={(artist) => onUpdate('featuredArtists', [...entry.featuredArtists, artist])} 206 + onRemove={(did) => onUpdate('featuredArtists', entry.featuredArtists.filter((a) => a.did !== did))} 207 + {disabled} 208 + /> 209 + </div> 210 + 211 + {#if artistProfile?.support_url} 212 + <div class="form-group supporter-gating"> 213 + <label class="checkbox-label"> 214 + <input 215 + type="checkbox" 216 + checked={entry.supportGated} 217 + onchange={(e) => onUpdate('supportGated', (e.target as HTMLInputElement).checked)} 218 + {disabled} 219 + /> 220 + <span class="checkbox-text"> 221 + <svg class="heart-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 222 + <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> 223 + </svg> 224 + supporters only 225 + </span> 226 + </label> 227 + <p class="gating-note"> 228 + only users who support you via <a href={artistProfile.support_url} target="_blank" rel="noopener">atprotofans</a> can play this track 229 + </p> 230 + </div> 231 + {/if} 232 + 233 + {#if entry.status === 'failed' && entry.error} 234 + <div class="error-banner">{entry.error}</div> 235 + {/if} 236 + 237 + <button 238 + type="button" 239 + class="remove-btn" 240 + onclick={onRemove} 241 + {disabled} 242 + > 243 + remove track 244 + </button> 245 + </div> 246 + {/if} 247 + </div> 248 + 249 + <style> 250 + .track-card { 251 + background: var(--bg-tertiary); 252 + border: 1px solid var(--border-subtle); 253 + border-radius: var(--radius-md); 254 + overflow: hidden; 255 + transition: border-color 0.2s; 256 + } 257 + 258 + .track-card.has-error { 259 + border-color: var(--error); 260 + } 261 + 262 + .track-card.expanded { 263 + border-color: var(--border-default); 264 + } 265 + 266 + .track-header { 267 + width: 100%; 268 + display: flex; 269 + align-items: center; 270 + gap: 0.75rem; 271 + padding: 0.875rem 1rem; 272 + background: transparent; 273 + border: none; 274 + color: var(--text-primary); 275 + font-family: inherit; 276 + font-size: var(--text-base); 277 + cursor: pointer; 278 + text-align: left; 279 + transition: background 0.15s; 280 + } 281 + 282 + .track-header:hover { 283 + background: var(--bg-hover); 284 + } 285 + 286 + .chevron { 287 + display: inline-flex; 288 + align-items: center; 289 + justify-content: center; 290 + flex-shrink: 0; 291 + color: var(--text-tertiary); 292 + transition: transform 0.2s ease; 293 + transform: rotate(0deg); 294 + } 295 + 296 + .chevron.rotated { 297 + transform: rotate(90deg); 298 + } 299 + 300 + .track-summary { 301 + display: flex; 302 + align-items: center; 303 + gap: 0.5rem; 304 + flex: 1; 305 + min-width: 0; 306 + overflow: hidden; 307 + } 308 + 309 + .track-number { 310 + font-weight: 600; 311 + color: var(--text-secondary); 312 + flex-shrink: 0; 313 + } 314 + 315 + .separator { 316 + color: var(--text-muted); 317 + flex-shrink: 0; 318 + } 319 + 320 + .track-filename { 321 + color: var(--text-tertiary); 322 + overflow: hidden; 323 + text-overflow: ellipsis; 324 + white-space: nowrap; 325 + font-size: var(--text-sm); 326 + } 327 + 328 + .track-title { 329 + font-weight: 500; 330 + color: var(--text-primary); 331 + overflow: hidden; 332 + text-overflow: ellipsis; 333 + white-space: nowrap; 334 + } 335 + 336 + .status-indicator { 337 + display: inline-flex; 338 + align-items: center; 339 + gap: 0.375rem; 340 + font-size: var(--text-sm); 341 + font-weight: 500; 342 + flex-shrink: 0; 343 + } 344 + 345 + .status-indicator.uploading { 346 + color: var(--accent); 347 + } 348 + 349 + .status-indicator.completed { 350 + color: var(--success); 351 + } 352 + 353 + .status-indicator.failed { 354 + color: var(--error); 355 + } 356 + 357 + .spin { 358 + animation: spin 1.2s linear infinite; 359 + } 360 + 361 + @keyframes spin { 362 + from { transform: rotate(0deg); } 363 + to { transform: rotate(360deg); } 364 + } 365 + 366 + .error-banner { 367 + padding: 0.5rem 1rem; 368 + background: color-mix(in srgb, var(--error) 10%, transparent); 369 + color: var(--error); 370 + font-size: var(--text-sm); 371 + border-top: 1px solid color-mix(in srgb, var(--error) 20%, transparent); 372 + } 373 + 374 + .track-body { 375 + padding: 1rem 1rem 1.25rem; 376 + border-top: 1px solid var(--border-subtle); 377 + display: flex; 378 + flex-direction: column; 379 + gap: 0; 380 + } 381 + 382 + .form-group { 383 + margin-bottom: 1.25rem; 384 + } 385 + 386 + .form-group label { 387 + display: block; 388 + color: var(--text-secondary); 389 + margin-bottom: 0.5rem; 390 + font-size: var(--text-base); 391 + } 392 + 393 + .form-group input[type="text"] { 394 + width: 100%; 395 + padding: 0.75rem; 396 + background: var(--bg-primary); 397 + border: 1px solid var(--border-default); 398 + border-radius: var(--radius-sm); 399 + color: var(--text-primary); 400 + font-size: var(--text-lg); 401 + font-family: inherit; 402 + transition: all 0.2s; 403 + } 404 + 405 + .form-group input[type="text"]:focus { 406 + outline: none; 407 + border-color: var(--accent); 408 + } 409 + 410 + .form-group input[type="text"]:disabled { 411 + opacity: 0.5; 412 + cursor: not-allowed; 413 + } 414 + 415 + .form-group textarea { 416 + width: 100%; 417 + padding: 0.75rem; 418 + background: var(--bg-primary); 419 + border: 1px solid var(--border-default); 420 + border-radius: var(--radius-sm); 421 + color: var(--text-primary); 422 + font-size: var(--text-base); 423 + font-family: inherit; 424 + transition: all 0.2s; 425 + resize: vertical; 426 + min-height: 4rem; 427 + } 428 + 429 + .form-group textarea:focus { 430 + outline: none; 431 + border-color: var(--accent); 432 + } 433 + 434 + .form-group textarea:disabled { 435 + opacity: 0.5; 436 + cursor: not-allowed; 437 + } 438 + 439 + .form-group input[type="file"] { 440 + width: 100%; 441 + padding: 0.75rem; 442 + background: var(--bg-primary); 443 + border: 1px solid var(--border-default); 444 + border-radius: var(--radius-sm); 445 + color: var(--text-primary); 446 + font-size: var(--text-base); 447 + font-family: inherit; 448 + cursor: pointer; 449 + } 450 + 451 + .form-group input[type="file"]:disabled { 452 + opacity: 0.5; 453 + cursor: not-allowed; 454 + } 455 + 456 + .file-info { 457 + margin-top: 0.5rem; 458 + font-size: var(--text-sm); 459 + color: var(--text-muted); 460 + } 461 + 462 + .char-count { 463 + font-size: var(--text-xs); 464 + color: var(--text-tertiary); 465 + text-align: right; 466 + } 467 + 468 + .checkbox-label { 469 + display: flex; 470 + align-items: flex-start; 471 + gap: 0.75rem; 472 + cursor: pointer; 473 + margin-bottom: 0; 474 + } 475 + 476 + .checkbox-label input[type="checkbox"] { 477 + width: 1.25rem; 478 + height: 1.25rem; 479 + margin-top: 0.1rem; 480 + flex-shrink: 0; 481 + accent-color: var(--accent); 482 + cursor: pointer; 483 + } 484 + 485 + .checkbox-label input[type="checkbox"]:disabled { 486 + cursor: not-allowed; 487 + } 488 + 489 + .checkbox-text { 490 + font-size: var(--text-base); 491 + color: var(--text-primary); 492 + line-height: 1.4; 493 + } 494 + 495 + .supporter-gating { 496 + background: color-mix(in srgb, var(--accent) 8%, var(--bg-primary)); 497 + padding: 1rem; 498 + border-radius: var(--radius-sm); 499 + border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-default)); 500 + } 501 + 502 + .supporter-gating .checkbox-text { 503 + display: inline-flex; 504 + align-items: center; 505 + gap: 0.4rem; 506 + } 507 + 508 + .supporter-gating .heart-icon { 509 + color: var(--accent); 510 + } 511 + 512 + .gating-note { 513 + margin-top: 0.5rem; 514 + margin-left: 2rem; 515 + font-size: var(--text-sm); 516 + color: var(--text-tertiary); 517 + line-height: 1.4; 518 + } 519 + 520 + .gating-note a { 521 + color: var(--accent); 522 + text-decoration: none; 523 + } 524 + 525 + .gating-note a:hover { 526 + text-decoration: underline; 527 + } 528 + 529 + .remove-btn { 530 + align-self: flex-start; 531 + padding: 0.5rem 1rem; 532 + background: transparent; 533 + border: 1px solid var(--error); 534 + border-radius: var(--radius-sm); 535 + color: var(--error); 536 + font-size: var(--text-sm); 537 + font-family: inherit; 538 + font-weight: 500; 539 + cursor: pointer; 540 + transition: all 0.2s; 541 + } 542 + 543 + .remove-btn:hover:not(:disabled) { 544 + background: color-mix(in srgb, var(--error) 10%, transparent); 545 + } 546 + 547 + .remove-btn:disabled { 548 + opacity: 0.5; 549 + cursor: not-allowed; 550 + } 551 + 552 + @media (max-width: 768px) { 553 + .track-header { 554 + padding: 0.75rem; 555 + gap: 0.5rem; 556 + } 557 + 558 + .track-body { 559 + padding: 0.75rem; 560 + } 561 + 562 + .track-summary { 563 + font-size: var(--text-sm); 564 + } 565 + 566 + .form-group input[type="text"], 567 + .form-group textarea { 568 + font-size: 16px; /* prevents zoom on iOS */ 569 + } 570 + } 571 + </style>
+88 -2
frontend/src/routes/upload/+page.svelte
··· 4 4 import Header from "$lib/components/Header.svelte"; 5 5 import HandleSearch from "$lib/components/HandleSearch.svelte"; 6 6 import AlbumSelect from "$lib/components/AlbumSelect.svelte"; 7 + import AlbumUploadForm from "$lib/components/AlbumUploadForm.svelte"; 7 8 import PdsTooltip from "$lib/components/PdsTooltip.svelte"; 8 9 import InfoTooltip from "$lib/components/InfoTooltip.svelte"; 9 10 import WaveLoading from "$lib/components/WaveLoading.svelte"; ··· 29 30 } 30 31 31 32 let loading = $state(true); 33 + 34 + // upload mode: track (single) or album (multi-track) 35 + let mode = $state<'track' | 'album'>( 36 + typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('mode') === 'album' 37 + ? 'album' 38 + : 'track' 39 + ); 40 + 41 + function setMode(newMode: 'track' | 'album') { 42 + mode = newMode; 43 + const url = new URL(window.location.href); 44 + if (newMode === 'album') { 45 + url.searchParams.set('mode', 'album'); 46 + } else { 47 + url.searchParams.delete('mode'); 48 + } 49 + window.history.replaceState({}, '', url.toString()); 50 + } 32 51 33 52 // upload form fields 34 53 let title = $state(""); ··· 214 233 </script> 215 234 216 235 <svelte:head> 217 - <title>upload track • plyr</title> 236 + <title>upload {mode === 'album' ? 'album' : 'track'} • plyr</title> 218 237 </svelte:head> 219 238 220 239 {#if loading} ··· 229 248 /> 230 249 <main> 231 250 <div class="section-header"> 232 - <h2>upload track</h2> 251 + <h2>upload {mode === 'album' ? 'album' : 'track'}</h2> 252 + </div> 253 + 254 + <div class="mode-toggle"> 255 + <button 256 + type="button" 257 + class="mode-btn" 258 + class:active={mode === 'track'} 259 + onclick={() => setMode('track')} 260 + >track</button> 261 + <button 262 + type="button" 263 + class="mode-btn" 264 + class:active={mode === 'album'} 265 + onclick={() => setMode('album')} 266 + >album</button> 233 267 </div> 234 268 269 + {#if mode === 'track'} 235 270 <form onsubmit={handleUpload}> 236 271 <div class="form-group"> 237 272 <label for="title">track title</label> ··· 418 453 </button> 419 454 420 455 </form> 456 + {:else} 457 + <AlbumUploadForm 458 + {albums} 459 + {artistProfile} 460 + onAlbumsReload={loadMyAlbums} 461 + /> 462 + {/if} 421 463 </main> 422 464 {/if} 423 465 ··· 455 497 font-weight: 700; 456 498 color: var(--text-primary); 457 499 margin: 0; 500 + } 501 + 502 + .mode-toggle { 503 + display: flex; 504 + gap: 0; 505 + margin-bottom: 1.5rem; 506 + background: var(--bg-tertiary); 507 + border: 1px solid var(--border-subtle); 508 + border-radius: var(--radius-sm); 509 + padding: 0.25rem; 510 + width: fit-content; 511 + } 512 + 513 + .mode-btn { 514 + width: auto; 515 + padding: 0.5rem 1.25rem; 516 + background: transparent; 517 + border: none; 518 + border-radius: var(--radius-sm); 519 + color: var(--text-tertiary); 520 + font-size: var(--text-base); 521 + font-weight: 500; 522 + font-family: inherit; 523 + cursor: pointer; 524 + transition: all 0.15s; 525 + } 526 + 527 + .mode-btn:hover { 528 + transform: none; 529 + box-shadow: none; 530 + } 531 + 532 + .mode-btn:hover:not(.active) { 533 + color: var(--text-secondary); 534 + background: transparent; 535 + } 536 + 537 + .mode-btn.active { 538 + background: var(--accent); 539 + color: var(--text-primary); 540 + } 541 + 542 + .mode-btn.active:hover { 543 + background: var(--accent); 458 544 } 459 545 460 546 form {
+9 -1
loq.toml
··· 183 183 184 184 [[rules]] 185 185 path = "frontend/src/routes/upload/+page.svelte" 186 - max_lines = 735 186 + max_lines = 798 187 187 188 188 [[rules]] 189 189 path = "services/moderation/src/admin.rs" ··· 244 244 [[rules]] 245 245 path = "frontend/src/routes/u/[[]handle[]]/+page.svelte" 246 246 max_lines = 1468 247 + 248 + [[rules]] 249 + path = "frontend/src/lib/components/AlbumUploadForm.svelte" 250 + max_lines = 742 251 + 252 + [[rules]] 253 + path = "frontend/src/lib/components/TrackEntryCard.svelte" 254 + max_lines = 571