Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
24 changed files with 310 additions and 388 deletions
Showing only changes of commit 1bdd7ec9f1 - Show all commits
+1 -1
View File
@@ -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) |
| 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) |
| 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 | ⬜ | | |
| P12 | ⬜ | | |
| P13 | ⬜ | | |
+1 -1
View File
@@ -76,7 +76,7 @@
{#if user}
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
<div class="flex items-center justify-between px-3 pb-1.5">
<span class="text-xs font-medium tracking-widest text-muted uppercase">Tus playlists</span>
<span class="text-eyebrow text-muted">Tus playlists</span>
{#if playlists.length > 0}
<IconButton
label="Crear playlist"
@@ -29,7 +29,7 @@
</div>
{#if player.current.id}
<p class="mb-2.75 text-xs font-medium tracking-widest text-fg-3 uppercase">Reproduciendo</p>
<p class="mb-2.75 text-eyebrow text-fg-3">Reproduciendo</p>
<div class="mb-6 rounded-control bg-accent-tint p-2.5">
<MediaIdentity
trackId={player.current.id}
@@ -42,9 +42,7 @@
{/if}
<div class="mb-2.75 flex items-center justify-between gap-3">
<span class="min-w-0 flex-1 truncate text-xs font-medium tracking-widest text-fg-3 uppercase">
A continuación
</span>
<span class="min-w-0 flex-1 truncate text-eyebrow text-fg-3"> A continuación </span>
{#if upcoming.length > 0}
<button
type="button"
@@ -27,7 +27,7 @@
></div>
</button>
{:else}
<div class="flex w-full max-w-135 items-center gap-2.75">
<div class="flex w-full max-w-hero items-center gap-2.75">
<span class="min-w-8 text-right text-xs text-fg-3 tabular-nums">
{fmtTime(player.progress)}
</span>
@@ -1,45 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
eyebrow: string;
title: string;
description?: string;
meta?: string;
align?: 'center' | 'end';
cover: Snippet;
actions?: Snippet;
}
let { eyebrow, title, description, meta, align = 'end', cover, actions }: Props = $props();
</script>
<div
class="flex flex-col gap-5 sm:flex-row sm:gap-6 {align === 'end'
? 'mt-3 sm:mt-4 sm:items-end'
: 'sm:items-center'}"
>
{@render cover()}
<div class="min-w-0 flex-1">
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">{eyebrow}</p>
<h1
class="{align === 'end'
? 'mt-1.5'
: 'mt-3'} font-display text-3xl font-medium tracking-tight wrap-break-word text-fg sm:text-4xl"
>
{title}
</h1>
{#if description}
<p class="mt-3 max-w-2xl text-sm text-fg-2 sm:text-base">{description}</p>
{/if}
{#if meta}
<p class="mt-3 text-sm text-fg-2">{meta}</p>
{/if}
</div>
</div>
{#if actions}
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
{@render actions()}
</div>
{/if}
@@ -0,0 +1,26 @@
<script lang="ts">
import Modal from './Modal.svelte';
import Button from './Button.svelte';
interface Props {
open: boolean;
onClose: () => void;
title: string;
description: string;
confirmLabel: string;
action: string;
}
let { open, onClose, title, description, confirmLabel, action }: Props = $props();
</script>
<Modal {open} {onClose} maxWidth="max-w-sm">
<h2 class="text-display-3 text-fg">{title}</h2>
<p class="mt-2.5 text-body text-fg-2">{description}</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
<form method="POST" {action}>
<Button type="submit" variant="danger">{confirmLabel}</Button>
</form>
</div>
</Modal>
@@ -15,10 +15,10 @@
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
{#if Icon}
<Icon class="h-9 w-9 text-fg-2" strokeWidth={1.25} />
<Icon class="size-icon-xl text-fg-2" strokeWidth={1.25} />
{/if}
{#if title}
<h3 class="mt-6 font-display text-xl font-medium tracking-tight text-fg">
<h3 class="mt-6 text-display-3 text-fg">
{title}
</h3>
{/if}
@@ -70,14 +70,13 @@
{#if open}
<div
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm"
class="animate-fade fixed inset-0 z-(--z-modal) grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm"
role="presentation"
onclick={onBackdropClick}
>
<div
bind:this={panel}
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 backdrop-blur-xl transition-[background] duration-500 outline-none sm:p-6 {panelClass}"
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
class="glass-panel animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
role="dialog"
aria-modal="true"
aria-label={title}
@@ -87,11 +86,11 @@
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
{#if eyebrow}
<p class="mb-2.5 text-xs font-medium tracking-widest text-muted uppercase">
<p class="mb-2.5 text-eyebrow text-muted">
{eyebrow}
</p>
{/if}
<h2 class="font-display text-xl font-medium tracking-[-0.02em] text-fg">
<h2 class="text-display-3 text-fg">
{title}
</h2>
</div>
@@ -3,24 +3,59 @@
interface Props {
title: string;
subtitle?: string;
class?: string;
eyebrow?: string;
description?: string;
meta?: string;
cover?: Snippet;
actions?: Snippet;
align?: 'center' | 'end';
class?: string;
}
let { title, subtitle, class: klass = '', actions }: Props = $props();
let {
title,
eyebrow,
description,
meta,
cover,
actions,
align = 'end',
class: klass = ''
}: Props = $props();
</script>
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
<div class="min-w-0">
<h1 class="font-display text-3xl font-medium tracking-tight text-fg sm:text-4xl">
{title}
</h1>
{#if subtitle}
<p class="mt-2 text-sm text-fg-2">{subtitle}</p>
<div class="mb-7 sm:mb-8 {klass}">
<div
class="flex flex-col gap-5 sm:flex-row sm:gap-6 {cover
? align === 'end'
? 'sm:items-end'
: 'sm:items-center'
: 'sm:items-end sm:justify-between'}"
>
{#if cover}
{@render cover()}
{/if}
<div class="min-w-0 flex-1">
{#if eyebrow}
<p class="text-eyebrow text-fg-2">{eyebrow}</p>
{/if}
<h1 class="text-display-1 wrap-break-word text-fg {eyebrow ? 'mt-1.5' : ''}">
{title}
</h1>
{#if description}
<p class="mt-3 max-w-2xl text-body text-fg-2">{description}</p>
{/if}
{#if meta}
<p class="mt-3 text-sm text-fg-2">{meta}</p>
{/if}
</div>
{#if actions && !cover}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{/if}
</div>
{#if actions}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{#if actions && cover}
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
{@render actions()}
</div>
{/if}
</div>
@@ -1,18 +1,32 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { plural } from '$lib/format';
interface Props {
title: string;
subtitle?: string;
count?: number;
href?: string;
linkLabel?: string;
class?: string;
actions?: Snippet;
}
let { title, subtitle, class: klass = '', actions }: Props = $props();
let {
title,
subtitle,
count,
href,
linkLabel = 'Ver todas',
class: klass = '',
actions
}: Props = $props();
const hasActions = $derived(!!actions || count !== undefined || !!href);
</script>
{#snippet heading()}
<h2 class="font-display text-lg font-medium tracking-tight text-fg">
<h2 class="text-display-4 text-fg">
{title}
</h2>
{#if subtitle}
@@ -20,10 +34,31 @@
{/if}
{/snippet}
{#if actions}
{#snippet autoActions()}
{#if count !== undefined}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{plural(count, 'resultado', 'resultados')}
</span>
{/if}
{#if href}
<a
{href}
class="shrink-0 text-xs font-medium tracking-wider text-fg-3 uppercase transition-colors hover:text-fg"
>
{linkLabel}
</a>
{/if}
{/snippet}
{#if hasActions}
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
<div>{@render heading()}</div>
{@render actions()}
{#if actions}
{@render actions()}
{:else}
{@render autoActions()}
{/if}
</div>
{:else}
<div class="mb-4 sm:mb-4.5 {klass}">
@@ -23,7 +23,7 @@
</svelte:head>
<Page>
<PageHeader title="Álbumes" subtitle="Agrupa la música que subes en discos.">
<PageHeader title="Álbumes" description="Agrupa la música que subes en discos.">
{#snippet actions()}
{#if items.length > 0}
<Button onclick={() => (creating = true)}>
@@ -35,7 +35,7 @@
</PageHeader>
{#if items.length > 0}
<div class="mt-7 grid-cards sm:mt-8">
<div class="grid-cards">
{#each items as album, i (album.id)}
<MediaCard
href="/albums/{album.id}"
@@ -2,14 +2,22 @@
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import X from '@lucide/svelte/icons/x';
import Plus from '@lucide/svelte/icons/plus';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
import Button from '$lib/components/ui/Button.svelte';
import BackLink from '$lib/components/ui/BackLink.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte';
import AlbumHeader from './components/AlbumHeader.svelte';
import { albumMeta } from '$lib/format';
let { data, form } = $props();
@@ -42,21 +50,54 @@
</svelte:head>
<Page>
<AlbumHeader
title={album.title}
description={album.description ?? undefined}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackIds={tracks.map((track) => track.id)}
coverUrl="/api/albums/{album.id}/cover?size=large"
playing={isCurrentQueue && player.playing}
{isOwner}
onPlayAll={playAll}
onEdit={() => (editing = true)}
onDelete={() => (confirmingDelete = true)}
<BackLink
href={isOwner ? '/albums' : '/'}
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
/>
<PageHeader
eyebrow="Álbum"
title={album.title}
description={album.description ?? undefined}
meta={albumMeta(
album.releaseYear === null ? undefined : Number(album.releaseYear),
tracks.length
)}
>
{#snippet cover()}
<Artwork
src="/api/albums/{album.id}/cover?size=large"
trackIds={tracks.map((track) => track.id)}
size="hero"
alt={album.title}
class="shrink-0"
/>
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
{#if isOwner}
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</Button>
{/if}
{/snippet}
</PageHeader>
{#if form?.message}
<Alert tone="danger" class="mt-6">{form.message}</Alert>
<Alert tone="danger" class="mb-6">{form.message}</Alert>
{/if}
{#if tracks.length > 0}
@@ -66,7 +107,6 @@
rowAction={isOwner
? { action: '?/removeTrack', icon: X, label: 'Quitar del álbum' }
: undefined}
class="mt-6 sm:mt-8"
/>
{/if}
@@ -98,17 +138,11 @@
/>
</Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<h2 class="text-lg font-semibold tracking-tight text-fg">
¿Eliminar «{album.title}»?
</h2>
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca.
</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
<form method="POST" action="?/delete">
<Button type="submit" variant="danger">Eliminar</Button>
</form>
</div>
</Modal>
<ConfirmDialog
open={confirmingDelete}
onClose={() => (confirmingDelete = false)}
title="¿Eliminar «{album.title}»?"
description="Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca."
confirmLabel="Eliminar"
action="?/delete"
/>
@@ -1,75 +0,0 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import Artwork from '$lib/components/ui/Artwork.svelte';
import BackLink from '$lib/components/ui/BackLink.svelte';
import Button from '$lib/components/ui/Button.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import { plural } from '$lib/format';
interface Props {
title: string;
description?: string;
releaseYear?: number;
trackIds: (string | number)[];
coverUrl?: string;
playing: boolean;
isOwner: boolean;
onPlayAll: () => void;
onEdit: () => void;
onDelete: () => void;
}
let {
title,
description,
releaseYear,
trackIds,
coverUrl,
playing,
isOwner,
onPlayAll,
onEdit,
onDelete
}: Props = $props();
const meta = $derived(
[releaseYear ? String(releaseYear) : undefined, plural(trackIds.length, 'canción', 'canciones')]
.filter((part) => part !== undefined)
.join(' · ')
);
</script>
<BackLink
href={isOwner ? '/albums' : '/'}
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
/>
<CollectionHeader eyebrow="Álbum" {title} {description} {meta}>
{#snippet cover()}
<Artwork src={coverUrl} {trackIds} size="hero" alt={title} class="shrink-0" />
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{#if playing}
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
{#if isOwner}
<Button size="sm" variant="secondary" onclick={onEdit}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={onDelete}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</Button>
{/if}
{/snippet}
</CollectionHeader>
@@ -247,24 +247,20 @@
{#if !data.query}
<PageHeader
title="Descubrir"
subtitle="Encuentra tu próxima canción favorita explorando por género."
description="Encuentra tu próxima canción favorita explorando por género."
/>
{:else if !nothingFound}
<div>
<div class="mb-2.5 text-sm font-semibold tracking-[0.16em] text-fg-3 uppercase">
Resultados de búsqueda
</div>
<h1
class="font-display text-2xl font-semibold tracking-[-0.03em] text-pretty text-fg sm:text-3xl"
>
Resultados para «{data.query}»
</h1>
<p class="mt-1.75 text-sm text-fg-2">
{plural(totalHits, 'coincidencia', 'coincidencias')} en canciones, álbumes, playlists y usuarios
</p>
</div>
<PageHeader
eyebrow="Resultados de búsqueda"
title="Resultados para «{data.query}»"
description="{plural(
totalHits,
'coincidencia',
'coincidencias'
)} en canciones, álbumes, playlists y usuarios"
/>
<div class="mt-5 flex flex-wrap gap-1.75">
<div class="mb-7 flex flex-wrap gap-1.75 sm:mb-8">
{#each searchChips as chip (chip.label)}
<button
type="button"
@@ -355,11 +351,11 @@
{/if}
{#if nothingFound}
<div class="mx-auto max-w-115 py-[8vh] text-center">
<h1 class="font-display text-xl font-semibold tracking-tight text-fg">
<div class="mx-auto max-w-prose-sm py-[8vh] text-center">
<h1 class="text-display-3 text-fg">
Sin resultados para «{data.query}»
</h1>
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
<p class="mt-2.5 text-body text-fg-2">
Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o
por playlist.
</p>
@@ -378,7 +374,7 @@
{#if !data.query}
<!-- GÉNEROS -->
<div class="mt-7 grid-wide">
<div class="grid-wide">
{#each genreTiles as genre, i (genre.query)}
<a
href={searchHref(genre.query)}
@@ -399,14 +395,7 @@
{#if showGroup('Canciones')}
<div>
{#if songRows.length > 0}
<SectionHeading title="Canciones">
{#snippet actions()}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{plural(searchCounts.Canciones, 'resultado', 'resultados')}
</span>
{/snippet}
</SectionHeading>
<SectionHeading title="Canciones" count={searchCounts.Canciones} />
<ul class="flex flex-col gap-1">
{#each capped(songRows) as { target: item, seconds }, i (targetId(item))}
<li>
@@ -450,14 +439,7 @@
{#if hasAlbums && showGroup('Álbumes')}
<div>
<SectionHeading title="Álbumes">
{#snippet actions()}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{plural(searchCounts.Álbumes, 'resultado', 'resultados')}
</span>
{/snippet}
</SectionHeading>
<SectionHeading title="Álbumes" count={searchCounts.Álbumes} />
<ul class="flex flex-col gap-1">
{#each capped(albums) as album (album.id)}
<li>
@@ -482,14 +464,7 @@
{#if hasUsers && showGroup('Usuarios')}
<div>
<SectionHeading title="Usuarios">
{#snippet actions()}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{plural(searchCounts.Usuarios, 'resultado', 'resultados')}
</span>
{/snippet}
</SectionHeading>
<SectionHeading title="Usuarios" count={searchCounts.Usuarios} />
<ul class="flex flex-col gap-1">
{#each capped(users) as u (u.id)}
<li>
@@ -506,14 +481,7 @@
{#if hasPlaylists && showGroup('Playlists')}
<div>
<SectionHeading title="Playlists">
{#snippet actions()}
<div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{plural(searchCounts.Playlists, 'resultado', 'resultados')}
</span>
{/snippet}
</SectionHeading>
<SectionHeading title="Playlists" count={searchCounts.Playlists} />
<ul class="flex flex-col gap-1">
{#each capped(playlistMatches) as playlist (playlist.id)}
<li>
@@ -29,7 +29,7 @@
<Page>
<PageHeader
title="Tus canciones subidas"
subtitle="{plural(total, 'canción', 'canciones')} subida{total === 1 ? '' : 's'}."
description="{plural(total, 'canción', 'canciones')} subida{total === 1 ? '' : 's'}."
>
{#snippet actions()}
{#if items.length > 0}
@@ -47,7 +47,6 @@
columns={['uploaded', 'plays']}
onPlay={togglePlay}
rowAction={{ action: '?/deleteTrack', icon: X, label: 'Borrar canción' }}
class="mt-7 sm:mt-8"
/>
{:else}
<EmptyState
@@ -5,7 +5,7 @@
import { liked } from '$lib/player/liked.svelte';
import { plural } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte';
@@ -29,7 +29,7 @@
</svelte:head>
<Page>
<CollectionHeader
<PageHeader
eyebrow="Colección"
title="Me gusta"
meta={plural(tracks.length, 'canción', 'canciones')}
@@ -37,9 +37,9 @@
>
{#snippet cover()}
<div
class="grid h-36 w-36 shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:h-41 sm:w-41"
class="grid size-cover-hero shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:size-cover-hero-sm"
>
<Heart class="h-10 w-10 text-on-art/90" fill="currentColor" strokeWidth={0} />
<Heart class="size-icon-xl text-on-art/90" fill="currentColor" strokeWidth={0} />
</div>
{/snippet}
{#snippet actions()}
@@ -47,7 +47,7 @@
{isCurrentQueue && player.playing ? 'Pausar' : 'Reproducir'}
</Button>
{/snippet}
</CollectionHeader>
</PageHeader>
{#if tracks.length > 0}
<TrackList
@@ -59,7 +59,6 @@
icon: X,
label: 'Quitar de Me gusta'
}}
class="mt-6 sm:mt-8"
/>
{:else}
<EmptyState
@@ -9,7 +9,7 @@
import Button from '$lib/components/ui/Button.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte';
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus';
@@ -53,7 +53,7 @@
<Page>
<BackLink href="/" label="Volver al inicio" />
<CollectionHeader
<PageHeader
eyebrow="Mezcla"
title={mix.title}
description={mix.subtitle ?? undefined}
@@ -87,10 +87,10 @@
Aleatorio
</Button>
{/snippet}
</CollectionHeader>
</PageHeader>
{#if form?.message}
<Alert tone="danger" class="mt-4">{form.message}</Alert>
<Alert tone="danger" class="mb-6">{form.message}</Alert>
{/if}
<TrackList
@@ -98,7 +98,6 @@
columns={['plays']}
onPlay={playFrom}
oncontextmenu={(e, _track, i) => trackMenu.open(e, targets[i])}
class="mt-6 sm:mt-8"
/>
</Page>
@@ -25,7 +25,7 @@
<Page>
<PageHeader
title="Mis listas"
subtitle={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
description={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
>
{#snippet actions()}
{#if items.length > 0}
@@ -35,7 +35,7 @@
</PageHeader>
{#if items.length > 0}
<div class="mt-7 grid-cards-lg sm:mt-8">
<div class="grid-cards-lg">
{#each items as playlist, i (playlist.id)}
<MediaCard
href="/playlists/{playlist.id}"
@@ -1,14 +1,21 @@
<script lang="ts">
import ListMusic from '@lucide/svelte/icons/list-music';
import X from '@lucide/svelte/icons/x';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import TrackList from '$lib/components/ui/TrackList.svelte';
import PlaylistHeader from './components/PlaylistHeader.svelte';
import { playlistMeta } from '$lib/format';
let { data, form } = $props();
@@ -36,18 +43,46 @@
</svelte:head>
<Page>
<PlaylistHeader
id={playlist.id}
name={playlist.name}
description={playlist.description}
trackIds={tracks.map((t) => t.id)}
updatedAt={playlist.updatedAt}
visibility={playlist.visibility}
playing={isCurrentQueue && player.playing}
onPlayAll={playAll}
onEdit={() => (editing = true)}
onDelete={() => (confirmingDelete = true)}
/>
<PageHeader
eyebrow="Lista"
title={playlist.name}
description={playlist.description ?? undefined}
meta="{playlistMeta(tracks.length)} · {playlist.visibility === 'Public'
? 'Pública'
: 'Privada'}"
align="center"
>
{#snippet cover()}
<Artwork
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={tracks.map((t) => t.id)}
size="hero"
alt={playlist.name}
class="shrink-0"
/>
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</Button>
{/snippet}
</PageHeader>
{#if tracks.length > 0}
<TrackList
@@ -55,14 +90,12 @@
columns={['added', 'plays']}
onPlay={playFrom}
rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }}
class="mt-6 sm:mt-8"
/>
{:else}
<EmptyState
icon={ListMusic}
title="Esta playlist todavía está vacía"
description="Añade canciones desde tu biblioteca o explora música nueva para empezar."
class="mt-6 sm:mt-8"
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
@@ -88,18 +121,11 @@
/>
</Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
¿Eliminar «{playlist.name}»?
</h2>
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
dispositivos donde la tengas guardada.
</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
<form method="POST" action="?/delete">
<Button type="submit" variant="danger">Eliminar</Button>
</form>
</div>
</Modal>
<ConfirmDialog
open={confirmingDelete}
onClose={() => (confirmingDelete = false)}
title="¿Eliminar «{playlist.name}»?"
description="Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los dispositivos donde la tengas guardada."
confirmLabel="Eliminar"
action="?/delete"
/>
@@ -1,63 +0,0 @@
<script lang="ts">
import Artwork from '$lib/components/ui/Artwork.svelte';
import Button from '$lib/components/ui/Button.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import { plural } from '$lib/format';
interface Props {
id: string;
name: string;
description?: string | null;
trackIds: (string | number)[];
updatedAt: string;
visibility: 'Public' | 'Private';
playing: boolean;
onPlayAll: () => void;
onEdit: () => void;
onDelete: () => void;
}
let {
id,
name,
description,
trackIds,
updatedAt,
visibility,
playing,
onPlayAll,
onEdit,
onDelete
}: Props = $props();
const meta = $derived(
`${plural(trackIds.length, 'canción', 'canciones')} · ${
visibility === 'Public' ? 'Pública' : 'Privada'
}`
);
</script>
<CollectionHeader
eyebrow="Lista"
title={name}
description={description ?? undefined}
{meta}
align="center"
>
{#snippet cover()}
<Artwork
src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
{trackIds}
size="hero"
alt={name}
class="shrink-0"
/>
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{playing ? 'Pausar' : 'Reproducir'}
</Button>
<Button size="sm" variant="secondary" onclick={onEdit}>Editar</Button>
<Button size="sm" variant="secondary" onclick={onDelete}>Eliminar</Button>
{/snippet}
</CollectionHeader>
@@ -7,7 +7,7 @@
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Avatar from '$lib/components/ui/Avatar.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { plural, playlistMeta } from '$lib/format';
@@ -55,7 +55,7 @@
{/snippet}
<Page>
<CollectionHeader
<PageHeader
eyebrow="Perfil"
title={profile.name}
meta="{plural(
@@ -69,9 +69,9 @@
{#snippet cover()}
<Avatar name={profile.name} src={profile.profilePictureUrl} size="hero" />
{/snippet}
</CollectionHeader>
</PageHeader>
<div class="mt-9">
<div>
<SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" />
{#if playlists.length > 0}
@@ -103,10 +103,10 @@
<Page>
<PageHeader
title="Subir música"
subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
description="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
/>
<ol class="mt-7 grid gap-3 sm:mt-8 sm:grid-cols-3 sm:gap-4">
<ol class="grid gap-3 sm:grid-cols-3 sm:gap-4">
{#each steps as step, i (step.key)}
<li>
<Surface padding="sm" class={stepClass(stepStates[i])}>
+3 -3
View File
@@ -3,11 +3,11 @@
import Button from '$lib/components/ui/Button.svelte';
</script>
<div class="animate-enter mx-auto my-[14vh] max-w-105 text-center">
<h1 class="mb-2.5 font-display text-xl font-semibold tracking-[-0.02em] text-fg">
<div class="animate-enter mx-auto my-[14vh] max-w-prose-sm text-center">
<h1 class="mb-2.5 text-display-3 text-fg">
{page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'}
</h1>
<p class="mb-6.5 text-sm leading-[1.65] text-fg-2">
<p class="mb-6.5 text-body text-fg-2">
{page.status === 404
? 'Comprueba el enlace o vuelve al inicio.'
: 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'}
+27 -40
View File
@@ -118,6 +118,18 @@
}
</script>
{#snippet noListeningHistory()}
<EmptyState
icon={History}
title="Todavía no has escuchado nada"
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</EmptyState>
{/snippet}
<svelte:head>
<title>Musify</title>
<meta name="description" content="Tu música, sin límites." />
@@ -130,13 +142,13 @@
<div
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
>
<div class="animate-enter max-w-135 min-w-65">
<div class="animate-enter max-w-hero min-w-65">
<h1
class="font-display text-3xl leading-tight font-medium tracking-tight text-pretty text-fg sm:text-4xl"
>
{data.greeting}
</h1>
<p class="mt-3.5 max-w-108 text-sm leading-[1.65] text-fg-2">
<p class="mt-3.5 max-w-prose-sm text-body text-fg-2">
Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.
</p>
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
@@ -153,19 +165,19 @@
{#await data.listeningStats then stats}
<div class="flex gap-6.5">
<div>
<div class="font-display text-2xl font-medium tracking-tight text-fg">
<div class="text-display-2 text-fg">
{stats.tracksThisWeek}
</div>
<div class="mt-1 text-xs text-fg-3">Canciones nuevas escuchadas esta semana</div>
</div>
<div>
<div class="font-display text-2xl font-medium tracking-tight text-fg">
<div class="text-display-2 text-fg">
{fmtDurationLong(Number(stats.secondsThisWeek))}
</div>
<div class="mt-1 text-xs text-fg-3">Tiempo de escucha esta semana</div>
</div>
<div>
<div class="font-display text-2xl font-medium tracking-tight text-fg">
<div class="text-display-2 text-fg">
{stats.streakDays}
</div>
<div class="mt-1 text-xs text-fg-3">Días consecutivos escuchando</div>
@@ -200,15 +212,7 @@
{/each}
</div>
{:else}
<EmptyState
icon={History}
title="Todavía no has escuchado nada"
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</EmptyState>
{@render noListeningHistory()}
{/if}
</section>
@@ -236,16 +240,14 @@
class="shrink-0"
/>
<div class="flex min-w-0 flex-col gap-3">
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">
Playlist destacada
</p>
<h3 class="truncate font-display text-2xl font-medium text-fg">
<p class="text-eyebrow text-fg-2">Playlist destacada</p>
<h3 class="truncate text-display-2 text-fg">
{spotlight.name}
</h3>
<p class="text-sm text-fg-2">{spotlightMeta}</p>
</div>
</div>
<p class="mt-5 max-w-110 text-sm text-fg-2">
<p class="mt-5 max-w-prose-sm text-sm text-fg-2">
{spotlight.description || 'Tu colección, siempre a mano.'}
</p>
<div class="mt-5 flex gap-2.5">
@@ -340,32 +342,17 @@
{/each}
</div>
{:else}
<EmptyState
icon={History}
title="Todavía no has escuchado nada"
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</EmptyState>
{@render noListeningHistory()}
{/if}
</section>
<!-- TUS PLAYLISTS -->
<section>
<SectionHeading title="Tus playlists" subtitle="Creadas y guardadas por ti">
{#snippet actions()}
{#if data.playlistsHasMore}
<a
href="/playlists"
class="shrink-0 text-xs font-medium tracking-wider text-fg-3 uppercase transition-colors hover:text-fg"
>
Ver todas
</a>
{/if}
{/snippet}
</SectionHeading>
<SectionHeading
title="Tus playlists"
subtitle="Creadas y guardadas por ti"
href={data.playlistsHasMore ? '/playlists' : undefined}
/>
{#if playlists.length > 0}
<div class="grid-wide">
{#each playlists as playlist, i (playlist.id)}