feat(web-player): add the albums pages

A grid of your albums with a create dialog, and a detail page with the
tracklist, inline editing and a picker that lists the songs you uploaded so
they can be dropped into the album. Covers reuse the playlist mosaic since an
album has no artwork of its own yet.
This commit is contained in:
2026-07-20 20:21:22 +02:00
parent 9cd916c098
commit 76812d1eeb
13 changed files with 764 additions and 0 deletions
@@ -4,6 +4,7 @@
import Home from '@lucide/svelte/icons/house';
import Search from '@lucide/svelte/icons/search';
import Folder from '@lucide/svelte/icons/folder';
import Disc from '@lucide/svelte/icons/disc-3';
import Music from '@lucide/svelte/icons/music';
import Upload from '@lucide/svelte/icons/upload';
import LogOut from '@lucide/svelte/icons/log-out';
@@ -35,6 +36,7 @@
];
const secondaryLinks = [
{ href: '/albums', label: 'Mis álbumes', icon: Disc },
{ href: '/library', label: 'Canciones subidas', icon: Folder },
{ 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_MIN_LENGTH = 2;
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 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;
}
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(
api: ReturnType<typeof createApiClient>,
playlistIds: string[]