feat(web): hero row at top of Home (For You + most recently added)
test-web / test (push) Failing after 36s
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:
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user