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:
@@ -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