web-player: P10 — cabeceras, diálogos y tipografía

PageHeader fusiona CollectionHeader (title/eyebrow/description/meta/
cover?/actions?/align?): sin cover se comporta como el antiguo
PageHeader simple (acciones en línea), con cover como el antiguo
CollectionHeader (acciones debajo). Fija su propio margen inferior
(mb-7/8); se quitan los mt-* que ponía cada página después. Migra los
9 sitios que usaban uno u otro (álbumes, biblioteca, listas, subir,
Explorar ×2, me gusta, mezcla, perfil). CollectionHeader se borra.

AlbumHeader y PlaylistHeader se inlinean en sus páginas y se borran.
Decisión (recomendada, sin bloquear): PlaylistHeader gana iconos en
Editar/Eliminar/Reproducir, igual que ya tenía AlbumHeader.

ConfirmDialog nuevo sustituye los dos diálogos de borrado (álbum y
playlist), que tenían tipografías distintas. Modal pasa a usar
.glass-panel (en vez de duplicar el style= inline) y z-(--z-modal); su
eyebrow y título usan las recetas text-eyebrow/text-display-3.

SectionHeading gana `count?` (línea "N resultados", sustituye 4 copias
en Explorar) y `href?`/`linkLabel?` ("Ver todas", sustituye la de
Inicio). Aplica recetas de tipografía (text-display-2/3,
text-eyebrow, text-body) y tokens de ancho (max-w-prose-sm, max-w-hero)
en EmptyState, +error, "Sin resultados", hero y stats de Inicio, Queue
y Sidebar. El estado vacío "Todavía no has escuchado nada" (repetido
2 veces en Inicio) pasa a un snippet local.

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 00:48:23 +02:00
co-authored by Claude Sonnet 5
parent 5dec00faf1
commit 2cf3e9f02d
24 changed files with 310 additions and 388 deletions
+1 -1
View File
@@ -782,7 +782,7 @@ Tareas:
| P7 | ✅ | (pendiente) | MediaIdentity queda "puro" (sin onClick) según spec; las 6 tablas de pista (P8) envuelven su propio `use:pressable` hasta que P8 las sustituya por TrackList. Deltas documentados: fila activa de Sidebar ahora usa bg-accent-tint/text-accent-soft (antes gris neutro, ahora consistente con el resto de filas activas); tarjeta "Tus playlists" de Inicio pierde la línea "por {tu nombre}" (redundante, siempre el propio usuario) y su radio pasa de rounded-panel a rounded-art (consistencia con la tarjeta "Continuar"); filas de Queue "A continuación" pierden el opacity-80 del arte (ListRow no expone esa personalización). Sin QA visual autenticada (Docker no disponible) | | P7 | ✅ | (pendiente) | MediaIdentity queda "puro" (sin onClick) según spec; las 6 tablas de pista (P8) envuelven su propio `use:pressable` hasta que P8 las sustituya por TrackList. Deltas documentados: fila activa de Sidebar ahora usa bg-accent-tint/text-accent-soft (antes gris neutro, ahora consistente con el resto de filas activas); tarjeta "Tus playlists" de Inicio pierde la línea "por {tu nombre}" (redundante, siempre el propio usuario) y su radio pasa de rounded-panel a rounded-art (consistencia con la tarjeta "Continuar"); filas de Queue "A continuación" pierden el opacity-80 del arte (ListRow no expone esa personalización). Sin QA visual autenticada (Docker no disponible) |
| P8 | ✅ | (pendiente) | Decisión tomada (recomendada por el propio plan, sin bloquear): se elimina la columna "Álbum" que siempre mostraba "—". TrackList unifica TrackTable+TrackRow+TrackIndexCell+TrackMeta; AlbumTrackTable/PlaylistTrackTable/LibraryPicker se inlinean en sus páginas y se borran. Sin QA visual autenticada (Docker no disponible) | | P8 | ✅ | (pendiente) | Decisión tomada (recomendada por el propio plan, sin bloquear): se elimina la columna "Álbum" que siempre mostraba "—". TrackList unifica TrackTable+TrackRow+TrackIndexCell+TrackMeta; AlbumTrackTable/PlaylistTrackTable/LibraryPicker se inlinean en sus páginas y se borran. Sin QA visual autenticada (Docker no disponible) |
| P9 | ✅ | (pendiente) | MediaCard gana hover-zoom del arte y scrim+PlayButton condicionados a `onPlay` (antes exclusivos de MixTile); ahora se aplicarían a cualquier card futura con onPlay, consistencia intencional. Sin QA visual autenticada (Docker no disponible) | | P9 | ✅ | (pendiente) | MediaCard gana hover-zoom del arte y scrim+PlayButton condicionados a `onPlay` (antes exclusivos de MixTile); ahora se aplicarían a cualquier card futura con onPlay, consistencia intencional. Sin QA visual autenticada (Docker no disponible) |
| P10 | ⬜ | | | | P10 | ✅ | (pendiente) | Decisión tomada (recomendada, sin bloquear): iconos en los botones de cabecera de playlist (antes solo álbum tenía). PlaylistHeader pierde su prop `id` sin usar tras inlinearlo. Explorar: la cabecera de "Resultados para…" ahora usa el `mb-7/8` de PageHeader en vez de un `mt-5` ajustado a mano antes de los chips de filtro — gap ligeramente mayor entre el resumen y los chips (Δ menor, documentado). Sin QA visual autenticada (Docker no disponible) |
| P11 | ⬜ | | | | P11 | ⬜ | | |
| P12 | ⬜ | | | | P12 | ⬜ | | |
| P13 | ⬜ | | | | P13 | ⬜ | | |
+1 -1
View File
@@ -76,7 +76,7 @@
{#if user} {#if user}
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div> <div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
<div class="flex items-center justify-between px-3 pb-1.5"> <div class="flex items-center justify-between px-3 pb-1.5">
<span class="text-xs font-medium tracking-widest text-muted uppercase">Tus playlists</span> <span class="text-eyebrow text-muted">Tus playlists</span>
{#if playlists.length > 0} {#if playlists.length > 0}
<IconButton <IconButton
label="Crear playlist" label="Crear playlist"
@@ -29,7 +29,7 @@
</div> </div>
{#if player.current.id} {#if player.current.id}
<p class="mb-2.75 text-xs font-medium tracking-widest text-fg-3 uppercase">Reproduciendo</p> <p class="mb-2.75 text-eyebrow text-fg-3">Reproduciendo</p>
<div class="mb-6 rounded-control bg-accent-tint p-2.5"> <div class="mb-6 rounded-control bg-accent-tint p-2.5">
<MediaIdentity <MediaIdentity
trackId={player.current.id} trackId={player.current.id}
@@ -42,9 +42,7 @@
{/if} {/if}
<div class="mb-2.75 flex items-center justify-between gap-3"> <div class="mb-2.75 flex items-center justify-between gap-3">
<span class="min-w-0 flex-1 truncate text-xs font-medium tracking-widest text-fg-3 uppercase"> <span class="min-w-0 flex-1 truncate text-eyebrow text-fg-3"> A continuación </span>
A continuación
</span>
{#if upcoming.length > 0} {#if upcoming.length > 0}
<button <button
type="button" type="button"
@@ -27,7 +27,7 @@
></div> ></div>
</button> </button>
{:else} {:else}
<div class="flex w-full max-w-135 items-center gap-2.75"> <div class="flex w-full max-w-hero items-center gap-2.75">
<span class="min-w-8 text-right text-xs text-fg-3 tabular-nums"> <span class="min-w-8 text-right text-xs text-fg-3 tabular-nums">
{fmtTime(player.progress)} {fmtTime(player.progress)}
</span> </span>
@@ -1,45 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
eyebrow: string;
title: string;
description?: string;
meta?: string;
align?: 'center' | 'end';
cover: Snippet;
actions?: Snippet;
}
let { eyebrow, title, description, meta, align = 'end', cover, actions }: Props = $props();
</script>
<div
class="flex flex-col gap-5 sm:flex-row sm:gap-6 {align === 'end'
? 'mt-3 sm:mt-4 sm:items-end'
: 'sm:items-center'}"
>
{@render cover()}
<div class="min-w-0 flex-1">
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">{eyebrow}</p>
<h1
class="{align === 'end'
? 'mt-1.5'
: 'mt-3'} font-display text-3xl font-medium tracking-tight wrap-break-word text-fg sm:text-4xl"
>
{title}
</h1>
{#if description}
<p class="mt-3 max-w-2xl text-sm text-fg-2 sm:text-base">{description}</p>
{/if}
{#if meta}
<p class="mt-3 text-sm text-fg-2">{meta}</p>
{/if}
</div>
</div>
{#if actions}
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
{@render actions()}
</div>
{/if}
@@ -0,0 +1,26 @@
<script lang="ts">
import Modal from './Modal.svelte';
import Button from './Button.svelte';
interface Props {
open: boolean;
onClose: () => void;
title: string;
description: string;
confirmLabel: string;
action: string;
}
let { open, onClose, title, description, confirmLabel, action }: Props = $props();
</script>
<Modal {open} {onClose} maxWidth="max-w-sm">
<h2 class="text-display-3 text-fg">{title}</h2>
<p class="mt-2.5 text-body text-fg-2">{description}</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
<form method="POST" {action}>
<Button type="submit" variant="danger">{confirmLabel}</Button>
</form>
</div>
</Modal>
@@ -15,10 +15,10 @@
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}"> <div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
{#if Icon} {#if Icon}
<Icon class="h-9 w-9 text-fg-2" strokeWidth={1.25} /> <Icon class="size-icon-xl text-fg-2" strokeWidth={1.25} />
{/if} {/if}
{#if title} {#if title}
<h3 class="mt-6 font-display text-xl font-medium tracking-tight text-fg"> <h3 class="mt-6 text-display-3 text-fg">
{title} {title}
</h3> </h3>
{/if} {/if}
@@ -70,14 +70,13 @@
{#if open} {#if open}
<div <div
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm" class="animate-fade fixed inset-0 z-(--z-modal) grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm"
role="presentation" role="presentation"
onclick={onBackdropClick} onclick={onBackdropClick}
> >
<div <div
bind:this={panel} bind:this={panel}
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 backdrop-blur-xl transition-[background] duration-500 outline-none sm:p-6 {panelClass}" class="glass-panel animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
@@ -87,11 +86,11 @@
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
{#if eyebrow} {#if eyebrow}
<p class="mb-2.5 text-xs font-medium tracking-widest text-muted uppercase"> <p class="mb-2.5 text-eyebrow text-muted">
{eyebrow} {eyebrow}
</p> </p>
{/if} {/if}
<h2 class="font-display text-xl font-medium tracking-[-0.02em] text-fg"> <h2 class="text-display-3 text-fg">
{title} {title}
</h2> </h2>
</div> </div>
@@ -3,24 +3,59 @@
interface Props { interface Props {
title: string; title: string;
subtitle?: string; eyebrow?: string;
class?: string; description?: string;
meta?: string;
cover?: Snippet;
actions?: Snippet; actions?: Snippet;
align?: 'center' | 'end';
class?: string;
} }
let { title, subtitle, class: klass = '', actions }: Props = $props(); let {
title,
eyebrow,
description,
meta,
cover,
actions,
align = 'end',
class: klass = ''
}: Props = $props();
</script> </script>
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}"> <div class="mb-7 sm:mb-8 {klass}">
<div class="min-w-0"> <div
<h1 class="font-display text-3xl font-medium tracking-tight text-fg sm:text-4xl"> class="flex flex-col gap-5 sm:flex-row sm:gap-6 {cover
? align === 'end'
? 'sm:items-end'
: 'sm:items-center'
: 'sm:items-end sm:justify-between'}"
>
{#if cover}
{@render cover()}
{/if}
<div class="min-w-0 flex-1">
{#if eyebrow}
<p class="text-eyebrow text-fg-2">{eyebrow}</p>
{/if}
<h1 class="text-display-1 wrap-break-word text-fg {eyebrow ? 'mt-1.5' : ''}">
{title} {title}
</h1> </h1>
{#if subtitle} {#if description}
<p class="mt-2 text-sm text-fg-2">{subtitle}</p> <p class="mt-3 max-w-2xl text-body text-fg-2">{description}</p>
{/if}
{#if meta}
<p class="mt-3 text-sm text-fg-2">{meta}</p>
{/if} {/if}
</div> </div>
{#if actions} {#if actions && !cover}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div> <div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{/if} {/if}
</div>
{#if actions && cover}
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
{@render actions()}
</div>
{/if}
</div> </div>
@@ -1,18 +1,32 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { plural } from '$lib/format';
interface Props { interface Props {
title: string; title: string;
subtitle?: string; subtitle?: string;
count?: number;
href?: string;
linkLabel?: string;
class?: string; class?: string;
actions?: Snippet; actions?: Snippet;
} }
let { title, subtitle, class: klass = '', actions }: Props = $props(); let {
title,
subtitle,
count,
href,
linkLabel = 'Ver todas',
class: klass = '',
actions
}: Props = $props();
const hasActions = $derived(!!actions || count !== undefined || !!href);
</script> </script>
{#snippet heading()} {#snippet heading()}
<h2 class="font-display text-lg font-medium tracking-tight text-fg"> <h2 class="text-display-4 text-fg">
{title} {title}
</h2> </h2>
{#if subtitle} {#if subtitle}
@@ -20,10 +34,31 @@
{/if} {/if}
{/snippet} {/snippet}
{#if actions} {#snippet autoActions()}
{#if count !== undefined}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{plural(count, 'resultado', 'resultados')}
</span>
{/if}
{#if href}
<a
{href}
class="shrink-0 text-xs font-medium tracking-wider text-fg-3 uppercase transition-colors hover:text-fg"
>
{linkLabel}
</a>
{/if}
{/snippet}
{#if hasActions}
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}"> <div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
<div>{@render heading()}</div> <div>{@render heading()}</div>
{#if actions}
{@render actions()} {@render actions()}
{:else}
{@render autoActions()}
{/if}
</div> </div>
{:else} {:else}
<div class="mb-4 sm:mb-4.5 {klass}"> <div class="mb-4 sm:mb-4.5 {klass}">
@@ -23,7 +23,7 @@
</svelte:head> </svelte:head>
<Page> <Page>
<PageHeader title="Álbumes" subtitle="Agrupa la música que subes en discos."> <PageHeader title="Álbumes" description="Agrupa la música que subes en discos.">
{#snippet actions()} {#snippet actions()}
{#if items.length > 0} {#if items.length > 0}
<Button onclick={() => (creating = true)}> <Button onclick={() => (creating = true)}>
@@ -35,7 +35,7 @@
</PageHeader> </PageHeader>
{#if items.length > 0} {#if items.length > 0}
<div class="mt-7 grid-cards sm:mt-8"> <div class="grid-cards">
{#each items as album, i (album.id)} {#each items as album, i (album.id)}
<MediaCard <MediaCard
href="/albums/{album.id}" href="/albums/{album.id}"
@@ -2,14 +2,22 @@
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte'; import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import Page from '$lib/components/ui/Page.svelte'; import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Modal from '$lib/components/ui/Modal.svelte'; import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Alert from '$lib/components/ui/Alert.svelte'; import Alert from '$lib/components/ui/Alert.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import AlbumForm from '$lib/components/ui/AlbumForm.svelte'; import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import BackLink from '$lib/components/ui/BackLink.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte'; import TrackList from '$lib/components/ui/TrackList.svelte';
import AlbumHeader from './components/AlbumHeader.svelte'; import { albumMeta } from '$lib/format';
let { data, form } = $props(); let { data, form } = $props();
@@ -42,21 +50,54 @@
</svelte:head> </svelte:head>
<Page> <Page>
<AlbumHeader <BackLink
title={album.title} href={isOwner ? '/albums' : '/'}
description={album.description ?? undefined} label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackIds={tracks.map((track) => track.id)}
coverUrl="/api/albums/{album.id}/cover?size=large"
playing={isCurrentQueue && player.playing}
{isOwner}
onPlayAll={playAll}
onEdit={() => (editing = true)}
onDelete={() => (confirmingDelete = true)}
/> />
<PageHeader
eyebrow="Álbum"
title={album.title}
description={album.description ?? undefined}
meta={albumMeta(
album.releaseYear === null ? undefined : Number(album.releaseYear),
tracks.length
)}
>
{#snippet cover()}
<Artwork
src="/api/albums/{album.id}/cover?size=large"
trackIds={tracks.map((track) => track.id)}
size="hero"
alt={album.title}
class="shrink-0"
/>
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
{#if isOwner}
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</Button>
{/if}
{/snippet}
</PageHeader>
{#if form?.message} {#if form?.message}
<Alert tone="danger" class="mt-6">{form.message}</Alert> <Alert tone="danger" class="mb-6">{form.message}</Alert>
{/if} {/if}
{#if tracks.length > 0} {#if tracks.length > 0}
@@ -66,7 +107,6 @@
rowAction={isOwner rowAction={isOwner
? { action: '?/removeTrack', icon: X, label: 'Quitar del álbum' } ? { action: '?/removeTrack', icon: X, label: 'Quitar del álbum' }
: undefined} : undefined}
class="mt-6 sm:mt-8"
/> />
{/if} {/if}
@@ -98,17 +138,11 @@
/> />
</Modal> </Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm"> <ConfirmDialog
<h2 class="text-lg font-semibold tracking-tight text-fg"> open={confirmingDelete}
¿Eliminar «{album.title}»? onClose={() => (confirmingDelete = false)}
</h2> title="¿Eliminar «{album.title}»?"
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2"> description="Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca."
Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca. confirmLabel="Eliminar"
</p> action="?/delete"
<div class="mt-6 flex items-center justify-end gap-3"> />
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
<form method="POST" action="?/delete">
<Button type="submit" variant="danger">Eliminar</Button>
</form>
</div>
</Modal>
@@ -1,75 +0,0 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import Artwork from '$lib/components/ui/Artwork.svelte';
import BackLink from '$lib/components/ui/BackLink.svelte';
import Button from '$lib/components/ui/Button.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import { plural } from '$lib/format';
interface Props {
title: string;
description?: string;
releaseYear?: number;
trackIds: (string | number)[];
coverUrl?: string;
playing: boolean;
isOwner: boolean;
onPlayAll: () => void;
onEdit: () => void;
onDelete: () => void;
}
let {
title,
description,
releaseYear,
trackIds,
coverUrl,
playing,
isOwner,
onPlayAll,
onEdit,
onDelete
}: Props = $props();
const meta = $derived(
[releaseYear ? String(releaseYear) : undefined, plural(trackIds.length, 'canción', 'canciones')]
.filter((part) => part !== undefined)
.join(' · ')
);
</script>
<BackLink
href={isOwner ? '/albums' : '/'}
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
/>
<CollectionHeader eyebrow="Álbum" {title} {description} {meta}>
{#snippet cover()}
<Artwork src={coverUrl} {trackIds} size="hero" alt={title} class="shrink-0" />
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{#if playing}
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
{#if isOwner}
<Button size="sm" variant="secondary" onclick={onEdit}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={onDelete}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</Button>
{/if}
{/snippet}
</CollectionHeader>
@@ -247,24 +247,20 @@
{#if !data.query} {#if !data.query}
<PageHeader <PageHeader
title="Descubrir" title="Descubrir"
subtitle="Encuentra tu próxima canción favorita explorando por género." description="Encuentra tu próxima canción favorita explorando por género."
/> />
{:else if !nothingFound} {:else if !nothingFound}
<div> <PageHeader
<div class="mb-2.5 text-sm font-semibold tracking-[0.16em] text-fg-3 uppercase"> eyebrow="Resultados de búsqueda"
Resultados de búsqueda title="Resultados para «{data.query}»"
</div> description="{plural(
<h1 totalHits,
class="font-display text-2xl font-semibold tracking-[-0.03em] text-pretty text-fg sm:text-3xl" 'coincidencia',
> 'coincidencias'
Resultados para «{data.query}» )} en canciones, álbumes, playlists y usuarios"
</h1> />
<p class="mt-1.75 text-sm text-fg-2">
{plural(totalHits, 'coincidencia', 'coincidencias')} en canciones, álbumes, playlists y usuarios
</p>
</div>
<div class="mt-5 flex flex-wrap gap-1.75"> <div class="mb-7 flex flex-wrap gap-1.75 sm:mb-8">
{#each searchChips as chip (chip.label)} {#each searchChips as chip (chip.label)}
<button <button
type="button" type="button"
@@ -355,11 +351,11 @@
{/if} {/if}
{#if nothingFound} {#if nothingFound}
<div class="mx-auto max-w-115 py-[8vh] text-center"> <div class="mx-auto max-w-prose-sm py-[8vh] text-center">
<h1 class="font-display text-xl font-semibold tracking-tight text-fg"> <h1 class="text-display-3 text-fg">
Sin resultados para «{data.query}» Sin resultados para «{data.query}»
</h1> </h1>
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2"> <p class="mt-2.5 text-body text-fg-2">
Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o
por playlist. por playlist.
</p> </p>
@@ -378,7 +374,7 @@
{#if !data.query} {#if !data.query}
<!-- GÉNEROS --> <!-- GÉNEROS -->
<div class="mt-7 grid-wide"> <div class="grid-wide">
{#each genreTiles as genre, i (genre.query)} {#each genreTiles as genre, i (genre.query)}
<a <a
href={searchHref(genre.query)} href={searchHref(genre.query)}
@@ -399,14 +395,7 @@
{#if showGroup('Canciones')} {#if showGroup('Canciones')}
<div> <div>
{#if songRows.length > 0} {#if songRows.length > 0}
<SectionHeading title="Canciones"> <SectionHeading title="Canciones" count={searchCounts.Canciones} />
{#snippet actions()}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{plural(searchCounts.Canciones, 'resultado', 'resultados')}
</span>
{/snippet}
</SectionHeading>
<ul class="flex flex-col gap-1"> <ul class="flex flex-col gap-1">
{#each capped(songRows) as { target: item, seconds }, i (targetId(item))} {#each capped(songRows) as { target: item, seconds }, i (targetId(item))}
<li> <li>
@@ -450,14 +439,7 @@
{#if hasAlbums && showGroup('Álbumes')} {#if hasAlbums && showGroup('Álbumes')}
<div> <div>
<SectionHeading title="Álbumes"> <SectionHeading title="Álbumes" count={searchCounts.Álbumes} />
{#snippet actions()}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{plural(searchCounts.Álbumes, 'resultado', 'resultados')}
</span>
{/snippet}
</SectionHeading>
<ul class="flex flex-col gap-1"> <ul class="flex flex-col gap-1">
{#each capped(albums) as album (album.id)} {#each capped(albums) as album (album.id)}
<li> <li>
@@ -482,14 +464,7 @@
{#if hasUsers && showGroup('Usuarios')} {#if hasUsers && showGroup('Usuarios')}
<div> <div>
<SectionHeading title="Usuarios"> <SectionHeading title="Usuarios" count={searchCounts.Usuarios} />
{#snippet actions()}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{plural(searchCounts.Usuarios, 'resultado', 'resultados')}
</span>
{/snippet}
</SectionHeading>
<ul class="flex flex-col gap-1"> <ul class="flex flex-col gap-1">
{#each capped(users) as u (u.id)} {#each capped(users) as u (u.id)}
<li> <li>
@@ -506,14 +481,7 @@
{#if hasPlaylists && showGroup('Playlists')} {#if hasPlaylists && showGroup('Playlists')}
<div> <div>
<SectionHeading title="Playlists"> <SectionHeading title="Playlists" count={searchCounts.Playlists} />
{#snippet actions()}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{plural(searchCounts.Playlists, 'resultado', 'resultados')}
</span>
{/snippet}
</SectionHeading>
<ul class="flex flex-col gap-1"> <ul class="flex flex-col gap-1">
{#each capped(playlistMatches) as playlist (playlist.id)} {#each capped(playlistMatches) as playlist (playlist.id)}
<li> <li>
@@ -29,7 +29,7 @@
<Page> <Page>
<PageHeader <PageHeader
title="Tus canciones subidas" title="Tus canciones subidas"
subtitle="{plural(total, 'canción', 'canciones')} subida{total === 1 ? '' : 's'}." description="{plural(total, 'canción', 'canciones')} subida{total === 1 ? '' : 's'}."
> >
{#snippet actions()} {#snippet actions()}
{#if items.length > 0} {#if items.length > 0}
@@ -47,7 +47,6 @@
columns={['uploaded', 'plays']} columns={['uploaded', 'plays']}
onPlay={togglePlay} onPlay={togglePlay}
rowAction={{ action: '?/deleteTrack', icon: X, label: 'Borrar canción' }} rowAction={{ action: '?/deleteTrack', icon: X, label: 'Borrar canción' }}
class="mt-7 sm:mt-8"
/> />
{:else} {:else}
<EmptyState <EmptyState
@@ -5,7 +5,7 @@
import { liked } from '$lib/player/liked.svelte'; import { liked } from '$lib/player/liked.svelte';
import { plural } from '$lib/format'; import { plural } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte'; import Page from '$lib/components/ui/Page.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte'; import TrackList from '$lib/components/ui/TrackList.svelte';
@@ -29,7 +29,7 @@
</svelte:head> </svelte:head>
<Page> <Page>
<CollectionHeader <PageHeader
eyebrow="Colección" eyebrow="Colección"
title="Me gusta" title="Me gusta"
meta={plural(tracks.length, 'canción', 'canciones')} meta={plural(tracks.length, 'canción', 'canciones')}
@@ -37,9 +37,9 @@
> >
{#snippet cover()} {#snippet cover()}
<div <div
class="grid h-36 w-36 shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:h-41 sm:w-41" class="grid size-cover-hero shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:size-cover-hero-sm"
> >
<Heart class="h-10 w-10 text-on-art/90" fill="currentColor" strokeWidth={0} /> <Heart class="size-icon-xl text-on-art/90" fill="currentColor" strokeWidth={0} />
</div> </div>
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
@@ -47,7 +47,7 @@
{isCurrentQueue && player.playing ? 'Pausar' : 'Reproducir'} {isCurrentQueue && player.playing ? 'Pausar' : 'Reproducir'}
</Button> </Button>
{/snippet} {/snippet}
</CollectionHeader> </PageHeader>
{#if tracks.length > 0} {#if tracks.length > 0}
<TrackList <TrackList
@@ -59,7 +59,6 @@
icon: X, icon: X,
label: 'Quitar de Me gusta' label: 'Quitar de Me gusta'
}} }}
class="mt-6 sm:mt-8"
/> />
{:else} {:else}
<EmptyState <EmptyState
@@ -9,7 +9,7 @@
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import Alert from '$lib/components/ui/Alert.svelte'; import Alert from '$lib/components/ui/Alert.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; import PageHeader from '$lib/components/ui/PageHeader.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte'; import TrackList from '$lib/components/ui/TrackList.svelte';
import ContextMenu from '$lib/components/ui/ContextMenu.svelte'; import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus'; import ListPlus from '@lucide/svelte/icons/list-plus';
@@ -53,7 +53,7 @@
<Page> <Page>
<BackLink href="/" label="Volver al inicio" /> <BackLink href="/" label="Volver al inicio" />
<CollectionHeader <PageHeader
eyebrow="Mezcla" eyebrow="Mezcla"
title={mix.title} title={mix.title}
description={mix.subtitle ?? undefined} description={mix.subtitle ?? undefined}
@@ -87,10 +87,10 @@
Aleatorio Aleatorio
</Button> </Button>
{/snippet} {/snippet}
</CollectionHeader> </PageHeader>
{#if form?.message} {#if form?.message}
<Alert tone="danger" class="mt-4">{form.message}</Alert> <Alert tone="danger" class="mb-6">{form.message}</Alert>
{/if} {/if}
<TrackList <TrackList
@@ -98,7 +98,6 @@
columns={['plays']} columns={['plays']}
onPlay={playFrom} onPlay={playFrom}
oncontextmenu={(e, _track, i) => trackMenu.open(e, targets[i])} oncontextmenu={(e, _track, i) => trackMenu.open(e, targets[i])}
class="mt-6 sm:mt-8"
/> />
</Page> </Page>
@@ -25,7 +25,7 @@
<Page> <Page>
<PageHeader <PageHeader
title="Mis listas" title="Mis listas"
subtitle={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'} description={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
> >
{#snippet actions()} {#snippet actions()}
{#if items.length > 0} {#if items.length > 0}
@@ -35,7 +35,7 @@
</PageHeader> </PageHeader>
{#if items.length > 0} {#if items.length > 0}
<div class="mt-7 grid-cards-lg sm:mt-8"> <div class="grid-cards-lg">
{#each items as playlist, i (playlist.id)} {#each items as playlist, i (playlist.id)}
<MediaCard <MediaCard
href="/playlists/{playlist.id}" href="/playlists/{playlist.id}"
@@ -1,14 +1,21 @@
<script lang="ts"> <script lang="ts">
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte'; import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import Page from '$lib/components/ui/Page.svelte'; import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Modal from '$lib/components/ui/Modal.svelte'; import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte'; import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte'; import TrackList from '$lib/components/ui/TrackList.svelte';
import PlaylistHeader from './components/PlaylistHeader.svelte'; import { playlistMeta } from '$lib/format';
let { data, form } = $props(); let { data, form } = $props();
@@ -36,18 +43,46 @@
</svelte:head> </svelte:head>
<Page> <Page>
<PlaylistHeader <PageHeader
id={playlist.id} eyebrow="Lista"
name={playlist.name} title={playlist.name}
description={playlist.description} description={playlist.description ?? undefined}
meta="{playlistMeta(tracks.length)} · {playlist.visibility === 'Public'
? 'Pública'
: 'Privada'}"
align="center"
>
{#snippet cover()}
<Artwork
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={tracks.map((t) => t.id)} trackIds={tracks.map((t) => t.id)}
updatedAt={playlist.updatedAt} size="hero"
visibility={playlist.visibility} alt={playlist.name}
playing={isCurrentQueue && player.playing} class="shrink-0"
onPlayAll={playAll}
onEdit={() => (editing = true)}
onDelete={() => (confirmingDelete = true)}
/> />
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</Button>
{/snippet}
</PageHeader>
{#if tracks.length > 0} {#if tracks.length > 0}
<TrackList <TrackList
@@ -55,14 +90,12 @@
columns={['added', 'plays']} columns={['added', 'plays']}
onPlay={playFrom} onPlay={playFrom}
rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }} rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }}
class="mt-6 sm:mt-8"
/> />
{:else} {:else}
<EmptyState <EmptyState
icon={ListMusic} icon={ListMusic}
title="Esta playlist todavía está vacía" title="Esta playlist todavía está vacía"
description="Añade canciones desde tu biblioteca o explora música nueva para empezar." description="Añade canciones desde tu biblioteca o explora música nueva para empezar."
class="mt-6 sm:mt-8"
> >
{#snippet actions()} {#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button> <Button href="/explore" variant="secondary">Explorar música</Button>
@@ -88,18 +121,11 @@
/> />
</Modal> </Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm"> <ConfirmDialog
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg"> open={confirmingDelete}
¿Eliminar «{playlist.name}»? onClose={() => (confirmingDelete = false)}
</h2> title="¿Eliminar «{playlist.name}»?"
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2"> description="Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los dispositivos donde la tengas guardada."
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los confirmLabel="Eliminar"
dispositivos donde la tengas guardada. action="?/delete"
</p> />
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
<form method="POST" action="?/delete">
<Button type="submit" variant="danger">Eliminar</Button>
</form>
</div>
</Modal>
@@ -1,63 +0,0 @@
<script lang="ts">
import Artwork from '$lib/components/ui/Artwork.svelte';
import Button from '$lib/components/ui/Button.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import { plural } from '$lib/format';
interface Props {
id: string;
name: string;
description?: string | null;
trackIds: (string | number)[];
updatedAt: string;
visibility: 'Public' | 'Private';
playing: boolean;
onPlayAll: () => void;
onEdit: () => void;
onDelete: () => void;
}
let {
id,
name,
description,
trackIds,
updatedAt,
visibility,
playing,
onPlayAll,
onEdit,
onDelete
}: Props = $props();
const meta = $derived(
`${plural(trackIds.length, 'canción', 'canciones')} · ${
visibility === 'Public' ? 'Pública' : 'Privada'
}`
);
</script>
<CollectionHeader
eyebrow="Lista"
title={name}
description={description ?? undefined}
{meta}
align="center"
>
{#snippet cover()}
<Artwork
src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
{trackIds}
size="hero"
alt={name}
class="shrink-0"
/>
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{playing ? 'Pausar' : 'Reproducir'}
</Button>
<Button size="sm" variant="secondary" onclick={onEdit}>Editar</Button>
<Button size="sm" variant="secondary" onclick={onDelete}>Eliminar</Button>
{/snippet}
</CollectionHeader>
@@ -7,7 +7,7 @@
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import Avatar from '$lib/components/ui/Avatar.svelte'; import Avatar from '$lib/components/ui/Avatar.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; import PageHeader from '$lib/components/ui/PageHeader.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { plural, playlistMeta } from '$lib/format'; import { plural, playlistMeta } from '$lib/format';
@@ -55,7 +55,7 @@
{/snippet} {/snippet}
<Page> <Page>
<CollectionHeader <PageHeader
eyebrow="Perfil" eyebrow="Perfil"
title={profile.name} title={profile.name}
meta="{plural( meta="{plural(
@@ -69,9 +69,9 @@
{#snippet cover()} {#snippet cover()}
<Avatar name={profile.name} src={profile.profilePictureUrl} size="hero" /> <Avatar name={profile.name} src={profile.profilePictureUrl} size="hero" />
{/snippet} {/snippet}
</CollectionHeader> </PageHeader>
<div class="mt-9"> <div>
<SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" /> <SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" />
{#if playlists.length > 0} {#if playlists.length > 0}
@@ -103,10 +103,10 @@
<Page> <Page>
<PageHeader <PageHeader
title="Subir música" title="Subir música"
subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming." description="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
/> />
<ol class="mt-7 grid gap-3 sm:mt-8 sm:grid-cols-3 sm:gap-4"> <ol class="grid gap-3 sm:grid-cols-3 sm:gap-4">
{#each steps as step, i (step.key)} {#each steps as step, i (step.key)}
<li> <li>
<Surface padding="sm" class={stepClass(stepStates[i])}> <Surface padding="sm" class={stepClass(stepStates[i])}>
+3 -3
View File
@@ -3,11 +3,11 @@
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
</script> </script>
<div class="animate-enter mx-auto my-[14vh] max-w-105 text-center"> <div class="animate-enter mx-auto my-[14vh] max-w-prose-sm text-center">
<h1 class="mb-2.5 font-display text-xl font-semibold tracking-[-0.02em] text-fg"> <h1 class="mb-2.5 text-display-3 text-fg">
{page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'} {page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'}
</h1> </h1>
<p class="mb-6.5 text-sm leading-[1.65] text-fg-2"> <p class="mb-6.5 text-body text-fg-2">
{page.status === 404 {page.status === 404
? 'Comprueba el enlace o vuelve al inicio.' ? 'Comprueba el enlace o vuelve al inicio.'
: 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'} : 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'}
+27 -40
View File
@@ -118,6 +118,18 @@
} }
</script> </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> <svelte:head>
<title>Musify</title> <title>Musify</title>
<meta name="description" content="Tu música, sin límites." /> <meta name="description" content="Tu música, sin límites." />
@@ -130,13 +142,13 @@
<div <div
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between" 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-135 min-w-65"> <div class="animate-enter max-w-hero min-w-65">
<h1 <h1
class="font-display text-3xl leading-tight font-medium tracking-tight text-pretty text-fg sm:text-4xl" class="font-display text-3xl leading-tight font-medium tracking-tight text-pretty text-fg sm:text-4xl"
> >
{data.greeting} {data.greeting}
</h1> </h1>
<p class="mt-3.5 max-w-108 text-sm leading-[1.65] text-fg-2"> <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. Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.
</p> </p>
<div class="mt-6.5 flex flex-wrap items-center gap-2.5"> <div class="mt-6.5 flex flex-wrap items-center gap-2.5">
@@ -153,19 +165,19 @@
{#await data.listeningStats then stats} {#await data.listeningStats then stats}
<div class="flex gap-6.5"> <div class="flex gap-6.5">
<div> <div>
<div class="font-display text-2xl font-medium tracking-tight text-fg"> <div class="text-display-2 text-fg">
{stats.tracksThisWeek} {stats.tracksThisWeek}
</div> </div>
<div class="mt-1 text-xs text-fg-3">Canciones nuevas escuchadas esta semana</div> <div class="mt-1 text-xs text-fg-3">Canciones nuevas escuchadas esta semana</div>
</div> </div>
<div> <div>
<div class="font-display text-2xl font-medium tracking-tight text-fg"> <div class="text-display-2 text-fg">
{fmtDurationLong(Number(stats.secondsThisWeek))} {fmtDurationLong(Number(stats.secondsThisWeek))}
</div> </div>
<div class="mt-1 text-xs text-fg-3">Tiempo de escucha esta semana</div> <div class="mt-1 text-xs text-fg-3">Tiempo de escucha esta semana</div>
</div> </div>
<div> <div>
<div class="font-display text-2xl font-medium tracking-tight text-fg"> <div class="text-display-2 text-fg">
{stats.streakDays} {stats.streakDays}
</div> </div>
<div class="mt-1 text-xs text-fg-3">Días consecutivos escuchando</div> <div class="mt-1 text-xs text-fg-3">Días consecutivos escuchando</div>
@@ -200,15 +212,7 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<EmptyState {@render noListeningHistory()}
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>
{/if} {/if}
</section> </section>
@@ -236,16 +240,14 @@
class="shrink-0" class="shrink-0"
/> />
<div class="flex min-w-0 flex-col gap-3"> <div class="flex min-w-0 flex-col gap-3">
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase"> <p class="text-eyebrow text-fg-2">Playlist destacada</p>
Playlist destacada <h3 class="truncate text-display-2 text-fg">
</p>
<h3 class="truncate font-display text-2xl font-medium text-fg">
{spotlight.name} {spotlight.name}
</h3> </h3>
<p class="text-sm text-fg-2">{spotlightMeta}</p> <p class="text-sm text-fg-2">{spotlightMeta}</p>
</div> </div>
</div> </div>
<p class="mt-5 max-w-110 text-sm text-fg-2"> <p class="mt-5 max-w-prose-sm text-sm text-fg-2">
{spotlight.description || 'Tu colección, siempre a mano.'} {spotlight.description || 'Tu colección, siempre a mano.'}
</p> </p>
<div class="mt-5 flex gap-2.5"> <div class="mt-5 flex gap-2.5">
@@ -340,32 +342,17 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<EmptyState {@render noListeningHistory()}
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>
{/if} {/if}
</section> </section>
<!-- TUS PLAYLISTS --> <!-- TUS PLAYLISTS -->
<section> <section>
<SectionHeading title="Tus playlists" subtitle="Creadas y guardadas por ti"> <SectionHeading
{#snippet actions()} title="Tus playlists"
{#if data.playlistsHasMore} subtitle="Creadas y guardadas por ti"
<a href={data.playlistsHasMore ? '/playlists' : undefined}
href="/playlists" />
class="shrink-0 text-xs font-medium tracking-wider text-fg-3 uppercase transition-colors hover:text-fg"
>
Ver todas
</a>
{/if}
{/snippet}
</SectionHeading>
{#if playlists.length > 0} {#if playlists.length > 0}
<div class="grid-wide"> <div class="grid-wide">
{#each playlists as playlist, i (playlist.id)} {#each playlists as playlist, i (playlist.id)}