feat(frontend): rework home page with recent, latest and playlists
Add SSR data loading for the home page and rebuild its layout (greeting hero, recently played, new releases, user playlists).
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { createApiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||||
|
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||||
|
|
||||||
|
const latestPromise = api.GET('/tracks', {
|
||||||
|
params: { query: { pageNumber: 1, pageSize: 12 } }
|
||||||
|
});
|
||||||
|
const playlistsPromise = locals.user
|
||||||
|
? api.GET('/playlists/users/{userId}', {
|
||||||
|
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 8 } }
|
||||||
|
})
|
||||||
|
: Promise.resolve({ data: undefined });
|
||||||
|
|
||||||
|
const [latest, playlists] = await Promise.all([latestPromise, playlistsPromise]);
|
||||||
|
const playlistItems = playlists.data?.items ?? [];
|
||||||
|
|
||||||
|
const covers = await Promise.all(
|
||||||
|
playlistItems.map(async (playlist) => {
|
||||||
|
const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', {
|
||||||
|
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 4 } }
|
||||||
|
});
|
||||||
|
return [playlist.id, tracks?.items.map((t) => t.id) ?? []] as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
latest: latest.data?.items ?? [],
|
||||||
|
playlists: playlistItems,
|
||||||
|
trackIds: Object.fromEntries(covers) as Record<string, string[]>
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,76 +1,176 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
const features = [
|
import Music from '@lucide/svelte/icons/music';
|
||||||
{
|
import { player } from '$lib/player/player.svelte';
|
||||||
icon: '🎧',
|
import { HUES } from '$lib/theme/color';
|
||||||
title: 'Escucha sin límites',
|
import Cover from '$lib/components/ui/Cover.svelte';
|
||||||
body: 'Reproducción con streaming adaptativo y soporte de peticiones por rango.'
|
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||||
},
|
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||||
{
|
|
||||||
icon: '🎵',
|
let { data } = $props();
|
||||||
title: 'Tus playlists',
|
|
||||||
body: 'Crea, organiza y comparte tus listas de reproducción a tu manera.'
|
const latest = $derived(data.latest);
|
||||||
},
|
const playlists = $derived(data.playlists);
|
||||||
{
|
const trackIds = $derived(data.trackIds);
|
||||||
icon: '⬆️',
|
|
||||||
title: 'Sube tu música',
|
const greeting = (() => {
|
||||||
body: 'Sube tus canciones y portadas; nosotros nos encargamos de procesarlas.'
|
const h = new Date().getHours();
|
||||||
}
|
const pools =
|
||||||
];
|
h < 6
|
||||||
|
? ['¿Aún despierto?', 'La noche también tiene banda sonora', 'Un ratito más, con música']
|
||||||
|
: h < 12
|
||||||
|
? ['Buenos días', 'Que empiece bien la mañana', 'Un nuevo día, una nueva lista']
|
||||||
|
: h < 14
|
||||||
|
? ['Buenos días', 'El mediodía sabe mejor con música']
|
||||||
|
: h < 19
|
||||||
|
? ['Buenas tardes', 'La tarde pide su banda sonora', 'Un respiro, con algo de música']
|
||||||
|
: h < 23
|
||||||
|
? ['Buenas noches', 'Hora de bajar el ritmo', 'Que la noche te acompañe']
|
||||||
|
: ['¿Aún despierto?', 'La noche también tiene banda sonora'];
|
||||||
|
const pick = pools[Math.floor(Math.random() * pools.length)];
|
||||||
|
return /[.!?]$/.test(pick) ? pick : `${pick}.`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function hueFor(id: string) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
return HUES[hash % HUES.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function playLatest(index: number) {
|
||||||
|
const track = latest[index];
|
||||||
|
if (player.current.id === track.id) player.toggle();
|
||||||
|
else player.playQueue(latest, index);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Musify · Tu música, en streaming</title>
|
<title>Musify</title>
|
||||||
<meta name="description" content="Escucha, organiza y sube tu música con Musify." />
|
<meta name="description" content="Tu música, sin límites." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="relative overflow-hidden">
|
<section class="relative overflow-hidden px-8 pb-16 pt-11">
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute -top-40 left-1/2 h-96 w-[40rem] -translate-x-1/2 rounded-full bg-emerald-500/20 blur-3xl"
|
class="pointer-events-none absolute inset-0 blur-[70px] saturate-150"
|
||||||
|
style="background:linear-gradient(135deg, var(--mf-accent), color-mix(in oklch, var(--mf-accent), black 55%));animation:breathe 9s ease-in-out infinite"
|
||||||
></div>
|
></div>
|
||||||
|
<div class="animate-enter relative flex flex-col gap-2.5">
|
||||||
<div class="mx-auto max-w-6xl px-6 py-24 text-center sm:py-32">
|
<p class="text-[13px] uppercase tracking-[0.14em] text-[var(--mf-text-3)]">{greeting}</p>
|
||||||
<span
|
<h1 class="max-w-[640px] font-display text-[42px] font-extrabold leading-[1.05] tracking-tight">
|
||||||
class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-1.5 text-xs font-medium text-neutral-300"
|
Tu música. Sin límites.
|
||||||
>
|
|
||||||
<span class="h-2 w-2 rounded-full bg-emerald-400"></span> Ahora en beta
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<h1 class="mx-auto mt-6 max-w-3xl text-5xl font-bold tracking-tight sm:text-6xl">
|
|
||||||
Tu música, <span class="text-emerald-400">en streaming</span>.
|
|
||||||
</h1>
|
</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p class="mx-auto mt-6 max-w-xl text-lg text-neutral-400">
|
{#if player.recentlyPlayed.length > 0}
|
||||||
Escucha, organiza y sube tus canciones. Musify es tu biblioteca musical, siempre contigo.
|
<section class="px-8 pb-7 pt-4">
|
||||||
</p>
|
<h2 class="mb-4 font-display text-xl font-bold">Escuchado recientemente</h2>
|
||||||
|
<div class="grid max-w-[1400px] grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-3.5">
|
||||||
<div class="mt-10 flex flex-wrap items-center justify-center gap-4">
|
{#each player.recentlyPlayed as track (track.id)}
|
||||||
<a
|
<button
|
||||||
href="/login?mode=register"
|
type="button"
|
||||||
data-sveltekit-reload
|
onclick={() => player.playTrack(track.id)}
|
||||||
class="rounded-full bg-emerald-500 px-6 py-3 text-sm font-semibold text-neutral-950 transition hover:bg-emerald-400"
|
class="flex items-center gap-3.5 rounded-xl bg-[var(--mf-surface)] p-2 text-left transition hover:bg-[var(--mf-surface-hover)]"
|
||||||
>
|
>
|
||||||
Empezar gratis
|
<Cover
|
||||||
</a>
|
trackId={track.id}
|
||||||
<a
|
hue={track.hue}
|
||||||
href="/explore"
|
size="small"
|
||||||
class="rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white/5"
|
alt={track.title}
|
||||||
>
|
class="h-14 w-14 flex-shrink-0 rounded-lg"
|
||||||
Explorar canciones
|
>
|
||||||
</a>
|
{#if player.current.id === track.id}
|
||||||
|
<NowPlaying paused={!player.playing} />
|
||||||
|
{/if}
|
||||||
|
</Cover>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-sm font-semibold text-[var(--mf-text)]">{track.title}</div>
|
||||||
|
<div class="truncate text-[12.5px] text-[var(--mf-text-3)]">{track.artist || '—'}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="px-8 pb-10 pt-6">
|
||||||
|
<h2 class="mb-4 font-display text-xl font-bold">Novedades</h2>
|
||||||
|
{#if latest.length > 0}
|
||||||
|
<div class="grid max-w-[1400px] grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
|
||||||
|
{#each latest as track, i (track.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => playLatest(i)}
|
||||||
|
class="group animate-enter text-left"
|
||||||
|
style="animation-delay:{i * 45}ms"
|
||||||
|
>
|
||||||
|
<Cover
|
||||||
|
trackId={track.id}
|
||||||
|
hue={hueFor(track.id)}
|
||||||
|
size="large"
|
||||||
|
alt={track.title}
|
||||||
|
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
|
||||||
|
class:!opacity-100={player.current.id === track.id}
|
||||||
|
class:!translate-y-0={player.current.id === track.id}
|
||||||
|
>
|
||||||
|
{#if player.current.id === track.id && player.playing}
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
||||||
|
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</Cover>
|
||||||
|
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]">{track.title}</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="max-w-[1400px] rounded-2xl border border-[var(--mf-border)] px-8 py-12 text-center">
|
||||||
|
<Music class="mx-auto h-9 w-9 text-[var(--mf-text-4)]" />
|
||||||
|
<h3 class="mt-4 font-display text-xl font-bold">Todavía no hay música</h3>
|
||||||
|
<p class="mt-2 text-sm text-[var(--mf-text-2)]">
|
||||||
|
<a href="/upload" class="text-emerald-400 underline underline-offset-2">Sube una canción</a>
|
||||||
|
y empieza a escuchar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mx-auto max-w-6xl px-6 pb-24">
|
<section class="px-8 pb-16 pt-2">
|
||||||
<div class="grid gap-6 sm:grid-cols-3">
|
<h2 class="mb-4 font-display text-xl font-bold">Tus listas</h2>
|
||||||
{#each features as feature (feature.title)}
|
{#if playlists.length > 0}
|
||||||
<div
|
<div class="grid max-w-[1400px] grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-4">
|
||||||
class="rounded-2xl border border-white/5 bg-white/[0.03] p-6 transition hover:border-emerald-500/30 hover:bg-white/[0.05]"
|
{#each playlists as playlist, i (playlist.id)}
|
||||||
>
|
<a
|
||||||
<div class="text-3xl">{feature.icon}</div>
|
href="/playlists/{playlist.id}"
|
||||||
<h3 class="mt-4 text-lg font-semibold">{feature.title}</h3>
|
class="group animate-enter text-left"
|
||||||
<p class="mt-2 text-sm text-neutral-400">{feature.body}</p>
|
style="animation-delay:{i * 45}ms"
|
||||||
</div>
|
>
|
||||||
{/each}
|
<PlaylistArt
|
||||||
</div>
|
trackIds={trackIds[playlist.id] ?? []}
|
||||||
|
hue={hueFor(playlist.id)}
|
||||||
|
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)] transition group-hover:shadow-[0_16px_36px_-10px_rgba(0,0,0,0.75)]"
|
||||||
|
/>
|
||||||
|
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]">
|
||||||
|
{playlist.name}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="max-w-[560px] rounded-xl border border-[var(--mf-border)] bg-[var(--mf-surface)] px-6 py-10 text-center"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-[var(--mf-text-2)]">Todavía no tienes listas</p>
|
||||||
|
<p class="mt-1.5 text-xs text-[var(--mf-text-4)]">
|
||||||
|
Cuando crees listas, tus últimas aparecerán aquí.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user