web-player: P3 — Artwork y Avatar unificados
Crea src/lib/components/ui/Artwork.svelte y Avatar.svelte (B.3) y migra todos los usos de Cover, PlaylistArt, MixArt y ArtistAvatar (Sidebar, Queue, TrackInfo, TrackContextMenu, AlbumContextMenu, TrackTitleCell, AlbumCard, PlaylistCard, MixTile, AlbumHeader, PlaylistHeader, Home, Explorar, mezcla, perfil de usuario, TopBar, MobileHeader). Borra los 4 componentes viejos. Artwork: tamaño decide dimensión y radio automáticamente (tokens cover-*/radius de P1); cascada src → mosaico (≥4 trackIds) → primera carátula → fallback, con caída en cascada si cada paso falla (antes repartida en 3 componentes con lógica ligeramente distinta). Añade un tamaño 'fill' (aspect-square w-full) no descrito en la spec, necesario para las tarjetas de grid fluidas hasta que P9 introduzca MediaCard. TrackTitleCell cambia su prop `coverSize` (clases Tailwind libres) por `size: 'sm'|'lg'` delegando en Artwork. SearchResultRow pierde su prop `kind` y el radio por tipo (ahora lo decide Artwork/Avatar). Decisión de producto (P3, sin bloquear en pregunta): se quita el nombre bajo el avatar de perfil en /u/[id] por ser redundante con el título de CollectionHeader. Small deltas de radio/tamaño documentados en README del plan (auto- radio por tamaño reemplaza clases ad-hoc; ver tabla B.1.1/B.1.2). Verificado con npm run check/build; sin QA visual autenticada (Docker no disponible en esta sesión). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user