feat(web): hero row at top of Home (For You + most recently added)
test-web / test (push) Failing after 36s

#6 — Adds a 2-up grid above the Playlists carousel:
- Today's pick: For-You playlist card with a Play button that
  invokes the system-shuffle endpoint (same path as PlaylistCard
  uses for the play overlay).
- Just added: most recently added album card with a Play button
  that fetches the album detail and queues the tracks.

Both cards share HomeHeroCard.svelte — cover left, eyebrow + title
+ subtitle + Play right, on a from-accent/15 → surface gradient so
the eye lands here before scrolling into the carousels. Each card
links to its detail page; the Play button is event-stopped so it
plays in-place. md:grid-cols-2 stacks on narrow viewports.

Only renders when at least one of the two pieces of data is
available so the page degrades cleanly on fresh installs.
This commit is contained in:
2026-06-01 20:10:47 -04:00
parent 5f297c4c21
commit 17b276b5f5
2 changed files with 156 additions and 0 deletions
@@ -0,0 +1,75 @@
<script lang="ts">
import { Play } from 'lucide-svelte';
import { FALLBACK_COVER } from '$lib/media/covers';
// Shared shape for the two hero tiles at the top of Home (the
// "Today's pick" + "Just added" anchors). Wider than the regular
// grid tiles, cover on the left + meta + play button on the right,
// accent-tinted background so the eye lands here first.
let {
href,
coverUrl,
eyebrow,
title,
subtitle,
onPlay,
playDisabled = false
}: {
href: string;
coverUrl: string;
eyebrow: string;
title: string;
subtitle?: string;
onPlay: (e: MouseEvent) => void;
playDisabled?: boolean;
} = $props();
function onImgError(e: Event) {
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
}
</script>
<a
{href}
class="group flex items-stretch gap-4 overflow-hidden rounded-lg border border-border
bg-gradient-to-br from-accent/15 via-surface to-surface
shadow-sm transition-all duration-200
hover:from-accent/25 hover:shadow-lg hover:ring-1 hover:ring-accent/40
focus-visible:ring-2 focus-visible:ring-accent"
>
<div class="relative h-32 w-32 flex-shrink-0 overflow-hidden md:h-40 md:w-40">
<img
src={coverUrl}
alt=""
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
onerror={onImgError}
/>
</div>
<div class="flex flex-1 flex-col justify-between gap-2 py-3 pr-4">
<div class="min-w-0">
<div class="text-xs font-medium uppercase tracking-wide text-accent">{eyebrow}</div>
<div class="font-display text-xl font-medium leading-tight text-text-primary
line-clamp-2 mt-1">
{title}
</div>
{#if subtitle}
<div class="mt-1 truncate text-sm text-text-secondary">{subtitle}</div>
{/if}
</div>
<button
type="button"
aria-label={`Play ${title}`}
onclick={onPlay}
disabled={playDisabled}
class="self-start inline-flex items-center gap-1.5 rounded-full
bg-accent px-4 py-1.5 text-sm font-medium text-text-primary
hover:opacity-90 focus-visible:ring-2 focus-visible:ring-accent
disabled:opacity-50"
>
<Play size={14} strokeWidth={1.5} fill="currentColor" />
Play
</button>
</div>
</a>
+81
View File
@@ -3,6 +3,13 @@
import { createHomeQuery } from '$lib/api/home';
import HorizontalScrollRow from '$lib/components/HorizontalScrollRow.svelte';
import AlbumCard from '$lib/components/AlbumCard.svelte';
import HomeHeroCard from '$lib/components/HomeHeroCard.svelte';
import { systemShuffle, getPlaylist } from '$lib/api/playlists';
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
import { playQueue } from '$lib/player/store.svelte';
import { api } from '$lib/api/client';
import { coverUrl as albumCoverUrl } from '$lib/media/covers';
import type { AlbumDetail, TrackRef as TrackRefType } from '$lib/api/types';
import ArtistCard from '$lib/components/ArtistCard.svelte';
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
@@ -75,6 +82,46 @@
const userPlaylists = $derived(userPlaylistsQ.data?.owned ?? []);
// Hero row: For-You + most-recently-added album. Anchors the page
// so the eye lands on these instead of cascading down a wall of
// identical tiles.
const latestAlbum = $derived(data?.recently_added_albums?.[0] ?? null);
function albumCoverFor(albumId: string | undefined | null): string {
return albumId ? albumCoverUrl(albumId) : '';
}
let heroStarting = $state(false);
async function playForYou(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (heroStarting || !forYouPlaylist?.system_variant) return;
heroStarting = true;
try {
const detail = await systemShuffle(forYouPlaylist.system_variant);
const refs = detail.tracks
.map((r) => playlistTrackToRef(r))
.filter((t): t is TrackRefType => t !== null);
if (refs.length > 0) playQueue(refs, 0, { source: forYouPlaylist.system_variant });
} finally {
heroStarting = false;
}
}
async function playLatestAlbum(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (heroStarting || !latestAlbum) return;
heroStarting = true;
try {
const detail = await api.get<AlbumDetail>(`/api/albums/${latestAlbum.id}`);
if (detail.tracks.length > 0) playQueue(detail.tracks, 0);
} finally {
heroStarting = false;
}
}
type PlaceholderVariant = 'building' | 'failed' | 'pending' | 'seed-needed';
function placeholderVariant(slot: 'for-you' | 'discover' | 'songs-like'): PlaceholderVariant {
const s = systemStatusQ.data;
@@ -134,6 +181,40 @@
<svelte:head><title>{pageTitle('Home')}</title></svelte:head>
<div class="space-y-8">
<!-- Hero anchors: For-You + most recently added album. Larger
cards on a tinted gradient so the page has a clear "land
here" point instead of opening on a wall of tiles. Only
rendered when both pieces of data exist; the page falls back
to the carousels cleanly when either is missing. -->
{#if forYouPlaylist || latestAlbum}
<section class="grid gap-4 md:grid-cols-2" aria-label="Featured">
{#if forYouPlaylist}
<HomeHeroCard
href={`/playlists/${forYouPlaylist.id}`}
coverUrl={forYouPlaylist.cover_url ?? ''}
eyebrow="Today's pick"
title={forYouPlaylist.name}
subtitle={forYouPlaylist.track_count > 0
? `${forYouPlaylist.track_count} tracks for you`
: 'Building your mix'}
onPlay={playForYou}
playDisabled={heroStarting || forYouPlaylist.track_count === 0}
/>
{/if}
{#if latestAlbum}
<HomeHeroCard
href={`/albums/${latestAlbum.id}`}
coverUrl={albumCoverFor(latestAlbum.id)}
eyebrow="Just added"
title={latestAlbum.title}
subtitle={latestAlbum.artist_name}
onPlay={playLatestAlbum}
playDisabled={heroStarting}
/>
{/if}
</section>
{/if}
<!-- Playlists: For-You + Songs-like + user playlists, single horizontal row -->
<section class="space-y-3">
<HorizontalScrollRow