feat(web): rewrite / as home page composing 4 horizontal-scroll sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 20:23:20 -04:00
parent cf18d51394
commit 7d09e605cc
2 changed files with 96 additions and 207 deletions
+96 -67
View File
@@ -1,83 +1,112 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { createArtistsQuery, type ArtistSort } from '$lib/api/queries';
import ArtistRow from '$lib/components/ArtistRow.svelte';
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
import { createHomeQuery } from '$lib/api/home';
import HorizontalScrollRow from '$lib/components/HorizontalScrollRow.svelte';
import AlbumCard from '$lib/components/AlbumCard.svelte';
import ArtistCard from '$lib/components/ArtistCard.svelte';
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
const sort: ArtistSort = $derived(
(page.url.searchParams.get('sort') as ArtistSort) === 'newest' ? 'newest' : 'alpha'
);
const queryStore = $derived(createArtistsQuery(sort));
const queryStore = createHomeQuery();
const query = $derived($queryStore);
// Flattened artist list from all loaded pages.
const artists = $derived(
query.data?.pages?.flatMap((p) => p.items) ?? []
);
const total = $derived(query.data?.pages?.[0]?.total ?? 0);
const data = $derived(query.data);
const showSkeleton = useDelayed(() => query.isPending);
function onSortChange(e: Event) {
const value = (e.currentTarget as HTMLSelectElement).value as ArtistSort;
goto(`?sort=${value}`, { replaceState: true });
// Each "Most played" row gets 25 of the 75 tracks (server returns up
// to 75; rows 0/1/2 slice [0..25), [25..50), [50..75)).
function chunk<T>(items: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
</script>
<div class="space-y-4">
<header class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Library</h1>
{#if !query.isPending && !query.isError}
<p class="text-sm text-text-secondary">
{total} {total === 1 ? 'artist' : 'artists'}
</p>
{/if}
</div>
<label class="flex items-center gap-2 text-sm">
<span class="text-text-secondary">Sort</span>
<select
value={sort}
onchange={onSortChange}
class="rounded border border-border bg-surface px-2 py-1"
>
<option value="alpha">AZ</option>
<option value="newest">Newest</option>
</select>
</label>
</header>
<div class="space-y-8">
{#if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && artists.length === 0}
<LibrarySkeleton variant="list" count={12} />
{:else if !query.isPending && total === 0}
<p class="text-text-secondary">
No artists yet — scan a library folder via the server's config.
</p>
{:else}
<div>
{#each artists as a (a.id)}
<ArtistRow artist={a} />
{/each}
</div>
{:else if showSkeleton.value && !data}
<p class="text-text-secondary">Loading…</p>
{:else if data}
<!-- Recently added: 50 albums in 2 rows of 25 -->
<section class="space-y-3">
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Recently added</h2>
</header>
{#if data.recently_added_albums.length === 0}
<p class="text-text-secondary">Nothing added yet. Scan a folder via the server's config.</p>
{:else}
{#each chunk(data.recently_added_albums, 25) as row, i (i)}
<HorizontalScrollRow items={row} ariaLabel="Recently added row {i + 1}">
{#snippet item(album: import('$lib/api/types').AlbumRef)}
<div class="w-44"><AlbumCard {album} /></div>
{/snippet}
</HorizontalScrollRow>
{/each}
{/if}
</section>
{#if query.hasNextPage}
<button
type="button"
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
disabled={query.isFetchingNextPage}
onclick={() => query.fetchNextPage()}
>
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
{:else if artists.length > 0}
<p class="py-2 text-center text-sm text-text-secondary">End of library</p>
{/if}
<!-- Rediscover: albums + artists -->
<section class="space-y-3">
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Rediscover</h2>
</header>
{#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0}
<p class="text-text-secondary">No forgotten favourites yet. Like some albums or artists to fill this in.</p>
{:else}
{#if data.rediscover_albums.length > 0}
<HorizontalScrollRow items={data.rediscover_albums} ariaLabel="Rediscover albums">
{#snippet item(album: import('$lib/api/types').AlbumRef)}
<div class="w-44"><AlbumCard {album} /></div>
{/snippet}
</HorizontalScrollRow>
{/if}
{#if data.rediscover_artists.length > 0}
<HorizontalScrollRow items={data.rediscover_artists} ariaLabel="Rediscover artists">
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
<div class="w-36"><ArtistCard {artist} /></div>
{/snippet}
</HorizontalScrollRow>
{/if}
{/if}
</section>
<!-- Most played: 75 tracks in 3 rows of 25 -->
<section class="space-y-3">
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Most played</h2>
</header>
{#if data.most_played_tracks.length === 0}
<p class="text-text-secondary">No plays to draw from. Listen to something.</p>
{:else}
{#each chunk(data.most_played_tracks, 25) as row, i (i)}
<HorizontalScrollRow items={row} ariaLabel="Most played row {i + 1}">
{#snippet item(track: import('$lib/api/types').TrackRef, idx: number)}
<CompactTrackCard
{track}
sectionTracks={row}
index={idx}
/>
{/snippet}
</HorizontalScrollRow>
{/each}
{/if}
</section>
<!-- Last played artists -->
<section class="space-y-3">
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Last played</h2>
</header>
{#if data.last_played_artists.length === 0}
<p class="text-text-secondary">No recent plays.</p>
{:else}
<HorizontalScrollRow items={data.last_played_artists} ariaLabel="Last played artists">
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
<div class="w-36"><ArtistCard {artist} /></div>
{/snippet}
</HorizontalScrollRow>
{/if}
</section>
{/if}
</div>