web-player: P10 — cabeceras, diálogos y tipografía
PageHeader fusiona CollectionHeader (title/eyebrow/description/meta/
cover?/actions?/align?): sin cover se comporta como el antiguo
PageHeader simple (acciones en línea), con cover como el antiguo
CollectionHeader (acciones debajo). Fija su propio margen inferior
(mb-7/8); se quitan los mt-* que ponía cada página después. Migra los
9 sitios que usaban uno u otro (álbumes, biblioteca, listas, subir,
Explorar ×2, me gusta, mezcla, perfil). CollectionHeader se borra.
AlbumHeader y PlaylistHeader se inlinean en sus páginas y se borran.
Decisión (recomendada, sin bloquear): PlaylistHeader gana iconos en
Editar/Eliminar/Reproducir, igual que ya tenía AlbumHeader.
ConfirmDialog nuevo sustituye los dos diálogos de borrado (álbum y
playlist), que tenían tipografías distintas. Modal pasa a usar
.glass-panel (en vez de duplicar el style= inline) y z-(--z-modal); su
eyebrow y título usan las recetas text-eyebrow/text-display-3.
SectionHeading gana `count?` (línea "N resultados", sustituye 4 copias
en Explorar) y `href?`/`linkLabel?` ("Ver todas", sustituye la de
Inicio). Aplica recetas de tipografía (text-display-2/3,
text-eyebrow, text-body) y tokens de ancho (max-w-prose-sm, max-w-hero)
en EmptyState, +error, "Sin resultados", hero y stats de Inicio, Queue
y Sidebar. El estado vacío "Todavía no has escuchado nada" (repetido
2 veces en Inicio) pasa a un snippet local.
Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
|
{#if actions && cover}
|
||||||
|
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||||
|
{@render actions()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { plural } from '$lib/format';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
count?: number;
|
||||||
|
href?: string;
|
||||||
|
linkLabel?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
actions?: Snippet;
|
actions?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { title, subtitle, class: klass = '', actions }: Props = $props();
|
let {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
count,
|
||||||
|
href,
|
||||||
|
linkLabel = 'Ver todas',
|
||||||
|
class: klass = '',
|
||||||
|
actions
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const hasActions = $derived(!!actions || count !== undefined || !!href);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet heading()}
|
{#snippet heading()}
|
||||||
<h2 class="font-display text-lg font-medium tracking-tight text-fg">
|
<h2 class="text-display-4 text-fg">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
@@ -20,10 +34,31 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if actions}
|
{#snippet autoActions()}
|
||||||
|
{#if count !== undefined}
|
||||||
|
<div class="h-px flex-1 self-center bg-line"></div>
|
||||||
|
<span class="shrink-0 text-xs text-muted tabular-nums">
|
||||||
|
{plural(count, 'resultado', 'resultados')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if href}
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="shrink-0 text-xs font-medium tracking-wider text-fg-3 uppercase transition-colors hover:text-fg"
|
||||||
|
>
|
||||||
|
{linkLabel}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if hasActions}
|
||||||
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
|
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
|
||||||
<div>{@render heading()}</div>
|
<div>{@render heading()}</div>
|
||||||
|
{#if actions}
|
||||||
{@render actions()}
|
{@render actions()}
|
||||||
|
{:else}
|
||||||
|
{@render autoActions()}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mb-4 sm:mb-4.5 {klass}">
|
<div class="mb-4 sm:mb-4.5 {klass}">
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader title="Álbumes" subtitle="Agrupa la música que subes en discos.">
|
<PageHeader title="Álbumes" description="Agrupa la música que subes en discos.">
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<Button onclick={() => (creating = true)}>
|
<Button onclick={() => (creating = true)}>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<div class="mt-7 grid-cards sm:mt-8">
|
<div class="grid-cards">
|
||||||
{#each items as album, i (album.id)}
|
{#each items as album, i (album.id)}
|
||||||
<MediaCard
|
<MediaCard
|
||||||
href="/albums/{album.id}"
|
href="/albums/{album.id}"
|
||||||
|
|||||||
@@ -2,14 +2,22 @@
|
|||||||
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import Play from '@lucide/svelte/icons/play';
|
||||||
|
import Pause from '@lucide/svelte/icons/pause';
|
||||||
|
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
||||||
|
import Trash from '@lucide/svelte/icons/trash';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import Modal from '$lib/components/ui/Modal.svelte';
|
import Modal from '$lib/components/ui/Modal.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||||
import Alert from '$lib/components/ui/Alert.svelte';
|
import Alert from '$lib/components/ui/Alert.svelte';
|
||||||
|
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||||
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
|
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
|
import BackLink from '$lib/components/ui/BackLink.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
import TrackList from '$lib/components/ui/TrackList.svelte';
|
import TrackList from '$lib/components/ui/TrackList.svelte';
|
||||||
import AlbumHeader from './components/AlbumHeader.svelte';
|
import { albumMeta } from '$lib/format';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -42,21 +50,54 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<AlbumHeader
|
<BackLink
|
||||||
title={album.title}
|
href={isOwner ? '/albums' : '/'}
|
||||||
description={album.description ?? undefined}
|
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
|
||||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
|
||||||
trackIds={tracks.map((track) => track.id)}
|
|
||||||
coverUrl="/api/albums/{album.id}/cover?size=large"
|
|
||||||
playing={isCurrentQueue && player.playing}
|
|
||||||
{isOwner}
|
|
||||||
onPlayAll={playAll}
|
|
||||||
onEdit={() => (editing = true)}
|
|
||||||
onDelete={() => (confirmingDelete = true)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Álbum"
|
||||||
|
title={album.title}
|
||||||
|
description={album.description ?? undefined}
|
||||||
|
meta={albumMeta(
|
||||||
|
album.releaseYear === null ? undefined : Number(album.releaseYear),
|
||||||
|
tracks.length
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{#snippet cover()}
|
||||||
|
<Artwork
|
||||||
|
src="/api/albums/{album.id}/cover?size=large"
|
||||||
|
trackIds={tracks.map((track) => track.id)}
|
||||||
|
size="hero"
|
||||||
|
alt={album.title}
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
|
||||||
|
{#if isCurrentQueue && player.playing}
|
||||||
|
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
Pausar
|
||||||
|
{:else}
|
||||||
|
<Play class="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
Reproducir
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
{#if isOwner}
|
||||||
|
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>
|
||||||
|
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
|
||||||
|
<Trash class="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
{#if form?.message}
|
{#if form?.message}
|
||||||
<Alert tone="danger" class="mt-6">{form.message}</Alert>
|
<Alert tone="danger" class="mb-6">{form.message}</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if tracks.length > 0}
|
{#if tracks.length > 0}
|
||||||
@@ -66,7 +107,6 @@
|
|||||||
rowAction={isOwner
|
rowAction={isOwner
|
||||||
? { action: '?/removeTrack', icon: X, label: 'Quitar del álbum' }
|
? { action: '?/removeTrack', icon: X, label: 'Quitar del álbum' }
|
||||||
: undefined}
|
: undefined}
|
||||||
class="mt-6 sm:mt-8"
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -98,17 +138,11 @@
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
|
<ConfirmDialog
|
||||||
<h2 class="text-lg font-semibold tracking-tight text-fg">
|
open={confirmingDelete}
|
||||||
¿Eliminar «{album.title}»?
|
onClose={() => (confirmingDelete = false)}
|
||||||
</h2>
|
title="¿Eliminar «{album.title}»?"
|
||||||
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
|
description="Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca."
|
||||||
Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca.
|
confirmLabel="Eliminar"
|
||||||
</p>
|
action="?/delete"
|
||||||
<div class="mt-6 flex items-center justify-end gap-3">
|
/>
|
||||||
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
|
|
||||||
<form method="POST" action="?/delete">
|
|
||||||
<Button type="submit" variant="danger">Eliminar</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Play from '@lucide/svelte/icons/play';
|
|
||||||
import Pause from '@lucide/svelte/icons/pause';
|
|
||||||
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
|
||||||
import Trash from '@lucide/svelte/icons/trash';
|
|
||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
|
||||||
import BackLink from '$lib/components/ui/BackLink.svelte';
|
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
|
||||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
|
||||||
import { plural } from '$lib/format';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
releaseYear?: number;
|
|
||||||
trackIds: (string | number)[];
|
|
||||||
coverUrl?: string;
|
|
||||||
playing: boolean;
|
|
||||||
isOwner: boolean;
|
|
||||||
onPlayAll: () => void;
|
|
||||||
onEdit: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
releaseYear,
|
|
||||||
trackIds,
|
|
||||||
coverUrl,
|
|
||||||
playing,
|
|
||||||
isOwner,
|
|
||||||
onPlayAll,
|
|
||||||
onEdit,
|
|
||||||
onDelete
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const meta = $derived(
|
|
||||||
[releaseYear ? String(releaseYear) : undefined, plural(trackIds.length, 'canción', 'canciones')]
|
|
||||||
.filter((part) => part !== undefined)
|
|
||||||
.join(' · ')
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<BackLink
|
|
||||||
href={isOwner ? '/albums' : '/'}
|
|
||||||
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CollectionHeader eyebrow="Álbum" {title} {description} {meta}>
|
|
||||||
{#snippet cover()}
|
|
||||||
<Artwork src={coverUrl} {trackIds} size="hero" alt={title} class="shrink-0" />
|
|
||||||
{/snippet}
|
|
||||||
{#snippet actions()}
|
|
||||||
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
|
|
||||||
{#if playing}
|
|
||||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
Pausar
|
|
||||||
{:else}
|
|
||||||
<Play class="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
Reproducir
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{#if isOwner}
|
|
||||||
<Button size="sm" variant="secondary" onclick={onEdit}>
|
|
||||||
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
Editar
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="secondary" onclick={onDelete}>
|
|
||||||
<Trash class="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
Eliminar
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
</CollectionHeader>
|
|
||||||
@@ -247,24 +247,20 @@
|
|||||||
{#if !data.query}
|
{#if !data.query}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Descubrir"
|
title="Descubrir"
|
||||||
subtitle="Encuentra tu próxima canción favorita explorando por género."
|
description="Encuentra tu próxima canción favorita explorando por género."
|
||||||
/>
|
/>
|
||||||
{:else if !nothingFound}
|
{:else if !nothingFound}
|
||||||
<div>
|
<PageHeader
|
||||||
<div class="mb-2.5 text-sm font-semibold tracking-[0.16em] text-fg-3 uppercase">
|
eyebrow="Resultados de búsqueda"
|
||||||
Resultados de búsqueda
|
title="Resultados para «{data.query}»"
|
||||||
</div>
|
description="{plural(
|
||||||
<h1
|
totalHits,
|
||||||
class="font-display text-2xl font-semibold tracking-[-0.03em] text-pretty text-fg sm:text-3xl"
|
'coincidencia',
|
||||||
>
|
'coincidencias'
|
||||||
Resultados para «{data.query}»
|
)} en canciones, álbumes, playlists y usuarios"
|
||||||
</h1>
|
/>
|
||||||
<p class="mt-1.75 text-sm text-fg-2">
|
|
||||||
{plural(totalHits, 'coincidencia', 'coincidencias')} en canciones, álbumes, playlists y usuarios
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 flex flex-wrap gap-1.75">
|
<div class="mb-7 flex flex-wrap gap-1.75 sm:mb-8">
|
||||||
{#each searchChips as chip (chip.label)}
|
{#each searchChips as chip (chip.label)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -355,11 +351,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if nothingFound}
|
{#if nothingFound}
|
||||||
<div class="mx-auto max-w-115 py-[8vh] text-center">
|
<div class="mx-auto max-w-prose-sm py-[8vh] text-center">
|
||||||
<h1 class="font-display text-xl font-semibold tracking-tight text-fg">
|
<h1 class="text-display-3 text-fg">
|
||||||
Sin resultados para «{data.query}»
|
Sin resultados para «{data.query}»
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
|
<p class="mt-2.5 text-body text-fg-2">
|
||||||
Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o
|
Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o
|
||||||
por playlist.
|
por playlist.
|
||||||
</p>
|
</p>
|
||||||
@@ -378,7 +374,7 @@
|
|||||||
|
|
||||||
{#if !data.query}
|
{#if !data.query}
|
||||||
<!-- GÉNEROS -->
|
<!-- GÉNEROS -->
|
||||||
<div class="mt-7 grid-wide">
|
<div class="grid-wide">
|
||||||
{#each genreTiles as genre, i (genre.query)}
|
{#each genreTiles as genre, i (genre.query)}
|
||||||
<a
|
<a
|
||||||
href={searchHref(genre.query)}
|
href={searchHref(genre.query)}
|
||||||
@@ -399,14 +395,7 @@
|
|||||||
{#if showGroup('Canciones')}
|
{#if showGroup('Canciones')}
|
||||||
<div>
|
<div>
|
||||||
{#if songRows.length > 0}
|
{#if songRows.length > 0}
|
||||||
<SectionHeading title="Canciones">
|
<SectionHeading title="Canciones" count={searchCounts.Canciones} />
|
||||||
{#snippet actions()}
|
|
||||||
<div class="h-px flex-1 self-center bg-line"></div>
|
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums">
|
|
||||||
{plural(searchCounts.Canciones, 'resultado', 'resultados')}
|
|
||||||
</span>
|
|
||||||
{/snippet}
|
|
||||||
</SectionHeading>
|
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each capped(songRows) as { target: item, seconds }, i (targetId(item))}
|
{#each capped(songRows) as { target: item, seconds }, i (targetId(item))}
|
||||||
<li>
|
<li>
|
||||||
@@ -450,14 +439,7 @@
|
|||||||
|
|
||||||
{#if hasAlbums && showGroup('Álbumes')}
|
{#if hasAlbums && showGroup('Álbumes')}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading title="Álbumes">
|
<SectionHeading title="Álbumes" count={searchCounts.Álbumes} />
|
||||||
{#snippet actions()}
|
|
||||||
<div class="h-px flex-1 self-center bg-line"></div>
|
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums">
|
|
||||||
{plural(searchCounts.Álbumes, 'resultado', 'resultados')}
|
|
||||||
</span>
|
|
||||||
{/snippet}
|
|
||||||
</SectionHeading>
|
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each capped(albums) as album (album.id)}
|
{#each capped(albums) as album (album.id)}
|
||||||
<li>
|
<li>
|
||||||
@@ -482,14 +464,7 @@
|
|||||||
|
|
||||||
{#if hasUsers && showGroup('Usuarios')}
|
{#if hasUsers && showGroup('Usuarios')}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading title="Usuarios">
|
<SectionHeading title="Usuarios" count={searchCounts.Usuarios} />
|
||||||
{#snippet actions()}
|
|
||||||
<div class="h-px flex-1 self-center bg-line"></div>
|
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums">
|
|
||||||
{plural(searchCounts.Usuarios, 'resultado', 'resultados')}
|
|
||||||
</span>
|
|
||||||
{/snippet}
|
|
||||||
</SectionHeading>
|
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each capped(users) as u (u.id)}
|
{#each capped(users) as u (u.id)}
|
||||||
<li>
|
<li>
|
||||||
@@ -506,14 +481,7 @@
|
|||||||
|
|
||||||
{#if hasPlaylists && showGroup('Playlists')}
|
{#if hasPlaylists && showGroup('Playlists')}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading title="Playlists">
|
<SectionHeading title="Playlists" count={searchCounts.Playlists} />
|
||||||
{#snippet actions()}
|
|
||||||
<div class="h-px flex-1 self-center bg-line"></div>
|
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums">
|
|
||||||
{plural(searchCounts.Playlists, 'resultado', 'resultados')}
|
|
||||||
</span>
|
|
||||||
{/snippet}
|
|
||||||
</SectionHeading>
|
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each capped(playlistMatches) as playlist (playlist.id)}
|
{#each capped(playlistMatches) as playlist (playlist.id)}
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Tus canciones subidas"
|
title="Tus canciones subidas"
|
||||||
subtitle="{plural(total, 'canción', 'canciones')} subida{total === 1 ? '' : 's'}."
|
description="{plural(total, 'canción', 'canciones')} subida{total === 1 ? '' : 's'}."
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
@@ -47,7 +47,6 @@
|
|||||||
columns={['uploaded', 'plays']}
|
columns={['uploaded', 'plays']}
|
||||||
onPlay={togglePlay}
|
onPlay={togglePlay}
|
||||||
rowAction={{ action: '?/deleteTrack', icon: X, label: 'Borrar canción' }}
|
rowAction={{ action: '?/deleteTrack', icon: X, label: 'Borrar canción' }}
|
||||||
class="mt-7 sm:mt-8"
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { liked } from '$lib/player/liked.svelte';
|
import { liked } from '$lib/player/liked.svelte';
|
||||||
import { plural } from '$lib/format';
|
import { plural } from '$lib/format';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import TrackList from '$lib/components/ui/TrackList.svelte';
|
import TrackList from '$lib/components/ui/TrackList.svelte';
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<CollectionHeader
|
<PageHeader
|
||||||
eyebrow="Colección"
|
eyebrow="Colección"
|
||||||
title="Me gusta"
|
title="Me gusta"
|
||||||
meta={plural(tracks.length, 'canción', 'canciones')}
|
meta={plural(tracks.length, 'canción', 'canciones')}
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
>
|
>
|
||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
<div
|
<div
|
||||||
class="grid h-36 w-36 shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:h-41 sm:w-41"
|
class="grid size-cover-hero shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:size-cover-hero-sm"
|
||||||
>
|
>
|
||||||
<Heart class="h-10 w-10 text-on-art/90" fill="currentColor" strokeWidth={0} />
|
<Heart class="size-icon-xl text-on-art/90" fill="currentColor" strokeWidth={0} />
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
{isCurrentQueue && player.playing ? 'Pausar' : 'Reproducir'}
|
{isCurrentQueue && player.playing ? 'Pausar' : 'Reproducir'}
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CollectionHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if tracks.length > 0}
|
{#if tracks.length > 0}
|
||||||
<TrackList
|
<TrackList
|
||||||
@@ -59,7 +59,6 @@
|
|||||||
icon: X,
|
icon: X,
|
||||||
label: 'Quitar de Me gusta'
|
label: 'Quitar de Me gusta'
|
||||||
}}
|
}}
|
||||||
class="mt-6 sm:mt-8"
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import Alert from '$lib/components/ui/Alert.svelte';
|
import Alert from '$lib/components/ui/Alert.svelte';
|
||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import TrackList from '$lib/components/ui/TrackList.svelte';
|
import TrackList from '$lib/components/ui/TrackList.svelte';
|
||||||
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
|
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
|
||||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<Page>
|
<Page>
|
||||||
<BackLink href="/" label="Volver al inicio" />
|
<BackLink href="/" label="Volver al inicio" />
|
||||||
|
|
||||||
<CollectionHeader
|
<PageHeader
|
||||||
eyebrow="Mezcla"
|
eyebrow="Mezcla"
|
||||||
title={mix.title}
|
title={mix.title}
|
||||||
description={mix.subtitle ?? undefined}
|
description={mix.subtitle ?? undefined}
|
||||||
@@ -87,10 +87,10 @@
|
|||||||
Aleatorio
|
Aleatorio
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CollectionHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if form?.message}
|
{#if form?.message}
|
||||||
<Alert tone="danger" class="mt-4">{form.message}</Alert>
|
<Alert tone="danger" class="mb-6">{form.message}</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<TrackList
|
<TrackList
|
||||||
@@ -98,7 +98,6 @@
|
|||||||
columns={['plays']}
|
columns={['plays']}
|
||||||
onPlay={playFrom}
|
onPlay={playFrom}
|
||||||
oncontextmenu={(e, _track, i) => trackMenu.open(e, targets[i])}
|
oncontextmenu={(e, _track, i) => trackMenu.open(e, targets[i])}
|
||||||
class="mt-6 sm:mt-8"
|
|
||||||
/>
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Mis listas"
|
title="Mis listas"
|
||||||
subtitle={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
|
description={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<div class="mt-7 grid-cards-lg sm:mt-8">
|
<div class="grid-cards-lg">
|
||||||
{#each items as playlist, i (playlist.id)}
|
{#each items as playlist, i (playlist.id)}
|
||||||
<MediaCard
|
<MediaCard
|
||||||
href="/playlists/{playlist.id}"
|
href="/playlists/{playlist.id}"
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
import Play from '@lucide/svelte/icons/play';
|
||||||
|
import Pause from '@lucide/svelte/icons/pause';
|
||||||
|
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
||||||
|
import Trash from '@lucide/svelte/icons/trash';
|
||||||
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import Modal from '$lib/components/ui/Modal.svelte';
|
import Modal from '$lib/components/ui/Modal.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||||
|
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||||
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import TrackList from '$lib/components/ui/TrackList.svelte';
|
import TrackList from '$lib/components/ui/TrackList.svelte';
|
||||||
import PlaylistHeader from './components/PlaylistHeader.svelte';
|
import { playlistMeta } from '$lib/format';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -36,18 +43,46 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<PlaylistHeader
|
<PageHeader
|
||||||
id={playlist.id}
|
eyebrow="Lista"
|
||||||
name={playlist.name}
|
title={playlist.name}
|
||||||
description={playlist.description}
|
description={playlist.description ?? undefined}
|
||||||
|
meta="{playlistMeta(tracks.length)} · {playlist.visibility === 'Public'
|
||||||
|
? 'Pública'
|
||||||
|
: 'Privada'}"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
{#snippet cover()}
|
||||||
|
<Artwork
|
||||||
|
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||||
|
playlist.updatedAt
|
||||||
|
)}"
|
||||||
trackIds={tracks.map((t) => t.id)}
|
trackIds={tracks.map((t) => t.id)}
|
||||||
updatedAt={playlist.updatedAt}
|
size="hero"
|
||||||
visibility={playlist.visibility}
|
alt={playlist.name}
|
||||||
playing={isCurrentQueue && player.playing}
|
class="shrink-0"
|
||||||
onPlayAll={playAll}
|
|
||||||
onEdit={() => (editing = true)}
|
|
||||||
onDelete={() => (confirmingDelete = true)}
|
|
||||||
/>
|
/>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
|
||||||
|
{#if isCurrentQueue && player.playing}
|
||||||
|
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
Pausar
|
||||||
|
{:else}
|
||||||
|
<Play class="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
Reproducir
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>
|
||||||
|
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
|
||||||
|
<Trash class="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
{#if tracks.length > 0}
|
{#if tracks.length > 0}
|
||||||
<TrackList
|
<TrackList
|
||||||
@@ -55,14 +90,12 @@
|
|||||||
columns={['added', 'plays']}
|
columns={['added', 'plays']}
|
||||||
onPlay={playFrom}
|
onPlay={playFrom}
|
||||||
rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }}
|
rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }}
|
||||||
class="mt-6 sm:mt-8"
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ListMusic}
|
icon={ListMusic}
|
||||||
title="Esta playlist todavía está vacía"
|
title="Esta playlist todavía está vacía"
|
||||||
description="Añade canciones desde tu biblioteca o explora música nueva para empezar."
|
description="Añade canciones desde tu biblioteca o explora música nueva para empezar."
|
||||||
class="mt-6 sm:mt-8"
|
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||||
@@ -88,18 +121,11 @@
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
|
<ConfirmDialog
|
||||||
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
|
open={confirmingDelete}
|
||||||
¿Eliminar «{playlist.name}»?
|
onClose={() => (confirmingDelete = false)}
|
||||||
</h2>
|
title="¿Eliminar «{playlist.name}»?"
|
||||||
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
|
description="Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los dispositivos donde la tengas guardada."
|
||||||
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
|
confirmLabel="Eliminar"
|
||||||
dispositivos donde la tengas guardada.
|
action="?/delete"
|
||||||
</p>
|
/>
|
||||||
<div class="mt-6 flex items-center justify-end gap-3">
|
|
||||||
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
|
|
||||||
<form method="POST" action="?/delete">
|
|
||||||
<Button type="submit" variant="danger">Eliminar</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
|
||||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
|
||||||
import { plural } from '$lib/format';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
trackIds: (string | number)[];
|
|
||||||
updatedAt: string;
|
|
||||||
visibility: 'Public' | 'Private';
|
|
||||||
playing: boolean;
|
|
||||||
onPlayAll: () => void;
|
|
||||||
onEdit: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
trackIds,
|
|
||||||
updatedAt,
|
|
||||||
visibility,
|
|
||||||
playing,
|
|
||||||
onPlayAll,
|
|
||||||
onEdit,
|
|
||||||
onDelete
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const meta = $derived(
|
|
||||||
`${plural(trackIds.length, 'canción', 'canciones')} · ${
|
|
||||||
visibility === 'Public' ? 'Pública' : 'Privada'
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<CollectionHeader
|
|
||||||
eyebrow="Lista"
|
|
||||||
title={name}
|
|
||||||
description={description ?? undefined}
|
|
||||||
{meta}
|
|
||||||
align="center"
|
|
||||||
>
|
|
||||||
{#snippet cover()}
|
|
||||||
<Artwork
|
|
||||||
src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
|
|
||||||
{trackIds}
|
|
||||||
size="hero"
|
|
||||||
alt={name}
|
|
||||||
class="shrink-0"
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet actions()}
|
|
||||||
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
|
|
||||||
{playing ? 'Pausar' : 'Reproducir'}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="secondary" onclick={onEdit}>Editar</Button>
|
|
||||||
<Button size="sm" variant="secondary" onclick={onDelete}>Eliminar</Button>
|
|
||||||
{/snippet}
|
|
||||||
</CollectionHeader>
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
import { plural, playlistMeta } from '$lib/format';
|
import { plural, playlistMeta } from '$lib/format';
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<CollectionHeader
|
<PageHeader
|
||||||
eyebrow="Perfil"
|
eyebrow="Perfil"
|
||||||
title={profile.name}
|
title={profile.name}
|
||||||
meta="{plural(
|
meta="{plural(
|
||||||
@@ -69,9 +69,9 @@
|
|||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
<Avatar name={profile.name} src={profile.profilePictureUrl} size="hero" />
|
<Avatar name={profile.name} src={profile.profilePictureUrl} size="hero" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CollectionHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="mt-9">
|
<div>
|
||||||
<SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" />
|
<SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" />
|
||||||
|
|
||||||
{#if playlists.length > 0}
|
{#if playlists.length > 0}
|
||||||
|
|||||||
@@ -103,10 +103,10 @@
|
|||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Subir música"
|
title="Subir música"
|
||||||
subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
|
description="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ol class="mt-7 grid gap-3 sm:mt-8 sm:grid-cols-3 sm:gap-4">
|
<ol class="grid gap-3 sm:grid-cols-3 sm:gap-4">
|
||||||
{#each steps as step, i (step.key)}
|
{#each steps as step, i (step.key)}
|
||||||
<li>
|
<li>
|
||||||
<Surface padding="sm" class={stepClass(stepStates[i])}>
|
<Surface padding="sm" class={stepClass(stepStates[i])}>
|
||||||
|
|||||||
@@ -3,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