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
@@ -0,0 +1,51 @@
<script lang="ts" module>
const SECTIONS = [
{
href: '/library',
label: 'Canciones',
description: 'Tus canciones subidas. Puedes reproducirlas o borrarlas desde aquí.'
},
{
href: '/library/albums',
label: 'Álbumes',
description: 'Crea álbumes, EPs y singles con las canciones de tu biblioteca.'
},
{
href: '/library/uploads',
label: 'Subidas',
description: 'Sube varias canciones a la vez y añádelas a tu biblioteca.'
}
];
</script>
<script lang="ts">
import { page } from '$app/state';
import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import Tabs from '$lib/components/ui/primitives/Tabs.svelte';
let { children } = $props();
const pathname = $derived(page.url.pathname.replace(/\/$/, ''));
const current = $derived(
SECTIONS.find(
(section) =>
section.href !== '/library' &&
(pathname === section.href || pathname.startsWith(`${section.href}/`))
) ?? SECTIONS[0]
);
const tabs = $derived(
SECTIONS.map(({ href, label }) => ({ href, label, active: href === current.href }))
);
</script>
<svelte:head>
<title>Gestionar música · {current.label}</title>
<meta name="description" content={current.description} />
</svelte:head>
<Page>
<PageHeader title="Gestionar música" description={current.description} />
<Tabs label="Secciones de la biblioteca" {tabs} />
{@render children()}
</Page>
@@ -1,81 +1,15 @@
import type { PageServerLoad, Actions } from './$types';
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 { fail } from '@sveltejs/kit';
import { apiFor, failOnError } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
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 { 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);
}
import { formIds, loadLibraryTracks, tagged } from '$lib/server/albums';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = apiFor({ fetch, locals });
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(tracksResult, 'No se pudo cargar tu biblioteca.'), (dto) =>
toLibraryTrack(dto, albumTitles.get(dto.id) ?? null)
),
albums,
albumTrackIds,
genres: genresResult.data ?? []
};
return { tracks: await loadLibraryTracks(api, user.sub) };
};
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 };
});
}
export const actions: Actions = {
deleteTracks: tagged('deleteTracks', async ({ api, form }) => {
const trackIds = formIds(form, 'trackIds');
@@ -94,72 +28,5 @@ export const actions: Actions = {
: '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,82 +1,10 @@
<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 { 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 Button from '$lib/components/ui/primitives/Button.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, form } = $props();
const items = $derived(data.tracks.items);
const total = $derived(data.tracks.totalItemCount);
let section = $state<Section>(
SECTIONS.find((option) => option.value === page.url.searchParams.get('section'))?.value ??
'tracks'
);
let resetKey = $state(0);
function selectSection(value: Section) {
if (value === section) resetKey += 1;
section = value;
}
</script>
<svelte:head>
<title>Gestionar música</title>
<meta name="description" content="Todas tus canciones subidas a Musify." />
</svelte:head>
<Page>
<PageHeader
title="Gestionar música"
description="{plural(total, 'canción', 'canciones')} subida{total === 1
? ''
: 's'}. Puedes borrarlas o reproducirlas desde aquí."
/>
<div class="flex gap-2">
{#each SECTIONS as option (option.value)}
<Button
variant={section === option.value ? 'primary' : 'secondary'}
onclick={() => selectSection(option.value)}
size="sm"
>
{option.label}
</Button>
{/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>
<LibraryTracksSection
tracks={data.tracks.items}
errorMessage={form?.action === 'deleteTracks' ? form.message : undefined}
/>
@@ -0,0 +1,10 @@
import type { PageServerLoad } from './$types';
import { apiFor } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { loadUserAlbums } from '$lib/server/albums';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = apiFor({ fetch, locals });
return { albums: await loadUserAlbums(api, user.sub) };
};
@@ -0,0 +1,22 @@
<script lang="ts">
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';
let { data } = $props();
</script>
<div class="mt-4 grid-cards-lg">
<MediaCreateCard title="Nuevo álbum" subtitle="Álbum, EP o Single" href="/library/albums/new" />
{#each data.albums.items as album, i (album.id)}
<MediaCard
href="/library/albums/{album.id}"
title={album.title}
subtitle={albumMeta(album.releaseYear, album.trackCount)}
trackIds={album.coverTrackIds}
src={albumCover(album.id, 'large')}
index={i + 1}
/>
{/each}
</div>
@@ -0,0 +1,19 @@
import type { Actions, PageServerLoad } from './$types';
import { apiFor, unwrapOrError } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { toAlbum } from '$lib/server/mappers';
import { deleteAlbum, loadLibraryTracks, saveAlbum, trackIdsByAlbum } from '$lib/server/albums';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = apiFor({ fetch, locals });
const albumResult = await api.GET('/albums/{id}', { params: { path: { id: params.id } } });
const album = toAlbum(unwrapOrError(albumResult, 'Álbum no encontrado.', 404));
const [tracks, memberIds] = await Promise.all([
loadLibraryTracks(api, user.sub),
trackIdsByAlbum(api, [album])
]);
return { album, tracks, memberIds: memberIds[album.id] ?? [] };
};
export const actions: Actions = { saveAlbum, deleteAlbum };
@@ -0,0 +1,18 @@
<script lang="ts">
import { goto } from '$app/navigation';
import LibraryAlbumEditor from '$lib/components/music/library/LibraryAlbumEditor.svelte';
let { data, form } = $props();
</script>
<div class="mt-4">
{#key data.album.id}
<LibraryAlbumEditor
album={data.album}
tracks={data.tracks.items}
memberIds={data.memberIds}
formMessage={form?.message}
onClose={() => goto('/library/albums')}
/>
{/key}
</div>
@@ -0,0 +1,12 @@
import type { Actions, PageServerLoad } from './$types';
import { apiFor } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
import { loadLibraryTracks, saveAlbum } from '$lib/server/albums';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = apiFor({ fetch, locals });
return { tracks: await loadLibraryTracks(api, user.sub) };
};
export const actions: Actions = { saveAlbum };
@@ -0,0 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import LibraryAlbumEditor from '$lib/components/music/library/LibraryAlbumEditor.svelte';
let { data, form } = $props();
</script>
<div class="mt-4">
<LibraryAlbumEditor
album={null}
tracks={data.tracks.items}
memberIds={[]}
formMessage={form?.action === 'saveAlbum' ? form.message : undefined}
onClose={() => goto('/library/albums')}
/>
</div>
@@ -0,0 +1,10 @@
import type { PageServerLoad } from './$types';
import { apiFor } from '$lib/server/api';
import { requireUser } from '$lib/server/guards';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url);
const api = apiFor({ fetch, locals });
const genres = await api.GET('/genres/available');
return { genres: genres.data ?? [] };
};
@@ -0,0 +1,7 @@
<script lang="ts">
import LibraryUploadsSection from '$lib/components/music/library/LibraryUploadsSection.svelte';
let { data } = $props();
</script>
<LibraryUploadsSection genres={data.genres} />