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:
@@ -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,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.'}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user