web-player: big pages (Explore, Home) and tracks.ts cleanup
This pass also tackles the task deferred earlier: removing
tracks.ts's TrackTarget wrapper and its 8 accessors
(targetId/targetTitle/targetArtist/targetOwnerUserId/targetExplicit/
targetListensCount/isTargetCurrent/queueItemForTarget/
targetForQueueItem). Explore, Home, Mix, menus.svelte.ts and mixes.ts
now use track objects directly, with
toQueueItem/trackFromQueueItem (new, in player.svelte.ts) as the only
converters. Along the way, TypeScript revealed that isExplicit
doesn't exist in the real /tracks schema: Explore's "explicit" badge
was already a no-op before this refactor (always undefined ??
false); the binding is removed instead of pretending support that
never existed.
Explore: GENRE_INFO/genreTiles are extracted to src/lib/genres.ts;
the search logic
(showGroup/capped/matchPlaylists/searchCounts/searchChips/findTopResult)
is extracted to src/lib/search.ts as pure functions parameterized by
filter/query instead of closing over component state. "Top result"
unifies the duplicated button/link with <svelte:element>. Decision
made without blocking: the 4 group blocks (Tracks/Albums/Users/
Playlists) are not collapsed into a single {#each groups} — each
group has genuinely different snippets, context menu and ListRow
props, and a generic config would have added more indirection than it
removed.
Home: uses Page instead of a hand-rolled page-x; the 3 stats become
an {#each}; the hero/spotlight backgrounds (inline style= with CSS
variables) become the hero-surface/spotlight-surface utilities, and
gap-11.5 is replaced by the --spacing-section token (2.875rem) via
gap-section.
Verified with npm run check (0 errors) and npm run build. /explore
without a session redirects to /login because +layout.server.ts
requires allowAnonymousListening from the backend (unreachable
without Docker this session) — pre-existing behavior, not a
regression. No authenticated visual QA; smoke-tested /login with no
console errors.
This commit is contained in:
+226
-232
@@ -3,23 +3,20 @@
|
||||
import History from '@lucide/svelte/icons/history';
|
||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
||||
import {
|
||||
player,
|
||||
isQueueCurrent,
|
||||
playAllOrToggle,
|
||||
playShuffled,
|
||||
toQueueItem,
|
||||
trackFromQueueItem,
|
||||
type ApiTrackLike
|
||||
} from '$lib/player/player.svelte';
|
||||
import { pressable } from '$lib/actions/pressable';
|
||||
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
|
||||
import { mixItemTarget } from '$lib/mixes';
|
||||
import { mixItemTrack } from '$lib/mixes';
|
||||
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format';
|
||||
import {
|
||||
isTargetCurrent,
|
||||
queueItemForTarget,
|
||||
targetArtist,
|
||||
targetExplicit,
|
||||
targetForQueueItem,
|
||||
targetId,
|
||||
targetListensCount,
|
||||
targetOwnerUserId,
|
||||
targetTitle,
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||
import MediaCard from '$lib/components/ui/MediaCard.svelte';
|
||||
import EqBars from '$lib/components/ui/EqBars.svelte';
|
||||
@@ -36,7 +33,8 @@
|
||||
|
||||
const trackMenu = createTrackMenu();
|
||||
|
||||
type SpotlightRow = { target: TrackTarget; seconds: number };
|
||||
type SpotlightTrack = Awaited<typeof data.spotlightTracks>['items'][number];
|
||||
type SpotlightRow = { track: SpotlightTrack; seconds: number };
|
||||
let spotlightRows = $state<SpotlightRow[]>([]);
|
||||
let spotlightTotal = $state(0);
|
||||
|
||||
@@ -47,7 +45,7 @@
|
||||
data.spotlightTracks.then(({ items, totalCount }) => {
|
||||
if (cancelled) return;
|
||||
spotlightRows = items.map((track) => ({
|
||||
target: { track },
|
||||
track,
|
||||
seconds: Number(track.duration)
|
||||
}));
|
||||
spotlightTotal = totalCount;
|
||||
@@ -60,7 +58,7 @@
|
||||
const mixes = $derived(data.mixes);
|
||||
const playlists = $derived(data.playlists);
|
||||
const spotlight = $derived(data.spotlightPlaylist);
|
||||
const spotlightItems = $derived(spotlightRows.map((row) => row.target));
|
||||
const spotlightItems = $derived(spotlightRows.map((row) => row.track));
|
||||
const spotlightDurationSeconds = $derived(
|
||||
spotlightRows.reduce((sum, row) => sum + row.seconds, 0)
|
||||
);
|
||||
@@ -81,11 +79,11 @@
|
||||
const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
|
||||
const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24));
|
||||
|
||||
const popularTargets = $derived<TrackTarget[]>(
|
||||
recentPool.map(targetForQueueItem).slice(0, HOME_POPULAR_MAX)
|
||||
const popularTracks = $derived<ApiTrackLike[]>(
|
||||
recentPool.map(trackFromQueueItem).slice(0, HOME_POPULAR_MAX)
|
||||
);
|
||||
|
||||
const spotlightQueue = $derived(spotlightItems.map(queueItemForTarget));
|
||||
const spotlightQueue = $derived(spotlightItems.map(toQueueItem));
|
||||
const spotlightIsCurrent = $derived(isQueueCurrent(spotlightQueue));
|
||||
const heroPlaying = $derived(spotlightIsCurrent && player.playing);
|
||||
|
||||
@@ -93,7 +91,7 @@
|
||||
player.playOrToggle(continueItems, index);
|
||||
}
|
||||
function playPopular(index: number) {
|
||||
player.playOrToggle(popularTargets.map(queueItemForTarget), index);
|
||||
player.playOrToggle(popularTracks.map(toQueueItem), index);
|
||||
}
|
||||
function playSpotlightTrack(index: number) {
|
||||
player.playOrToggle(spotlightQueue, index);
|
||||
@@ -105,14 +103,14 @@
|
||||
playShuffled(spotlightQueue);
|
||||
}
|
||||
|
||||
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
||||
trackMenu.open(event, target);
|
||||
function openContextMenu(event: MouseEvent, track: ApiTrackLike) {
|
||||
trackMenu.open(event, track);
|
||||
}
|
||||
|
||||
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
player.playQueue(
|
||||
mix.items.map((item) => queueItemForTarget(mixItemTarget(item))),
|
||||
mix.items.map((item) => toQueueItem(mixItemTrack(item))),
|
||||
0
|
||||
);
|
||||
}
|
||||
@@ -135,9 +133,9 @@
|
||||
<meta name="description" content="Tu música, sin límites." />
|
||||
</svelte:head>
|
||||
|
||||
<section class="page-x pt-3 sm:pt-4">
|
||||
<Page>
|
||||
<div
|
||||
class="relative overflow-hidden rounded-3xl bg-[image:var(--mf-hero-bg)] px-6 py-8 theme-transition sm:px-9 sm:py-8.5"
|
||||
class="relative overflow-hidden rounded-3xl hero-surface px-6 py-8 theme-transition 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"
|
||||
@@ -163,226 +161,222 @@
|
||||
</div>
|
||||
|
||||
{#await data.listeningStats then stats}
|
||||
{@const statsList = [
|
||||
{ value: stats.tracksThisWeek, label: 'Canciones nuevas escuchadas esta semana' },
|
||||
{
|
||||
value: fmtDurationLong(Number(stats.secondsThisWeek)),
|
||||
label: 'Tiempo de escucha esta semana'
|
||||
},
|
||||
{ value: stats.streakDays, label: 'Días consecutivos escuchando' }
|
||||
]}
|
||||
<div class="flex gap-6.5">
|
||||
<div>
|
||||
<div class="text-display-2 text-fg">
|
||||
{stats.tracksThisWeek}
|
||||
{#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-3">{stat.label}</div>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-fg-3">Canciones nuevas escuchadas esta semana</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-display-2 text-fg">
|
||||
{fmtDurationLong(Number(stats.secondsThisWeek))}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-fg-3">Tiempo de escucha esta semana</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-display-2 text-fg">
|
||||
{stats.streakDays}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-fg-3">Días consecutivos escuchando</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col gap-11.5 page-x pt-10 pb-8 sm:pb-10">
|
||||
<!-- CONTINUAR ESCUCHANDO -->
|
||||
<section>
|
||||
<SectionHeading title="Continuar escuchando" subtitle="Retomalo 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.current.id === item.id}
|
||||
<ListRow
|
||||
onclick={() => playContinue(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
|
||||
active={isCurrent}
|
||||
size="lg"
|
||||
variant="card"
|
||||
title={item.title}
|
||||
subtitle={item.artist}
|
||||
subtitleHref={item.ownerUserId}
|
||||
trackId={item.id}
|
||||
class="animate-enter"
|
||||
style="--i:{i}"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{@render noListeningHistory()}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- PLAYLIST DESTACADA -->
|
||||
{#if spotlight}
|
||||
<div class="mt-10 flex flex-col gap-section">
|
||||
<!-- CONTINUAR ESCUCHANDO -->
|
||||
<section>
|
||||
<div
|
||||
class="grid grid-cols-1 overflow-hidden rounded-panel-lg theme-transition lg:grid-cols-2"
|
||||
style="background-image:var(--mf-spotlight-bg), linear-gradient(to right, transparent 50%, color-mix(in oklch, var(--mf-scrim) 22%, transparent) 61%, transparent 92.5%)"
|
||||
>
|
||||
<div class="flex flex-col justify-center p-8 sm:p-9">
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
use:pressable={() => goto(`/playlists/${spotlight.id}`)}
|
||||
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
||||
>
|
||||
<Artwork
|
||||
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
|
||||
spotlight.updatedAt
|
||||
)}"
|
||||
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">{spotlightMeta}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
|
||||
<Button variant="glass" onclick={shuffleSpotlight}>Aleatorio</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6">
|
||||
{#each spotlightRows as { target, seconds }, i (targetId(target))}
|
||||
<SectionHeading title="Continuar escuchando" subtitle="Retomalo 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.current.id === item.id}
|
||||
<ListRow
|
||||
onclick={() => playSpotlightTrack(i)}
|
||||
active={isTargetCurrent(target)}
|
||||
size="sm"
|
||||
title={targetTitle(target)}
|
||||
subtitle={targetArtist(target)}
|
||||
subtitleHref={targetOwnerUserId(target)}
|
||||
>
|
||||
{#snippet leading()}
|
||||
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums">{i + 1}</span
|
||||
>
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
{:else}
|
||||
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
|
||||
onclick={() => playContinue(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, trackFromQueueItem(item))}
|
||||
active={isCurrent}
|
||||
size="lg"
|
||||
variant="card"
|
||||
title={item.title}
|
||||
subtitle={item.artist}
|
||||
subtitleHref={item.ownerUserId}
|
||||
trackId={item.id}
|
||||
class="animate-enter"
|
||||
style="--i:{i}"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{@render noListeningHistory()}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- MIXES -->
|
||||
<section>
|
||||
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
|
||||
{#if mixes.length > 0}
|
||||
<div class="grid-tiles">
|
||||
{#each mixes as mix, i (mix.id)}
|
||||
<MediaCard
|
||||
href="/mixes/{mix.id}"
|
||||
title={mix.title}
|
||||
subtitle={mix.subtitle}
|
||||
trackIds={mix.items.map((item) => item.trackId)}
|
||||
onPlay={(event) => playMix(mix, event)}
|
||||
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."
|
||||
<!-- PLAYLIST DESTACADA -->
|
||||
{#if spotlight}
|
||||
<section>
|
||||
<div
|
||||
class="grid grid-cols-1 overflow-hidden rounded-panel-lg spotlight-surface theme-transition lg:grid-cols-2"
|
||||
>
|
||||
<div class="flex flex-col justify-center p-8 sm:p-9">
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
use:pressable={() => goto(`/playlists/${spotlight.id}`)}
|
||||
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
||||
>
|
||||
<Artwork
|
||||
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
|
||||
spotlight.updatedAt
|
||||
)}"
|
||||
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">{spotlightMeta}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
|
||||
<Button variant="glass" onclick={shuffleSpotlight}>Aleatorio</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6">
|
||||
{#each spotlightRows as { track, seconds }, i (track.id)}
|
||||
<ListRow
|
||||
onclick={() => playSpotlightTrack(i)}
|
||||
active={player.current.id === track.id}
|
||||
size="sm"
|
||||
title={track.title}
|
||||
subtitle={track.artist}
|
||||
subtitleHref={track.ownerUserId}
|
||||
>
|
||||
{#snippet leading()}
|
||||
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums"
|
||||
>{i + 1}</span
|
||||
>
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
{:else}
|
||||
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- MIXES -->
|
||||
<section>
|
||||
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
|
||||
{#if mixes.length > 0}
|
||||
<div class="grid-tiles">
|
||||
{#each mixes as mix, i (mix.id)}
|
||||
<MediaCard
|
||||
href="/mixes/{mix.id}"
|
||||
title={mix.title}
|
||||
subtitle={mix.subtitle}
|
||||
trackIds={mix.items.map((item) => item.trackId)}
|
||||
onPlay={(event) => playMix(mix, event)}
|
||||
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>
|
||||
|
||||
<!-- POPULARES ESTA SEMANA -->
|
||||
<section>
|
||||
<SectionHeading title="Populares esta semana" subtitle="Lo más escuchado de tu biblioteca" />
|
||||
{#if popularTracks.length > 0}
|
||||
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
|
||||
{#each popularTracks as track, i (track.id)}
|
||||
{@const isCurrent = player.current.id === track.id}
|
||||
<ListRow
|
||||
onclick={() => playPopular(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, track)}
|
||||
active={isCurrent}
|
||||
size="sm"
|
||||
title={track.title}
|
||||
subtitle={track.artist}
|
||||
subtitleHref={track.ownerUserId}
|
||||
explicit={track.isExplicit}
|
||||
trackId={track.id}
|
||||
>
|
||||
{#snippet leading()}
|
||||
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
|
||||
{#if isCurrent}
|
||||
<EqBars size={13} paused={!player.playing} />
|
||||
{:else}
|
||||
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
|
||||
{fmtPlays(track.listensCount)}
|
||||
</span>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{@render noListeningHistory()}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- TUS PLAYLISTS -->
|
||||
<section>
|
||||
<SectionHeading
|
||||
title="Tus playlists"
|
||||
subtitle="Creadas y guardadas por ti"
|
||||
href={data.playlistsHasMore ? '/playlists' : undefined}
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- POPULARES ESTA SEMANA -->
|
||||
<section>
|
||||
<SectionHeading title="Populares esta semana" subtitle="Lo más escuchado de tu biblioteca" />
|
||||
{#if popularTargets.length > 0}
|
||||
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
|
||||
{#each popularTargets as target, i (targetId(target))}
|
||||
{@const isCurrent = isTargetCurrent(target)}
|
||||
<ListRow
|
||||
onclick={() => playPopular(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, target)}
|
||||
active={isCurrent}
|
||||
size="sm"
|
||||
title={targetTitle(target)}
|
||||
subtitle={targetArtist(target)}
|
||||
subtitleHref={targetOwnerUserId(target)}
|
||||
explicit={targetExplicit(target)}
|
||||
trackId={targetId(target)}
|
||||
>
|
||||
{#snippet leading()}
|
||||
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
|
||||
{#if isCurrent}
|
||||
<EqBars size={13} paused={!player.playing} />
|
||||
{:else}
|
||||
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
{#snippet trailing()}
|
||||
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
|
||||
{fmtPlays(targetListensCount(target))}
|
||||
</span>
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{@render noListeningHistory()}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- TUS PLAYLISTS -->
|
||||
<section>
|
||||
<SectionHeading
|
||||
title="Tus playlists"
|
||||
subtitle="Creadas y guardadas por ti"
|
||||
href={data.playlistsHasMore ? '/playlists' : undefined}
|
||||
/>
|
||||
{#if playlists.length > 0}
|
||||
<div class="grid-wide">
|
||||
{#each playlists as playlist, i (playlist.id)}
|
||||
<ListRow
|
||||
onclick={() => goto(`/playlists/${playlist.id}`)}
|
||||
size="lg"
|
||||
variant="card"
|
||||
title={playlist.name}
|
||||
subtitle={playlist.description}
|
||||
coverSrc="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
trackIds={playlist.coverTrackIds}
|
||||
style="--i:{i}"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="Todavía no tienes listas"
|
||||
description="Crea tu primera lista y añádele canciones de tu biblioteca."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/playlists">Crear lista</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{#if playlists.length > 0}
|
||||
<div class="grid-wide">
|
||||
{#each playlists as playlist, i (playlist.id)}
|
||||
<ListRow
|
||||
onclick={() => goto(`/playlists/${playlist.id}`)}
|
||||
size="lg"
|
||||
variant="card"
|
||||
title={playlist.name}
|
||||
subtitle={playlist.description}
|
||||
coverSrc="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
trackIds={playlist.coverTrackIds}
|
||||
style="--i:{i}"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="Todavía no tienes listas"
|
||||
description="Crea tu primera lista y añádele canciones de tu biblioteca."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/playlists">Crear lista</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
{#if trackMenu.state}
|
||||
<ContextMenu
|
||||
|
||||
Reference in New Issue
Block a user