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