web-player: consistent imports, tokens, glossary and app route grouping
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiFor, requireUser } from '$lib/server/api';
|
||||
import { toMix, toPlaylist, toTrack } from '$lib/server/mappers';
|
||||
import { addTrackAction } from '$lib/server/playlistActions';
|
||||
import { HOME_MIXES_LIMIT, HOME_SHELF_LIMIT, HOME_SPOTLIGHT_TRACKS_LIMIT } from '$lib/config';
|
||||
import { pickGreeting } from '$lib/server/greeting';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
if (!locals.user) redirect(302, '/explore');
|
||||
const user = requireUser(locals, url);
|
||||
const api = apiFor({ fetch, locals });
|
||||
|
||||
const playlistsPromise = api.GET('/playlists/users/{userId}', {
|
||||
params: {
|
||||
path: { userId: user.sub },
|
||||
query: { pageNumber: 1, pageSize: HOME_SHELF_LIMIT + 1 }
|
||||
}
|
||||
});
|
||||
const mixesPromise = api.GET('/mixes');
|
||||
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
|
||||
params: { path: { id: user.sub } }
|
||||
});
|
||||
const listeningStatsPromise = api
|
||||
.GET('/users/{id}/listening-stats', { params: { path: { id: user.sub } } })
|
||||
.then((res) => ({
|
||||
tracksThisWeek: Number(res.data?.tracksThisWeek ?? 0),
|
||||
secondsThisWeek: Number(res.data?.secondsThisWeek ?? 0),
|
||||
streakDays: Number(res.data?.streakDays ?? 0)
|
||||
}))
|
||||
.catch(() => ({ tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 }));
|
||||
|
||||
const [playlists, mixes, recentlyPlayed] = await Promise.all([
|
||||
playlistsPromise,
|
||||
mixesPromise,
|
||||
recentlyPlayedPromise
|
||||
]);
|
||||
const playlistItems = (playlists.data?.items ?? []).map(toPlaylist);
|
||||
|
||||
const spotlightPlaylist = playlistItems[0] ?? null;
|
||||
const spotlightTracksPromise = spotlightPlaylist
|
||||
? api
|
||||
.GET('/playlists/{playlistId}/tracks', {
|
||||
params: {
|
||||
path: { playlistId: spotlightPlaylist.id },
|
||||
query: { pageNumber: 1, pageSize: HOME_SPOTLIGHT_TRACKS_LIMIT }
|
||||
}
|
||||
})
|
||||
.then((res) => ({
|
||||
items: (res.data?.items ?? []).map(toTrack),
|
||||
totalCount: Number(res.data?.totalItemCount ?? 0)
|
||||
}))
|
||||
.catch(() => ({ items: [], totalCount: 0 }))
|
||||
: Promise.resolve({ items: [], totalCount: 0 });
|
||||
|
||||
return {
|
||||
greeting: pickGreeting(new Date()),
|
||||
recentlyPlayed: (recentlyPlayed.data ?? []).map(toTrack),
|
||||
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_LIMIT).map(toMix),
|
||||
playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
|
||||
playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
|
||||
spotlightPlaylist,
|
||||
spotlightTracks: spotlightTracksPromise,
|
||||
listeningStats: listeningStatsPromise
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: addTrackAction
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
<script lang="ts">
|
||||
import { playlistCover } from '$lib/utils/hrefs';
|
||||
import History from '@lucide/svelte/icons/history';
|
||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { playShuffled } from '$lib/player/actions';
|
||||
import type { Track } from '$lib/types';
|
||||
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
|
||||
import { fetchPlaylistTracks } from '$lib/data/albums';
|
||||
import { fmtTime, fmtDurationLong, plural } from '$lib/utils/format';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
||||
import MediaCard from '$lib/components/ui/media/MediaCard.svelte';
|
||||
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
|
||||
import TrackContextMenu from '$lib/components/ui/overlay/TrackContextMenu.svelte';
|
||||
import { createMenu } from '$lib/state/menu.svelte';
|
||||
import { HOME_CONTINUE_LIMIT } from '$lib/config';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const trackMenu = createMenu<Track>();
|
||||
|
||||
const spotlight = $derived(data.spotlightPlaylist);
|
||||
const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
|
||||
|
||||
async function shuffleSpotlight() {
|
||||
if (data.spotlightPlaylist) playShuffled(await fetchPlaylistTracks(data.spotlightPlaylist.id));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet noListeningHistory()}
|
||||
<EmptyState
|
||||
icon={History}
|
||||
title="Todavía no has escuchado nada"
|
||||
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/snippet}
|
||||
|
||||
<svelte:head>
|
||||
<title>Musify</title>
|
||||
<meta name="description" content="Tu música, sin límites." />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<div class="relative hero-surface overflow-hidden rounded-panel-lg px-6 py-8 sm:px-9 sm:py-8.5">
|
||||
<div
|
||||
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
|
||||
>
|
||||
<div class="animate-enter max-w-hero min-w-65 dark:shadow-accent">
|
||||
<h1
|
||||
class="font-display text-3xl leading-tight font-medium tracking-tight text-pretty text-fg sm:text-4xl"
|
||||
>
|
||||
{data.greeting}
|
||||
</h1>
|
||||
<p class="mt-3.5 max-w-prose-sm text-body text-fg-2">
|
||||
Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.
|
||||
</p>
|
||||
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
|
||||
{#if spotlight}
|
||||
{#await data.spotlightTracks then { items }}
|
||||
<PlayAllButton
|
||||
{items}
|
||||
load={() => fetchPlaylistTracks(spotlight.id)}
|
||||
size="md"
|
||||
idleLabel="Reanudar {spotlight.name}"
|
||||
activeLabel="Pausar {spotlight.name}"
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#await data.listeningStats then stats}
|
||||
{@const statsList = [
|
||||
{ value: stats.tracksThisWeek, label: 'Canciones nuevas escuchadas esta semana' },
|
||||
{
|
||||
value: fmtDurationLong(stats.secondsThisWeek),
|
||||
label: 'Tiempo de escucha esta semana'
|
||||
},
|
||||
{ value: stats.streakDays, label: 'Días consecutivos escuchando' }
|
||||
]}
|
||||
<div class="flex gap-6.5">
|
||||
{#each statsList as stat (stat.label)}
|
||||
<div>
|
||||
<div class="text-display-2 text-fg">{stat.value}</div>
|
||||
<div class="mt-1 text-xs text-fg-2">{stat.label}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex flex-col gap-section">
|
||||
<section>
|
||||
<SectionHeading title="Continuar escuchando" subtitle="Retómalo donde lo dejaste" />
|
||||
{#if continueItems.length > 0}
|
||||
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{#each continueItems as item, i (item.id)}
|
||||
{@const isCurrent = player.currentId === item.id}
|
||||
<ListRow
|
||||
onclick={() => player.playOrToggle(continueItems, i)}
|
||||
oncontextmenu={(e) => trackMenu.open(e, item)}
|
||||
active={isCurrent}
|
||||
size="lg"
|
||||
variant="card"
|
||||
title={item.title}
|
||||
subtitle={item.artist}
|
||||
subtitleHref={item.ownerUserId}
|
||||
explicit={item.explicit}
|
||||
trackId={item.id}
|
||||
class="animate-enter"
|
||||
style="--i:{i}"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{@render noListeningHistory()}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if spotlight}
|
||||
<section>
|
||||
{#await data.spotlightTracks then { items: previewItems, totalCount }}
|
||||
{@const visibilityLabel = spotlight.visibility === 'Public' ? 'Pública' : 'Privada'}
|
||||
{@const meta =
|
||||
totalCount === 0
|
||||
? visibilityLabel
|
||||
: `${plural(totalCount, 'canción', 'canciones')} · ${fmtDurationLong(spotlight.durationSeconds)} · ${visibilityLabel}`}
|
||||
<div
|
||||
class="grid spotlight-surface grid-cols-1 overflow-hidden rounded-panel-lg lg:grid-cols-2"
|
||||
>
|
||||
<div class="flex flex-col justify-center p-8 sm:p-9">
|
||||
<a
|
||||
href="/playlists/{spotlight.id}"
|
||||
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
||||
>
|
||||
<Artwork
|
||||
src={playlistCover(spotlight, 'large')}
|
||||
trackIds={spotlight.coverTrackIds}
|
||||
size="2xl"
|
||||
alt={spotlight.name}
|
||||
class="shrink-0"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-3">
|
||||
<p class="text-eyebrow text-fg-2">Playlist destacada</p>
|
||||
<h3 class="truncate text-display-2 text-fg">
|
||||
{spotlight.name}
|
||||
</h3>
|
||||
<p class="text-sm text-fg-2">{meta}</p>
|
||||
</div>
|
||||
</a>
|
||||
<p class="mt-5 max-w-prose-sm text-sm text-fg-2">
|
||||
{spotlight.description || 'Tu colección, siempre a mano.'}
|
||||
</p>
|
||||
<div class="mt-5 flex gap-2.5">
|
||||
<PlayAllButton
|
||||
items={previewItems}
|
||||
load={() => fetchPlaylistTracks(spotlight.id)}
|
||||
variant="accent"
|
||||
size="md"
|
||||
/>
|
||||
<Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 p-5.5 sm:p-6">
|
||||
{#each previewItems as track, i (track.id)}
|
||||
<ListRow
|
||||
onclick={() => player.playOrToggle(previewItems, i)}
|
||||
active={player.currentId === track.id}
|
||||
size="sm"
|
||||
title={track.title}
|
||||
subtitle={track.artist}
|
||||
subtitleHref={track.ownerUserId}
|
||||
explicit={track.explicit}
|
||||
>
|
||||
{#snippet leading()}
|
||||
<span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums"
|
||||
>{i + 1}</span
|
||||
>
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<span class="shrink-0 text-xs text-fg-2 tabular-nums"
|
||||
>{fmtTime(track.duration)}</span
|
||||
>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
{:else}
|
||||
<p class="p-2 text-sm text-fg-2">Esta playlist todavía no tiene canciones.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/await}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<SectionHeading title="Mezclas" subtitle="Generados a partir de lo que más repites" />
|
||||
{#if data.mixes.length > 0}
|
||||
<div class="grid-tiles">
|
||||
{#each data.mixes as mix, i (mix.id)}
|
||||
<MediaCard
|
||||
href="/mixes/{mix.id}"
|
||||
title={mix.title}
|
||||
subtitle={mix.subtitle}
|
||||
trackIds={mix.tracks.map((track) => track.id)}
|
||||
onPlay={(event) => {
|
||||
event.preventDefault();
|
||||
player.playQueue(mix.tracks, 0);
|
||||
}}
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={Shuffle}
|
||||
title="Todavía no tienes mezclas"
|
||||
description="Se generan automáticamente a partir de lo que escuchas."
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeading
|
||||
title="Tus playlists"
|
||||
subtitle="Creadas y guardadas por ti"
|
||||
href={data.playlistsHasMore ? '/playlists' : undefined}
|
||||
/>
|
||||
{#if data.playlists.length > 0}
|
||||
<div class="grid-wide">
|
||||
{#each data.playlists as playlist, i (playlist.id)}
|
||||
<ListRow
|
||||
href="/playlists/{playlist.id}"
|
||||
size="lg"
|
||||
variant="card"
|
||||
title={playlist.name}
|
||||
subtitle={playlist.description}
|
||||
coverSrc={playlistCover(playlist, 'medium')}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
style="--i:{i}"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="Todavía no tienes playlists"
|
||||
description="Crea tu primera playlist y añádele canciones de tu biblioteca."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/playlists">Crear playlist</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
<TrackContextMenu menu={trackMenu} playlists={data.userPlaylists} />
|
||||
@@ -28,7 +28,7 @@
|
||||
{#snippet actions()}
|
||||
{#if items.length > 0}
|
||||
<Button onclick={() => (creating = true)}>
|
||||
<Plus class="size-4" />
|
||||
<Plus class="size-icon-sm" />
|
||||
Nuevo álbum
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
{#snippet actions()}
|
||||
{#if items.length > 0}
|
||||
<Button href="/upload">
|
||||
<Upload class="size-4" />
|
||||
<Upload class="size-icon-sm" />
|
||||
Subir canción
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -62,7 +62,7 @@
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/upload">
|
||||
<Upload class="size-4" />
|
||||
<Upload class="size-icon-sm" />
|
||||
Sube la primera
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
onclick={() => playShuffled(tracks)}
|
||||
disabled={tracks.length === 0}
|
||||
>
|
||||
<Shuffle class="size-4" />
|
||||
<Shuffle class="size-icon-sm" />
|
||||
Aleatorio
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
@@ -19,18 +19,18 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mis listas</title>
|
||||
<meta name="description" content="Mis listas en Musify." />
|
||||
<title>Mis playlists</title>
|
||||
<meta name="description" content="Mis playlists en Musify." />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PageHeader
|
||||
title="Mis listas"
|
||||
title="Mis playlists"
|
||||
description={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if items.length > 0}
|
||||
<Button onclick={() => createPlaylistModal.show()}>Crear nueva lista</Button>
|
||||
<Button onclick={() => createPlaylistModal.show()}>Crear nueva playlist</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
@@ -51,11 +51,11 @@
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="Todavía no tienes listas"
|
||||
title="Todavía no tienes playlists"
|
||||
description="Crea la primera y añádele canciones de tu biblioteca."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => createPlaylistModal.show()}>Crear nueva lista</Button>
|
||||
<Button onclick={() => createPlaylistModal.show()}>Crear nueva playlist</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
: 'bg-accent-btn text-accent-soft'}"
|
||||
>
|
||||
{#if stepStates[i] === 'done'}
|
||||
<Check class="size-3.5" />
|
||||
<Check class="size-icon-xs" />
|
||||
{:else}
|
||||
{i + 1}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user