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:
@@ -775,7 +775,7 @@ Tareas:
|
||||
| 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) |
|
||||
| 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 | ⬜ | | |
|
||||
| P5 | ⬜ | | |
|
||||
| P6 | ⬜ | | |
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
@@ -99,11 +99,10 @@
|
||||
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"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
trackIds={[]}
|
||||
size="small"
|
||||
class="h-9 w-9 shrink-0 rounded-control"
|
||||
<Artwork
|
||||
src="/api/playlists/{playlist.id}/cover?size=small"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="truncate text-fg-2">{playlist.name}</span>
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import LogIn from '@lucide/svelte/icons/log-in';
|
||||
import MenuItem from './ui/MenuItem.svelte';
|
||||
import AccountMenu from './ui/AccountMenu.svelte';
|
||||
import Avatar from './ui/Avatar.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
@@ -40,15 +41,7 @@
|
||||
aria-expanded={open}
|
||||
class="flex items-center rounded-full"
|
||||
>
|
||||
{#if user.picture}
|
||||
<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}
|
||||
<Avatar name={user.name} src={user.picture} size="sm" />
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet extraItems({ close })}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||
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 Compass from '@lucide/svelte/icons/compass';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
@@ -97,12 +97,13 @@
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-hover'}"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
<Artwork
|
||||
src="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
trackIds={playlist.coverTrackIds}
|
||||
version={playlist.updatedAt}
|
||||
size="small"
|
||||
class="h-8.5 w-8.5 shrink-0 overflow-hidden rounded-lg opacity-90"
|
||||
size="xs"
|
||||
class="shrink-0 opacity-90"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm {active ? 'text-fg' : 'text-fg-2'}">
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import AccountMenu from './ui/AccountMenu.svelte';
|
||||
import Avatar from './ui/Avatar.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
@@ -113,17 +114,14 @@
|
||||
onclick={toggle}
|
||||
aria-label="Tu cuenta"
|
||||
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}
|
||||
<img src={user.picture} alt="" class="h-full w-full object-cover opacity-90" />
|
||||
{:else}
|
||||
<span
|
||||
class="grid h-full w-full place-items-center bg-surface-2 text-xs font-semibold text-fg uppercase"
|
||||
>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
<Avatar
|
||||
name={user.name}
|
||||
src={user.picture}
|
||||
size="sm"
|
||||
class="border border-line-strong opacity-90"
|
||||
/>
|
||||
</button>
|
||||
{/snippet}
|
||||
</AccountMenu>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import { enhance } from '$app/forms';
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
@@ -91,11 +91,10 @@
|
||||
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"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
trackIds={[]}
|
||||
size="small"
|
||||
class="h-9 w-9 shrink-0 rounded-control"
|
||||
<Artwork
|
||||
src="/api/playlists/{playlist.id}/cover?size=small"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="truncate text-fg-2">{playlist.name}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.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 { fmtTime } from '$lib/format';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
@@ -35,11 +35,11 @@
|
||||
{#if player.current.id}
|
||||
<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">
|
||||
<Cover
|
||||
trackId={player.current.id}
|
||||
size="small"
|
||||
<Artwork
|
||||
trackIds={[player.current.id]}
|
||||
size="sm"
|
||||
alt={player.current.title}
|
||||
class="h-10 w-10 shrink-0 rounded-lg"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm text-accent-soft">
|
||||
@@ -55,9 +55,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"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-xs font-medium tracking-widest text-fg-3 uppercase">
|
||||
A continuación
|
||||
</span>
|
||||
{#if upcoming.length > 0}
|
||||
@@ -85,12 +83,7 @@
|
||||
}}
|
||||
class="flex items-center gap-3 rounded-control p-2.25 text-left hover:bg-hover"
|
||||
>
|
||||
<Cover
|
||||
trackId={track.id}
|
||||
size="small"
|
||||
alt={track.title}
|
||||
class="h-10 w-10 shrink-0 rounded-lg opacity-80"
|
||||
/>
|
||||
<Artwork trackIds={[track.id]} size="sm" alt={track.title} class="shrink-0 opacity-80" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-xs text-fg">{track.title}</div>
|
||||
<ArtistLink
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.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 ArtistLink from '$lib/components/ui/ArtistLink.svelte';
|
||||
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">
|
||||
{#if player.current.id}
|
||||
<Cover
|
||||
trackId={player.current.id}
|
||||
size="small"
|
||||
<Artwork
|
||||
trackIds={[player.current.id]}
|
||||
size="sm"
|
||||
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}
|
||||
<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}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import MediaCard from './MediaCard.svelte';
|
||||
import PlaylistArt from './PlaylistArt.svelte';
|
||||
import Cover from './Cover.svelte';
|
||||
import Artwork from './Artwork.svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -53,10 +52,6 @@
|
||||
oncontextmenu={onContextMenu}
|
||||
>
|
||||
{#snippet art(artClass)}
|
||||
{#if coverSrc}
|
||||
<Cover trackId={id} src={coverSrc} size="large" alt={title} class={artClass} />
|
||||
{:else}
|
||||
<PlaylistArt {trackIds} class={artClass} />
|
||||
{/if}
|
||||
<Artwork src={coverSrc} {trackIds} size="fill" alt={title} class={artClass} />
|
||||
{/snippet}
|
||||
</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}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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 { queueItemForTarget } from '$lib/tracks';
|
||||
import { mixItemTarget, type Mix } from '$lib/mixes';
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
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)));
|
||||
@@ -23,15 +25,16 @@
|
||||
<a
|
||||
href="/mixes/{mix.id}"
|
||||
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">
|
||||
<MixArt
|
||||
items={mix.items}
|
||||
class="h-full w-full scale-100 transition duration-300 group-hover/tile:scale-105"
|
||||
/>
|
||||
<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-black/42"
|
||||
class="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent from-45% to-scrim/40"
|
||||
></div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -41,8 +44,8 @@
|
||||
>
|
||||
<Play class="h-3.5 w-3.5" fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3.25 truncate text-sm font-semibold tracking-[-0.01em] text-fg">
|
||||
</Artwork>
|
||||
<div class="mt-3.25 truncate text-sm font-semibold text-fg">
|
||||
{mix.title}
|
||||
</div>
|
||||
{#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">
|
||||
import MediaCard from './MediaCard.svelte';
|
||||
import PlaylistArt from './PlaylistArt.svelte';
|
||||
import Artwork from './Artwork.svelte';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -18,6 +18,12 @@
|
||||
|
||||
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
|
||||
{#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}
|
||||
</MediaCard>
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
<script lang="ts" module>
|
||||
export type SearchResultKind = 'track' | 'album' | 'playlist' | 'user';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from '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 {
|
||||
kind: SearchResultKind;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
meta?: string;
|
||||
@@ -26,7 +14,6 @@
|
||||
}
|
||||
|
||||
let {
|
||||
kind,
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
@@ -37,7 +24,7 @@
|
||||
art
|
||||
}: Props = $props();
|
||||
|
||||
const artClass = $derived(`relative h-13 w-13 shrink-0 overflow-hidden ${RADIUS[kind]}`);
|
||||
const artClass = 'relative shrink-0';
|
||||
</script>
|
||||
|
||||
{#snippet content()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Cover from './Cover.svelte';
|
||||
import Artwork from './Artwork.svelte';
|
||||
import ExplicitBadge from './ExplicitBadge.svelte';
|
||||
import ArtistLink from './ArtistLink.svelte';
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
artist?: string | null;
|
||||
ownerUserId?: string | number | null;
|
||||
coverSrc?: string | null;
|
||||
coverSize?: string;
|
||||
size?: 'sm' | 'lg';
|
||||
explicit?: boolean;
|
||||
active?: boolean;
|
||||
titleClass?: string;
|
||||
@@ -25,7 +25,7 @@
|
||||
artist,
|
||||
ownerUserId,
|
||||
coverSrc,
|
||||
coverSize = 'h-10.5 w-10.5',
|
||||
size = 'sm',
|
||||
explicit = false,
|
||||
active = false,
|
||||
titleClass = '',
|
||||
@@ -53,15 +53,9 @@
|
||||
onkeydown={onKeydown}
|
||||
class="flex min-w-0 items-center gap-3.5 text-left"
|
||||
>
|
||||
<Cover
|
||||
{trackId}
|
||||
src={coverSrc}
|
||||
size="small"
|
||||
alt={title}
|
||||
class="{coverSize} shrink-0 rounded-control"
|
||||
>
|
||||
<Artwork trackIds={[trackId]} src={coverSrc} {size} alt={title} class="shrink-0">
|
||||
{@render overlay?.()}
|
||||
</Cover>
|
||||
</Artwork>
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
{#if explicit}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
||||
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 Button from '$lib/components/ui/Button.svelte';
|
||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
||||
@@ -51,12 +51,7 @@
|
||||
|
||||
<CollectionHeader eyebrow="Álbum" {title} {description} {meta}>
|
||||
{#snippet cover()}
|
||||
<PlaylistArt
|
||||
{trackIds}
|
||||
{coverUrl}
|
||||
size="large"
|
||||
class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41"
|
||||
/>
|
||||
<Artwork src={coverUrl} {trackIds} size="hero" alt={title} class="shrink-0" />
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte';
|
||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
@@ -296,40 +296,25 @@
|
||||
{#if topResult}
|
||||
{#snippet topResultBody()}
|
||||
{#if topResult.kind === 'track'}
|
||||
<Cover
|
||||
trackId={targetId(topResult.target)}
|
||||
size="large"
|
||||
<Artwork
|
||||
trackIds={[targetId(topResult.target)]}
|
||||
size="xl"
|
||||
alt={targetTitle(topResult.target)}
|
||||
class="h-22 w-22 shrink-0 rounded-2xl"
|
||||
class="shrink-0"
|
||||
/>
|
||||
{:else if topResult.kind === 'album'}
|
||||
<PlaylistArt
|
||||
trackIds={topResult.album.coverTrackIds}
|
||||
class="h-22 w-22 shrink-0 rounded-2xl"
|
||||
/>
|
||||
<Artwork trackIds={topResult.album.coverTrackIds} size="xl" class="shrink-0" />
|
||||
{:else if topResult.kind === 'playlist'}
|
||||
<PlaylistArt
|
||||
playlistId={topResult.playlist.id}
|
||||
<Artwork
|
||||
src="/api/playlists/{topResult.playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||
topResult.playlist.updatedAt
|
||||
)}"
|
||||
trackIds={topResult.playlist.coverTrackIds}
|
||||
version={topResult.playlist.updatedAt}
|
||||
class="h-22 w-22 shrink-0 rounded-2xl"
|
||||
size="xl"
|
||||
class="shrink-0"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
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>
|
||||
<Avatar name={topResult.user.name} src={topResult.user.profilePictureUrl} size="lg" />
|
||||
{/if}
|
||||
|
||||
<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))}
|
||||
<li>
|
||||
<SearchResultRow
|
||||
kind="track"
|
||||
title={targetTitle(item)}
|
||||
subtitle={targetArtist(item)}
|
||||
meta="{fmtTime(seconds)} · {fmtPlays(targetListensCount(item))}"
|
||||
@@ -452,16 +436,16 @@
|
||||
oncontextmenu={(e) => openContextMenu(e, item)}
|
||||
>
|
||||
{#snippet art(artClass)}
|
||||
<Cover
|
||||
trackId={targetId(item)}
|
||||
size="small"
|
||||
<Artwork
|
||||
trackIds={[targetId(item)]}
|
||||
size="lg"
|
||||
alt={targetTitle(item)}
|
||||
class="{artClass} ring-1 ring-line ring-inset"
|
||||
>
|
||||
{#if isTargetCurrent(item)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
</Artwork>
|
||||
{/snippet}
|
||||
</SearchResultRow>
|
||||
</li>
|
||||
@@ -495,14 +479,13 @@
|
||||
{#each capped(albums) as album (album.id)}
|
||||
<li>
|
||||
<SearchResultRow
|
||||
kind="album"
|
||||
title={album.title}
|
||||
meta="{Number(album.trackCount)} canciones"
|
||||
href="/albums/{album.id}"
|
||||
oncontextmenu={(e) => openAlbumMenu(e, album.id)}
|
||||
>
|
||||
{#snippet art(artClass)}
|
||||
<PlaylistArt trackIds={album.coverTrackIds} class={artClass} />
|
||||
<Artwork trackIds={album.coverTrackIds} size="lg" class={artClass} />
|
||||
{/snippet}
|
||||
</SearchResultRow>
|
||||
</li>
|
||||
@@ -525,17 +508,9 @@
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(users) as u (u.id)}
|
||||
<li>
|
||||
<SearchResultRow kind="user" title={u.name} href="/u/{u.id}">
|
||||
<SearchResultRow title={u.name} href="/u/{u.id}">
|
||||
{#snippet art(artClass)}
|
||||
<div class="{artClass} grid place-items-center bg-surface-2">
|
||||
{#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>
|
||||
<Avatar name={u.name} src={u.profilePictureUrl} size="md" class={artClass} />
|
||||
{/snippet}
|
||||
</SearchResultRow>
|
||||
</li>
|
||||
@@ -559,16 +534,17 @@
|
||||
{#each capped(playlistMatches) as playlist (playlist.id)}
|
||||
<li>
|
||||
<SearchResultRow
|
||||
kind="playlist"
|
||||
title={playlist.name}
|
||||
subtitle={playlist.description}
|
||||
href="/playlists/{playlist.id}"
|
||||
>
|
||||
{#snippet art(artClass)}
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
<Artwork
|
||||
src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
trackIds={playlist.coverTrackIds}
|
||||
version={playlist.updatedAt}
|
||||
size="lg"
|
||||
class={artClass}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import BackLink from '$lib/components/ui/BackLink.svelte';
|
||||
import Button from '$lib/components/ui/Button.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 TrackTable from '$lib/components/ui/TrackTable.svelte';
|
||||
import TrackRow from '$lib/components/ui/TrackRow.svelte';
|
||||
@@ -75,7 +75,12 @@
|
||||
meta="{items.length} {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}"
|
||||
>
|
||||
{#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 actions()}
|
||||
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
||||
|
||||
@@ -44,12 +44,12 @@
|
||||
align="center"
|
||||
>
|
||||
{#snippet cover()}
|
||||
<PlaylistArt
|
||||
playlistId={id}
|
||||
<Artwork
|
||||
src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
|
||||
{trackIds}
|
||||
size="large"
|
||||
version={updatedAt}
|
||||
class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41"
|
||||
size="hero"
|
||||
alt={name}
|
||||
class="shrink-0"
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.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 SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
actions={canFollow ? followAction : undefined}
|
||||
>
|
||||
{#snippet cover()}
|
||||
<ArtistAvatar name={profile.name} imageUrl={profile.profilePictureUrl} size={144} />
|
||||
<Avatar name={profile.name} src={profile.profilePictureUrl} size="hero" />
|
||||
{/snippet}
|
||||
</CollectionHeader>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
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 EqBars from '$lib/components/ui/EqBars.svelte';
|
||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||
@@ -215,7 +215,7 @@
|
||||
title={item.title}
|
||||
artist={item.artist}
|
||||
ownerUserId={item.ownerUserId}
|
||||
coverSize="h-14 w-14"
|
||||
size="lg"
|
||||
active={isCurrent}
|
||||
onClick={() => playContinue(i)}
|
||||
/>
|
||||
@@ -250,12 +250,14 @@
|
||||
onclick={() => goto(`/playlists/${spotlight.id}`)}
|
||||
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${spotlight.id}`))}
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={spotlight.id}
|
||||
<Artwork
|
||||
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
|
||||
spotlight.updatedAt
|
||||
)}"
|
||||
trackIds={spotlight.coverTrackIds}
|
||||
version={spotlight.updatedAt}
|
||||
size="large"
|
||||
class="h-34 w-34 shrink-0 rounded-2xl"
|
||||
size="2xl"
|
||||
alt={spotlight.name}
|
||||
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">
|
||||
@@ -292,9 +294,7 @@
|
||||
? 'bg-accent-tint'
|
||||
: 'hover:bg-hover'}"
|
||||
>
|
||||
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums"
|
||||
>{i + 1}</span
|
||||
>
|
||||
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums">{i + 1}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
class="truncate text-sm {isTargetCurrent(target)
|
||||
@@ -309,9 +309,7 @@
|
||||
class="mt-0.5 text-xs text-fg-3"
|
||||
/>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-muted tabular-nums"
|
||||
>{fmtTime(seconds)}</span
|
||||
>
|
||||
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
|
||||
@@ -369,7 +367,6 @@
|
||||
title={targetTitle(target)}
|
||||
artist={targetArtist(target)}
|
||||
ownerUserId={targetOwnerUserId(target)}
|
||||
coverSize="h-10 w-10"
|
||||
explicit={targetExplicit(target)}
|
||||
active={isCurrent}
|
||||
onClick={() => playPopular(i)}
|
||||
@@ -414,17 +411,19 @@
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group/card animate-enter flex items-center gap-3.5 rounded-2xl p-3 transition-colors hover:bg-hover"
|
||||
style="animation-delay:{Math.min(i, 10) * 40}ms"
|
||||
class="group/card animate-enter flex items-center gap-3.5 rounded-panel p-3 transition-colors hover:bg-hover"
|
||||
style="--i:{i}"
|
||||
onclick={() => goto(`/playlists/${playlist.id}`)}
|
||||
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${playlist.id}`))}
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
<Artwork
|
||||
src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
trackIds={playlist.coverTrackIds}
|
||||
version={playlist.updatedAt}
|
||||
size="medium"
|
||||
class="h-15.5 w-15.5 shrink-0 rounded-control"
|
||||
size="lg"
|
||||
alt={playlist.name}
|
||||
class="shrink-0"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm tracking-tight text-fg">
|
||||
|
||||
Reference in New Issue
Block a user