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,143 @@
<script lang="ts" module>
type ColumnId = 'album' | 'genres' | 'status';
const COLUMNS: { id: ColumnId; label: string; width: string }[] = [
{ id: 'album', label: 'Álbum', width: 'minmax(96px,160px)' },
{ id: 'genres', label: 'Géneros', width: 'minmax(96px,200px)' },
{ id: 'status', label: 'Estado', width: '112px' }
];
const CHECK_W = '28px';
const TITLE_W = { desktop: 'minmax(180px,1fr)', mobile: 'minmax(120px,1fr)' };
const DURATION_W = { desktop: '80px', mobile: '52px' };
const ACTION_W = { desktop: '36px', mobile: '32px' };
</script>
<script lang="ts">
import X from '@lucide/svelte/icons/x';
import type { LibraryTrack } from '$lib/types';
import { player } from '$lib/player/player.svelte';
import { genreInfo } from '$lib/data/genres';
import { TRACK_STATUS } from '$lib/data/libraryTracks';
import { fmtTime } from '$lib/utils/format';
import { pressable } from '$lib/actions/pressable';
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
import EqBars from '$lib/components/ui/media/EqBars.svelte';
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
interface Props {
tracks: LibraryTrack[];
selectedIds: string[];
onPlay: (index: number) => void;
onDelete: (track: LibraryTrack) => void;
class?: string;
}
let { tracks, selectedIds = $bindable(), onPlay, onDelete, class: klass = '' }: Props = $props();
const selected = $derived(new Set(selectedIds));
const allSelected = $derived(
tracks.length > 0 && tracks.every((track) => selected.has(track.id))
);
const gridColumns = $derived(
[
CHECK_W,
TITLE_W.desktop,
...COLUMNS.map((c) => c.width),
DURATION_W.desktop,
ACTION_W.desktop
].join(' ')
);
const gridColumnsMobile = $derived(
[CHECK_W, TITLE_W.mobile, DURATION_W.mobile, ACTION_W.mobile].join(' ')
);
const gridStyle = $derived(
`--mf-track-cols:${gridColumns}; --mf-track-cols-mobile:${gridColumnsMobile}`
);
function toggleAll(checked: boolean) {
selectedIds = checked ? tracks.map((track) => track.id) : [];
}
function toggle(id: string, checked: boolean) {
selectedIds = checked ? [...selectedIds, id] : selectedIds.filter((item) => item !== id);
}
function genresLabel(track: LibraryTrack): string {
return track.genres.map((genre) => genreInfo(genre).label).join(', ') || '-';
}
</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={gridStyle}
>
<Checkbox label="Seleccionar todas" bind:checked={() => allSelected, toggleAll} />
<span>Título</span>
{#each COLUMNS as column (column.id)}
<span class="hidden sm:block">{column.label}</span>
{/each}
<span class="hidden text-center sm:block">Duración</span>
<span></span>
</div>
<div class="mt-1 flex flex-col gap-0.5" role="list">
{#each tracks as track, i (track.id)}
{@const active = player.currentId === track.id}
<div
role="listitem"
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-6 sm:pr-6 sm:pl-3 {active
? 'surface-active'
: 'hover:bg-hover'}"
style={gridStyle}
>
<Checkbox
label="Seleccionar «{track.title}»"
bind:checked={() => selected.has(track.id), (checked) => toggle(track.id, checked)}
/>
<div
role="button"
tabindex="0"
use:pressable={() => onPlay(i)}
class="flex min-w-0 flex-1 text-left"
>
<MediaIdentity
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.explicit}
{active}
>
{#snippet overlay()}
{#if active}
<EqBars overlay paused={!player.playing} />
{/if}
{/snippet}
</MediaIdentity>
</div>
{#each COLUMNS as column (column.id)}
<span class="hidden min-w-0 truncate text-xs text-fg-2 sm:block">
{#if column.id === 'album'}
{track.albumTitle ?? '-'}
{:else if column.id === 'genres'}
{genresLabel(track)}
{:else}
{@const status = TRACK_STATUS[track.status]}
<StatusBadge label={status.label} tone={status.tone} />
{/if}
</span>
{/each}
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
{fmtTime(track.duration)}
</span>
<IconButton label="Borrar canción" size="xs" revealOnHover onclick={() => onDelete(track)}>
<X class="size-icon-sm" />
</IconButton>
</div>
{/each}
</div>
</div>
@@ -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>
@@ -4,10 +4,9 @@
import type { SessionUser } from '$lib/types';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import { searchHref } from '$lib/utils/hrefs';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import AccountMenu from '$lib/components/shell/AccountMenu.svelte';
import SearchInput from '$lib/components/ui/primitives/SearchInput.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
@@ -21,7 +20,6 @@
let query = $derived(
page.url.pathname === '/explore' ? (page.url.searchParams.get('q') ?? '') : ''
);
let searchInput: HTMLInputElement | undefined = $state();
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function runSearch(term: string) {
@@ -33,16 +31,14 @@
});
}
function onInput() {
function oninput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => runSearch(query.trim()), SEARCH_DEBOUNCE_MS);
}
function clearQuery() {
query = '';
function onclear() {
clearTimeout(searchTimeout);
runSearch('');
searchInput?.focus();
}
function onSubmit(event: SubmitEvent) {
@@ -58,29 +54,12 @@
</IconButton>
<form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1">
<label
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
>
<Search class="size-icon-sm shrink-0 text-fg-2" />
<input
type="text"
bind:this={searchInput}
<SearchInput
bind:value={query}
oninput={onInput}
placeholder="Canciones, artistas, álbumes o playlists"
class="w-full min-w-0 bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
{oninput}
{onclear}
/>
{#if query}
<button
type="button"
onclick={clearQuery}
aria-label="Borrar búsqueda"
class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
>
<X class="size-icon-xs" />
</button>
{/if}
</label>
</form>
<div class="ml-auto shrink-0">
@@ -8,7 +8,7 @@
required?: boolean;
icon?: LucideIcon;
gradient?: boolean;
size?: 'default' | 'hero';
size?: 'default' | 'hero' | 'fill';
onselect?: (file: File) => void;
class?: string;
}
@@ -24,9 +24,10 @@
class: klass = ''
}: Props = $props();
const SIZE: Record<'default' | 'hero', string> = {
const SIZE: Record<'default' | 'hero' | 'fill', string> = {
default: 'size-40 sm:size-52 rounded-control',
hero: 'size-cover-hero rounded-art-lg'
hero: 'size-cover-hero rounded-art-lg',
fill: 'aspect-square w-full rounded-art-lg'
};
let preview = $state('');
@@ -9,6 +9,7 @@
subtitle?: string | null;
src?: string | null;
trackIds?: string[];
onclick?: (event: MouseEvent) => void;
onPlay?: (event: MouseEvent) => void;
art?: Snippet;
oncontextmenu?: (event: MouseEvent) => void;
@@ -21,6 +22,7 @@
subtitle,
src,
trackIds = [],
onclick,
onPlay,
art,
oncontextmenu,
@@ -28,18 +30,18 @@
}: Props = $props();
</script>
<a {href} class="group animate-enter min-w-0 text-left" style="--i:{index}" {oncontextmenu}>
<a
{href}
{onclick}
class="group animate-enter min-w-0 rounded-panel p-2.5 text-left transition-colors hover:bg-hover"
style="--i:{index}"
{oncontextmenu}
>
<div class="relative">
{#if art}
{@render art()}
{:else}
<Artwork
{src}
{trackIds}
size="fill"
alt={title}
class="rounded-art transition duration-300 ease-snappy group-hover:scale-105"
/>
<Artwork {src} {trackIds} size="fill" alt={title} class="rounded-art" />
{/if}
{#if onPlay}
<div
@@ -0,0 +1,29 @@
<script lang="ts">
import Plus from '@lucide/svelte/icons/plus';
interface Props {
title: string;
subtitle?: string | null;
onclick: () => void;
index?: number;
}
let { title, subtitle, onclick, index = 0 }: Props = $props();
</script>
<button
type="button"
{onclick}
class="group animate-enter min-w-0 rounded-panel p-2.5 text-left focus-ring transition-colors hover:bg-hover"
style="--i:{index}"
>
<div
class="grid aspect-square w-full place-items-center rounded-art bg-[image:var(--mf-liked-grad)]"
>
<Plus class="size-icon-xl text-on-art" />
</div>
<div class="mt-2.5 truncate text-sm text-fg">{title}</div>
{#if subtitle}
<div class="mt-0.5 truncate text-xs text-fg-2">{subtitle}</div>
{/if}
</button>
@@ -0,0 +1,21 @@
<script lang="ts">
interface Props {
label: string;
tone?: 'success' | 'neutral' | 'danger';
class?: string;
}
let { label, tone = 'neutral', class: klass = '' }: Props = $props();
const TONE = {
success: 'bg-accent-tint text-accent-soft',
neutral: 'bg-btn text-fg-2',
danger: 'bg-danger/15 text-danger-fg'
};
</script>
<span
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap {TONE[
tone
]} {klass}">{label}</span
>
@@ -5,11 +5,12 @@
interface Props {
checked?: boolean;
name?: string;
label?: string;
class?: string;
children: Snippet;
children?: Snippet;
}
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
let { checked = $bindable(false), name, label, class: klass = '', children }: Props = $props();
</script>
<label class="flex items-center gap-3 text-sm text-fg-2 {klass}">
@@ -17,6 +18,7 @@
<input
type="checkbox"
{name}
aria-label={label}
bind:checked
class="peer size-5 cursor-pointer appearance-none rounded-tag border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
/>
@@ -24,5 +26,7 @@
class="pointer-events-none absolute size-icon-xs text-ink opacity-0 transition peer-checked:opacity-100"
/>
</span>
{#if children}
<span class="cursor-pointer">{@render children()}</span>
{/if}
</label>
@@ -28,6 +28,7 @@
type={href ? undefined : 'button'}
disabled={href ? undefined : disabled}
onclick={href ? undefined : onclick}
aria-pressed={href ? undefined : selected}
class={classes}
>
{@render children()}
@@ -0,0 +1,30 @@
<script lang="ts" generics="T extends string">
import Chip from './Chip.svelte';
interface Option {
value: T;
label: string;
count?: number;
}
interface Props {
options: readonly Option[];
value: T;
label: string;
class?: string;
}
let { options, value = $bindable(), label, class: klass = '' }: Props = $props();
</script>
<div role="group" aria-label={label} class="flex flex-wrap gap-2 {klass}">
{#each options as option (option.value)}
<Chip
selected={value === option.value}
count={option.count}
onclick={() => (value = option.value)}
>
{option.label}
</Chip>
{/each}
</div>
@@ -11,9 +11,11 @@
placeholder?: string;
maxlength?: number;
required?: boolean;
disabled?: boolean;
autocomplete?: 'on' | 'off';
icon?: LucideIcon;
oninput?: (event: Event) => void;
onkeydown?: (event: KeyboardEvent) => void;
class?: string;
}
@@ -27,9 +29,11 @@
placeholder,
maxlength,
required = false,
disabled = false,
autocomplete = 'off',
icon: Icon,
oninput,
onkeydown,
class: klass = ''
}: Props = $props();
</script>
@@ -49,8 +53,10 @@
{placeholder}
{maxlength}
{required}
{disabled}
{autocomplete}
{oninput}
{onkeydown}
bind:value
class="field-control {Icon ? 'pr-4 pl-11' : 'px-4'} {klass}"
/>
@@ -0,0 +1,55 @@
<script lang="ts">
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
interface Props {
value?: string;
placeholder?: string;
label?: string;
oninput?: () => void;
onclear?: () => void;
class?: string;
}
let {
value = $bindable(''),
placeholder = 'Buscar',
label = placeholder,
oninput,
onclear,
class: klass = ''
}: Props = $props();
let input: HTMLInputElement | undefined = $state();
function clear() {
value = '';
onclear?.();
input?.focus();
}
</script>
<label
class="flex h-9.5 min-w-0 items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover {klass}"
>
<Search class="size-icon-xs shrink-0 text-fg-2" />
<input
type="text"
bind:this={input}
bind:value
{oninput}
{placeholder}
aria-label={label}
class="w-full min-w-0 bg-transparent text-xs text-fg placeholder:text-fg-2 focus:outline-none"
/>
{#if value}
<button
type="button"
onclick={clear}
aria-label="Borrar búsqueda"
class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
>
<X class="size-icon-xs" />
</button>
{/if}
</label>
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import type { LibraryTrack } from '$lib/types';
import { filterLibraryTracks } from './libraryTracks';
function track(id: string, title: string, status: LibraryTrack['status']): LibraryTrack {
return {
id,
title,
artist: 'Artista',
ownerUserId: '1',
explicit: false,
duration: 60,
listensCount: 0,
date: '2026-01-01T00:00:00Z',
status,
genres: [],
albumTitle: null
};
}
const tracks = [
track('1', 'Alba', 'Completed'),
track('2', 'Noche', 'Processing'),
track('3', 'Ruido', 'Pending'),
track('4', 'Roto', 'Failed')
];
describe('filterLibraryTracks', () => {
it('returns everything for the "all" filter without a query', () => {
expect(filterLibraryTracks(tracks, '', 'all')).toHaveLength(4);
});
it('groups pending and processing tracks together', () => {
expect(filterLibraryTracks(tracks, '', 'processing').map((t) => t.id)).toEqual(['2', '3']);
});
it('combines the status filter with the text query', () => {
expect(filterLibraryTracks(tracks, ' alb ', 'published').map((t) => t.id)).toEqual(['1']);
expect(filterLibraryTracks(tracks, 'alb', 'failed')).toEqual([]);
});
it('matches the artist too', () => {
expect(filterLibraryTracks(tracks, 'artista', 'all')).toHaveLength(4);
});
});
+41
View File
@@ -0,0 +1,41 @@
import type { LibraryTrack, TrackStatus } from '$lib/types';
export type TrackTone = 'success' | 'neutral' | 'danger';
export const TRACK_STATUS: Record<TrackStatus, { label: string; tone: TrackTone }> = {
Completed: { label: 'Publicada', tone: 'success' },
Processing: { label: 'Procesando', tone: 'neutral' },
Pending: { label: 'En cola', tone: 'neutral' },
Failed: { label: 'Con error', tone: 'danger' }
};
export const TRACK_FILTERS = [
{ value: 'all', label: 'Todas', statuses: null },
{ value: 'published', label: 'Publicadas', statuses: ['Completed'] },
{ value: 'processing', label: 'Procesando', statuses: ['Pending', 'Processing'] },
{ value: 'failed', label: 'Con error', statuses: ['Failed'] }
] as const satisfies readonly {
value: string;
label: string;
statuses: readonly TrackStatus[] | null;
}[];
export type TrackFilter = (typeof TRACK_FILTERS)[number]['value'];
export function filterLibraryTracks(
tracks: LibraryTrack[],
query: string,
filter: TrackFilter
): LibraryTrack[] {
const statuses: readonly TrackStatus[] | null =
TRACK_FILTERS.find((option) => option.value === filter)?.statuses ?? null;
const needle = query.trim().toLowerCase();
return tracks.filter(
(track) =>
(!statuses || statuses.includes(track.status)) &&
(!needle ||
track.title.toLowerCase().includes(needle) ||
(track.artist ?? '').toLowerCase().includes(needle))
);
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import {
summarizeQueue,
titleFromFileName,
toQueuedUpload,
uploadError,
UPLOAD_MAX_BYTES
} from '$lib/data/uploadQueue';
function file(name: string, size = 1024) {
return new File([new Uint8Array(size)], name);
}
describe('titleFromFileName', () => {
it('drops the extension and track number and humanizes underscores', () => {
expect(titleFromFileName('05_ultima_ola_master.wav')).toBe('Ultima ola master');
expect(titleFromFileName('12 - Niebla.flac')).toBe('Niebla');
});
it('keeps the base name when nothing is left after cleaning', () => {
expect(titleFromFileName('07.mp3')).toBe('07');
});
});
describe('uploadError', () => {
it('accepts supported formats', () => {
for (const name of ['a.wav', 'a.FLAC', 'a.mp3', 'a.aiff']) {
expect(uploadError(file(name))).toBeNull();
}
});
it('rejects unsupported formats, empty and oversized files', () => {
expect(uploadError(file('portada.psd'))).toBe('Formato no compatible');
expect(uploadError(file('a.wav', 0))).toBe('Archivo vacío');
const big = { name: 'a.wav', size: UPLOAD_MAX_BYTES + 1 } as File;
expect(uploadError(big)).toBe('Supera 500 MB');
});
});
describe('summarizeQueue', () => {
it('counts ready and failed items', () => {
const items = [file('a.wav'), file('b.mp3'), file('c.psd')].map(toQueuedUpload);
expect(summarizeQueue(items)).toEqual({ ready: 2, failed: 1 });
});
});
+98
View File
@@ -0,0 +1,98 @@
export const UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
export const UPLOAD_FORMATS = ['wav', 'flac', 'aiff', 'aif', 'mp3'];
export type Visibility = 'private' | 'public';
export interface QueuedUpload {
id: string;
file: File;
title: string;
format: string;
error: string | null;
duration: number | null;
}
export function fileExtension(name: string): string {
const dot = name.lastIndexOf('.');
return dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
}
export function titleFromFileName(name: string): string {
const base = name.replace(/\.[^.]+$/, '');
const cleaned = base
.replace(/^\d{1,3}\s*[-_.)]\s*/, '')
.replace(/[_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (cleaned === '') return base;
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
export function uploadError(file: File): string | null {
if (!UPLOAD_FORMATS.includes(fileExtension(file.name))) return 'Formato no compatible';
if (file.size > UPLOAD_MAX_BYTES) return 'Supera 500 MB';
if (file.size === 0) return 'Archivo vacío';
return null;
}
export function toQueuedUpload(file: File): QueuedUpload {
return {
id: crypto.randomUUID(),
file,
title: titleFromFileName(file.name),
format: fileExtension(file.name).toUpperCase() || '?',
error: uploadError(file),
duration: null
};
}
export function summarizeQueue(items: QueuedUpload[]): { ready: number; failed: number } {
const failed = items.filter((item) => item.error !== null).length;
return { ready: items.length - failed, failed };
}
async function readEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
const all: FileSystemEntry[] = [];
for (;;) {
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
reader.readEntries(resolve, reject)
);
if (batch.length === 0) return all;
all.push(...batch);
}
}
async function filesFromEntry(entry: FileSystemEntry): Promise<File[]> {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) =>
(entry as FileSystemFileEntry).file(resolve, reject)
);
return [file];
}
if (!entry.isDirectory) return [];
const children = await readEntries((entry as FileSystemDirectoryEntry).createReader());
return (await Promise.all(children.map(filesFromEntry))).flat();
}
export async function filesFromDrop(transfer: DataTransfer): Promise<File[]> {
const entries = Array.from(transfer.items)
.map((item) => item.webkitGetAsEntry?.() ?? null)
.filter((entry): entry is FileSystemEntry => entry !== null);
if (entries.length === 0) return Array.from(transfer.files);
return (await Promise.all(entries.map(filesFromEntry))).flat();
}
export function readDuration(file: File): Promise<number | null> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const audio = new Audio();
const done = (value: number | null) => {
URL.revokeObjectURL(url);
resolve(value);
};
audio.preload = 'metadata';
audio.onloadedmetadata = () => done(Number.isFinite(audio.duration) ? audio.duration : null);
audio.onerror = () => done(null);
audio.src = url;
});
}
+19 -1
View File
@@ -1,5 +1,14 @@
import type { components } from '$lib/api/schema';
import type { Album, LikedTrack, ListeningStats, Mix, Paged, Playlist, Track } from '$lib/types';
import type {
Album,
LibraryTrack,
LikedTrack,
ListeningStats,
Mix,
Paged,
Playlist,
Track
} from '$lib/types';
type TrackDto = components['schemas']['TrackApplicationResponse'];
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
@@ -30,6 +39,15 @@ export function toDatedTrack(dto: TrackDto): Track & { date: string } {
return { ...toTrack(dto), date: dto.createdAt };
}
export function toLibraryTrack(dto: TrackDto, albumTitle: string | null): LibraryTrack {
return {
...toDatedTrack(dto),
status: dto.audioStatus,
genres: dto.tags,
albumTitle
};
}
export function toLikedTrack(dto: TrackDto): LikedTrack {
return { ...toTrack(dto), likedAt: new Date(dto.createdAt).getTime() };
}
+9
View File
@@ -17,6 +17,15 @@ export interface Track {
listensCount: number;
}
export type TrackStatus = 'Pending' | 'Processing' | 'Completed' | 'Failed';
export interface LibraryTrack extends Track {
date: string;
status: TrackStatus;
genres: string[];
albumTitle: string | null;
}
export interface LikedTrack extends Track {
likedAt: number;
}
@@ -36,7 +36,7 @@
</PageHeader>
{#if items.length > 0}
<div class="grid-cards">
<div class="grid-cards-lg">
{#each items as album, i (album.id)}
<MediaCard
href="/albums/{album.id}"
@@ -1,32 +1,165 @@
import type { PageServerLoad, Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { toDatedTrack, toPage } from '$lib/server/mappers';
import { apiFor, failOnError, unwrapOrError } from '$lib/server/api';
import { fail, isActionFailure, redirect } from '@sveltejs/kit';
import type { Album } from '$lib/types';
import { toAlbum, toLibraryTrack, toPage } from '$lib/server/mappers';
import { apiFor, failOnError, unwrapOrError, type ApiClient } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { formString } from '$lib/server/forms/fields';
import { formFile, formString } from '$lib/server/forms/fields';
import { parseAlbumForm } from '$lib/server/forms/albumForm';
import { uploadOptionalCover } from '$lib/server/upload';
import { authedAction } from '$lib/server/actions/authedAction';
import { LIBRARY_PAGE_SIZE } from '$lib/config';
import { ALBUMS_PAGE_SIZE, ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PAGE_SIZE } from '$lib/config';
const ALBUMS_SECTION_URL = '/library?section=albums';
async function trackIdsByAlbum(api: ApiClient, albums: Album[]): Promise<Record<string, string[]>> {
const entries = await Promise.all(
albums.map(async (album) => {
const tracks = await api.GET('/albums/{id}/tracks', {
params: {
path: { id: album.id },
query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE }
}
});
return [album.id, (tracks.data?.items ?? []).map((track) => track.id)] as const;
})
);
return Object.fromEntries(entries);
}
function albumTitleByTrackId(albums: Album[], trackIds: Record<string, string[]>) {
return new Map(
albums.flatMap((album) => (trackIds[album.id] ?? []).map((id) => [id, album.title] as const))
);
}
function formIds(form: FormData, key: string): string[] {
return formString(form, key).split(',').filter(Boolean);
}
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = apiFor({ fetch, locals });
const result = await api.GET('/tracks/users/{userId}', {
const [tracksResult, albumsResult, genresResult] = await Promise.all([
api.GET('/tracks/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } }
});
}),
api.GET('/albums/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } }
}),
api.GET('/genres/available')
]);
const albums = toPage(unwrapOrError(albumsResult, 'No se pudieron cargar tus álbumes.'), toAlbum);
const albumTrackIds = await trackIdsByAlbum(api, albums.items);
const albumTitles = albumTitleByTrackId(albums.items, albumTrackIds);
return {
tracks: toPage(unwrapOrError(result, 'No se pudo cargar tu biblioteca.'), toDatedTrack)
tracks: toPage(unwrapOrError(tracksResult, 'No se pudo cargar tu biblioteca.'), (dto) =>
toLibraryTrack(dto, albumTitles.get(dto.id) ?? null)
),
albums,
albumTrackIds,
genres: genresResult.data ?? []
};
};
export const actions: Actions = {
deleteTrack: authedAction(async ({ api, form }) => {
const trackId = formString(form, 'trackId');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const result = await api.DELETE('/tracks/{id}', {
params: { path: { id: trackId } }
function tagged<T extends object>(
action: string,
handler: (event: Parameters<Parameters<typeof authedAction>[0]>[0]) => Promise<T>
) {
return authedAction(async (event) => {
const result = await handler(event);
return isActionFailure(result)
? fail(result.status, { ...(result.data as Record<string, unknown> | undefined), action })
: { ...result, action };
});
return failOnError(result, 'No se pudo borrar la canción.') ?? { deleted: true };
}
export const actions: Actions = {
deleteTracks: tagged('deleteTracks', async ({ api, form }) => {
const trackIds = formIds(form, 'trackIds');
if (trackIds.length === 0) return fail(400, { message: 'Falta la canción.' });
const results = await Promise.all(
trackIds.map((id) => api.DELETE('/tracks/{id}', { params: { path: { id } } }))
);
const failed = results.find((result) => result.error);
return (
(failed &&
failOnError(
failed,
trackIds.length === 1
? 'No se pudo borrar la canción.'
: 'No se pudieron borrar todas las canciones.'
)) ?? { deleted: true }
);
}),
saveAlbum: tagged('saveAlbum', async ({ api, form }) => {
const parsed = parseAlbumForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const existingId = formString(form, 'albumId');
const cover = await uploadOptionalCover(api, '/albums/upload-picture', formFile(form, 'cover'));
if ('failMessage' in cover)
return fail(502, { message: cover.failMessage, detail: cover.detail });
let albumId = existingId;
if (existingId) {
const updated = await api.PUT('/albums/{id}', {
params: { path: { id: existingId } },
body: parsed.body
});
const failure = failOnError(updated, 'No se pudo actualizar el álbum.');
if (failure) return failure;
if (cover.intentId) {
const picture = await api.PUT('/albums/{id}/picture', {
params: { path: { id: existingId } },
body: { pictureIntentId: cover.intentId }
});
const pictureFailure = failOnError(picture, 'No se pudo actualizar la portada.');
if (pictureFailure) return pictureFailure;
}
} else {
if (!cover.intentId) return fail(400, { message: 'La portada es obligatoria.' });
const created = await api.POST('/albums', {
body: { ...parsed.body, pictureIntentId: cover.intentId }
});
if (created.error || !created.data)
return fail(502, { message: 'No se pudo crear el álbum.' });
albumId = created.data.id;
}
const addIds = formIds(form, 'addTrackIds');
if (addIds.length > 0) {
const added = await api.POST('/albums/{id}/tracks', {
params: { path: { id: albumId } },
body: { trackIds: addIds }
});
const failure = failOnError(added, 'No se pudieron añadir las canciones.');
if (failure) return failure;
}
const removed = await Promise.all(
formIds(form, 'removeTrackIds').map((trackId) =>
api.DELETE('/albums/{id}/tracks/{trackId}', { params: { path: { id: albumId, trackId } } })
)
);
const removeFailed = removed.find((result) => result.error);
const failure =
removeFailed && failOnError(removeFailed, 'No se pudieron quitar las canciones.');
return failure ?? { saved: true };
}),
deleteAlbum: tagged('deleteAlbum', async ({ api, form }) => {
const albumId = formString(form, 'albumId');
if (!albumId) return fail(400, { message: 'Falta el álbum.' });
const result = await api.DELETE('/albums/{id}', { params: { path: { id: albumId } } });
const failure = failOnError(result, 'No se pudo eliminar el álbum.');
if (failure) return failure;
redirect(303, ALBUMS_SECTION_URL);
})
};
@@ -1,81 +1,82 @@
<script lang="ts" module>
const SECTIONS = [
{ value: 'tracks', label: 'Canciones' },
{ value: 'albums', label: 'Álbumes' },
{ value: 'uploads', label: 'Subidas' }
] as const;
type Section = (typeof SECTIONS)[number]['value'];
</script>
<script lang="ts">
import Upload from '@lucide/svelte/icons/upload';
import X from '@lucide/svelte/icons/x';
import Music from '@lucide/svelte/icons/music';
import { player } from '$lib/player/player.svelte';
import { page } from '$app/state';
import { plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
import TrackList from '$lib/components/music/TrackList.svelte';
import LibraryTracksSection from '$lib/components/music/library/LibraryTracksSection.svelte';
import LibraryAlbumsSection from '$lib/components/music/library/LibraryAlbumsSection.svelte';
import LibraryUploadsSection from '$lib/components/music/library/LibraryUploadsSection.svelte';
let { data } = $props();
let { data, form } = $props();
const items = $derived(data.tracks.items);
const total = $derived(data.tracks.totalItemCount);
let deleting = $state<(typeof items)[number] | null>(null);
let section = $state<Section>(
SECTIONS.find((option) => option.value === page.url.searchParams.get('section'))?.value ??
'tracks'
);
let resetKey = $state(0);
function togglePlay(index: number) {
player.playOrToggle(items, index);
function selectSection(value: Section) {
if (value === section) resetKey += 1;
section = value;
}
</script>
<svelte:head>
<title>Tus canciones subidas</title>
<title>Gestionar música</title>
<meta name="description" content="Todas tus canciones subidas a Musify." />
</svelte:head>
<Page>
<PageHeader
title="Tus canciones subidas"
description="{plural(total, 'canción', 'canciones')} subida{total === 1 ? '' : 's'}."
>
{#snippet actions()}
{#if items.length > 0}
<Button href="/upload">
<Upload class="size-icon-sm" />
Subir canción
</Button>
{/if}
{/snippet}
</PageHeader>
{#if items.length > 0}
<TrackList
tracks={items}
columns={['uploaded', 'plays']}
onPlay={togglePlay}
rowAction={{
onclick: (track) => (deleting = items.find((item) => item.id === track.id) ?? null),
icon: X,
label: 'Borrar canción'
}}
title="Gestionar música"
description="{plural(total, 'canción', 'canciones')} subida{total === 1
? ''
: 's'}. Puedes borrarlas o reproducirlas desde aquí."
/>
{:else}
<EmptyState
icon={Music}
title="Todavía no tienes canciones"
description="Sube la primera y aparecerá aquí lista para reproducir."
<div class="flex gap-2">
{#each SECTIONS as option (option.value)}
<Button
variant={section === option.value ? 'primary' : 'secondary'}
onclick={() => selectSection(option.value)}
size="sm"
>
{#snippet actions()}
<Button href="/upload">
<Upload class="size-icon-sm" />
Sube la primera
{option.label}
</Button>
{/snippet}
</EmptyState>
{/each}
</div>
{#if section === 'tracks'}
<LibraryTracksSection
tracks={items}
errorMessage={form?.action === 'deleteTracks' ? form.message : undefined}
/>
{:else if section === 'albums'}
{#key resetKey}
<LibraryAlbumsSection
albums={data.albums.items}
tracks={items}
albumTrackIds={data.albumTrackIds}
formMessage={form?.action === 'saveAlbum' || form?.action === 'deleteAlbum'
? form.message
: undefined}
/>
{/key}
{:else}
<LibraryUploadsSection genres={data.genres} />
{/if}
</Page>
<ConfirmDialog
open={deleting !== null}
onClose={() => (deleting = null)}
title="¿Borrar «{deleting?.title ?? ''}»?"
description="Esta acción no se puede deshacer, la canción se eliminará de tu biblioteca, de tus álbumes y de las playlists donde esté."
confirmLabel="Borrar"
action="?/deleteTrack"
fields={{ trackId: deleting?.id ?? '' }}
/>
+10
View File
@@ -271,6 +271,16 @@
}
@layer base {
input[type='number'] {
appearance: textfield;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
appearance: none;
margin: 0;
}
svg.lucide {
stroke-width: 1.5;
}