web-player: headers, dialogs and typography

PageHeader absorbs CollectionHeader
(title/eyebrow/description/meta/cover?/actions?/align?): without a
cover it behaves like the old simple PageHeader (inline actions),
with a cover like the old CollectionHeader (actions below). It sets
its own bottom margin (mb-7/8); the mt-* each page used to add
afterward are removed. Migrates the 9 places that used either one
(albums, library, playlists, upload, Explore x2, liked, mix,
profile). CollectionHeader is removed.

AlbumHeader and PlaylistHeader are inlined into their pages and
removed. Decision (recommended, not blocking): PlaylistHeader gains
icons on Edit/Delete/Play, matching what AlbumHeader already had.

New ConfirmDialog replaces the two delete dialogs (album and
playlist), which had different typography. Modal now uses
.glass-panel (instead of duplicating the inline style=) and
z-(--z-modal); its eyebrow and title use the
text-eyebrow/text-display-3 recipes.

SectionHeading gains `count?` (a "N results" line, replacing 4 copies
in Explore) and `href?`/`linkLabel?` ("See all", replacing Home's).
Applies typography recipes (text-display-2/3, text-eyebrow,
text-body) and width tokens (max-w-prose-sm, max-w-hero) across
EmptyState, +error, "No results", and the Home/Queue/Sidebar hero and
stats. The "You haven't listened to anything yet" empty state
(duplicated twice on Home) becomes a local snippet.

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
This commit is contained in:
2026-09-21 00:48:23 +02:00
parent 964dfc809f
commit 1bdd7ec9f1
24 changed files with 310 additions and 388 deletions
@@ -23,7 +23,7 @@
</svelte:head>
<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()}
{#if items.length > 0}
<Button onclick={() => (creating = true)}>
@@ -35,7 +35,7 @@
</PageHeader>
{#if items.length > 0}
<div class="mt-7 grid-cards sm:mt-8">
<div class="grid-cards">
{#each items as album, i (album.id)}
<MediaCard
href="/albums/{album.id}"
@@ -2,14 +2,22 @@
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import X from '@lucide/svelte/icons/x';
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 PageHeader from '$lib/components/ui/PageHeader.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 Artwork from '$lib/components/ui/Artwork.svelte';
import AlbumForm from '$lib/components/ui/AlbumForm.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 TrackList from '$lib/components/ui/TrackList.svelte';
import AlbumHeader from './components/AlbumHeader.svelte';
import { albumMeta } from '$lib/format';
let { data, form } = $props();
@@ -42,21 +50,54 @@
</svelte:head>
<Page>
<AlbumHeader
title={album.title}
description={album.description ?? undefined}
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)}
<BackLink
href={isOwner ? '/albums' : '/'}
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
/>
<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}
<Alert tone="danger" class="mt-6">{form.message}</Alert>
<Alert tone="danger" class="mb-6">{form.message}</Alert>
{/if}
{#if tracks.length > 0}
@@ -66,7 +107,6 @@
rowAction={isOwner
? { action: '?/removeTrack', icon: X, label: 'Quitar del álbum' }
: undefined}
class="mt-6 sm:mt-8"
/>
{/if}
@@ -98,17 +138,11 @@
/>
</Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<h2 class="text-lg font-semibold tracking-tight text-fg">
¿Eliminar «{album.title}»?
</h2>
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca.
</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>
<ConfirmDialog
open={confirmingDelete}
onClose={() => (confirmingDelete = false)}
title="¿Eliminar «{album.title}»?"
description="Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca."
confirmLabel="Eliminar"
action="?/delete"
/>
@@ -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}
<PageHeader
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}
<div>
<div class="mb-2.5 text-sm font-semibold tracking-[0.16em] text-fg-3 uppercase">
Resultados de búsqueda
</div>
<h1
class="font-display text-2xl font-semibold tracking-[-0.03em] text-pretty text-fg sm:text-3xl"
>
Resultados para «{data.query}»
</h1>
<p class="mt-1.75 text-sm text-fg-2">
{plural(totalHits, 'coincidencia', 'coincidencias')} en canciones, álbumes, playlists y usuarios
</p>
</div>
<PageHeader
eyebrow="Resultados de búsqueda"
title="Resultados para «{data.query}»"
description="{plural(
totalHits,
'coincidencia',
'coincidencias'
)} en canciones, álbumes, playlists y usuarios"
/>
<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)}
<button
type="button"
@@ -355,11 +351,11 @@
{/if}
{#if nothingFound}
<div class="mx-auto max-w-115 py-[8vh] text-center">
<h1 class="font-display text-xl font-semibold tracking-tight text-fg">
<div class="mx-auto max-w-prose-sm py-[8vh] text-center">
<h1 class="text-display-3 text-fg">
Sin resultados para «{data.query}»
</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
por playlist.
</p>
@@ -378,7 +374,7 @@
{#if !data.query}
<!-- GÉNEROS -->
<div class="mt-7 grid-wide">
<div class="grid-wide">
{#each genreTiles as genre, i (genre.query)}
<a
href={searchHref(genre.query)}
@@ -399,14 +395,7 @@
{#if showGroup('Canciones')}
<div>
{#if songRows.length > 0}
<SectionHeading title="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>
<SectionHeading title="Canciones" count={searchCounts.Canciones} />
<ul class="flex flex-col gap-1">
{#each capped(songRows) as { target: item, seconds }, i (targetId(item))}
<li>
@@ -450,14 +439,7 @@
{#if hasAlbums && showGroup('Álbumes')}
<div>
<SectionHeading title="Á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>
<SectionHeading title="Álbumes" count={searchCounts.Álbumes} />
<ul class="flex flex-col gap-1">
{#each capped(albums) as album (album.id)}
<li>
@@ -482,14 +464,7 @@
{#if hasUsers && showGroup('Usuarios')}
<div>
<SectionHeading title="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>
<SectionHeading title="Usuarios" count={searchCounts.Usuarios} />
<ul class="flex flex-col gap-1">
{#each capped(users) as u (u.id)}
<li>
@@ -506,14 +481,7 @@
{#if hasPlaylists && showGroup('Playlists')}
<div>
<SectionHeading title="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>
<SectionHeading title="Playlists" count={searchCounts.Playlists} />
<ul class="flex flex-col gap-1">
{#each capped(playlistMatches) as playlist (playlist.id)}
<li>
@@ -29,7 +29,7 @@
<Page>
<PageHeader
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()}
{#if items.length > 0}
@@ -47,7 +47,6 @@
columns={['uploaded', 'plays']}
onPlay={togglePlay}
rowAction={{ action: '?/deleteTrack', icon: X, label: 'Borrar canción' }}
class="mt-7 sm:mt-8"
/>
{:else}
<EmptyState
@@ -5,7 +5,7 @@
import { liked } from '$lib/player/liked.svelte';
import { plural } from '$lib/format';
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 EmptyState from '$lib/components/ui/EmptyState.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte';
@@ -29,7 +29,7 @@
</svelte:head>
<Page>
<CollectionHeader
<PageHeader
eyebrow="Colección"
title="Me gusta"
meta={plural(tracks.length, 'canción', 'canciones')}
@@ -37,9 +37,9 @@
>
{#snippet cover()}
<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>
{/snippet}
{#snippet actions()}
@@ -47,7 +47,7 @@
{isCurrentQueue && player.playing ? 'Pausar' : 'Reproducir'}
</Button>
{/snippet}
</CollectionHeader>
</PageHeader>
{#if tracks.length > 0}
<TrackList
@@ -59,7 +59,6 @@
icon: X,
label: 'Quitar de Me gusta'
}}
class="mt-6 sm:mt-8"
/>
{:else}
<EmptyState
@@ -9,7 +9,7 @@
import Button from '$lib/components/ui/Button.svelte';
import Alert from '$lib/components/ui/Alert.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 ContextMenu from '$lib/components/ui/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus';
@@ -53,7 +53,7 @@
<Page>
<BackLink href="/" label="Volver al inicio" />
<CollectionHeader
<PageHeader
eyebrow="Mezcla"
title={mix.title}
description={mix.subtitle ?? undefined}
@@ -87,10 +87,10 @@
Aleatorio
</Button>
{/snippet}
</CollectionHeader>
</PageHeader>
{#if form?.message}
<Alert tone="danger" class="mt-4">{form.message}</Alert>
<Alert tone="danger" class="mb-6">{form.message}</Alert>
{/if}
<TrackList
@@ -98,7 +98,6 @@
columns={['plays']}
onPlay={playFrom}
oncontextmenu={(e, _track, i) => trackMenu.open(e, targets[i])}
class="mt-6 sm:mt-8"
/>
</Page>
@@ -25,7 +25,7 @@
<Page>
<PageHeader
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()}
{#if items.length > 0}
@@ -35,7 +35,7 @@
</PageHeader>
{#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)}
<MediaCard
href="/playlists/{playlist.id}"
@@ -1,14 +1,21 @@
<script lang="ts">
import ListMusic from '@lucide/svelte/icons/list-music';
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 Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.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 Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte';
import PlaylistHeader from './components/PlaylistHeader.svelte';
import { playlistMeta } from '$lib/format';
let { data, form } = $props();
@@ -36,18 +43,46 @@
</svelte:head>
<Page>
<PlaylistHeader
id={playlist.id}
name={playlist.name}
description={playlist.description}
trackIds={tracks.map((t) => t.id)}
updatedAt={playlist.updatedAt}
visibility={playlist.visibility}
playing={isCurrentQueue && player.playing}
onPlayAll={playAll}
onEdit={() => (editing = true)}
onDelete={() => (confirmingDelete = true)}
/>
<PageHeader
eyebrow="Lista"
title={playlist.name}
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)}
size="hero"
alt={playlist.name}
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>
<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}
<TrackList
@@ -55,14 +90,12 @@
columns={['added', 'plays']}
onPlay={playFrom}
rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }}
class="mt-6 sm:mt-8"
/>
{:else}
<EmptyState
icon={ListMusic}
title="Esta playlist todavía está vacía"
description="Añade canciones desde tu biblioteca o explora música nueva para empezar."
class="mt-6 sm:mt-8"
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
@@ -88,18 +121,11 @@
/>
</Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
¿Eliminar «{playlist.name}»?
</h2>
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
dispositivos donde la tengas guardada.
</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>
<ConfirmDialog
open={confirmingDelete}
onClose={() => (confirmingDelete = false)}
title="¿Eliminar «{playlist.name}»?"
description="Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los dispositivos donde la tengas guardada."
confirmLabel="Eliminar"
action="?/delete"
/>
@@ -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 Button from '$lib/components/ui/Button.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 { plural, playlistMeta } from '$lib/format';
@@ -55,7 +55,7 @@
{/snippet}
<Page>
<CollectionHeader
<PageHeader
eyebrow="Perfil"
title={profile.name}
meta="{plural(
@@ -69,9 +69,9 @@
{#snippet cover()}
<Avatar name={profile.name} src={profile.profilePictureUrl} size="hero" />
{/snippet}
</CollectionHeader>
</PageHeader>
<div class="mt-9">
<div>
<SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" />
{#if playlists.length > 0}
@@ -103,10 +103,10 @@
<Page>
<PageHeader
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)}
<li>
<Surface padding="sm" class={stepClass(stepStates[i])}>