Design Prot Merge #1
@@ -781,7 +781,7 @@ Tareas:
|
||||
| P6 | ✅ | (pendiente) | z-index de AccountMenu unificado a z-(--z-menu) (antes z-20, ahora al mismo nivel que ContextMenu — Δ menor, mejora consistencia); 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) |
|
||||
| P9 | ⬜ | | |
|
||||
| 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 | ⬜ | | |
|
||||
| P11 | ⬜ | | |
|
||||
| P12 | ⬜ | | |
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MediaCard from './MediaCard.svelte';
|
||||
import Artwork from './Artwork.svelte';
|
||||
import { plural } from '$lib/format';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: string;
|
||||
href?: string;
|
||||
subtitle?: string;
|
||||
typeLabel?: string;
|
||||
releaseYear?: number;
|
||||
trackCount?: number;
|
||||
trackIds?: (string | number)[];
|
||||
coverSrc?: string | null;
|
||||
index?: number;
|
||||
onContextMenu?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
title,
|
||||
href,
|
||||
subtitle,
|
||||
typeLabel,
|
||||
releaseYear,
|
||||
trackCount,
|
||||
trackIds = [],
|
||||
coverSrc,
|
||||
index = 0,
|
||||
onContextMenu
|
||||
}: Props = $props();
|
||||
|
||||
const meta = $derived(
|
||||
[
|
||||
typeLabel,
|
||||
subtitle,
|
||||
releaseYear ? String(releaseYear) : undefined,
|
||||
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
|
||||
]
|
||||
.filter((part) => !!part)
|
||||
.join(' · ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<MediaCard
|
||||
href={href ?? `/albums/${id}`}
|
||||
{title}
|
||||
subtitle={meta}
|
||||
{index}
|
||||
oncontextmenu={onContextMenu}
|
||||
>
|
||||
{#snippet art(artClass)}
|
||||
<Artwork src={coverSrc} {trackIds} size="fill" alt={title} class={artClass} />
|
||||
{/snippet}
|
||||
</MediaCard>
|
||||
@@ -1,30 +1,59 @@
|
||||
<script lang="ts" module>
|
||||
export const mediaCardArt =
|
||||
'aspect-square w-full rounded-art transition duration-300 ease-out';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Artwork from './Artwork.svelte';
|
||||
import PlayButton from './PlayButton.svelte';
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
index?: number;
|
||||
src?: string | null;
|
||||
trackIds?: (string | number)[];
|
||||
onPlay?: (event: MouseEvent) => void;
|
||||
art?: Snippet;
|
||||
oncontextmenu?: (event: MouseEvent) => void;
|
||||
art: Snippet<[string]>;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { href, title, subtitle, index = 0, oncontextmenu, art }: Props = $props();
|
||||
let {
|
||||
href,
|
||||
title,
|
||||
subtitle,
|
||||
src,
|
||||
trackIds = [],
|
||||
onPlay,
|
||||
art,
|
||||
oncontextmenu,
|
||||
index = 0
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<a
|
||||
{href}
|
||||
class="group animate-enter min-w-0 text-left"
|
||||
style="animation-delay:{index * 45}ms"
|
||||
{oncontextmenu}
|
||||
>
|
||||
{@render art(mediaCardArt)}
|
||||
<a {href} class="group animate-enter min-w-0 text-left" style="--i:{index}" {oncontextmenu}>
|
||||
<div class="relative">
|
||||
{#if art}
|
||||
{@render art()}
|
||||
{:else}
|
||||
<Artwork
|
||||
{src}
|
||||
{trackIds}
|
||||
size="fill"
|
||||
alt={title}
|
||||
class="rounded-art transition duration-300 group-hover:scale-105"
|
||||
/>
|
||||
{/if}
|
||||
{#if onPlay}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 rounded-art bg-linear-to-b from-transparent from-45% to-scrim/40"
|
||||
></div>
|
||||
<PlayButton
|
||||
variant="glass"
|
||||
size="sm"
|
||||
onclick={onPlay}
|
||||
label="Reproducir {title}"
|
||||
class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-2.5 truncate text-sm text-fg">{title}</div>
|
||||
{#if subtitle}
|
||||
<div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
as?: 'ul' | 'div';
|
||||
min?: string;
|
||||
minMobile?: string;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
as = 'div',
|
||||
min = '165px',
|
||||
minMobile = '150px',
|
||||
class: klass = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={as}
|
||||
class="grid media-grid gap-3 sm:gap-4 {klass}"
|
||||
style="--mf-grid-min:{min}; --mf-grid-min-mobile:{minMobile}"
|
||||
>
|
||||
{@render children()}
|
||||
</svelte:element>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Artwork from './Artwork.svelte';
|
||||
import PlayButton from './PlayButton.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { queueItemForTarget } from '$lib/tracks';
|
||||
import { mixItemTarget, type Mix } from '$lib/mixes';
|
||||
|
||||
interface Props {
|
||||
mix: Mix;
|
||||
index?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { mix, index = 0, class: klass = '' }: Props = $props();
|
||||
|
||||
const trackIds = $derived(mix.items.map((item) => item.trackId));
|
||||
|
||||
function play(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
const items = mix.items.map((item) => queueItemForTarget(mixItemTarget(item)));
|
||||
player.playQueue(items, 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/mixes/{mix.id}"
|
||||
class="group/tile animate-enter block cursor-pointer {klass}"
|
||||
style="--i:{index}"
|
||||
>
|
||||
<Artwork
|
||||
{trackIds}
|
||||
size="fill"
|
||||
alt={mix.title}
|
||||
class="rounded-art transition duration-300 group-hover/tile:scale-105"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent from-45% to-scrim/40"
|
||||
></div>
|
||||
<PlayButton
|
||||
variant="glass"
|
||||
size="sm"
|
||||
onclick={play}
|
||||
label="Reproducir {mix.title}"
|
||||
class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity duration-150 group-hover/tile:opacity-100"
|
||||
/>
|
||||
</Artwork>
|
||||
<div class="mt-3.25 truncate text-sm font-semibold text-fg">
|
||||
{mix.title}
|
||||
</div>
|
||||
{#if mix.subtitle}
|
||||
<div class="mt-1 truncate text-xs text-fg-3">{mix.subtitle}</div>
|
||||
{/if}
|
||||
</a>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script lang="ts">
|
||||
import MediaCard from './MediaCard.svelte';
|
||||
import Artwork from './Artwork.svelte';
|
||||
import { plural } from '$lib/format';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
name: string;
|
||||
trackCount: number;
|
||||
trackIds: (string | number)[];
|
||||
updatedAt: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { id, name, trackCount, trackIds, updatedAt, index = 0 }: Props = $props();
|
||||
|
||||
const subtitle = $derived(plural(trackCount, 'canción', 'canciones'));
|
||||
</script>
|
||||
|
||||
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
|
||||
{#snippet art(artClass)}
|
||||
<Artwork
|
||||
src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
|
||||
{trackIds}
|
||||
size="fill"
|
||||
alt={name}
|
||||
class={artClass}
|
||||
/>
|
||||
{/snippet}
|
||||
</MediaCard>
|
||||
@@ -26,3 +26,16 @@ export function fmtPlays(count: number | string | undefined): string {
|
||||
const value = Math.max(0, Math.floor(Number(count ?? 0)));
|
||||
return plural(value, 'reproducción', 'reproducciones');
|
||||
}
|
||||
|
||||
export function albumMeta(releaseYear?: number, trackCount?: number): string {
|
||||
return [
|
||||
releaseYear ? String(releaseYear) : undefined,
|
||||
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
|
||||
]
|
||||
.filter((part): part is string => !!part)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
export function playlistMeta(trackCount: number): string {
|
||||
return plural(trackCount, 'canción', 'canciones');
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import Disc from '@lucide/svelte/icons/disc-3';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
|
||||
import MediaCard from '$lib/components/ui/MediaCard.svelte';
|
||||
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import { albumMeta } from '$lib/format';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
@@ -35,19 +35,21 @@
|
||||
</PageHeader>
|
||||
|
||||
{#if items.length > 0}
|
||||
<MediaGrid class="mt-7 sm:mt-8">
|
||||
<div class="mt-7 grid-cards sm:mt-8">
|
||||
{#each items as album, i (album.id)}
|
||||
<AlbumCard
|
||||
id={album.id}
|
||||
<MediaCard
|
||||
href="/albums/{album.id}"
|
||||
title={album.title}
|
||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
||||
trackCount={Number(album.trackCount)}
|
||||
subtitle={albumMeta(
|
||||
album.releaseYear === null ? undefined : Number(album.releaseYear),
|
||||
Number(album.trackCount)
|
||||
)}
|
||||
trackIds={album.coverTrackIds}
|
||||
coverSrc="/api/albums/{album.id}/cover?size=large"
|
||||
src="/api/albums/{album.id}/cover?size=large"
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={Disc}
|
||||
|
||||
@@ -378,13 +378,12 @@
|
||||
|
||||
{#if !data.query}
|
||||
<!-- GÉNEROS -->
|
||||
<div class="mt-7 grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
|
||||
<div class="mt-7 grid-wide">
|
||||
{#each genreTiles as genre, i (genre.query)}
|
||||
<a
|
||||
href={searchHref(genre.query)}
|
||||
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-110"
|
||||
style="animation-delay:{Math.min(i, 10) *
|
||||
40}ms; background:linear-gradient(140deg, oklch(var(--mf-tile-l) var(--mf-tile-c) {genre.hue}), oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) {genre.hue}))"
|
||||
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-panel p-4.25 transition-[filter] genre-tile hover:brightness-110"
|
||||
style="--i:{i}; --tile-hue:{genre.hue}"
|
||||
>
|
||||
<div class="text-on-art">
|
||||
{genre.label}
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
||||
import MediaCard from '$lib/components/ui/MediaCard.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||
import { fmtDurationLong, plural } from '$lib/format';
|
||||
import { fmtDurationLong, plural, playlistMeta } from '$lib/format';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -36,18 +35,20 @@
|
||||
</PageHeader>
|
||||
|
||||
{#if items.length > 0}
|
||||
<MediaGrid min="210px" minMobile="180px" class="mt-7 sm:mt-8">
|
||||
<div class="mt-7 grid-cards-lg sm:mt-8">
|
||||
{#each items as playlist, i (playlist.id)}
|
||||
<PlaylistCard
|
||||
id={playlist.id}
|
||||
name={playlist.name}
|
||||
trackCount={playlist.trackCount}
|
||||
<MediaCard
|
||||
href="/playlists/{playlist.id}"
|
||||
title={playlist.name}
|
||||
subtitle={playlistMeta(Number(playlist.trackCount))}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
updatedAt={playlist.updatedAt}
|
||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { enhance } from '$app/forms';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
||||
import MediaCard from '$lib/components/ui/MediaCard.svelte';
|
||||
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 SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
import { plural, playlistMeta } from '$lib/format';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
@@ -58,9 +58,11 @@
|
||||
<CollectionHeader
|
||||
eyebrow="Perfil"
|
||||
title={profile.name}
|
||||
meta="{profile.followersCount} {profile.followersCount === 1
|
||||
? 'seguidor'
|
||||
: 'seguidores'} · {profile.followingCount} siguiendo"
|
||||
meta="{plural(
|
||||
Number(profile.followersCount),
|
||||
'seguidor',
|
||||
'seguidores'
|
||||
)} · {profile.followingCount} siguiendo"
|
||||
align="center"
|
||||
actions={canFollow ? followAction : undefined}
|
||||
>
|
||||
@@ -73,18 +75,20 @@
|
||||
<SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" />
|
||||
|
||||
{#if playlists.length > 0}
|
||||
<MediaGrid min="210px" minMobile="180px" class="mt-6">
|
||||
<div class="mt-6 grid-cards-lg">
|
||||
{#each playlists as playlist, i (playlist.id)}
|
||||
<PlaylistCard
|
||||
id={playlist.id}
|
||||
name={playlist.name}
|
||||
trackCount={playlist.trackCount}
|
||||
<MediaCard
|
||||
href="/playlists/{playlist.id}"
|
||||
title={playlist.name}
|
||||
subtitle={playlistMeta(Number(playlist.trackCount))}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
updatedAt={playlist.updatedAt}
|
||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
||||
import { pressable } from '$lib/actions/pressable';
|
||||
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
|
||||
import { mixItemTarget } from '$lib/mixes';
|
||||
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format';
|
||||
import {
|
||||
isTargetCurrent,
|
||||
@@ -20,7 +21,7 @@
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||
import MixTile from '$lib/components/ui/MixTile.svelte';
|
||||
import MediaCard from '$lib/components/ui/MediaCard.svelte';
|
||||
import EqBars from '$lib/components/ui/EqBars.svelte';
|
||||
import ListRow from '$lib/components/ui/ListRow.svelte';
|
||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
@@ -107,6 +108,14 @@
|
||||
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
||||
trackMenu.open(event, target);
|
||||
}
|
||||
|
||||
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
player.playQueue(
|
||||
mix.items.map((item) => queueItemForTarget(mixItemTarget(item))),
|
||||
0
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -274,9 +283,16 @@
|
||||
<section>
|
||||
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
|
||||
{#if mixes.length > 0}
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(164px,1fr))] gap-5">
|
||||
<div class="grid-tiles">
|
||||
{#each mixes as mix, i (mix.id)}
|
||||
<MixTile {mix} index={i} />
|
||||
<MediaCard
|
||||
href="/mixes/{mix.id}"
|
||||
title={mix.title}
|
||||
subtitle={mix.subtitle}
|
||||
trackIds={mix.items.map((item) => item.trackId)}
|
||||
onPlay={(event) => playMix(mix, event)}
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -351,7 +367,7 @@
|
||||
{/snippet}
|
||||
</SectionHeading>
|
||||
{#if playlists.length > 0}
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(258px,1fr))] gap-3.5">
|
||||
<div class="grid-wide">
|
||||
{#each playlists as playlist, i (playlist.id)}
|
||||
<ListRow
|
||||
onclick={() => goto(`/playlists/${playlist.id}`)}
|
||||
|
||||
@@ -196,6 +196,13 @@
|
||||
--mf-dock-h: var(--mf-player-h);
|
||||
}
|
||||
}
|
||||
@utility genre-tile {
|
||||
background: linear-gradient(
|
||||
140deg,
|
||||
oklch(var(--mf-tile-l) var(--mf-tile-c) var(--tile-hue)),
|
||||
oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) var(--tile-hue))
|
||||
);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
svg.lucide {
|
||||
|
||||
Reference in New Issue
Block a user