Split the library page into song, album and upload routes with linked tabs
This commit is contained in:
@@ -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>
|
||||
@@ -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']
|
||||
|
||||
@@ -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} />
|
||||
Reference in New Issue
Block a user