Split the library page into song, album and upload routes with linked tabs
This commit is contained in:
@@ -12,7 +12,11 @@ src/routes/
|
||||
+page.svelte / Home
|
||||
explore/ /explore Search and genre discovery
|
||||
liked/ /liked Liked songs
|
||||
library/ /library Your uploaded songs
|
||||
library/ /library Manage music: your songs
|
||||
albums/ /library/albums Your albums (grid)
|
||||
new/ /library/albums/new Create an album
|
||||
[id]/ /library/albums/:id Edit an album
|
||||
uploads/ /library/uploads Upload queue
|
||||
upload/ /upload Upload a song
|
||||
albums/ /albums Your albums
|
||||
[id]/ /albums/:id Album detail
|
||||
@@ -32,7 +36,7 @@ Global chrome on every app page: sidebar (desktop) or tab bar (mobile), top bar
|
||||
- **Home `/`**: a greeting, weekly listening stats, "continue listening" (recently played merged with the in-session history), the featured playlist with its first tracks (play and shuffle load the full list on demand), the user's mixes and playlists. Anonymous visitors are redirected to `/explore`.
|
||||
- **Explore `/explore`**: genre tiles when there is no query, otherwise search results grouped by songs, albums, users and playlists with filter chips and counts taken from the API totals. Songs paginate with infinite scroll. Track and album context menus offer play next, add to queue and add to playlist.
|
||||
- **Liked `/liked`**: the songs the user liked, newest first, with unlike per row.
|
||||
- **Library `/library`**: the songs the user uploaded, with a confirmed delete.
|
||||
- **Library `/library`, `/library/albums`, `/library/uploads`**: the manage-music area, one route per section behind shared tabs. Songs lists the user's uploads with a confirmed delete; Albums lists the user's albums and edits them at `/library/albums/new` and `/library/albums/:id`; Uploads is the upload queue.
|
||||
- **Upload `/upload`**: audio, cover, title, genres (incompatible genres are blocked) and explicit flag. The server reserves presigned URLs, uploads both files and creates the track; processing happens asynchronously in the backend.
|
||||
- **Albums `/albums`, `/albums/:id`**: list and create albums. The owner can edit, delete and add or remove tracks from their library.
|
||||
- **Playlists `/playlists`, `/playlists/:id`**: list and create playlists. The owner can edit, delete and remove tracks; everyone else gets a read only view.
|
||||
|
||||
@@ -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,80 +1,14 @@
|
||||
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 }) => {
|
||||
@@ -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}
|
||||
tracks={data.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>
|
||||
|
||||
@@ -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