web-player: cards and grids (MediaCard)
MediaCard now builds its own art (src/trackIds via Artwork size="fill", or a custom art snippet) and its own play button (onPlay, with a scrim and a glass PlayButton on hover) instead of relying on each wrapper passing it a snippet. Replaces AlbumCard, PlaylistCard and MixTile, which are removed along with MediaGrid.svelte (their grids move to the grid-cards/grid-cards-lg/grid-tiles/grid-wide @utility recipes). format.ts gains albumMeta() and playlistMeta() (previously ad-hoc calculations inside each card). Explore's genre tile moves from an inline style= gradient to the genre-tile @utility recipe + --tile-hue. Along the way, fixes the "follower/s" pluralization on the user profile that had been missed. Delta documented: the art's hover-zoom and the scrim+PlayButton (previously exclusive to MixTile) become MediaCard behavior gated on the onPlay prop — they'll apply to any future card that uses it, for consistency. Verified with npm run check/build; no authenticated visual QA (Docker unavailable this session).
This commit is contained in:
@@ -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) |
|
| 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) |
|
| 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 | ⬜ | | |
|
| 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 | ⬜ | | |
|
||||||
| P11 | ⬜ | | |
|
| P11 | ⬜ | | |
|
||||||
| P12 | ⬜ | | |
|
| 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">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import Artwork from './Artwork.svelte';
|
||||||
|
import PlayButton from './PlayButton.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
href: string;
|
href: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
index?: number;
|
src?: string | null;
|
||||||
|
trackIds?: (string | number)[];
|
||||||
|
onPlay?: (event: MouseEvent) => void;
|
||||||
|
art?: Snippet;
|
||||||
oncontextmenu?: (event: MouseEvent) => void;
|
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>
|
</script>
|
||||||
|
|
||||||
<a
|
<a {href} class="group animate-enter min-w-0 text-left" style="--i:{index}" {oncontextmenu}>
|
||||||
{href}
|
<div class="relative">
|
||||||
class="group animate-enter min-w-0 text-left"
|
{#if art}
|
||||||
style="animation-delay:{index * 45}ms"
|
{@render art()}
|
||||||
{oncontextmenu}
|
{:else}
|
||||||
>
|
<Artwork
|
||||||
{@render art(mediaCardArt)}
|
{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>
|
<div class="mt-2.5 truncate text-sm text-fg">{title}</div>
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
<div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div>
|
<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)));
|
const value = Math.max(0, Math.floor(Number(count ?? 0)));
|
||||||
return plural(value, 'reproducción', 'reproducciones');
|
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 Disc from '@lucide/svelte/icons/disc-3';
|
||||||
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 PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
import MediaCard from '$lib/components/ui/MediaCard.svelte';
|
||||||
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
|
|
||||||
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
|
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
|
||||||
import Modal from '$lib/components/ui/Modal.svelte';
|
import Modal from '$lib/components/ui/Modal.svelte';
|
||||||
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 { albumMeta } from '$lib/format';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -35,19 +35,21 @@
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#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)}
|
{#each items as album, i (album.id)}
|
||||||
<AlbumCard
|
<MediaCard
|
||||||
id={album.id}
|
href="/albums/{album.id}"
|
||||||
title={album.title}
|
title={album.title}
|
||||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
subtitle={albumMeta(
|
||||||
trackCount={Number(album.trackCount)}
|
album.releaseYear === null ? undefined : Number(album.releaseYear),
|
||||||
|
Number(album.trackCount)
|
||||||
|
)}
|
||||||
trackIds={album.coverTrackIds}
|
trackIds={album.coverTrackIds}
|
||||||
coverSrc="/api/albums/{album.id}/cover?size=large"
|
src="/api/albums/{album.id}/cover?size=large"
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</MediaGrid>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Disc}
|
icon={Disc}
|
||||||
|
|||||||
@@ -378,13 +378,12 @@
|
|||||||
|
|
||||||
{#if !data.query}
|
{#if !data.query}
|
||||||
<!-- GÉNEROS -->
|
<!-- 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)}
|
{#each genreTiles as genre, i (genre.query)}
|
||||||
<a
|
<a
|
||||||
href={searchHref(genre.query)}
|
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"
|
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="animation-delay:{Math.min(i, 10) *
|
style="--i:{i}; --tile-hue:{genre.hue}"
|
||||||
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}))"
|
|
||||||
>
|
>
|
||||||
<div class="text-on-art">
|
<div class="text-on-art">
|
||||||
{genre.label}
|
{genre.label}
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
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 PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
import MediaCard from '$lib/components/ui/MediaCard.svelte';
|
||||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
|
||||||
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 { createPlaylistModal } from '$lib/playlists.svelte';
|
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||||
import { fmtDurationLong, plural } from '$lib/format';
|
import { fmtDurationLong, plural, playlistMeta } from '$lib/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -36,18 +35,20 @@
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#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)}
|
{#each items as playlist, i (playlist.id)}
|
||||||
<PlaylistCard
|
<MediaCard
|
||||||
id={playlist.id}
|
href="/playlists/{playlist.id}"
|
||||||
name={playlist.name}
|
title={playlist.name}
|
||||||
trackCount={playlist.trackCount}
|
subtitle={playlistMeta(Number(playlist.trackCount))}
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
updatedAt={playlist.updatedAt}
|
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||||
|
playlist.updatedAt
|
||||||
|
)}"
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</MediaGrid>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ListMusic}
|
icon={ListMusic}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
import MediaCard from '$lib/components/ui/MediaCard.svelte';
|
||||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
|
||||||
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 CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
|
import { plural, playlistMeta } from '$lib/format';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -58,9 +58,11 @@
|
|||||||
<CollectionHeader
|
<CollectionHeader
|
||||||
eyebrow="Perfil"
|
eyebrow="Perfil"
|
||||||
title={profile.name}
|
title={profile.name}
|
||||||
meta="{profile.followersCount} {profile.followersCount === 1
|
meta="{plural(
|
||||||
? 'seguidor'
|
Number(profile.followersCount),
|
||||||
: 'seguidores'} · {profile.followingCount} siguiendo"
|
'seguidor',
|
||||||
|
'seguidores'
|
||||||
|
)} · {profile.followingCount} siguiendo"
|
||||||
align="center"
|
align="center"
|
||||||
actions={canFollow ? followAction : undefined}
|
actions={canFollow ? followAction : undefined}
|
||||||
>
|
>
|
||||||
@@ -73,18 +75,20 @@
|
|||||||
<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}
|
||||||
<MediaGrid min="210px" minMobile="180px" class="mt-6">
|
<div class="mt-6 grid-cards-lg">
|
||||||
{#each playlists as playlist, i (playlist.id)}
|
{#each playlists as playlist, i (playlist.id)}
|
||||||
<PlaylistCard
|
<MediaCard
|
||||||
id={playlist.id}
|
href="/playlists/{playlist.id}"
|
||||||
name={playlist.name}
|
title={playlist.name}
|
||||||
trackCount={playlist.trackCount}
|
subtitle={playlistMeta(Number(playlist.trackCount))}
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
updatedAt={playlist.updatedAt}
|
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||||
|
playlist.updatedAt
|
||||||
|
)}"
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</MediaGrid>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ListMusic}
|
icon={ListMusic}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
||||||
import { pressable } from '$lib/actions/pressable';
|
import { pressable } from '$lib/actions/pressable';
|
||||||
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
|
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
|
||||||
|
import { mixItemTarget } from '$lib/mixes';
|
||||||
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format';
|
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format';
|
||||||
import {
|
import {
|
||||||
isTargetCurrent,
|
isTargetCurrent,
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
type TrackTarget
|
type TrackTarget
|
||||||
} from '$lib/tracks';
|
} from '$lib/tracks';
|
||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
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 EqBars from '$lib/components/ui/EqBars.svelte';
|
||||||
import ListRow from '$lib/components/ui/ListRow.svelte';
|
import ListRow from '$lib/components/ui/ListRow.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
@@ -107,6 +108,14 @@
|
|||||||
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
||||||
trackMenu.open(event, target);
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -274,9 +283,16 @@
|
|||||||
<section>
|
<section>
|
||||||
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
|
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
|
||||||
{#if mixes.length > 0}
|
{#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)}
|
{#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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -351,7 +367,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SectionHeading>
|
</SectionHeading>
|
||||||
{#if playlists.length > 0}
|
{#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)}
|
{#each playlists as playlist, i (playlist.id)}
|
||||||
<ListRow
|
<ListRow
|
||||||
onclick={() => goto(`/playlists/${playlist.id}`)}
|
onclick={() => goto(`/playlists/${playlist.id}`)}
|
||||||
|
|||||||
@@ -196,6 +196,13 @@
|
|||||||
--mf-dock-h: var(--mf-player-h);
|
--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 {
|
@layer base {
|
||||||
svg.lucide {
|
svg.lucide {
|
||||||
|
|||||||
Reference in New Issue
Block a user