Add library manager with tracks, albums and uploads sections

This commit is contained in:
2026-09-25 01:07:15 +02:00
parent 4ac73d8fa3
commit 9928840577
28 changed files with 1622 additions and 118 deletions
@@ -0,0 +1,193 @@
<script lang="ts" module>
const ALBUM_TYPES = [
{ value: 'album', label: 'Álbum' },
{ value: 'ep', label: 'EP' },
{ value: 'single', label: 'Single' }
] as const;
type AlbumType = (typeof ALBUM_TYPES)[number]['value'];
</script>
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import { page } from '$app/state';
import Disc3 from '@lucide/svelte/icons/disc-3';
import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x';
import type { Album, LibraryTrack } from '$lib/types';
import { albumCover } from '$lib/utils/hrefs';
import { plural } from '$lib/utils/format';
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import Field from '$lib/components/ui/primitives/Field.svelte';
import Input from '$lib/components/ui/primitives/Input.svelte';
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
import TrackPickList from './TrackPickList.svelte';
interface Props {
album: Album | null;
tracks: LibraryTrack[];
memberIds: string[];
formMessage?: string;
onClose: () => void;
}
let { album, tracks, memberIds, formMessage, onClose }: Props = $props();
const isNew = $derived(album === null);
const latestYear = new Date().getFullYear() + 1;
let title = $state(untrack(() => album?.title ?? ''));
let releaseYear = $state(untrack(() => String(album?.releaseYear ?? new Date().getFullYear())));
let type = $state<AlbumType>('album');
let currentIds = $state<string[]>(untrack(() => [...memberIds]));
let submitting = $state(false);
let confirmingDelete = $state(false);
const initialIds = untrack(() => new Set(memberIds));
const current = $derived(new Set(currentIds));
const inAlbum = $derived(tracks.filter((track) => current.has(track.id)));
const available = $derived(tracks.filter((track) => !current.has(track.id)));
const addedIds = $derived(currentIds.filter((id) => !initialIds.has(id)));
const removedIds = $derived([...initialIds].filter((id) => !current.has(id)));
function add(track: LibraryTrack) {
currentIds = [...currentIds, track.id];
}
function remove(track: LibraryTrack) {
currentIds = currentIds.filter((id) => id !== track.id);
}
</script>
<div>
<form
method="POST"
action="?/saveAlbum"
enctype="multipart/form-data"
use:enhance={() => {
submitting = true;
return async ({ update, result }) => {
await update({ reset: false });
submitting = false;
if (result.type === 'success') onClose();
};
}}
class="mt-6 grid gap-8 lg:grid-cols-[auto_1fr]"
>
<input type="hidden" name="albumId" value={album?.id ?? ''} />
<input type="hidden" name="addTrackIds" value={addedIds.join(',')} />
<input type="hidden" name="removeTrackIds" value={removedIds.join(',')} />
<div class="flex w-full flex-col gap-4 lg:w-64">
<ImageDropzone
name="cover"
fallbackUrl={album ? albumCover(album.id, 'large') : undefined}
required={isNew}
icon={Disc3}
gradient
size="fill"
/>
<p class="text-xs text-fg-2">Las canciones del álbum usan esta portada.</p>
<Field label="Título" for="album-title">
<Input
id="album-title"
name="title"
maxlength={LIMITS.albumTitle}
required
bind:value={title}
placeholder="Sin título"
/>
</Field>
<Field label="Artista principal" for="album-artist">
<Input id="album-artist" value={page.data.user?.name ?? ''} disabled />
</Field>
<Field label="Tipo">
<SegmentedControl options={[...ALBUM_TYPES]} bind:value={type} />
</Field>
<Field label="Año de lanzamiento" for="album-year">
<Input
id="album-year"
name="releaseYear"
type="number"
min={ALBUM_EARLIEST_YEAR}
max={latestYear}
bind:value={releaseYear}
/>
</Field>
</div>
<div class="min-w-0">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-display-3">Canciones</h2>
<p class="mt-1 text-sm text-fg-2">
{inAlbum.length > 0 ? plural(inAlbum.length, 'canción', 'canciones') : 'Sin canciones'}
</p>
</div>
<Button variant="secondary" size="sm" onclick={onClose}>Cerrar</Button>
</div>
{#if available.length > 0}
<h3 class="mt-6 mb-2 px-3 text-eyebrow text-fg-2">Añadir desde tu biblioteca</h3>
<TrackPickList tracks={available} icon={Plus} onPick={add} />
{/if}
<div class="mt-6">
{#if inAlbum.length > 0}
<h3 class="mb-2 px-3 text-eyebrow text-fg-2">En este álbum</h3>
<TrackPickList tracks={inAlbum} icon={X} onPick={remove} />
{:else}
<EmptyState
title="Este álbum aún no tiene canciones"
description="Añádelas desde tu biblioteca o sube archivos nuevos."
/>
{/if}
</div>
{#if formMessage}
<Alert tone="danger" class="mt-6">{formMessage}</Alert>
{/if}
<div class="mt-8 flex items-center justify-between gap-4">
<Button type="submit" disabled={submitting || title.trim() === ''}>
{#if submitting}
{isNew ? 'Creando…' : 'Guardando…'}
{:else}
{isNew ? 'Crear álbum' : 'Guardar cambios'}
{/if}
</Button>
{#if !isNew}
<button
type="button"
onclick={() => (confirmingDelete = true)}
class="text-sm text-danger-fg transition-opacity hover:opacity-80"
>
Eliminar álbum
</button>
{/if}
</div>
</div>
</form>
</div>
{#if album}
<ConfirmDialog
open={confirmingDelete}
onClose={() => (confirmingDelete = false)}
title="¿Eliminar «{album.title}»?"
description="Las canciones no se borran: solo se elimina el álbum."
confirmLabel="Eliminar"
action="?/deleteAlbum"
fields={{ albumId: album.id }}
/>
{/if}
@@ -0,0 +1,55 @@
<script lang="ts">
import type { Album, LibraryTrack } from '$lib/types';
import { albumCover } from '$lib/utils/hrefs';
import { albumMeta } from '$lib/utils/format';
import MediaCard from '$lib/components/ui/media/MediaCard.svelte';
import MediaCreateCard from '$lib/components/ui/media/MediaCreateCard.svelte';
import LibraryAlbumEditor from './LibraryAlbumEditor.svelte';
interface Props {
albums: Album[];
tracks: LibraryTrack[];
albumTrackIds: Record<string, string[]>;
formMessage?: string;
}
let { albums, tracks, albumTrackIds, formMessage }: Props = $props();
let editing = $state<{ album: Album | null } | null>(null);
</script>
{#if editing}
<div class="mt-4">
{#key editing.album?.id}
<LibraryAlbumEditor
album={editing.album}
{tracks}
memberIds={editing.album ? (albumTrackIds[editing.album.id] ?? []) : []}
{formMessage}
onClose={() => (editing = null)}
/>
{/key}
</div>
{:else}
<div class="mt-4 grid-cards-lg">
<MediaCreateCard
title="Nuevo álbum"
subtitle="Álbum, EP o Single"
onclick={() => (editing = { album: null })}
/>
{#each albums as album, i (album.id)}
<MediaCard
href="/albums/{album.id}"
title={album.title}
subtitle={albumMeta(album.releaseYear, album.trackCount)}
trackIds={album.coverTrackIds}
src={albumCover(album.id, 'large')}
index={i + 1}
onclick={(event) => {
event.preventDefault();
editing = { album };
}}
/>
{/each}
</div>
{/if}
@@ -0,0 +1,124 @@
<script lang="ts">
import Upload from '@lucide/svelte/icons/upload';
import Music from '@lucide/svelte/icons/music';
import SearchX from '@lucide/svelte/icons/search-x';
import type { LibraryTrack } from '$lib/types';
import { player } from '$lib/player/player.svelte';
import { filterLibraryTracks, TRACK_FILTERS, type TrackFilter } from '$lib/data/libraryTracks';
import { plural } from '$lib/utils/format';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import SearchInput from '$lib/components/ui/primitives/SearchInput.svelte';
import ChipGroup from '$lib/components/ui/primitives/ChipGroup.svelte';
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
import ManageTrackList from '$lib/components/music/ManageTrackList.svelte';
interface Props {
tracks: LibraryTrack[];
errorMessage?: string;
}
let { tracks, errorMessage }: Props = $props();
let query = $state('');
let filter = $state<TrackFilter>('all');
let selectedIds = $state<string[]>([]);
let pendingDelete = $state<LibraryTrack[]>([]);
const visible = $derived(filterLibraryTracks(tracks, query, filter));
const filterOptions = $derived(
TRACK_FILTERS.map(({ value, label }) => ({
value,
label,
count: filterLibraryTracks(tracks, '', value).length
}))
);
const pendingTitle = $derived(
pendingDelete.length === 1
? `«${pendingDelete[0].title}»`
: plural(pendingDelete.length, 'canción', 'canciones')
);
function togglePlay(index: number) {
player.playOrToggle(visible, index);
}
$effect(() => {
const visibleIds = new Set(visible.map((track) => track.id));
if (selectedIds.some((id) => !visibleIds.has(id))) {
selectedIds = selectedIds.filter((id) => visibleIds.has(id));
}
});
function deleteSelected() {
const ids = new Set(selectedIds);
pendingDelete = visible.filter((track) => ids.has(track.id));
}
</script>
{#if errorMessage}
<Alert tone="danger" class="mt-4">{errorMessage}</Alert>
{/if}
{#if tracks.length > 0}
{#if selectedIds.length > 0}
<div class="mt-4 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3">
<span class="text-sm text-fg-2"
>{plural(selectedIds.length, 'seleccionada', 'seleccionadas')}</span
>
<Button size="sm" variant="danger" onclick={deleteSelected}>Borrar</Button>
<Button size="sm" variant="secondary" onclick={() => (selectedIds = [])}>Deseleccionar</Button
>
</div>
{:else}
<div class="mt-4 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3">
<SearchInput
bind:value={query}
placeholder="Filtrar por título o artista"
class="w-full sm:max-w-search sm:flex-1"
/>
<ChipGroup label="Filtrar por estado" options={filterOptions} bind:value={filter} />
</div>
{/if}
{#if visible.length > 0}
<ManageTrackList
class="mt-4"
tracks={visible}
bind:selectedIds
onPlay={togglePlay}
onDelete={(track) => (pendingDelete = [track])}
/>
{:else}
<EmptyState
icon={SearchX}
title="Sin resultados"
description="Ninguna canción coincide con la búsqueda o el filtro."
/>
{/if}
{:else}
<EmptyState
icon={Music}
title="Todavía no tienes canciones"
description="Sube la primera y aparecerá aquí lista para reproducir."
>
{#snippet actions()}
<Button href="/upload">
<Upload class="size-icon-sm" />
Sube la primera
</Button>
{/snippet}
</EmptyState>
{/if}
<ConfirmDialog
open={pendingDelete.length > 0}
onClose={() => (pendingDelete = [])}
title="¿Borrar {pendingTitle}?"
description="Esta acción no se puede deshacer: se eliminarán de tu biblioteca, de tus álbumes y de las playlists donde estén."
confirmLabel="Borrar"
action="?/deleteTracks"
fields={{ trackIds: pendingDelete.map((track) => track.id).join(',') }}
/>
@@ -0,0 +1,161 @@
<script lang="ts">
import UploadCloud from '@lucide/svelte/icons/cloud-upload';
import { genreInfo } from '$lib/data/genres';
import {
filesFromDrop,
readDuration,
summarizeQueue,
toQueuedUpload,
UPLOAD_FORMATS,
type QueuedUpload,
type Visibility
} from '$lib/data/uploadQueue';
import { plural } from '$lib/utils/format';
import Button from '$lib/components/ui/primitives/Button.svelte';
import UploadQueueList from '$lib/components/music/library/UploadQueueList.svelte';
import UploadSettings from '$lib/components/music/library/UploadSettings.svelte';
interface Props {
genres: { genre: string; incompatibleWith: string[] }[];
}
let { genres }: Props = $props();
const ACCEPT = UPLOAD_FORMATS.map((ext) => `.${ext}`).join(',');
let queue = $state<QueuedUpload[]>([]);
let selectedGenres = $state<string[]>([]);
let visibility = $state<Visibility>('private');
let coauthors = $state<string[]>([]);
let dragging = $state(false);
let input = $state<HTMLInputElement>();
const single = $derived(queue.length === 1 ? queue[0] : null);
const summary = $derived(summarizeQueue(queue));
const genresLabel = $derived(
selectedGenres.length > 0
? selectedGenres.map((genre) => genreInfo(genre).label).join(', ')
: '-'
);
const visibilityLabel = $derived(visibility === 'private' ? 'Privado' : 'Público');
const queueMeta = $derived(
[
plural(summary.ready, 'lista', 'listas'),
summary.failed > 0 ? `${summary.failed} con error` : null
]
.filter(Boolean)
.join(' · ')
);
function enqueue(files: File[]) {
const added = files.map(toQueuedUpload);
queue = [...queue, ...added];
for (const item of added) {
if (item.error) continue;
readDuration(item.file).then((duration) => {
queue = queue.map((current) =>
current.id === item.id ? { ...current, duration } : current
);
});
}
}
function onPick(event: Event) {
const target = event.currentTarget as HTMLInputElement;
enqueue(Array.from(target.files ?? []));
target.value = '';
}
async function onDrop(event: DragEvent) {
event.preventDefault();
dragging = false;
if (!event.dataTransfer) return;
enqueue(await filesFromDrop(event.dataTransfer));
}
function setTitle(id: string, title: string) {
queue = queue.map((item) => (item.id === id ? { ...item, title } : item));
}
function remove(id: string) {
queue = queue.filter((item) => item.id !== id);
}
</script>
<div class="mt-6 grid items-start gap-6 lg:grid-cols-2">
<div class="flex min-w-0 flex-col gap-8">
<div
role="presentation"
ondragover={(event) => {
event.preventDefault();
dragging = true;
}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
class="flex min-h-64 flex-col items-center justify-center rounded-panel border border-dashed p-6 text-center transition sm:p-10 {dragging
? 'border-accent/60 bg-accent-tint'
: 'border-line-strong bg-surface'}"
>
<span class="grid size-14 place-items-center rounded-full bg-surface-2 text-fg-2">
<UploadCloud class="size-icon-xl" />
</span>
<h2 class="mt-5 font-display text-xl font-semibold tracking-display text-fg">
Arrastra tus archivos aquí
</h2>
<p class="mt-2 text-xs text-fg-2">
WAV, FLAC, AIFF o MP3 · hasta 500 MB por archivo · puedes soltar carpetas enteras
</p>
<Button class="mt-6" onclick={() => input?.click()}>Elegir archivos</Button>
<input
bind:this={input}
type="file"
multiple
accept={ACCEPT}
onchange={onPick}
class="hidden"
aria-label="Elegir archivos de audio"
tabindex="-1"
/>
</div>
<UploadSettings
{genres}
bind:selectedGenres
bind:visibility
bind:coauthors
bind:title={() => single?.title ?? '', (value) => single && setTitle(single.id, value)}
titleDisabled={single === null}
/>
</div>
<section class="min-w-0 rounded-panel bg-surface p-5 sm:p-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="font-display text-xl font-semibold tracking-display text-fg">Cola de subida</h2>
<p class="mt-1 text-xs text-fg-2">
{queue.length > 0 ? queueMeta : 'Todavía no hay archivos'}
</p>
</div>
<div class="flex items-center gap-3">
{#if queue.length > 0}
<Button variant="secondary" size="sm" onclick={() => (queue = [])}>Vaciar cola</Button>
{/if}
<Button disabled>Añadir {summary.ready} a la biblioteca</Button>
</div>
</div>
{#if queue.length > 0}
<UploadQueueList
class="mt-4"
items={queue}
{genresLabel}
{visibilityLabel}
onRemove={remove}
onTitle={setTitle}
/>
{:else}
<p class="mt-8 mb-6 text-center text-sm text-fg-2">
Los archivos que añadas aparecerán aquí.
</p>
{/if}
</section>
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { LucideIcon } from '@lucide/svelte';
import type { LibraryTrack } from '$lib/types';
import ListRow from '$lib/components/ui/media/ListRow.svelte';
interface Props {
tracks: LibraryTrack[];
icon: LucideIcon;
onPick: (track: LibraryTrack) => void;
}
let { tracks, icon: Icon, onPick }: Props = $props();
function subtitle(track: LibraryTrack): string {
const album = track.albumTitle ? `en ${track.albumTitle}` : 'Sin álbum';
return track.artist ? `${track.artist} · ${album}` : album;
}
</script>
<div class="flex flex-col gap-px" role="list">
{#each tracks as track (track.id)}
<div role="listitem">
<ListRow
size="xs"
trackId={track.id}
title={track.title}
subtitle={subtitle(track)}
explicit={track.explicit}
onclick={() => onPick(track)}
class="px-3 py-2"
>
{#snippet trailing()}
<Icon class="size-icon-sm shrink-0 text-fg-2" />
{/snippet}
</ListRow>
</div>
{/each}
</div>
@@ -0,0 +1,98 @@
<script lang="ts" module>
const GRID_COLUMNS = [
'minmax(200px,1fr)',
'minmax(96px,220px)',
'96px',
'88px',
'72px',
'140px',
'36px'
].join(' ');
const GRID_COLUMNS_MOBILE = ['minmax(120px,1fr)', '96px', '32px'].join(' ');
const GRID_STYLE = `--mf-track-cols:${GRID_COLUMNS}; --mf-track-cols-mobile:${GRID_COLUMNS_MOBILE}`;
</script>
<script lang="ts">
import X from '@lucide/svelte/icons/x';
import { fmtTime, formatSize } from '$lib/utils/format';
import type { QueuedUpload } from '$lib/data/uploadQueue';
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
interface Props {
items: QueuedUpload[];
genresLabel: string;
visibilityLabel: string;
onRemove: (id: string) => void;
onTitle: (id: string, title: string) => void;
class?: string;
}
let {
items,
genresLabel,
visibilityLabel,
onRemove,
onTitle,
class: klass = ''
}: Props = $props();
</script>
<div class="overflow-x-auto {klass}">
<div
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-2 sm:gap-6 sm:pr-6 sm:pl-3"
style={GRID_STYLE}
>
<span>Título</span>
<span class="hidden sm:block">Géneros</span>
<span class="hidden sm:block">Acceso</span>
<span class="hidden sm:block">Tamaño</span>
<span class="hidden text-center sm:block">Duración</span>
<span>Estado</span>
<span></span>
</div>
<div class="mt-1 flex flex-col gap-0.5" role="list">
{#each items as item (item.id)}
<div
role="listitem"
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors hover:bg-hover sm:gap-6 sm:pr-6 sm:pl-3"
style={GRID_STYLE}
>
<div class="flex min-w-0 items-center gap-3">
<span
class="grid size-10 shrink-0 place-items-center rounded-control bg-surface text-xs font-medium text-fg-2"
>
{item.format}
</span>
<div class="min-w-0">
<input
value={item.title}
oninput={(event) => onTitle(item.id, event.currentTarget.value)}
aria-label="Título de {item.file.name}"
class="w-full truncate rounded-tag bg-transparent text-sm text-fg transition-colors hover:bg-surface focus:bg-surface-2 focus:outline-none"
/>
<p class="truncate text-xs text-fg-2">{item.file.name}</p>
</div>
</div>
<span class="hidden min-w-0 truncate text-xs text-fg-2 sm:block">{genresLabel}</span>
<span class="hidden min-w-0 truncate text-xs text-fg-2 sm:block">{visibilityLabel}</span>
<span class="hidden truncate text-xs text-fg-2 tabular-nums sm:block">
{formatSize(item.file.size) || '-'}
</span>
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
{item.duration === null ? '-' : fmtTime(item.duration)}
</span>
<span>
{#if item.error}
<StatusBadge label={item.error} tone="danger" />
{:else}
<StatusBadge label="Lista" tone="success" />
{/if}
</span>
<IconButton label="Quitar de la cola" size="xs" onclick={() => onRemove(item.id)}>
<X class="size-icon-sm" />
</IconButton>
</div>
{/each}
</div>
</div>
@@ -0,0 +1,167 @@
<script lang="ts">
import ImageIcon from '@lucide/svelte/icons/image';
import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x';
import { genreOptions } from '$lib/data/genres';
import type { Visibility } from '$lib/data/uploadQueue';
import { LIMITS } from '$lib/validation';
import Chip from '$lib/components/ui/primitives/Chip.svelte';
import Field from '$lib/components/ui/primitives/Field.svelte';
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
import Input from '$lib/components/ui/primitives/Input.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
import ImageDropzone from '$lib/components/ui/forms/ImageDropzone.svelte';
interface Props {
genres: { genre: string; incompatibleWith: string[] }[];
selectedGenres: string[];
visibility: Visibility;
coauthors: string[];
title: string;
titleDisabled: boolean;
class?: string;
}
let {
genres,
selectedGenres = $bindable(),
visibility = $bindable(),
coauthors = $bindable(),
title = $bindable(),
titleDisabled,
class: klass = ''
}: Props = $props();
const VISIBILITY: { value: Visibility; label: string }[] = [
{ value: 'private', label: 'Privado' },
{ value: 'public', label: 'Público' }
];
let artist = $state('');
let hasCover = $state(false);
const options = $derived(genreOptions(genres));
const blocked = $derived(
new Set(
options
.filter((option) => selectedGenres.includes(option.genre))
.flatMap((option) => option.incompatibleWith)
)
);
function toggleGenre(genre: string) {
selectedGenres = selectedGenres.includes(genre)
? selectedGenres.filter((current) => current !== genre)
: [...selectedGenres, genre];
}
function addArtist() {
const name = artist.trim();
if (name === '') return;
const known = coauthors.some((current) => current.toLowerCase() === name.toLowerCase());
if (!known) coauthors = [...coauthors, name];
artist = '';
}
function onArtistKey(event: KeyboardEvent) {
if (event.key !== 'Enter') return;
event.preventDefault();
addArtist();
}
</script>
<aside class="flex flex-col gap-6 {klass}">
<p class="text-xs font-medium tracking-eyebrow text-fg-2 uppercase">Aplicar a esta subida</p>
<div class="grid gap-8 sm:grid-cols-[11rem_minmax(0,1fr)]">
<Field label="Portada">
<div class="w-full max-w-44">
<ImageDropzone
name="cover"
icon={ImageIcon}
gradient
size="fill"
onselect={() => (hasCover = true)}
/>
</div>
{#snippet hint()}
<p>{hasCover ? 'Portada lista' : 'Haz clic para elegir una imagen'}</p>
{/snippet}
</Field>
<div class="flex min-w-0 flex-col gap-7">
<Field label="Géneros">
<div class="flex flex-wrap gap-2">
{#each options as option (option.genre)}
<Chip
selected={selectedGenres.includes(option.genre)}
disabled={blocked.has(option.genre)}
onclick={() => toggleGenre(option.genre)}
>
{option.label}
</Chip>
{/each}
</div>
{#snippet hint()}
<p>Elige varios. Algunos no se pueden combinar.</p>
<span class="shrink-0 tabular-nums">{selectedGenres.length} elegidos</span>
{/snippet}
</Field>
<Field label="Título" for="upload-title">
<Input
id="upload-title"
bind:value={title}
maxlength={LIMITS.trackTitle}
disabled={titleDisabled}
placeholder={titleDisabled
? 'Varios archivos · edita cada título en la cola'
: 'Nombre de la canción'}
/>
</Field>
<div class="grid gap-7 md:grid-cols-2">
<Field label="Visibilidad">
<SegmentedControl options={VISIBILITY} bind:value={visibility} />
</Field>
<Field label="Artistas colaboradores" for="upload-coauthor">
<div class="flex items-center gap-2">
<Input
id="upload-coauthor"
bind:value={artist}
maxlength={LIMITS.trackTitle}
placeholder="Nombre del artista"
onkeydown={onArtistKey}
/>
<IconButton label="Añadir artista" surface onclick={addArtist}>
<Plus class="size-icon-sm" />
</IconButton>
</div>
{#if coauthors.length > 0}
<ul class="mt-3 flex flex-wrap gap-2">
{#each coauthors as name (name)}
<li
class="flex items-center gap-1 rounded-full bg-btn py-1 pr-1 pl-3.5 text-xs text-fg"
>
{name}
<IconButton
label="Quitar a {name}"
size="xs"
plain
onclick={() => (coauthors = coauthors.filter((current) => current !== name))}
>
<X class="size-icon-xs" />
</IconButton>
</li>
{/each}
</ul>
{/if}
{#snippet hint()}
<p>Aparecerán como coautores de cada canción.</p>
{/snippet}
</Field>
</div>
</div>
</div>
</aside>