feat(web-player): add the albums pages

A grid of your albums with a create dialog, and a detail page with the
tracklist, inline editing and a picker that lists the songs you uploaded so
they can be dropped into the album. Covers reuse the playlist mosaic since an
album has no artwork of its own yet.
This commit is contained in:
2026-07-20 20:21:22 +02:00
parent 9cd916c098
commit 76812d1eeb
13 changed files with 764 additions and 0 deletions
@@ -4,6 +4,7 @@
import Home from '@lucide/svelte/icons/house'; import Home from '@lucide/svelte/icons/house';
import Search from '@lucide/svelte/icons/search'; import Search from '@lucide/svelte/icons/search';
import Folder from '@lucide/svelte/icons/folder'; import Folder from '@lucide/svelte/icons/folder';
import Disc from '@lucide/svelte/icons/disc-3';
import Music from '@lucide/svelte/icons/music'; import Music from '@lucide/svelte/icons/music';
import Upload from '@lucide/svelte/icons/upload'; import Upload from '@lucide/svelte/icons/upload';
import LogOut from '@lucide/svelte/icons/log-out'; import LogOut from '@lucide/svelte/icons/log-out';
@@ -35,6 +36,7 @@
]; ];
const secondaryLinks = [ const secondaryLinks = [
{ href: '/albums', label: 'Mis álbumes', icon: Disc },
{ href: '/library', label: 'Canciones subidas', icon: Folder }, { href: '/library', label: 'Canciones subidas', icon: Folder },
{ href: '/upload', label: 'Subir música', icon: Upload } { href: '/upload', label: 'Subir música', icon: Upload }
]; ];
@@ -0,0 +1,35 @@
<script lang="ts">
import PlaylistArt from './PlaylistArt.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
id: string;
title: string;
releaseYear?: number;
trackCount: number;
trackIds: (string | number)[];
index?: number;
}
let { id, title, releaseYear, trackCount, trackIds, index = 0 }: Props = $props();
const meta = $derived(
[releaseYear, `${trackCount} ${trackCount === 1 ? 'canción' : 'canciones'}`]
.filter((part) => part !== undefined && part !== null)
.join(' · ')
);
</script>
<a
href="/albums/{id}"
class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms"
>
<PlaylistArt
{trackIds}
hue={hueFor(id)}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
/>
<div class="mt-2.5 truncate text-fg">{title}</div>
<div class="mt-0.5 truncate text-sm text-fg-3">{meta}</div>
</a>
@@ -0,0 +1,101 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Button from './Button.svelte';
import Field from './Field.svelte';
import Input from './Input.svelte';
import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte';
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
interface Props {
action: string;
initialTitle?: string;
initialDescription?: string;
initialReleaseYear?: number;
titlePlaceholder?: string;
formMessage?: string;
submitLabel: string;
submittingLabel: string;
onCancel: () => void;
onSuccess?: () => void;
}
let {
action,
initialTitle = '',
initialDescription = '',
initialReleaseYear,
titlePlaceholder = '',
formMessage,
submitLabel,
submittingLabel,
onCancel,
onSuccess
}: Props = $props();
let title = $state(untrack(() => initialTitle));
let description = $state(untrack(() => initialDescription));
let releaseYear = $state(untrack(() => (initialReleaseYear ? String(initialReleaseYear) : '')));
let submitting = $state(false);
const latestYear = new Date().getFullYear() + 1;
</script>
<form
method="POST"
{action}
use:enhance={() => {
submitting = true;
return async ({ update, result }) => {
await update({ reset: false });
submitting = false;
if (result.type === 'success') onSuccess?.();
};
}}
class="mt-4 w-full max-w-2xl space-y-4"
>
<Field label="Título" for="album-title">
<Input
id="album-title"
name="title"
maxlength={200}
required
bind:value={title}
placeholder={titlePlaceholder}
/>
</Field>
<Field label="Año de publicación" for="album-year" optional>
<Input
id="album-year"
name="releaseYear"
type="number"
min={ALBUM_EARLIEST_YEAR}
max={latestYear}
bind:value={releaseYear}
placeholder={String(new Date().getFullYear())}
/>
</Field>
<Field label="Descripción" for="album-description" optional>
<Textarea
id="album-description"
name="description"
maxlength={256}
bind:value={description}
placeholder="¿De qué va este álbum?"
/>
</Field>
{#if formMessage}
<Alert tone="danger">{formMessage}</Alert>
{/if}
<div class="flex items-center justify-end gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || title.trim() === ''}>
{submitting ? submittingLabel : submitLabel}
</Button>
</div>
</form>
+4
View File
@@ -2,5 +2,9 @@ export const EXPLORE_PAGE_SIZE = 27;
export const SEARCH_DEBOUNCE_MS = 450; export const SEARCH_DEBOUNCE_MS = 450;
export const SEARCH_MIN_LENGTH = 2; export const SEARCH_MIN_LENGTH = 2;
export const INFINITE_SCROLL_ROOT_MARGIN = '300px'; export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
export const ALBUMS_PAGE_SIZE = 50;
export const ALBUM_TRACKS_PAGE_SIZE = 200;
export const ALBUM_EARLIEST_YEAR = 1877;
export const LIBRARY_PICKER_PAGE_SIZE = 50;
export const HOME_LATEST_PAGE_SIZE = 12; export const HOME_LATEST_PAGE_SIZE = 12;
export const YOUTUBE_FILLER_LIMIT = 27; export const YOUTUBE_FILLER_LIMIT = 27;
+39
View File
@@ -0,0 +1,39 @@
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
interface AlbumFormBody {
title: string;
description: string | null;
releaseYear: number | null;
}
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
const title = String(form.get('title') ?? '').trim();
if (title === '' || title.length > 200) {
return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' };
}
const description = String(form.get('description') ?? '').trim();
if (description.length > 256) {
return { failMessage: 'La descripción no puede pasar de 256 caracteres.' };
}
const rawYear = String(form.get('releaseYear') ?? '').trim();
const latestYear = new Date().getFullYear() + 1;
let releaseYear: number | null = null;
if (rawYear !== '') {
const parsed = Number(rawYear);
if (!Number.isInteger(parsed) || parsed < ALBUM_EARLIEST_YEAR || parsed > latestYear) {
return { failMessage: `El año debe estar entre ${ALBUM_EARLIEST_YEAR} y ${latestYear}.` };
}
releaseYear = parsed;
}
return {
body: {
title,
description: description === '' ? null : description,
releaseYear
}
};
}
+15
View File
@@ -53,6 +53,21 @@ export function unwrapOrFail(
return result.error ? fail(502, { message }) : undefined; return result.error ? fail(502, { message }) : undefined;
} }
export async function albumCoverTrackIds(
api: ReturnType<typeof createApiClient>,
albumIds: string[]
): Promise<Record<string, string[]>> {
const covers = await Promise.all(
albumIds.map(async (albumId) => {
const { data: tracks } = await api.GET('/albums/{albumId}/tracks', {
params: { path: { albumId }, query: { pageNumber: 1, pageSize: 4 } }
});
return [albumId, tracks?.items.map((t) => t.id) ?? []] as const;
})
);
return Object.fromEntries(covers);
}
export async function playlistCoverTrackIds( export async function playlistCoverTrackIds(
api: ReturnType<typeof createApiClient>, api: ReturnType<typeof createApiClient>,
playlistIds: string[] playlistIds: string[]
@@ -0,0 +1,45 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
albumCoverTrackIds,
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrError
} from '$lib/server/api';
import { ALBUMS_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/albumForm';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/albums/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } }
});
const albums = unwrapOrError(result, 'No se pudieron cargar tus álbumes.');
const trackIds = await albumCoverTrackIds(
api,
albums.items.map((album) => album.id)
);
return { albums, trackIds };
};
export const actions: Actions = {
create: async ({ request, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const parsed = parseAlbumForm(await request.formData());
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const api = createApiClient({ fetch, accessToken });
const { data, error: err } = await api.POST('/albums', { body: parsed.body });
if (err || !data) return fail(502, { message: 'No se pudo crear el álbum.' });
redirect(303, `/albums/${data.id}`);
}
};
@@ -0,0 +1,76 @@
<script lang="ts">
import Plus from '@lucide/svelte/icons/plus';
import Disc from '@lucide/svelte/icons/disc-3';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
let { data, form } = $props();
const items = $derived(data.albums.items);
const trackIds = $derived(data.trackIds);
let creating = $state(false);
</script>
<svelte:head>
<title>Álbumes · Musify</title>
<meta name="description" content="Los álbumes que has publicado con tu música subida." />
</svelte:head>
<Page>
<PageHeader title="Álbumes" subtitle="Agrupa la música que subes en discos.">
{#snippet actions()}
{#if items.length > 0}
<Button onclick={() => (creating = true)}>
<Plus class="h-4 w-4" strokeWidth={2.5} />
Nuevo álbum
</Button>
{/if}
{/snippet}
</PageHeader>
{#if items.length > 0}
<MediaGrid class="mt-9">
{#each items as album, i (album.id)}
<AlbumCard
id={album.id}
title={album.title}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackCount={Number(album.trackCount)}
trackIds={trackIds[album.id] ?? []}
index={i}
/>
{/each}
</MediaGrid>
{:else}
<EmptyState
icon={Disc}
title="Todavía no tienes álbumes"
description="Crea el primero y añádele las canciones que hayas subido."
>
{#snippet actions()}
<Button onclick={() => (creating = true)}>
<Plus class="h-4 w-4" strokeWidth={2} />
Crear álbum
</Button>
{/snippet}
</EmptyState>
{/if}
</Page>
<Modal open={creating} onClose={() => (creating = false)} title="Nuevo álbum">
<AlbumForm
action="?/create"
titlePlaceholder="Mi primer disco"
formMessage={form?.message}
submitLabel="Crear"
submittingLabel="Creando…"
onCancel={() => (creating = false)}
/>
</Modal>
@@ -0,0 +1,110 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrError,
unwrapOrFail
} from '$lib/server/api';
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/albumForm';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [albumRes, tracksRes, libraryRes] = await Promise.all([
api.GET('/albums/{id}', { params: { path: { id: params.id } } }),
api.GET('/albums/{albumId}/tracks', {
params: {
path: { albumId: params.id },
query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE }
}
}),
api.GET('/tracks/users/{userId}', {
params: {
path: { userId: user.sub },
query: { pageNumber: 1, pageSize: LIBRARY_PICKER_PAGE_SIZE }
}
})
]);
const album = unwrapOrError(albumRes, 'Álbum no encontrado.', 404);
const tracks = tracksRes.data?.items ?? [];
const inAlbum = new Set(tracks.map((track) => track.id));
const library = (libraryRes.data?.items ?? []).filter(
(track) => track.source === 'Local' && !inAlbum.has(track.id)
);
return { album, tracks, library, isOwner: Number(album.ownerUserId) === Number(user.sub) };
};
export const actions: Actions = {
addTrack: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.POST('/albums/{albumId}/tracks/{trackId}', {
params: { path: { albumId: params.id, trackId } }
});
const failure = unwrapOrFail(result, 'No se pudo añadir la canción.');
if (failure) return failure;
return { added: true };
},
removeTrack: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', {
params: { path: { albumId: params.id, trackId } }
});
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
if (failure) return failure;
return { removed: true };
},
edit: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const parsed = parseAlbumForm(await request.formData());
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const api = createApiClient({ fetch, accessToken });
const result = await api.PUT('/albums/{albumId}', {
params: { path: { albumId: params.id } },
body: {
newTitle: parsed.body.title,
newDescription: parsed.body.description,
newReleaseYear: parsed.body.releaseYear
}
});
const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.');
if (failure) return failure;
return { edited: true };
},
delete: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/albums/{albumId}', {
params: { path: { albumId: params.id } }
});
const failure = unwrapOrFail(result, 'No se pudo eliminar el álbum.');
if (failure) return failure;
redirect(303, '/albums');
}
};
@@ -0,0 +1,92 @@
<script lang="ts">
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
import Page from '$lib/components/ui/Page.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
import Button from '$lib/components/ui/Button.svelte';
import AlbumHeader from './components/AlbumHeader.svelte';
import AlbumTrackTable from './components/AlbumTrackTable.svelte';
import LibraryPicker from './components/LibraryPicker.svelte';
let { data, form } = $props();
const album = $derived(data.album);
const tracks = $derived(data.tracks);
const library = $derived(data.library);
const isOwner = $derived(data.isOwner);
let editing = $state(false);
let confirmingDelete = $state(false);
const isCurrentQueue = $derived(
tracks.some((track) => queueIdForTrack(track) === player.current.id)
);
function playAll() {
if (tracks.length === 0) return;
if (isCurrentQueue) player.toggle();
else player.playQueue(toQueueItems(tracks), 0);
}
</script>
<svelte:head>
<title>{album.title} · Musify</title>
<meta name="description" content="Álbum {album.title} en Musify." />
</svelte:head>
<Page>
<AlbumHeader
id={album.id}
title={album.title}
description={album.description ?? undefined}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackIds={tracks.map((track) => track.id)}
playing={isCurrentQueue && player.playing}
{isOwner}
onPlayAll={playAll}
onEdit={() => (editing = true)}
onDelete={() => (confirmingDelete = true)}
/>
{#if form?.message}
<Alert tone="danger" class="mt-6">{form.message}</Alert>
{/if}
{#if tracks.length > 0}
<AlbumTrackTable {tracks} {isOwner} />
{/if}
{#if isOwner && library.length > 0}
<LibraryPicker {library} />
{/if}
</Page>
<Modal open={editing} onClose={() => (editing = false)} title="Editar álbum">
<AlbumForm
action="?/edit"
initialTitle={album.title}
initialDescription={album.description ?? ''}
initialReleaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
formMessage={form?.message}
submitLabel="Guardar"
submittingLabel="Guardando…"
onCancel={() => (editing = false)}
onSuccess={() => (editing = false)}
/>
</Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<h2 class="text-lg font-bold tracking-tight text-fg">
¿Eliminar «{album.title}»?
</h2>
<p class="mt-2 text-base text-fg-3">
Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca.
</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
<form method="POST" action="?/delete">
<Button type="submit" variant="danger">Eliminar</Button>
</form>
</div>
</Modal>
@@ -0,0 +1,98 @@
<script lang="ts">
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
id: string;
title: string;
description?: string;
releaseYear?: number;
trackIds: (string | number)[];
playing: boolean;
isOwner: boolean;
onPlayAll: () => void;
onEdit: () => void;
onDelete: () => void;
}
let {
id,
title,
description,
releaseYear,
trackIds,
playing,
isOwner,
onPlayAll,
onEdit,
onDelete
}: Props = $props();
const meta = $derived(
[
releaseYear ? String(releaseYear) : undefined,
`${trackIds.length} ${trackIds.length === 1 ? 'canción' : 'canciones'}`
]
.filter((part) => part !== undefined)
.join(' · ')
);
</script>
<a
href="/albums"
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
>
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
<ArrowLeft class="h-4 w-4" />
</span>
Volver a tus álbumes
</a>
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<PlaylistArt
{trackIds}
hue={hueFor(id)}
size="large"
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Álbum</p>
<h1
class="mt-1.5 text-3xl font-bold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
>
{title}
</h1>
{#if description}
<p class="mt-3 max-w-2xl text-sm text-fg-2 sm:text-base">{description}</p>
{/if}
<p class="mt-3 text-sm text-muted">{meta}</p>
</div>
</div>
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{#if playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
{#if isOwner}
<Button size="sm" variant="secondary" onclick={onEdit}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={onDelete}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</Button>
{/if}
</div>
@@ -0,0 +1,79 @@
<script lang="ts">
import { enhance } from '$app/forms';
import X from '@lucide/svelte/icons/x';
import {
player,
queueIdForTrack,
toQueueItems,
type ApiTrackLike
} from '$lib/player/player.svelte';
import { hueFor } from '$lib/theme/color';
import { fmtTime } from '$lib/format';
import IconButton from '$lib/components/ui/IconButton.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
type AlbumTrack = ApiTrackLike & { duration: number | string };
interface Props {
tracks: AlbumTrack[];
isOwner: boolean;
}
let { tracks, isOwner }: Props = $props();
function playFrom(index: number) {
player.playOrToggle(toQueueItems(tracks), index);
}
</script>
<TrackTable
columns={isOwner ? '32px 1fr 64px 36px' : '32px 1fr 64px'}
columnsMobile={isOwner ? '28px 1fr 52px 32px' : '28px 1fr 52px'}
class="mt-6 sm:mt-8"
>
{#snippet headers()}
<span class="text-center">#</span>
<span>Título</span>
<span class="hidden text-center sm:block">Duración</span>
{#if isOwner}
<span></span>
{/if}
{/snippet}
{#each tracks as track, i (track.id)}
<TrackRow>
<TrackIndexCell
index={i}
active={player.current.id === queueIdForTrack(track)}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
hue={hueFor(track.id)}
explicit={track.isExplicit}
onClick={() => playFrom(i)}
/>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
{#if isOwner}
<form
method="POST"
action="?/removeTrack"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label="Quitar del álbum" size="sm" revealOnHover>
<X class="h-3.5 w-3.5" />
</IconButton>
</form>
{/if}
</TrackRow>
{/each}
</TrackTable>
@@ -0,0 +1,68 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Plus from '@lucide/svelte/icons/plus';
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
import { hueFor } from '$lib/theme/color';
import { fmtTime } from '$lib/format';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
type LibraryTrack = ApiTrackLike & { duration: number | string };
interface Props {
library: LibraryTrack[];
}
let { library }: Props = $props();
function playFrom(index: number) {
player.playOrToggle(toQueueItems(library), index);
}
</script>
<div class="mt-10 sm:mt-12">
<SectionHeading title="Tus canciones subidas" />
<TrackTable columns="1fr 64px 36px" columnsMobile="1fr 52px 32px">
{#snippet headers()}
<span>Título</span>
<span class="hidden text-center sm:block">Duración</span>
<span></span>
{/snippet}
{#each library as track, i (track.id)}
<TrackRow>
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
hue={hueFor(track.id)}
explicit={track.isExplicit}
onClick={() => playFrom(i)}
>
{#snippet overlay()}
{#if player.current.id === track.id}
<NowPlaying paused={!player.playing} />
{/if}
{/snippet}
</TrackTitleCell>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<form
method="POST"
action="?/addTrack"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label="Añadir al álbum" size="sm" revealOnHover>
<Plus class="h-3.5 w-3.5" />
</IconButton>
</form>
</TrackRow>
{/each}
</TrackTable>
</div>