Files
Musify/web-player/src/routes/+page.svelte
T
Admin 611b2a72cd refactor: tighten the home typography and card craft
The playlist cards were showing "No description was pr…" because the API
stored that English sentence whenever a playlist was created without a
description. Descriptions are optional now and the old rows are cleared.

Section headings drop to 18/20px so they stop competing with the content,
and the space moved from inside the sections to between them.

The three media cards repeated the same anchor, art class and label
block, so they share a MediaCard that also carries the hover lift the
track tiles already had.
2026-07-21 23:14:36 +02:00

328 lines
9.5 KiB
Svelte

<script lang="ts">
import Music from '@lucide/svelte/icons/music';
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
import {
isTargetCurrent,
queueItemForTarget,
targetArtist,
targetCoverSrc,
targetExplicit,
targetForQueueItem,
targetId,
targetTitle,
type TrackTarget
} from '$lib/tracks';
import { hueFor } from '$lib/theme/color';
import Cover from '$lib/components/ui/Cover.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import MixCard from '$lib/components/ui/MixCard.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import { HOME_LATEST_PAGE_SIZE } from '$lib/config';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import TrackTile from '$lib/components/TrackTile.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import type { YouTubeSong } from '$lib/types';
import { appendUnique } from '$lib/collections';
let { data } = $props();
let youtubeFillerItems = $state<YouTubeSong[]>([]);
let ytContinuation = $state('');
let ytQuery = $state('');
let contextMenu = $state<ContextMenuState | null>(null);
type LocalLatest = (typeof data.latest)[number];
let localLatest = $state<LocalLatest[]>([]);
let localPage = $state(1);
let localHasNext = $state(false);
let loadingMore = $state(false);
$effect(() => {
youtubeFillerItems = [];
ytContinuation = '';
ytQuery = '';
localLatest = data.latest;
localPage = 1;
localHasNext = data.latestHasNext;
let cancelled = false;
data.youtubeFiller.then((filler) => {
if (cancelled) return;
youtubeFillerItems = filler.items;
ytContinuation = filler.continuationToken;
ytQuery = filler.query;
});
return () => {
cancelled = true;
};
});
const latest = $derived<TrackTarget[]>([
...localLatest,
...youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
]);
const hasMoreLatest = $derived(localHasNext || ytContinuation !== '');
async function loadMoreLatest() {
if (loadingMore) return;
loadingMore = true;
try {
if (localHasNext) {
const params = new URLSearchParams({
pageNumber: String(localPage + 1),
pageSize: String(HOME_LATEST_PAGE_SIZE)
});
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = await res.json();
localLatest = appendUnique(
localLatest,
next.items.map((track: LocalLatest['track']) => ({ kind: 'local' as const, track })),
(item) => item.track.id
);
localPage = Number(next.pageNumber);
localHasNext = Boolean(next.hasNextPage);
} else if (ytContinuation && ytQuery) {
const params = new URLSearchParams({ query: ytQuery, continuation: ytContinuation });
const res = await fetch(`/api/youtube/search?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
youtubeFillerItems = appendUnique(youtubeFillerItems, next.items, (i) => i.videoId);
ytContinuation = next.continuationToken;
}
} catch {
if (localHasNext) localHasNext = false;
else ytContinuation = '';
} finally {
loadingMore = false;
}
}
const playlists = $derived(data.playlists);
const albums = $derived(data.albums);
const mixes = $derived(data.mixes);
function playLatest(index: number) {
player.playOrToggle(latest.map(queueItemForTarget), index);
}
function openContextMenu(event: MouseEvent, target: TrackTarget) {
contextMenu = contextMenuStateFor(event, target);
}
function closeContextMenu() {
contextMenu = null;
}
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(queueItemForTarget(contextMenu));
closeContextMenu();
}
const recentlyPlayed = $derived.by(() => {
const seen = new Set<string>();
const merged: QueueItem[] = [];
for (const t of player.recentlyPlayed) {
const id = String(t.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({
id,
title: t.title,
artist: t.artist,
source: t.source,
coverUrl: t.coverUrl,
explicit: t.explicit
});
}
for (const item of toQueueItems(data.recentlyPlayed)) {
const id = String(item.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({
id,
title: item.title,
artist: item.artist,
source: item.source ?? 'local',
coverUrl: item.coverUrl,
explicit: item.explicit
});
}
return merged.slice(0, 15);
});
function playRecent(index: number) {
player.playOrToggle(recentlyPlayed, index);
}
</script>
<svelte:head>
<title>Musify</title>
<meta name="description" content="Tu música, sin límites." />
</svelte:head>
<section class="relative overflow-hidden page-x pt-8 pb-10 sm:pt-11 sm:pb-16">
<div
class="pointer-events-none absolute inset-0 blur-[70px] saturate-150"
style="background:linear-gradient(135deg, var(--color-accent), color-mix(in oklch, var(--color-accent), black 55%));animation:breathe 9s ease-in-out infinite"
></div>
<div class="animate-enter relative flex flex-col gap-2 sm:gap-2.5">
<p class="text-sm tracking-[0.14em] text-fg-2 uppercase sm:text-base">{data.greeting}</p>
<h1 class="max-w-160 text-3xl leading-[1.05] font-semibold text-fg sm:text-5xl">
Tu música. Sin límites.
</h1>
</div>
</section>
{#if recentlyPlayed.length > 0}
<section class="page-x pt-10 sm:pt-14">
<SectionHeading title="Volver a escuchar" />
<MediaGrid min="280px" minMobile="220px">
{#each recentlyPlayed as track, i (track.id)}
<button
type="button"
onclick={() => playRecent(i)}
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(track))}
aria-label="{player.current.id === track.id && player.playing
? 'Pausar'
: 'Reproducir'} {track.title}"
class="flex min-w-0 items-center gap-3 rounded-control bg-surface p-2 text-left transition hover:bg-surface-hover"
>
<Cover
trackId={track.id}
src={track.coverUrl}
hue={hueFor(track.id)}
size="small"
alt={track.title}
class="h-14 w-14 shrink-0 rounded-control"
>
{#if player.current.id === track.id}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-1.5">
{#if track.explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-fg">{track.title}</span>
</div>
{#if track.artist}
<div class="truncate text-sm text-muted">{track.artist}</div>
{/if}
</div>
</button>
{/each}
</MediaGrid>
</section>
{/if}
{#if mixes.length > 0 || playlists.length > 0}
<section class="grid gap-x-8 gap-y-10 page-x pt-10 sm:pt-14 lg:grid-cols-2">
{#if mixes.length > 0}
<div class="min-w-0">
<SectionHeading title="Hechas para ti" />
<MediaGrid min="150px" minMobile="130px">
{#each mixes as mix, i (mix.id)}
<MixCard {mix} index={i} />
{/each}
</MediaGrid>
</div>
{/if}
{#if playlists.length > 0}
<div class="min-w-0">
<SectionHeading title="Mis listas">
{#snippet actions()}
{#if data.playlistsHasMore}
<a href="/playlists" class="text-sm text-fg-3 transition hover:text-accent-soft">
Ver todas
</a>
{/if}
{/snippet}
</SectionHeading>
<MediaGrid min="150px" minMobile="130px">
{#each playlists as playlist, i (playlist.id)}
<PlaylistCard
id={playlist.id}
name={playlist.name}
description={playlist.description}
trackIds={playlist.coverTrackIds}
updatedAt={playlist.updatedAt}
index={i}
/>
{/each}
</MediaGrid>
</div>
{/if}
</section>
{/if}
{#if albums.length > 0}
<section class="page-x pt-10 sm:pt-14">
<SectionHeading title="Álbumes escuchados recientemente">
{#snippet actions()}
<a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">Ver todos</a>
{/snippet}
</SectionHeading>
<MediaGrid>
{#each albums as album, i (album.id)}
<AlbumCard
id={album.id}
title={album.title}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackCount={Number(album.trackCount)}
trackIds={album.coverTrackIds}
index={i}
/>
{/each}
</MediaGrid>
</section>
{/if}
<section class="page-x pt-10 pb-16 sm:pt-14">
<SectionHeading title="Descubrir" />
{#if latest.length > 0}
<MediaGrid as="ul">
{#each latest as item, i (targetId(item))}
<TrackTile
id={targetId(item)}
title={targetTitle(item)}
artist={targetArtist(item)}
coverShape="round"
coverSrc={targetCoverSrc(item)}
hue={hueFor(targetId(item))}
explicit={targetExplicit(item)}
active={isTargetCurrent(item)}
playing={player.isPlaying}
index={i}
onClick={() => playLatest(i)}
onContextMenu={(e) => openContextMenu(e, item)}
/>
{/each}
</MediaGrid>
<InfiniteScroll onLoadMore={loadMoreLatest} hasMore={hasMoreLatest} loading={loadingMore} />
{:else}
<EmptyState icon={Music} title="Todavía no hay música" />
{/if}
</section>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
{playlists}
onClose={closeContextMenu}
onAddToQueue={addToQueue}
/>
{/if}