Split the library page into song, album and upload routes with linked tabs

This commit is contained in:
2026-09-25 01:11:01 +02:00
parent 9928840577
commit 797bed8e7d
20 changed files with 351 additions and 279 deletions
@@ -1,55 +0,0 @@
<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}
@@ -105,7 +105,7 @@
description="Sube la primera y aparecerá aquí lista para reproducir."
>
{#snippet actions()}
<Button href="/upload">
<Button href="/library/uploads">
<Upload class="size-icon-sm" />
Sube la primera
</Button>
@@ -73,7 +73,7 @@
<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)]">
<div class="grid gap-8 sm:grid-cols-[auto_1fr]">
<Field label="Portada">
<div class="w-full max-w-44">
<ImageDropzone
@@ -27,7 +27,7 @@
<AccountMenu {user} {accountUrl} width="md">
{#snippet extraItems({ close })}
<MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} />
<MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} />
<MenuItem icon={Upload} label="Subir música" href="/library/uploads" onclick={close} />
{/snippet}
</AccountMenu>
{:else}
@@ -4,16 +4,15 @@
interface Props {
title: string;
subtitle?: string | null;
onclick: () => void;
href: string;
index?: number;
}
let { title, subtitle, onclick, index = 0 }: Props = $props();
let { title, subtitle, href, index = 0 }: Props = $props();
</script>
<button
type="button"
{onclick}
<a
{href}
class="group animate-enter min-w-0 rounded-panel p-2.5 text-left focus-ring transition-colors hover:bg-hover"
style="--i:{index}"
>
@@ -26,4 +25,4 @@
{#if subtitle}
<div class="mt-0.5 truncate text-xs text-fg-2">{subtitle}</div>
{/if}
</button>
</a>
@@ -0,0 +1,29 @@
<script lang="ts">
interface Tab {
href: string;
label: string;
active: boolean;
}
interface Props {
label: string;
tabs: Tab[];
class?: string;
}
let { label, tabs, class: klass = '' }: Props = $props();
</script>
<nav aria-label={label} class="flex gap-2 {klass}">
{#each tabs as tab (tab.href)}
<a
href={tab.href}
aria-current={tab.active ? 'page' : undefined}
class="rounded-full px-4 py-1.5 text-xs font-medium focus-ring transition-colors {tab.active
? 'bg-fg text-ink'
: 'bg-surface-2 text-fg-2 hover:text-fg'}"
>
{tab.label}
</a>
{/each}
</nav>
+133
View File
@@ -0,0 +1,133 @@
import { fail, isActionFailure, redirect } from '@sveltejs/kit';
import type { Album } from '$lib/types';
import { toAlbum, toLibraryTrack, toPage } from '$lib/server/mappers';
import { failOnError, unwrapOrError, type ApiClient } from '$lib/server/api';
import { formFile, formString } from '$lib/server/forms/fields';
import { parseAlbumForm } from '$lib/server/forms/albumForm';
import { uploadOptionalCover } from '$lib/server/upload';
import { authedAction, type ActionContext } from '$lib/server/actions/authedAction';
import { ALBUMS_PAGE_SIZE, ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PAGE_SIZE } from '$lib/config';
export const ALBUMS_SECTION_URL = '/library/albums';
export 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))
);
}
export async function loadUserAlbums(api: ApiClient, userId: string) {
const result = await api.GET('/albums/users/{userId}', {
params: { path: { userId }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } }
});
return toPage(unwrapOrError(result, 'No se pudieron cargar tus álbumes.'), toAlbum);
}
export async function loadLibraryTracks(api: ApiClient, userId: string) {
const [tracksResult, albums] = await Promise.all([
api.GET('/tracks/users/{userId}', {
params: { path: { userId }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } }
}),
loadUserAlbums(api, userId)
]);
const albumTitles = albumTitleByTrackId(albums.items, await trackIdsByAlbum(api, albums.items));
return toPage(unwrapOrError(tracksResult, 'No se pudo cargar tu biblioteca.'), (dto) =>
toLibraryTrack(dto, albumTitles.get(dto.id) ?? null)
);
}
export function formIds(form: FormData, key: string): string[] {
return formString(form, key).split(',').filter(Boolean);
}
export function tagged<T>(action: string, handler: (context: ActionContext) => Promise<T>) {
return authedAction(async (context) => {
const result = await handler(context);
return isActionFailure(result)
? fail(result.status, { ...(result.data as Record<string, unknown> | undefined), action })
: { ...result, action };
});
}
export const 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 };
});
export const 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);
});
@@ -9,6 +9,8 @@ const LABELS: [RegExp, string][] = [
[/^\/playlists\/?$/, 'Volver a tus playlists'],
[/^\/albums\/[^/]+/, 'Volver al álbum'],
[/^\/albums\/?$/, 'Volver a tus álbumes'],
[/^\/library\/albums\/?$/, 'Volver a tus álbumes'],
[/^\/library\/uploads\/?$/, 'Volver a subir música'],
[/^\/library\/?$/, 'Volver a tus canciones'],
[/^\/upload\/?$/, 'Volver a subir música'],
[/^\/mixes\/[^/]+/, 'Volver a la mezcla']