web-player: unify Artwork and Avatar

Add src/lib/components/ui/Artwork.svelte and Avatar.svelte (B.3) and
migrate every use of Cover, PlaylistArt, MixArt and ArtistAvatar
(Sidebar, Queue, TrackInfo, TrackContextMenu, AlbumContextMenu,
TrackTitleCell, AlbumCard, PlaylistCard, MixTile, AlbumHeader,
PlaylistHeader, Home, Explore, mix, user profile, TopBar,
MobileHeader). Remove the 4 old components.

Artwork: size decides both dimension and radius automatically
(cover-*/radius tokens from the earlier token pass); fallback chain
is src -> mosaic (>=4 trackIds) -> first cover -> placeholder,
cascading if each step fails (previously split across 3 components
with slightly different logic each). Adds a 'fill' size (aspect-square
w-full), not in the spec, needed for fluid grid cards until MediaCard
lands later.

TrackTitleCell's `coverSize` prop (free-form Tailwind classes)
becomes `size: 'sm'|'lg'`, delegating to Artwork. SearchResultRow
loses its `kind` prop and the per-type radius (now decided by
Artwork/Avatar).

Product decision (not blocking): remove the name under the profile
avatar on /u/[id] since it duplicated the CollectionHeader title.

Small radius/size deltas are documented in the plan's README
(auto-radius by size replaces ad-hoc classes; see table B.1.1/B.1.2).

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
This commit is contained in:
2026-09-20 22:39:05 +02:00
parent 7b1b81c45a
commit 58eb670fed
25 changed files with 326 additions and 359 deletions
+1 -1
View File
@@ -775,7 +775,7 @@ Tareas:
| P0 | ✅ | (pendiente) | Button primary/secondary se mantienen iguales (decisión del usuario) | | P0 | ✅ | (pendiente) | Button primary/secondary se mantienen iguales (decisión del usuario) |
| P1 | ✅ | (pendiente) | Verificado con `build` (Tailwind compila `@apply sm:` dentro de `@utility`); sin QA visual autenticada (Docker no disponible en esta sesión) | | P1 | ✅ | (pendiente) | Verificado con `build` (Tailwind compila `@apply sm:` dentro de `@utility`); sin QA visual autenticada (Docker no disponible en esta sesión) |
| P2 | ✅ | (pendiente) | `<style>{expr}</style>` no interpola en Svelte (se trata como CSS del componente); solución: `{@html \`<style>${css}</style>\`}` | | P2 | ✅ | (pendiente) | `<style>{expr}</style>` no interpola en Svelte (se trata como CSS del componente); solución: `{@html \`<style>${css}</style>\`}` |
| P3 | ⬜ | | | | P3 | ✅ | (pendiente) | Se quitó el nombre bajo el avatar de perfil (decisión tomada: redundante con el título de CollectionHeader). Se añadió `size="fill"` a Artwork (no está en la spec) para las tarjetas de grid fluidas (AlbumCard/PlaylistCard/MixTile), ya que P9 (MediaCard) aún no existe. SearchResultRow perdió su prop `kind`/radio-por-tipo (ahora el radio lo decide Artwork/Avatar). Sin QA visual autenticada (Docker no disponible) |
| P4 | ⬜ | | | | P4 | ⬜ | | |
| P5 | ⬜ | | | | P5 | ⬜ | | |
| P6 | ⬜ | | | | P6 | ⬜ | | |
@@ -20,7 +20,7 @@
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ListPlus from '@lucide/svelte/icons/list-plus'; import ListPlus from '@lucide/svelte/icons/list-plus';
import ListEnd from '@lucide/svelte/icons/list-end'; import ListEnd from '@lucide/svelte/icons/list-end';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import GlassMenu from '$lib/components/ui/GlassMenu.svelte'; import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
interface Props { interface Props {
@@ -99,11 +99,10 @@
type="submit" type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover" class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
> >
<PlaylistArt <Artwork
playlistId={playlist.id} src="/api/playlists/{playlist.id}/cover?size=small"
trackIds={[]} size="xs"
size="small" class="shrink-0"
class="h-9 w-9 shrink-0 rounded-control"
/> />
<span class="truncate text-fg-2">{playlist.name}</span> <span class="truncate text-fg-2">{playlist.name}</span>
</button> </button>
@@ -5,6 +5,7 @@
import LogIn from '@lucide/svelte/icons/log-in'; import LogIn from '@lucide/svelte/icons/log-in';
import MenuItem from './ui/MenuItem.svelte'; import MenuItem from './ui/MenuItem.svelte';
import AccountMenu from './ui/AccountMenu.svelte'; import AccountMenu from './ui/AccountMenu.svelte';
import Avatar from './ui/Avatar.svelte';
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
@@ -40,15 +41,7 @@
aria-expanded={open} aria-expanded={open}
class="flex items-center rounded-full" class="flex items-center rounded-full"
> >
{#if user.picture} <Avatar name={user.name} src={user.picture} size="sm" />
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<span
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
</button> </button>
{/snippet} {/snippet}
{#snippet extraItems({ close })} {#snippet extraItems({ close })}
+7 -6
View File
@@ -4,7 +4,7 @@
import { liked } from '$lib/player/liked.svelte'; import { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte'; import { createPlaylistModal } from '$lib/playlists.svelte';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import PlaylistArt from './ui/PlaylistArt.svelte'; import Artwork from './ui/Artwork.svelte';
import Home from '@lucide/svelte/icons/house'; import Home from '@lucide/svelte/icons/house';
import Compass from '@lucide/svelte/icons/compass'; import Compass from '@lucide/svelte/icons/compass';
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
@@ -97,12 +97,13 @@
? 'bg-surface-hover' ? 'bg-surface-hover'
: 'hover:bg-hover'}" : 'hover:bg-hover'}"
> >
<PlaylistArt <Artwork
playlistId={playlist.id} src="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds} trackIds={playlist.coverTrackIds}
version={playlist.updatedAt} size="xs"
size="small" class="shrink-0 opacity-90"
class="h-8.5 w-8.5 shrink-0 overflow-hidden rounded-lg opacity-90"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-sm {active ? 'text-fg' : 'text-fg-2'}"> <div class="truncate text-sm {active ? 'text-fg' : 'text-fg-2'}">
+8 -10
View File
@@ -8,6 +8,7 @@
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import AccountMenu from './ui/AccountMenu.svelte'; import AccountMenu from './ui/AccountMenu.svelte';
import Avatar from './ui/Avatar.svelte';
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
@@ -113,17 +114,14 @@
onclick={toggle} onclick={toggle}
aria-label="Tu cuenta" aria-label="Tu cuenta"
aria-expanded={open} aria-expanded={open}
class="block h-8.25 w-8.25 shrink-0 overflow-hidden rounded-full border border-line-strong transition active:scale-95" class="block shrink-0 rounded-full transition active:scale-95"
> >
{#if user.picture} <Avatar
<img src={user.picture} alt="" class="h-full w-full object-cover opacity-90" /> name={user.name}
{:else} src={user.picture}
<span size="sm"
class="grid h-full w-full place-items-center bg-surface-2 text-xs font-semibold text-fg uppercase" class="border border-line-strong opacity-90"
> />
{user.name.charAt(0)}
</span>
{/if}
</button> </button>
{/snippet} {/snippet}
</AccountMenu> </AccountMenu>
@@ -21,7 +21,7 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import ListPlus from '@lucide/svelte/icons/list-plus'; import ListPlus from '@lucide/svelte/icons/list-plus';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import GlassMenu from '$lib/components/ui/GlassMenu.svelte'; import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
interface Props { interface Props {
@@ -91,11 +91,10 @@
type="submit" type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover" class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
> >
<PlaylistArt <Artwork
playlistId={playlist.id} src="/api/playlists/{playlist.id}/cover?size=small"
trackIds={[]} size="xs"
size="small" class="shrink-0"
class="h-9 w-9 shrink-0 rounded-control"
/> />
<span class="truncate text-fg-2">{playlist.name}</span> <span class="truncate text-fg-2">{playlist.name}</span>
</button> </button>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte'; import { queuePanel } from '$lib/player/queuePanel.svelte';
import Cover from '$lib/components/ui/Cover.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte'; import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import { fmtTime } from '$lib/format'; import { fmtTime } from '$lib/format';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
@@ -35,11 +35,11 @@
{#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-xs font-medium tracking-widest text-fg-3 uppercase">Reproduciendo</p>
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5"> <div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
<Cover <Artwork
trackId={player.current.id} trackIds={[player.current.id]}
size="small" size="sm"
alt={player.current.title} alt={player.current.title}
class="h-10 w-10 shrink-0 rounded-lg" class="shrink-0"
/> />
<div class="min-w-0"> <div class="min-w-0">
<div class="truncate text-sm text-accent-soft"> <div class="truncate text-sm text-accent-soft">
@@ -55,9 +55,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 <span class="min-w-0 flex-1 truncate text-xs font-medium tracking-widest text-fg-3 uppercase">
class="min-w-0 flex-1 truncate text-xs font-medium tracking-widest text-fg-3 uppercase"
>
A continuación A continuación
</span> </span>
{#if upcoming.length > 0} {#if upcoming.length > 0}
@@ -85,12 +83,7 @@
}} }}
class="flex items-center gap-3 rounded-control p-2.25 text-left hover:bg-hover" class="flex items-center gap-3 rounded-control p-2.25 text-left hover:bg-hover"
> >
<Cover <Artwork trackIds={[track.id]} size="sm" alt={track.title} class="shrink-0 opacity-80" />
trackId={track.id}
size="small"
alt={track.title}
class="h-10 w-10 shrink-0 rounded-lg opacity-80"
/>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-xs text-fg">{track.title}</div> <div class="truncate text-xs text-fg">{track.title}</div>
<ArtistLink <ArtistLink
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { liked } from '$lib/player/liked.svelte'; import { liked } from '$lib/player/liked.svelte';
import Cover from '$lib/components/ui/Cover.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte'; import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte'; import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import Heart from '@lucide/svelte/icons/heart'; import Heart from '@lucide/svelte/icons/heart';
@@ -29,14 +29,14 @@
<div class="flex min-w-0 items-center gap-2.5 md:gap-3.25"> <div class="flex min-w-0 items-center gap-2.5 md:gap-3.25">
{#if player.current.id} {#if player.current.id}
<Cover <Artwork
trackId={player.current.id} trackIds={[player.current.id]}
size="small" size="sm"
alt={player.current.title} alt={player.current.title}
class="h-11 w-11 shrink-0 rounded-xl md:h-12 md:w-12" class="shrink-0 md:size-cover-md"
/> />
{:else} {:else}
<div class="h-11 w-11 shrink-0 rounded-xl bg-surface md:h-12 md:w-12"></div> <div class="size-cover-sm shrink-0 rounded-art bg-surface md:size-cover-md"></div>
{/if} {/if}
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5"> <div class="flex min-w-0 items-center gap-1.5">
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import MediaCard from './MediaCard.svelte'; import MediaCard from './MediaCard.svelte';
import PlaylistArt from './PlaylistArt.svelte'; import Artwork from './Artwork.svelte';
import Cover from './Cover.svelte';
interface Props { interface Props {
id: string; id: string;
@@ -53,10 +52,6 @@
oncontextmenu={onContextMenu} oncontextmenu={onContextMenu}
> >
{#snippet art(artClass)} {#snippet art(artClass)}
{#if coverSrc} <Artwork src={coverSrc} {trackIds} size="fill" alt={title} class={artClass} />
<Cover trackId={id} src={coverSrc} size="large" alt={title} class={artClass} />
{:else}
<PlaylistArt {trackIds} class={artClass} />
{/if}
{/snippet} {/snippet}
</MediaCard> </MediaCard>
@@ -1,36 +0,0 @@
<script lang="ts">
interface Props {
name: string;
imageUrl?: string | null;
size?: number;
}
let { name, imageUrl, size = 108 }: Props = $props();
const initials = $derived(
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
);
</script>
<div class="flex flex-col items-center gap-2.5 text-center" style="width:{size}px">
<div
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface ring-1 ring-line transition duration-300 ease-out ring-inset"
style="width:{size}px;height:{size}px"
>
{#if imageUrl}
<img src={imageUrl} alt="" class="absolute inset-0 h-full w-full object-cover" />
{:else}
<div
class="pointer-events-none absolute inset-0"
style="background:repeating-linear-gradient(135deg, color-mix(in oklch, var(--mf-text) 12%, transparent) 0 2px, transparent 2px 10px)"
></div>
<span class="relative text-lg font-semibold text-fg">{initials}</span>
{/if}
</div>
<div class="w-full truncate text-sm text-fg">{name}</div>
</div>
@@ -0,0 +1,133 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'hero' | 'fill';
interface Props {
src?: string | null;
trackIds?: (string | number)[];
size: Size;
shape?: 'auto' | 'round';
imageSize?: 'small' | 'medium' | 'large';
fallback?: 'music' | 'gradient' | 'none';
alt?: string;
class?: string;
children?: Snippet;
}
let {
src,
trackIds = [],
size,
shape = 'auto',
imageSize,
fallback = 'music',
alt = '',
class: klass = '',
children
}: Props = $props();
const DIMENSION: Record<Size, string> = {
xs: 'size-cover-xs',
sm: 'size-cover-sm',
md: 'size-cover-md',
lg: 'size-cover-lg',
xl: 'size-cover-xl',
'2xl': 'size-cover-2xl',
hero: 'size-cover-hero sm:size-cover-hero-sm',
fill: 'aspect-square w-full'
};
const RADIUS: Record<Size, string> = {
xs: 'rounded-thumb',
sm: 'rounded-art',
md: 'rounded-art',
lg: 'rounded-art',
xl: 'rounded-art',
'2xl': 'rounded-art-lg',
hero: 'rounded-art-lg',
fill: ''
};
const AUTO_IMAGE_SIZE: Record<Size, 'small' | 'medium' | 'large'> = {
xs: 'small',
sm: 'small',
md: 'medium',
lg: 'medium',
xl: 'medium',
'2xl': 'large',
hero: 'large',
fill: 'large'
};
const resolvedImageSize = $derived(imageSize ?? AUTO_IMAGE_SIZE[size]);
const radius = $derived(shape === 'round' ? 'rounded-full' : RADIUS[size]);
let srcFailed = $state(false);
$effect(() => {
src;
srcFailed = false;
});
let singleFailed = $state(false);
$effect(() => {
trackIds[0];
singleFailed = false;
});
const showSrc = $derived(!!src && !srcFailed);
const showMosaic = $derived(!showSrc && trackIds.length >= 4);
const singleId = $derived(!showSrc && !showMosaic && trackIds.length > 0 ? trackIds[0] : null);
const showSingle = $derived(singleId !== null && !singleFailed);
</script>
<div class="relative overflow-hidden {DIMENSION[size]} {radius} {klass}">
{#if showSrc}
<img
{src}
{alt}
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={() => (srcFailed = true)}
class="h-full w-full object-cover"
/>
{:else if showMosaic}
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
{#each trackIds.slice(0, 4) as id (id)}
<img
src="/api/tracks/{id}/cover?size=small"
alt=""
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={(e) => ((e.currentTarget as HTMLImageElement).style.visibility = 'hidden')}
class="h-full w-full object-cover"
/>
{/each}
</div>
{:else if showSingle}
<img
src="/api/tracks/{singleId}/cover?size={resolvedImageSize}"
{alt}
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={() => (singleFailed = true)}
class="h-full w-full object-cover"
/>
{:else if fallback === 'music'}
<div class="grid h-full w-full place-items-center bg-surface">
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-3">
<path
d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z"
/>
</svg>
</div>
{:else if fallback === 'gradient'}
<div class="h-full w-full bg-[image:var(--mf-cover-grad)]"></div>
{:else}
<div class="h-full w-full bg-surface"></div>
{/if}
{@render children?.()}
</div>
@@ -0,0 +1,60 @@
<script lang="ts">
type Size = 'sm' | 'md' | 'lg' | 'hero';
interface Props {
name: string;
src?: string | null;
size: Size;
class?: string;
}
let { name, src, size, class: klass = '' }: Props = $props();
const DIMENSION: Record<Size, string> = {
sm: 'size-cover-xs',
md: 'size-cover-lg',
lg: 'size-cover-xl',
hero: 'size-cover-hero'
};
const TEXT: Record<Size, string> = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-xl',
hero: 'text-3xl'
};
const initials = $derived(
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
);
let failed = $state(false);
$effect(() => {
src;
failed = false;
});
</script>
<div
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface-2 {DIMENSION[
size
]} {klass}"
>
{#if src && !failed}
<img
{src}
alt=""
loading="lazy"
decoding="async"
onerror={() => (failed = true)}
class="absolute inset-0 h-full w-full object-cover"
/>
{:else}
<span class="font-semibold text-fg uppercase {TEXT[size]}">{initials}</span>
{/if}
</div>
@@ -1,47 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
trackId: string | number;
size?: 'small' | 'medium' | 'large';
src?: string | null;
alt?: string;
class?: string;
children?: Snippet;
oncontextmenu?: (event: MouseEvent) => void;
}
let {
trackId,
size = 'medium',
src,
alt = '',
class: klass = '',
children,
oncontextmenu
}: Props = $props();
let failed = $state(false);
const imageSrc = $derived(src ?? `/api/tracks/${trackId}/cover?size=${size}`);
$effect(() => {
imageSrc;
failed = false;
});
</script>
<div class="relative overflow-hidden bg-surface {klass}" role="presentation" {oncontextmenu}>
{#if !failed}
<img
src={imageSrc}
{alt}
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={() => (failed = true)}
class="h-full w-full object-cover"
/>
{/if}
{@render children?.()}
</div>
@@ -1,25 +0,0 @@
<script lang="ts">
import Cover from './Cover.svelte';
import { mixItemKey, type MixItem } from '$lib/mixes';
interface Props {
items: MixItem[];
class?: string;
}
let { items, class: klass = '' }: Props = $props();
const tiles = $derived(items.slice(0, 4));
</script>
{#if tiles.length >= 4}
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
{#each tiles as item (mixItemKey(item))}
<Cover trackId={item.trackId} size="small" class="h-full w-full" />
{/each}
</div>
{:else if tiles.length > 0}
<Cover trackId={tiles[0].trackId} size="medium" class={klass} />
{:else}
<div class="bg-surface {klass}"></div>
{/if}
+13 -10
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Play from '@lucide/svelte/icons/play'; import Play from '@lucide/svelte/icons/play';
import MixArt from './MixArt.svelte'; import Artwork from './Artwork.svelte';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { queueItemForTarget } from '$lib/tracks'; import { queueItemForTarget } from '$lib/tracks';
import { mixItemTarget, type Mix } from '$lib/mixes'; import { mixItemTarget, type Mix } from '$lib/mixes';
@@ -13,6 +13,8 @@
let { mix, index = 0, class: klass = '' }: Props = $props(); let { mix, index = 0, class: klass = '' }: Props = $props();
const trackIds = $derived(mix.items.map((item) => item.trackId));
function play(event: MouseEvent) { function play(event: MouseEvent) {
event.preventDefault(); event.preventDefault();
const items = mix.items.map((item) => queueItemForTarget(mixItemTarget(item))); const items = mix.items.map((item) => queueItemForTarget(mixItemTarget(item)));
@@ -23,15 +25,16 @@
<a <a
href="/mixes/{mix.id}" href="/mixes/{mix.id}"
class="group/tile animate-enter block cursor-pointer {klass}" class="group/tile animate-enter block cursor-pointer {klass}"
style="animation-delay:{Math.min(index, 10) * 45}ms" style="--i:{index}"
> >
<div class="relative aspect-square overflow-hidden rounded-art"> <Artwork
<MixArt {trackIds}
items={mix.items} size="fill"
class="h-full w-full scale-100 transition duration-300 group-hover/tile:scale-105" alt={mix.title}
/> class="rounded-art transition duration-300 group-hover/tile:scale-105"
>
<div <div
class="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent from-45% to-black/42" class="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent from-45% to-scrim/40"
></div> ></div>
<button <button
type="button" type="button"
@@ -41,8 +44,8 @@
> >
<Play class="h-3.5 w-3.5" fill="currentColor" /> <Play class="h-3.5 w-3.5" fill="currentColor" />
</button> </button>
</div> </Artwork>
<div class="mt-3.25 truncate text-sm font-semibold tracking-[-0.01em] text-fg"> <div class="mt-3.25 truncate text-sm font-semibold text-fg">
{mix.title} {mix.title}
</div> </div>
{#if mix.subtitle} {#if mix.subtitle}
@@ -1,61 +0,0 @@
<script lang="ts">
import Cover from './Cover.svelte';
interface Props {
playlistId?: string;
coverUrl?: string;
trackIds: (string | number)[];
size?: 'small' | 'medium' | 'large';
version?: string;
class?: string;
}
let {
playlistId,
coverUrl,
trackIds,
size = 'medium',
version,
class: klass = ''
}: Props = $props();
let coverFailed = $state(false);
const mosaic = $derived(trackIds.length >= 4);
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
const src = $derived(
coverUrl
? coverUrl
: playlistId
? `/api/playlists/${playlistId}/cover?size=${size}${version ? `&v=${encodeURIComponent(version)}` : ''}`
: undefined
);
</script>
{#if src && !coverFailed}
<div class="relative overflow-hidden bg-surface {klass}">
<img
{src}
alt=""
loading="lazy"
onerror={() => (coverFailed = true)}
class="h-full w-full object-cover"
/>
</div>
{:else if mosaic}
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
{#each trackIds.slice(0, 4) as id (id)}
<Cover trackId={id} size="small" class="h-full w-full" />
{/each}
</div>
{:else if single !== null}
<Cover trackId={single} size="medium" class={klass} />
{:else}
<div class="flex items-center justify-center bg-surface {klass}">
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-3">
<path
d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z"
/>
</svg>
</div>
{/if}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import MediaCard from './MediaCard.svelte'; import MediaCard from './MediaCard.svelte';
import PlaylistArt from './PlaylistArt.svelte'; import Artwork from './Artwork.svelte';
interface Props { interface Props {
id: string; id: string;
@@ -18,6 +18,12 @@
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}> <MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
{#snippet art(artClass)} {#snippet art(artClass)}
<PlaylistArt playlistId={id} {trackIds} version={updatedAt} class={artClass} /> <Artwork
src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
{trackIds}
size="fill"
alt={name}
class={artClass}
/>
{/snippet} {/snippet}
</MediaCard> </MediaCard>
@@ -1,20 +1,8 @@
<script lang="ts" module>
export type SearchResultKind = 'track' | 'album' | 'playlist' | 'user';
</script>
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import ExplicitBadge from './ExplicitBadge.svelte'; import ExplicitBadge from './ExplicitBadge.svelte';
const RADIUS: Record<SearchResultKind, string> = {
track: 'rounded-control',
album: 'rounded-md',
playlist: 'rounded-art',
user: 'rounded-full'
};
interface Props { interface Props {
kind: SearchResultKind;
title: string; title: string;
subtitle?: string | null; subtitle?: string | null;
meta?: string; meta?: string;
@@ -26,7 +14,6 @@
} }
let { let {
kind,
title, title,
subtitle, subtitle,
meta, meta,
@@ -37,7 +24,7 @@
art art
}: Props = $props(); }: Props = $props();
const artClass = $derived(`relative h-13 w-13 shrink-0 overflow-hidden ${RADIUS[kind]}`); const artClass = 'relative shrink-0';
</script> </script>
{#snippet content()} {#snippet content()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import Cover from './Cover.svelte'; import Artwork from './Artwork.svelte';
import ExplicitBadge from './ExplicitBadge.svelte'; import ExplicitBadge from './ExplicitBadge.svelte';
import ArtistLink from './ArtistLink.svelte'; import ArtistLink from './ArtistLink.svelte';
@@ -10,7 +10,7 @@
artist?: string | null; artist?: string | null;
ownerUserId?: string | number | null; ownerUserId?: string | number | null;
coverSrc?: string | null; coverSrc?: string | null;
coverSize?: string; size?: 'sm' | 'lg';
explicit?: boolean; explicit?: boolean;
active?: boolean; active?: boolean;
titleClass?: string; titleClass?: string;
@@ -25,7 +25,7 @@
artist, artist,
ownerUserId, ownerUserId,
coverSrc, coverSrc,
coverSize = 'h-10.5 w-10.5', size = 'sm',
explicit = false, explicit = false,
active = false, active = false,
titleClass = '', titleClass = '',
@@ -53,15 +53,9 @@
onkeydown={onKeydown} onkeydown={onKeydown}
class="flex min-w-0 items-center gap-3.5 text-left" class="flex min-w-0 items-center gap-3.5 text-left"
> >
<Cover <Artwork trackIds={[trackId]} src={coverSrc} {size} alt={title} class="shrink-0">
{trackId}
src={coverSrc}
size="small"
alt={title}
class="{coverSize} shrink-0 rounded-control"
>
{@render overlay?.()} {@render overlay?.()}
</Cover> </Artwork>
<div class="min-w-0"> <div class="min-w-0">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
{#if explicit} {#if explicit}
@@ -3,7 +3,7 @@
import Pause from '@lucide/svelte/icons/pause'; import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen'; import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash'; import Trash from '@lucide/svelte/icons/trash';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import BackLink from '$lib/components/ui/BackLink.svelte'; import BackLink from '$lib/components/ui/BackLink.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
@@ -51,12 +51,7 @@
<CollectionHeader eyebrow="Álbum" {title} {description} {meta}> <CollectionHeader eyebrow="Álbum" {title} {description} {meta}>
{#snippet cover()} {#snippet cover()}
<PlaylistArt <Artwork src={coverUrl} {trackIds} size="hero" alt={title} class="shrink-0" />
{trackIds}
{coverUrl}
size="large"
class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41"
/>
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}> <Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
@@ -9,8 +9,8 @@
import Alert from '$lib/components/ui/Alert.svelte'; import Alert from '$lib/components/ui/Alert.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import Cover from '$lib/components/ui/Cover.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import Avatar from '$lib/components/ui/Avatar.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte'; import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte'; import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
@@ -296,40 +296,25 @@
{#if topResult} {#if topResult}
{#snippet topResultBody()} {#snippet topResultBody()}
{#if topResult.kind === 'track'} {#if topResult.kind === 'track'}
<Cover <Artwork
trackId={targetId(topResult.target)} trackIds={[targetId(topResult.target)]}
size="large" size="xl"
alt={targetTitle(topResult.target)} alt={targetTitle(topResult.target)}
class="h-22 w-22 shrink-0 rounded-2xl" class="shrink-0"
/> />
{:else if topResult.kind === 'album'} {:else if topResult.kind === 'album'}
<PlaylistArt <Artwork trackIds={topResult.album.coverTrackIds} size="xl" class="shrink-0" />
trackIds={topResult.album.coverTrackIds}
class="h-22 w-22 shrink-0 rounded-2xl"
/>
{:else if topResult.kind === 'playlist'} {:else if topResult.kind === 'playlist'}
<PlaylistArt <Artwork
playlistId={topResult.playlist.id} src="/api/playlists/{topResult.playlist.id}/cover?size=large&v={encodeURIComponent(
topResult.playlist.updatedAt
)}"
trackIds={topResult.playlist.coverTrackIds} trackIds={topResult.playlist.coverTrackIds}
version={topResult.playlist.updatedAt} size="xl"
class="h-22 w-22 shrink-0 rounded-2xl" class="shrink-0"
/> />
{:else} {:else}
<div <Avatar name={topResult.user.name} src={topResult.user.profilePictureUrl} size="lg" />
class="grid h-22 w-22 shrink-0 place-items-center overflow-hidden rounded-full bg-surface-2"
>
{#if topResult.user.profilePictureUrl}
<img
src={topResult.user.profilePictureUrl}
alt=""
class="h-full w-full object-cover"
/>
{:else}
<span class="text-xl font-semibold text-fg uppercase"
>{topResult.user.name.charAt(0)}</span
>
{/if}
</div>
{/if} {/if}
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3"> <div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3">
@@ -443,7 +428,6 @@
{#each capped(songRows) as { target: item, seconds }, i (targetId(item))} {#each capped(songRows) as { target: item, seconds }, i (targetId(item))}
<li> <li>
<SearchResultRow <SearchResultRow
kind="track"
title={targetTitle(item)} title={targetTitle(item)}
subtitle={targetArtist(item)} subtitle={targetArtist(item)}
meta="{fmtTime(seconds)} · {fmtPlays(targetListensCount(item))}" meta="{fmtTime(seconds)} · {fmtPlays(targetListensCount(item))}"
@@ -452,16 +436,16 @@
oncontextmenu={(e) => openContextMenu(e, item)} oncontextmenu={(e) => openContextMenu(e, item)}
> >
{#snippet art(artClass)} {#snippet art(artClass)}
<Cover <Artwork
trackId={targetId(item)} trackIds={[targetId(item)]}
size="small" size="lg"
alt={targetTitle(item)} alt={targetTitle(item)}
class="{artClass} ring-1 ring-line ring-inset" class="{artClass} ring-1 ring-line ring-inset"
> >
{#if isTargetCurrent(item)} {#if isTargetCurrent(item)}
<NowPlaying paused={!player.playing} /> <NowPlaying paused={!player.playing} />
{/if} {/if}
</Cover> </Artwork>
{/snippet} {/snippet}
</SearchResultRow> </SearchResultRow>
</li> </li>
@@ -495,14 +479,13 @@
{#each capped(albums) as album (album.id)} {#each capped(albums) as album (album.id)}
<li> <li>
<SearchResultRow <SearchResultRow
kind="album"
title={album.title} title={album.title}
meta="{Number(album.trackCount)} canciones" meta="{Number(album.trackCount)} canciones"
href="/albums/{album.id}" href="/albums/{album.id}"
oncontextmenu={(e) => openAlbumMenu(e, album.id)} oncontextmenu={(e) => openAlbumMenu(e, album.id)}
> >
{#snippet art(artClass)} {#snippet art(artClass)}
<PlaylistArt trackIds={album.coverTrackIds} class={artClass} /> <Artwork trackIds={album.coverTrackIds} size="lg" class={artClass} />
{/snippet} {/snippet}
</SearchResultRow> </SearchResultRow>
</li> </li>
@@ -525,17 +508,9 @@
<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>
<SearchResultRow kind="user" title={u.name} href="/u/{u.id}"> <SearchResultRow title={u.name} href="/u/{u.id}">
{#snippet art(artClass)} {#snippet art(artClass)}
<div class="{artClass} grid place-items-center bg-surface-2"> <Avatar name={u.name} src={u.profilePictureUrl} size="md" class={artClass} />
{#if u.profilePictureUrl}
<img src={u.profilePictureUrl} alt="" class="h-full w-full object-cover" />
{:else}
<span class="text-sm font-semibold text-fg uppercase">
{u.name.charAt(0)}
</span>
{/if}
</div>
{/snippet} {/snippet}
</SearchResultRow> </SearchResultRow>
</li> </li>
@@ -559,16 +534,17 @@
{#each capped(playlistMatches) as playlist (playlist.id)} {#each capped(playlistMatches) as playlist (playlist.id)}
<li> <li>
<SearchResultRow <SearchResultRow
kind="playlist"
title={playlist.name} title={playlist.name}
subtitle={playlist.description} subtitle={playlist.description}
href="/playlists/{playlist.id}" href="/playlists/{playlist.id}"
> >
{#snippet art(artClass)} {#snippet art(artClass)}
<PlaylistArt <Artwork
playlistId={playlist.id} src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds} trackIds={playlist.coverTrackIds}
version={playlist.updatedAt} size="lg"
class={artClass} class={artClass}
/> />
{/snippet} {/snippet}
@@ -9,7 +9,7 @@
import BackLink from '$lib/components/ui/BackLink.svelte'; import BackLink from '$lib/components/ui/BackLink.svelte';
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 MixArt from '$lib/components/ui/MixArt.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte'; import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte'; import TrackRow from '$lib/components/ui/TrackRow.svelte';
@@ -75,7 +75,12 @@
meta="{items.length} {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}" meta="{items.length} {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}"
> >
{#snippet cover()} {#snippet cover()}
<MixArt {items} class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41" /> <Artwork
trackIds={items.map((item) => item.trackId)}
size="hero"
alt={mix.title}
class="shrink-0"
/>
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
<Button size="sm" onclick={playAll} disabled={queue.length === 0}> <Button size="sm" onclick={playAll} disabled={queue.length === 0}>
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
@@ -44,12 +44,12 @@
align="center" align="center"
> >
{#snippet cover()} {#snippet cover()}
<PlaylistArt <Artwork
playlistId={id} src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
{trackIds} {trackIds}
size="large" size="hero"
version={updatedAt} alt={name}
class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41" class="shrink-0"
/> />
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
@@ -7,7 +7,7 @@
import PlaylistCard from '$lib/components/ui/PlaylistCard.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 ArtistAvatar from '$lib/components/ui/ArtistAvatar.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';
@@ -65,7 +65,7 @@
actions={canFollow ? followAction : undefined} actions={canFollow ? followAction : undefined}
> >
{#snippet cover()} {#snippet cover()}
<ArtistAvatar name={profile.name} imageUrl={profile.profilePictureUrl} size={144} /> <Avatar name={profile.name} src={profile.profilePictureUrl} size="hero" />
{/snippet} {/snippet}
</CollectionHeader> </CollectionHeader>
+20 -21
View File
@@ -20,7 +20,7 @@
type TrackTarget type TrackTarget
} from '$lib/tracks'; } from '$lib/tracks';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte'; import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import MixTile from '$lib/components/ui/MixTile.svelte'; import MixTile from '$lib/components/ui/MixTile.svelte';
import EqBars from '$lib/components/ui/EqBars.svelte'; import EqBars from '$lib/components/ui/EqBars.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte'; import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
@@ -215,7 +215,7 @@
title={item.title} title={item.title}
artist={item.artist} artist={item.artist}
ownerUserId={item.ownerUserId} ownerUserId={item.ownerUserId}
coverSize="h-14 w-14" size="lg"
active={isCurrent} active={isCurrent}
onClick={() => playContinue(i)} onClick={() => playContinue(i)}
/> />
@@ -250,12 +250,14 @@
onclick={() => goto(`/playlists/${spotlight.id}`)} onclick={() => goto(`/playlists/${spotlight.id}`)}
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${spotlight.id}`))} onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${spotlight.id}`))}
> >
<PlaylistArt <Artwork
playlistId={spotlight.id} src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
spotlight.updatedAt
)}"
trackIds={spotlight.coverTrackIds} trackIds={spotlight.coverTrackIds}
version={spotlight.updatedAt} size="2xl"
size="large" alt={spotlight.name}
class="h-34 w-34 shrink-0 rounded-2xl" 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-xs font-medium tracking-widest text-fg-2 uppercase">
@@ -292,9 +294,7 @@
? 'bg-accent-tint' ? 'bg-accent-tint'
: 'hover:bg-hover'}" : 'hover:bg-hover'}"
> >
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums" <span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums">{i + 1}</span>
>{i + 1}</span
>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div <div
class="truncate text-sm {isTargetCurrent(target) class="truncate text-sm {isTargetCurrent(target)
@@ -309,9 +309,7 @@
class="mt-0.5 text-xs text-fg-3" class="mt-0.5 text-xs text-fg-3"
/> />
</div> </div>
<span class="shrink-0 text-xs text-muted tabular-nums" <span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
>{fmtTime(seconds)}</span
>
</div> </div>
{:else} {:else}
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p> <p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
@@ -369,7 +367,6 @@
title={targetTitle(target)} title={targetTitle(target)}
artist={targetArtist(target)} artist={targetArtist(target)}
ownerUserId={targetOwnerUserId(target)} ownerUserId={targetOwnerUserId(target)}
coverSize="h-10 w-10"
explicit={targetExplicit(target)} explicit={targetExplicit(target)}
active={isCurrent} active={isCurrent}
onClick={() => playPopular(i)} onClick={() => playPopular(i)}
@@ -414,17 +411,19 @@
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
class="group/card animate-enter flex items-center gap-3.5 rounded-2xl p-3 transition-colors hover:bg-hover" class="group/card animate-enter flex items-center gap-3.5 rounded-panel p-3 transition-colors hover:bg-hover"
style="animation-delay:{Math.min(i, 10) * 40}ms" style="--i:{i}"
onclick={() => goto(`/playlists/${playlist.id}`)} onclick={() => goto(`/playlists/${playlist.id}`)}
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${playlist.id}`))} onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${playlist.id}`))}
> >
<PlaylistArt <Artwork
playlistId={playlist.id} src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds} trackIds={playlist.coverTrackIds}
version={playlist.updatedAt} size="lg"
size="medium" alt={playlist.name}
class="h-15.5 w-15.5 shrink-0 rounded-control" class="shrink-0"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-sm tracking-tight text-fg"> <div class="truncate text-sm tracking-tight text-fg">