Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
13 changed files with 764 additions and 0 deletions
Showing only changes of commit 76812d1eeb - Show all commits
@@ -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>