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
+1 -1
View File
@@ -76,7 +76,7 @@
{#if user}
<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">
<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}
<IconButton
label="Crear playlist"
@@ -29,7 +29,7 @@
</div>
{#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">
<MediaIdentity
trackId={player.current.id}
@@ -42,9 +42,7 @@
{/if}
<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">
A continuación
</span>
<span class="min-w-0 flex-1 truncate text-eyebrow text-fg-3"> A continuación </span>
{#if upcoming.length > 0}
<button
type="button"
@@ -27,7 +27,7 @@
></div>
</button>
{: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">
{fmtTime(player.progress)}
</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}">
{#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 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}
</h3>
{/if}
@@ -70,14 +70,13 @@
{#if open}
<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"
onclick={onBackdropClick}
>
<div
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}"
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
class="glass-panel animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
role="dialog"
aria-modal="true"
aria-label={title}
@@ -87,11 +86,11 @@
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
{#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}
</p>
{/if}
<h2 class="font-display text-xl font-medium tracking-[-0.02em] text-fg">
<h2 class="text-display-3 text-fg">
{title}
</h2>
</div>
@@ -3,24 +3,59 @@
interface Props {
title: string;
subtitle?: string;
class?: string;
eyebrow?: string;
description?: string;
meta?: string;
cover?: 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>
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
<div class="min-w-0">
<h1 class="font-display text-3xl font-medium tracking-tight text-fg sm:text-4xl">
{title}
</h1>
{#if subtitle}
<p class="mt-2 text-sm text-fg-2">{subtitle}</p>
<div class="mb-7 sm:mb-8 {klass}">
<div
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}
</h1>
{#if description}
<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}
</div>
{#if actions && !cover}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{/if}
</div>
{#if actions}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{#if actions && cover}
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
{@render actions()}
</div>
{/if}
</div>
@@ -1,18 +1,32 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { plural } from '$lib/format';
interface Props {
title: string;
subtitle?: string;
count?: number;
href?: string;
linkLabel?: string;
class?: string;
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>
{#snippet heading()}
<h2 class="font-display text-lg font-medium tracking-tight text-fg">
<h2 class="text-display-4 text-fg">
{title}
</h2>
{#if subtitle}
@@ -20,10 +34,31 @@
{/if}
{/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>{@render heading()}</div>
{@render actions()}
{#if actions}
{@render actions()}
{:else}
{@render autoActions()}
{/if}
</div>
{:else}
<div class="mb-4 sm:mb-4.5 {klass}">
@@ -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])}>
+3 -3
View File
@@ -3,11 +3,11 @@
import Button from '$lib/components/ui/Button.svelte';
</script>
<div class="animate-enter mx-auto my-[14vh] max-w-105 text-center">
<h1 class="mb-2.5 font-display text-xl font-semibold tracking-[-0.02em] text-fg">
<div class="animate-enter mx-auto my-[14vh] max-w-prose-sm text-center">
<h1 class="mb-2.5 text-display-3 text-fg">
{page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'}
</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
? 'Comprueba el enlace o vuelve al inicio.'
: 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'}
+27 -40
View File
@@ -118,6 +118,18 @@
}
</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." />
@@ -130,13 +142,13 @@
<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-135 min-w-65">
<div class="animate-enter max-w-hero min-w-65">
<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-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.
</p>
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
@@ -153,19 +165,19 @@
{#await data.listeningStats then stats}
<div class="flex gap-6.5">
<div>
<div class="font-display text-2xl font-medium tracking-tight text-fg">
<div class="text-display-2 text-fg">
{stats.tracksThisWeek}
</div>
<div class="mt-1 text-xs text-fg-3">Canciones nuevas escuchadas esta semana</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))}
</div>
<div class="mt-1 text-xs text-fg-3">Tiempo de escucha esta semana</div>
</div>
<div>
<div class="font-display text-2xl font-medium tracking-tight text-fg">
<div class="text-display-2 text-fg">
{stats.streakDays}
</div>
<div class="mt-1 text-xs text-fg-3">Días consecutivos escuchando</div>
@@ -200,15 +212,7 @@
{/each}
</div>
{:else}
<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>
{@render noListeningHistory()}
{/if}
</section>
@@ -236,16 +240,14 @@
class="shrink-0"
/>
<div class="flex min-w-0 flex-col gap-3">
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">
Playlist destacada
</p>
<h3 class="truncate font-display text-2xl font-medium text-fg">
<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-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.'}
</p>
<div class="mt-5 flex gap-2.5">
@@ -340,32 +342,17 @@
{/each}
</div>
{:else}
<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>
{@render noListeningHistory()}
{/if}
</section>
<!-- TUS PLAYLISTS -->
<section>
<SectionHeading title="Tus playlists" subtitle="Creadas y guardadas por ti">
{#snippet actions()}
{#if data.playlistsHasMore}
<a
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>
<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)}