audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: likers strip expands inline, no separate panel (#1303)

The prior PR moved liker avatars inline but left the hover tooltip
and mobile bottom sheet in place. On hover, a tooltip opened above
the track row and showed... the same avatars, just a few more of
them. That was redundant and, as you pointed out, the separate
panel opening on hover was the most egregious part.

New model: the inline strip *is* the interaction.

- hover → per-avatar lift (already worked via AvatarStack)
- click an avatar → navigate to /u/{handle}
- click "+N" → the stack itself expands in place to a
horizontally-scrollable strip of every liker. same widget, just
longer. lazy-fetched via the existing tooltip-cache so the
data is there the first time you expand and instant on
subsequent expansions
- click × (or click outside, or press Escape) → collapses back

No popover, no bottom sheet, no tooltip. One affordance, consistent
across mobile and desktop.

Implementation

- AvatarStack.svelte — new scrollable + maxScrollWidth props.
When scrollable, the container gets overflow-x: auto, scroll-snap,
and a thin scrollbar. Overlap is preserved so it stays visually
the same widget, not a different one.
- LikersStrip.svelte — new wrapper that owns the expansion state
and the lazy fetch. Parents pass trackId + likeCount + topLikers
and don't think about anything else.
- TrackItem, TrackCard, track/[id]/+page.svelte — all the
tooltip/sheet state, hover timers, click-to-open-sheet handlers,
cursor: help, tooltip-open z-index gymnastics — all gone.
Replaced with <LikersStrip>.
- Deleted: LikersTooltip.svelte, LikersSheet.svelte,
likers-sheet.svelte.ts. Removed mount from +layout.svelte.
tooltip-cache.svelte.ts stays — LikersStrip uses it for the
expansion fetch.

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
072f9061 8d4335a6

+250 -994
+43 -2
frontend/src/lib/components/AvatarStack.svelte
··· 28 28 ariaLabel?: string; 29 29 /** extra class on the container, for site-specific tweaks. */ 30 30 class?: string; 31 + /** if true, the stack becomes horizontally scrollable within `maxScrollWidth` 32 + * and the overlap is slightly reduced so individual avatars are easier 33 + * to tap. use for the "expanded" state when the caller has loaded the 34 + * full liker/supporter list. */ 35 + scrollable?: boolean; 36 + /** max width of the stack container in scrollable mode. default `20rem`. */ 37 + maxScrollWidth?: string; 31 38 } 32 39 33 40 let { ··· 42 49 avatarHref, 43 50 onAvatarClick, 44 51 ariaLabel, 45 - class: klass = '' 52 + class: klass = '', 53 + scrollable = false, 54 + maxScrollWidth = '20rem' 46 55 }: Props = $props(); 47 56 48 57 let visible = $derived(users.slice(0, maxVisible)); ··· 64 73 65 74 <span 66 75 class="avatar-stack {klass}" 76 + class:scrollable 67 77 role={ariaLabel ? 'group' : undefined} 68 78 aria-label={ariaLabel} 69 - style="--stack-size: {size}px; --stack-overlap: {overlap}px; --stack-border: {borderColor};" 79 + style="--stack-size: {size}px; --stack-overlap: {overlap}px; --stack-border: {borderColor}; --stack-max-width: {maxScrollWidth};" 70 80 > 71 81 {#each visible as user (user.did)} 72 82 {@const title = user.display_name || user.handle} ··· 122 132 display: inline-flex; 123 133 align-items: center; 124 134 vertical-align: middle; 135 + } 136 + 137 + /* expanded mode: horizontal scroll so the full list is reachable without 138 + a separate popover. overlap is preserved so the stack keeps its 139 + visual identity — it doesn't morph into a different widget. */ 140 + .avatar-stack.scrollable { 141 + max-width: var(--stack-max-width); 142 + overflow-x: auto; 143 + overflow-y: visible; 144 + padding: 4px 0; 145 + scrollbar-width: thin; 146 + scrollbar-color: var(--border-default) transparent; 147 + scroll-snap-type: x proximity; 148 + -webkit-overflow-scrolling: touch; 149 + } 150 + 151 + .avatar-stack.scrollable::-webkit-scrollbar { 152 + height: 4px; 153 + } 154 + 155 + .avatar-stack.scrollable::-webkit-scrollbar-track { 156 + background: transparent; 157 + } 158 + 159 + .avatar-stack.scrollable::-webkit-scrollbar-thumb { 160 + background: var(--border-default); 161 + border-radius: 2px; 162 + } 163 + 164 + .avatar-stack.scrollable .avatar { 165 + scroll-snap-align: center; 125 166 } 126 167 127 168 .avatar {
-315
frontend/src/lib/components/LikersSheet.svelte
··· 1 - <script lang="ts"> 2 - import { likersSheet } from '$lib/likers-sheet.svelte'; 3 - import { getRefreshedAvatar, triggerAvatarRefresh, hasAttemptedRefresh } from '$lib/avatar-refresh.svelte'; 4 - import SensitiveImage from './SensitiveImage.svelte'; 5 - import type { LikerData } from '$lib/tooltip-cache.svelte'; 6 - 7 - let avatarErrors = $state<Set<string>>(new Set()); 8 - 9 - function getDisplayUrl(liker: LikerData): string | null { 10 - return getRefreshedAvatar(liker.did) ?? liker.avatar_url; 11 - } 12 - 13 - function handleAvatarError(did: string) { 14 - avatarErrors = new Set([...avatarErrors, did]); 15 - if (!hasAttemptedRefresh(did)) triggerAvatarRefresh(did); 16 - } 17 - 18 - function shouldShowFallback(liker: LikerData): boolean { 19 - const url = getDisplayUrl(liker); 20 - return !url || avatarErrors.has(liker.did); 21 - } 22 - 23 - function formatTime(isoString: string): string { 24 - const seconds = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000); 25 - if (seconds < 60) return 'just now'; 26 - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 27 - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 28 - if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; 29 - return `${Math.floor(seconds / 604800)}w ago`; 30 - } 31 - 32 - function handleBackdropClick(event: MouseEvent) { 33 - if (event.target === event.currentTarget) likersSheet.close(); 34 - } 35 - </script> 36 - 37 - <div 38 - class="sheet-backdrop" 39 - class:open={likersSheet.isOpen} 40 - role="presentation" 41 - onclick={handleBackdropClick} 42 - > 43 - <div class="sheet" role="dialog" aria-modal="true" aria-label="liked by"> 44 - <div class="sheet-handle"></div> 45 - <div class="sheet-header"> 46 - <span class="sheet-title"> 47 - {likersSheet.likeCount} {likersSheet.likeCount === 1 ? 'like' : 'likes'} 48 - </span> 49 - <button class="sheet-close" onclick={() => likersSheet.close()} aria-label="close"> 50 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 51 - <line x1="18" y1="6" x2="6" y2="18"></line> 52 - <line x1="6" y1="6" x2="18" y2="18"></line> 53 - </svg> 54 - </button> 55 - </div> 56 - <div class="sheet-content"> 57 - {#if likersSheet.loading} 58 - <div class="sheet-loading"> 59 - {#each [1, 2, 3] as _, i (i)} 60 - <div class="liker-skeleton"> 61 - <div class="avatar-skeleton"></div> 62 - <div class="text-skeleton"></div> 63 - </div> 64 - {/each} 65 - </div> 66 - {:else if likersSheet.error} 67 - <div class="sheet-empty">{likersSheet.error}</div> 68 - {:else if likersSheet.likers.length > 0} 69 - <div class="likers-list"> 70 - {#each likersSheet.likers as liker (liker.did)} 71 - {@const displayUrl = getDisplayUrl(liker)} 72 - {@const showFallback = shouldShowFallback(liker)} 73 - <a href="/u/{liker.handle}/liked" class="liker-row" onclick={() => likersSheet.close()}> 74 - <div class="liker-avatar"> 75 - {#if displayUrl && !showFallback} 76 - <SensitiveImage src={displayUrl} compact> 77 - <img 78 - src={displayUrl} 79 - alt="" 80 - onerror={() => handleAvatarError(liker.did)} 81 - /> 82 - </SensitiveImage> 83 - {:else} 84 - <span class="liker-initial">{(liker.display_name || liker.handle).charAt(0).toUpperCase()}</span> 85 - {/if} 86 - </div> 87 - <div class="liker-info"> 88 - <span class="liker-name">{liker.display_name || liker.handle}</span> 89 - <span class="liker-time">{formatTime(liker.liked_at)}</span> 90 - </div> 91 - </a> 92 - {/each} 93 - </div> 94 - {:else} 95 - <div class="sheet-empty">be the first to like this</div> 96 - {/if} 97 - </div> 98 - </div> 99 - </div> 100 - 101 - <style> 102 - .sheet-backdrop { 103 - position: fixed; 104 - inset: 0; 105 - background: color-mix(in srgb, var(--bg-primary) 60%, transparent); 106 - backdrop-filter: blur(4px); 107 - -webkit-backdrop-filter: blur(4px); 108 - z-index: 9999; 109 - opacity: 0; 110 - pointer-events: none; 111 - transition: opacity 0.15s; 112 - display: flex; 113 - align-items: flex-end; 114 - justify-content: center; 115 - } 116 - 117 - .sheet-backdrop.open { 118 - opacity: 1; 119 - pointer-events: auto; 120 - } 121 - 122 - .sheet { 123 - width: 100%; 124 - max-width: 400px; 125 - max-height: 60vh; 126 - background: var(--bg-secondary); 127 - border: 1px solid var(--border-subtle); 128 - border-bottom: none; 129 - border-radius: var(--radius-xl) var(--radius-xl) 0 0; 130 - display: flex; 131 - flex-direction: column; 132 - transform: translateY(100%); 133 - transition: transform 0.2s ease-out; 134 - padding-bottom: env(safe-area-inset-bottom, 0px); 135 - } 136 - 137 - .sheet-backdrop.open .sheet { 138 - transform: translateY(0); 139 - } 140 - 141 - .sheet-handle { 142 - width: 32px; 143 - height: 4px; 144 - background: var(--border-default); 145 - border-radius: 2px; 146 - margin: 0.75rem auto 0; 147 - flex-shrink: 0; 148 - } 149 - 150 - .sheet-header { 151 - display: flex; 152 - align-items: center; 153 - justify-content: space-between; 154 - padding: 0.75rem 1rem; 155 - flex-shrink: 0; 156 - } 157 - 158 - .sheet-title { 159 - font-size: var(--text-base); 160 - font-weight: 600; 161 - color: var(--text-primary); 162 - } 163 - 164 - .sheet-close { 165 - background: none; 166 - border: none; 167 - color: var(--text-muted); 168 - cursor: pointer; 169 - padding: 0.25rem; 170 - border-radius: var(--radius-sm); 171 - transition: color 0.15s; 172 - display: flex; 173 - align-items: center; 174 - justify-content: center; 175 - } 176 - 177 - .sheet-close:hover { 178 - color: var(--text-primary); 179 - } 180 - 181 - .sheet-content { 182 - overflow-y: auto; 183 - padding: 0 1rem 1rem; 184 - flex: 1; 185 - min-height: 0; 186 - } 187 - 188 - .likers-list { 189 - display: flex; 190 - flex-direction: column; 191 - } 192 - 193 - .liker-row { 194 - display: flex; 195 - align-items: center; 196 - gap: 0.75rem; 197 - padding: 0.625rem 0; 198 - text-decoration: none; 199 - color: inherit; 200 - border-bottom: 1px solid var(--border-subtle); 201 - transition: background 0.15s; 202 - border-radius: var(--radius-sm); 203 - padding-left: 0.25rem; 204 - padding-right: 0.25rem; 205 - } 206 - 207 - .liker-row:last-child { 208 - border-bottom: none; 209 - } 210 - 211 - .liker-row:active { 212 - background: var(--bg-tertiary); 213 - } 214 - 215 - .liker-avatar { 216 - width: 40px; 217 - height: 40px; 218 - border-radius: var(--radius-full); 219 - overflow: hidden; 220 - background: var(--bg-tertiary); 221 - flex-shrink: 0; 222 - display: flex; 223 - align-items: center; 224 - justify-content: center; 225 - } 226 - 227 - .liker-avatar img { 228 - width: 100%; 229 - height: 100%; 230 - object-fit: cover; 231 - } 232 - 233 - .liker-initial { 234 - font-size: var(--text-sm); 235 - font-weight: 600; 236 - color: var(--text-secondary); 237 - } 238 - 239 - .liker-info { 240 - display: flex; 241 - flex-direction: column; 242 - gap: 0.125rem; 243 - min-width: 0; 244 - } 245 - 246 - .liker-name { 247 - font-size: var(--text-sm); 248 - font-weight: 500; 249 - color: var(--text-primary); 250 - white-space: nowrap; 251 - overflow: hidden; 252 - text-overflow: ellipsis; 253 - } 254 - 255 - .liker-time { 256 - font-size: var(--text-xs); 257 - color: var(--text-tertiary); 258 - } 259 - 260 - .sheet-empty { 261 - color: var(--text-tertiary); 262 - font-size: var(--text-sm); 263 - text-align: center; 264 - padding: 2rem 1rem; 265 - } 266 - 267 - .sheet-loading { 268 - display: flex; 269 - flex-direction: column; 270 - } 271 - 272 - .liker-skeleton { 273 - display: flex; 274 - align-items: center; 275 - gap: 0.75rem; 276 - padding: 0.625rem 0.25rem; 277 - } 278 - 279 - .avatar-skeleton { 280 - width: 40px; 281 - height: 40px; 282 - border-radius: var(--radius-full); 283 - background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%); 284 - background-size: 200% 100%; 285 - animation: shimmer 1.5s ease-in-out infinite; 286 - flex-shrink: 0; 287 - } 288 - 289 - .text-skeleton { 290 - width: 120px; 291 - height: 14px; 292 - border-radius: var(--radius-sm); 293 - background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%); 294 - background-size: 200% 100%; 295 - animation: shimmer 1.5s ease-in-out infinite; 296 - } 297 - 298 - @keyframes shimmer { 299 - 0% { background-position: 200% 0; } 300 - 100% { background-position: -200% 0; } 301 - } 302 - 303 - @media (prefers-reduced-motion: reduce) { 304 - .avatar-skeleton, 305 - .text-skeleton { 306 - animation: none; 307 - } 308 - .sheet { 309 - transition: none; 310 - } 311 - .sheet-backdrop { 312 - transition: none; 313 - } 314 - } 315 - </style>
+179
frontend/src/lib/components/LikersStrip.svelte
··· 1 + <script lang="ts"> 2 + import { API_URL } from '$lib/config'; 3 + import { getLikers, setLikers, type LikerData } from '$lib/tooltip-cache.svelte'; 4 + import type { UserPreview } from '$lib/types'; 5 + import AvatarStack from './AvatarStack.svelte'; 6 + 7 + interface Props { 8 + trackId: number; 9 + /** total like count (for the "+N" overflow label). */ 10 + likeCount: number; 11 + /** preview likers embedded in the track response — up to 3. */ 12 + topLikers: UserPreview[]; 13 + /** avatar diameter. default 22. */ 14 + size?: number; 15 + /** border color matching the surrounding background. */ 16 + borderColor?: string; 17 + /** max width of the horizontal scroll container when expanded. */ 18 + maxScrollWidth?: string; 19 + } 20 + 21 + let { 22 + trackId, 23 + likeCount, 24 + topLikers, 25 + size = 22, 26 + borderColor = 'var(--bg-secondary)', 27 + maxScrollWidth = '20rem' 28 + }: Props = $props(); 29 + 30 + // expansion state: managed locally so each strip on a page is independent. 31 + let expanded = $state(false); 32 + let allLikers = $state<LikerData[] | null>(null); 33 + let loading = $state(false); 34 + let container: HTMLSpanElement | null = $state(null); 35 + 36 + // once the full list has been loaded, show everything — otherwise show the 37 + // 3 previewed likers the backend sent inline with the track response. 38 + let usersForStack = $derived<UserPreview[]>( 39 + expanded && allLikers ? allLikers : topLikers 40 + ); 41 + 42 + async function fetchAllLikers(): Promise<LikerData[]> { 43 + const cached = getLikers(trackId); 44 + if (cached) return cached; 45 + const response = await fetch(`${API_URL}/tracks/${trackId}/likes`); 46 + if (!response.ok) throw new Error(`failed to fetch likers: ${response.status}`); 47 + const data = await response.json(); 48 + const users: LikerData[] = data.users ?? []; 49 + setLikers(trackId, users); 50 + return users; 51 + } 52 + 53 + async function handleMoreClick() { 54 + if (loading) return; 55 + if (allLikers) { 56 + // already loaded — just toggle. second click collapses. 57 + expanded = !expanded; 58 + return; 59 + } 60 + loading = true; 61 + try { 62 + allLikers = await fetchAllLikers(); 63 + expanded = true; 64 + } catch (e) { 65 + console.error('error expanding likers:', e); 66 + } finally { 67 + loading = false; 68 + } 69 + } 70 + 71 + // click-outside to collapse: the expanded horizontal scroll is transient. 72 + // click-to-expand, click-again (on +N) or click-outside to collapse. 73 + function handleDocumentClick(e: MouseEvent) { 74 + if (!expanded || !container) return; 75 + if (e.target instanceof Node && !container.contains(e.target)) { 76 + expanded = false; 77 + } 78 + } 79 + 80 + function handleDocumentKeydown(e: KeyboardEvent) { 81 + if (expanded && e.key === 'Escape') { 82 + expanded = false; 83 + } 84 + } 85 + 86 + $effect(() => { 87 + if (!expanded) return; 88 + document.addEventListener('click', handleDocumentClick, true); 89 + document.addEventListener('keydown', handleDocumentKeydown); 90 + return () => { 91 + document.removeEventListener('click', handleDocumentClick, true); 92 + document.removeEventListener('keydown', handleDocumentKeydown); 93 + }; 94 + }); 95 + 96 + let likeWord = $derived(likeCount === 1 ? 'like' : 'likes'); 97 + </script> 98 + 99 + <span 100 + class="likers-strip" 101 + class:expanded 102 + class:loading 103 + bind:this={container} 104 + aria-live="polite" 105 + > 106 + <AvatarStack 107 + users={usersForStack} 108 + total={likeCount} 109 + maxVisible={expanded ? likeCount : 3} 110 + {size} 111 + {borderColor} 112 + {maxScrollWidth} 113 + scrollable={expanded} 114 + onMoreClick={expanded ? undefined : handleMoreClick} 115 + avatarHref={(u) => `/u/${u.handle}`} 116 + ariaLabel={`${likeCount} ${likeWord}`} 117 + /> 118 + {#if expanded} 119 + <button 120 + class="collapse" 121 + type="button" 122 + onclick={(e) => { 123 + e.stopPropagation(); 124 + expanded = false; 125 + }} 126 + title="collapse" 127 + aria-label="collapse likers" 128 + style="--collapse-size: {size}px;" 129 + > 130 + <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"> 131 + <line x1="6" y1="6" x2="14" y2="14"></line> 132 + <line x1="14" y1="6" x2="6" y2="14"></line> 133 + </svg> 134 + </button> 135 + {/if} 136 + </span> 137 + 138 + <style> 139 + .likers-strip { 140 + display: inline-flex; 141 + align-items: center; 142 + gap: 0.25rem; 143 + vertical-align: middle; 144 + transition: opacity 0.15s; 145 + } 146 + 147 + .likers-strip.loading { 148 + opacity: 0.6; 149 + } 150 + 151 + .collapse { 152 + width: var(--collapse-size); 153 + height: var(--collapse-size); 154 + display: inline-flex; 155 + align-items: center; 156 + justify-content: center; 157 + padding: 0; 158 + margin-left: 0.25rem; 159 + background: transparent; 160 + border: 1px solid var(--border-subtle); 161 + border-radius: var(--radius-full); 162 + color: var(--text-tertiary); 163 + cursor: pointer; 164 + transition: color 0.15s, border-color 0.15s, transform 0.15s; 165 + flex-shrink: 0; 166 + } 167 + 168 + .collapse:hover, 169 + .collapse:focus-visible { 170 + color: var(--accent); 171 + border-color: var(--accent); 172 + transform: scale(1.08); 173 + } 174 + 175 + .collapse svg { 176 + width: 55%; 177 + height: 55%; 178 + } 179 + </style>
-328
frontend/src/lib/components/LikersTooltip.svelte
··· 1 - <script lang="ts"> 2 - import { API_URL } from '$lib/config'; 3 - import { getLikers, setLikers, type LikerData } from '$lib/tooltip-cache.svelte'; 4 - import { 5 - getRefreshedAvatar, 6 - triggerAvatarRefresh, 7 - hasAttemptedRefresh 8 - } from '$lib/avatar-refresh.svelte'; 9 - import { fade } from 'svelte/transition'; 10 - import SensitiveImage from './SensitiveImage.svelte'; 11 - 12 - interface Props { 13 - trackId: number; 14 - likeCount: number; 15 - onMouseEnter?: () => void; 16 - onMouseLeave?: () => void; 17 - forceBelow?: boolean; 18 - } 19 - 20 - let { trackId, likeCount, onMouseEnter, onMouseLeave, forceBelow = false }: Props = $props(); 21 - 22 - let likers = $state<LikerData[]>([]); 23 - let loading = $state(true); 24 - let error = $state<string | null>(null); 25 - let tooltipElement: HTMLDivElement | null = $state(null); 26 - let positionBelow = $state(false); 27 - 28 - // fixed positioning for tooltips inside overflow containers 29 - let fixedTop = $state(0); 30 - let fixedLeft = $state(0); 31 - 32 - // track which avatars have errored (by DID) 33 - let avatarErrors = $state<Set<string>>(new Set()); 34 - 35 - /** 36 - * get the display URL for a liker's avatar. 37 - * prefers refreshed URL from global cache, falls back to original. 38 - */ 39 - function getDisplayUrl(liker: LikerData): string | null { 40 - const refreshed = getRefreshedAvatar(liker.did); 41 - return refreshed ?? liker.avatar_url; 42 - } 43 - 44 - /** 45 - * handle avatar load error - show fallback and trigger refresh. 46 - */ 47 - function handleAvatarError(did: string) { 48 - avatarErrors = new Set([...avatarErrors, did]); 49 - 50 - if (!hasAttemptedRefresh(did)) { 51 - triggerAvatarRefresh(did); 52 - } 53 - } 54 - 55 - /** 56 - * check if avatar should show fallback. 57 - */ 58 - function shouldShowFallback(liker: LikerData): boolean { 59 - const url = getDisplayUrl(liker); 60 - return !url || avatarErrors.has(liker.did); 61 - } 62 - 63 - // position tooltip — use fixed positioning when forceBelow to escape overflow containers 64 - $effect(() => { 65 - if (forceBelow) { 66 - positionBelow = true; 67 - if (!tooltipElement) return; 68 - const parent = tooltipElement.parentElement; 69 - if (!parent) return; 70 - const rect = parent.getBoundingClientRect(); 71 - fixedTop = rect.bottom + 8; 72 - fixedLeft = rect.left + rect.width / 2; 73 - return; 74 - } 75 - 76 - if (!tooltipElement) return; 77 - 78 - const parent = tooltipElement.parentElement; 79 - if (!parent) return; 80 - 81 - const parentRect = parent.getBoundingClientRect(); 82 - positionBelow = parentRect.top < 200; 83 - }); 84 - 85 - $effect(() => { 86 - if (likeCount === 0) { 87 - loading = false; 88 - return; 89 - } 90 - 91 - // check cache first 92 - const cached = getLikers(trackId); 93 - if (cached) { 94 - likers = cached; 95 - loading = false; 96 - return; 97 - } 98 - 99 - const fetchLikers = async () => { 100 - try { 101 - const url = `${API_URL}/tracks/${trackId}/likes`; 102 - const response = await fetch(url); 103 - 104 - if (!response.ok) { 105 - throw new Error(`failed to fetch likers: ${response.status}`); 106 - } 107 - 108 - const data = await response.json(); 109 - const users = data.users || []; 110 - likers = users; 111 - setLikers(trackId, users); 112 - } catch (err) { 113 - error = 'failed to load'; 114 - console.error('error fetching likers:', err); 115 - } finally { 116 - loading = false; 117 - } 118 - }; 119 - 120 - fetchLikers(); 121 - }); 122 - 123 - function formatTime(isoString: string): string { 124 - const date = new Date(isoString); 125 - const now = new Date(); 126 - const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); 127 - 128 - if (seconds < 60) return 'just now'; 129 - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 130 - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 131 - if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; 132 - return `${Math.floor(seconds / 604800)}w ago`; 133 - } 134 - </script> 135 - 136 - <div 137 - bind:this={tooltipElement} 138 - class="likers-tooltip" 139 - class:position-below={positionBelow} 140 - class:position-fixed={forceBelow} 141 - style={forceBelow ? `top: ${fixedTop}px; left: ${fixedLeft}px;` : ''} 142 - role="tooltip" 143 - onmouseenter={onMouseEnter} 144 - onmouseleave={onMouseLeave} 145 - > 146 - {#key loading} 147 - {#if loading} 148 - <div class="loading" transition:fade={{ duration: 200 }}> 149 - <div class="loading-avatars"> 150 - {#each [1, 2, 3] as _} 151 - <div class="avatar-skeleton"></div> 152 - {/each} 153 - </div> 154 - </div> 155 - {:else if error} 156 - <div class="error" transition:fade={{ duration: 200 }}>{error}</div> 157 - {:else if likers.length > 0} 158 - <div class="likers-avatars" transition:fade={{ duration: 200 }}> 159 - {#each likers as liker (liker.did)} 160 - {@const displayUrl = getDisplayUrl(liker)} 161 - {@const showFallback = shouldShowFallback(liker)} 162 - <a 163 - href="/u/{liker.handle}/liked" 164 - class="liker-circle" 165 - title="{liker.display_name} (@{liker.handle}) • {formatTime(liker.liked_at)}" 166 - > 167 - {#if displayUrl && !showFallback} 168 - <SensitiveImage src={displayUrl} compact> 169 - <img 170 - src={displayUrl} 171 - alt="" 172 - onerror={() => handleAvatarError(liker.did)} 173 - /> 174 - </SensitiveImage> 175 - {:else} 176 - <span>{(liker.display_name || liker.handle).charAt(0).toUpperCase()}</span> 177 - {/if} 178 - </a> 179 - {/each} 180 - </div> 181 - {:else} 182 - <div class="empty" transition:fade={{ duration: 200 }}>be the first to like this</div> 183 - {/if} 184 - {/key} 185 - </div> 186 - 187 - <style> 188 - .likers-tooltip { 189 - position: absolute; 190 - bottom: 100%; 191 - left: 50%; 192 - transform: translateX(-50%); 193 - margin-bottom: 0.625rem; 194 - background: var(--bg-secondary); 195 - border: 1px solid var(--border-subtle); 196 - border-radius: var(--radius-lg); 197 - padding: 0.5rem 0.625rem; 198 - box-shadow: 199 - 0 4px 16px rgba(0, 0, 0, 0.4), 200 - 0 0 0 1px rgba(255, 255, 255, 0.03); 201 - z-index: 1000; 202 - pointer-events: auto; 203 - } 204 - 205 - .likers-tooltip.position-below { 206 - bottom: auto; 207 - top: 100%; 208 - margin-bottom: 0; 209 - margin-top: 0.625rem; 210 - } 211 - 212 - .likers-tooltip.position-fixed { 213 - position: fixed; 214 - bottom: auto; 215 - top: auto; 216 - left: auto; 217 - margin: 0; 218 - } 219 - 220 - .loading, 221 - .error, 222 - .empty { 223 - color: var(--text-tertiary); 224 - font-size: var(--text-sm); 225 - text-align: center; 226 - padding: 0.25rem 0.5rem; 227 - white-space: nowrap; 228 - } 229 - 230 - .error { 231 - color: var(--error); 232 - } 233 - 234 - .loading-avatars { 235 - display: flex; 236 - justify-content: center; 237 - } 238 - 239 - .avatar-skeleton { 240 - width: 32px; 241 - height: 32px; 242 - border-radius: var(--radius-full); 243 - background: linear-gradient( 244 - 90deg, 245 - var(--bg-tertiary) 0%, 246 - var(--bg-hover) 50%, 247 - var(--bg-tertiary) 100% 248 - ); 249 - background-size: 200% 100%; 250 - animation: shimmer 1.5s ease-in-out infinite; 251 - border: 2px solid var(--bg-secondary); 252 - margin-left: -8px; 253 - flex-shrink: 0; 254 - } 255 - 256 - .avatar-skeleton:first-child { 257 - margin-left: 0; 258 - } 259 - 260 - @keyframes shimmer { 261 - 0% { background-position: 200% 0; } 262 - 100% { background-position: -200% 0; } 263 - } 264 - 265 - .likers-avatars { 266 - display: flex; 267 - justify-content: flex-start; 268 - overflow-x: auto; 269 - max-width: 240px; 270 - width: fit-content; 271 - margin: 0 auto; 272 - padding: 0.25rem 0; 273 - scrollbar-width: none; 274 - } 275 - 276 - .likers-avatars::-webkit-scrollbar { 277 - display: none; 278 - } 279 - 280 - .liker-circle { 281 - width: 32px; 282 - height: 32px; 283 - border-radius: var(--radius-full); 284 - border: 2px solid var(--bg-secondary); 285 - background: var(--bg-tertiary); 286 - display: flex; 287 - align-items: center; 288 - justify-content: center; 289 - overflow: hidden; 290 - margin-left: -8px; 291 - transition: 292 - transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), 293 - z-index 0s; 294 - position: relative; 295 - text-decoration: none; 296 - flex-shrink: 0; 297 - } 298 - 299 - .liker-circle:first-child { 300 - margin-left: 0; 301 - } 302 - 303 - .liker-circle:hover { 304 - transform: translateY(-2px) scale(1.08); 305 - z-index: 10; 306 - } 307 - 308 - .liker-circle img { 309 - width: 100%; 310 - height: 100%; 311 - object-fit: cover; 312 - } 313 - 314 - .liker-circle span { 315 - font-size: var(--text-xs); 316 - font-weight: 600; 317 - color: var(--text-secondary); 318 - } 319 - 320 - @media (prefers-reduced-motion: reduce) { 321 - .avatar-skeleton { 322 - animation: none; 323 - } 324 - .liker-circle { 325 - transition: none; 326 - } 327 - } 328 - </style>
+9 -107
frontend/src/lib/components/TrackCard.svelte
··· 1 1 <script lang="ts"> 2 - import { browser } from '$app/environment'; 3 2 import SensitiveImage from './SensitiveImage.svelte'; 4 - import AvatarStack from './AvatarStack.svelte'; 5 - import LikersTooltip from './LikersTooltip.svelte'; 6 - import { likersSheet } from '$lib/likers-sheet.svelte'; 3 + import LikersStrip from './LikersStrip.svelte'; 7 4 import type { Track } from '$lib/types'; 8 5 9 6 interface Props { ··· 18 15 let imageLoading = $derived(index < 3 ? 'eager' as const : 'lazy' as const); 19 16 let likeCount = $derived(track.like_count || 0); 20 17 let topLikers = $derived(track.top_likers ?? []); 21 - 22 - let isMobile = $state(false); 23 - 24 - $effect(() => { 25 - if (browser) { 26 - const mq = window.matchMedia('(max-width: 768px)'); 27 - isMobile = mq.matches; 28 - const handler = (e: MediaQueryListEvent) => (isMobile = e.matches); 29 - mq.addEventListener('change', handler); 30 - return () => mq.removeEventListener('change', handler); 31 - } 32 - }); 33 - 34 - // desktop tooltip state 35 - let showLikersTooltip = $state(false); 36 - let likersTooltipTimeout: ReturnType<typeof setTimeout> | null = null; 37 - 38 - function openLikers() { 39 - if (likersTooltipTimeout) { 40 - clearTimeout(likersTooltipTimeout); 41 - likersTooltipTimeout = null; 42 - } 43 - showLikersTooltip = true; 44 - } 45 - 46 - function closeLikers() { 47 - likersTooltipTimeout = setTimeout(() => { 48 - showLikersTooltip = false; 49 - likersTooltipTimeout = null; 50 - }, 150); 51 - } 52 - 53 - function handleLikesClick(e: Event) { 54 - e.stopPropagation(); 55 - if (isMobile) { 56 - likersSheet.open(track.id, likeCount); 57 - } 58 - } 59 - 60 - function handleLikesKeydown(e: KeyboardEvent) { 61 - if (e.key === 'Enter' || e.key === ' ') { 62 - e.stopPropagation(); 63 - if (isMobile) { 64 - likersSheet.open(track.id, likeCount); 65 - } 66 - } 67 - } 68 - 69 - function handleLikesMouseEnter(e: Event) { 70 - if (isMobile) return; 71 - e.stopPropagation(); 72 - openLikers(); 73 - } 74 - 75 - function handleLikesMouseLeave(e: Event) { 76 - if (isMobile) return; 77 - e.stopPropagation(); 78 - closeLikers(); 79 - } 80 18 </script> 81 19 82 20 <button 83 21 class="track-card" 84 22 class:playing={isPlaying} 85 - class:tooltip-open={showLikersTooltip} 86 23 onclick={(e) => { 87 24 if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) return; 88 25 onPlay(track); ··· 134 71 <span>{track.play_count} {track.play_count === 1 ? 'play' : 'plays'}</span> 135 72 {#if likeCount > 0} 136 73 <span class="meta-sep">&middot;</span> 137 - <span 138 - class="likes" 139 - role="button" 140 - tabindex="0" 141 - aria-label="{likeCount} {likeCount === 1 ? 'like' : 'likes'}" 142 - aria-expanded={showLikersTooltip} 143 - onclick={handleLikesClick} 144 - onkeydown={handleLikesKeydown} 145 - onmouseenter={handleLikesMouseEnter} 146 - onmouseleave={handleLikesMouseLeave} 147 - onfocus={handleLikesMouseEnter} 148 - onblur={handleLikesMouseLeave} 149 - > 74 + <span class="likes" onclick={(e) => e.stopPropagation()} role="presentation"> 150 75 {#if topLikers.length > 0} 151 - <AvatarStack 152 - users={topLikers} 153 - total={likeCount} 76 + <LikersStrip 77 + trackId={track.id} 78 + {likeCount} 79 + {topLikers} 154 80 size={18} 155 81 borderColor="var(--track-bg, var(--bg-secondary))" 156 - ariaLabel={`${likeCount} ${likeCount === 1 ? 'like' : 'likes'}`} 82 + maxScrollWidth="16rem" 157 83 /> 158 84 {:else} 159 85 {likeCount} {likeCount === 1 ? 'like' : 'likes'} 160 - {/if} 161 - {#if showLikersTooltip && !isMobile} 162 - <LikersTooltip 163 - trackId={track.id} 164 - {likeCount} 165 - onMouseEnter={openLikers} 166 - onMouseLeave={closeLikers} 167 - forceBelow 168 - /> 169 86 {/if} 170 87 </span> 171 88 {/if} ··· 204 121 .track-card.playing { 205 122 background: color-mix(in srgb, var(--accent) 10%, var(--track-bg-playing, var(--bg-tertiary))); 206 123 border-color: color-mix(in srgb, var(--accent) 20%, var(--track-border, var(--border-subtle))); 207 - } 208 - 209 - .track-card.tooltip-open { 210 - z-index: 60; 211 124 } 212 125 213 126 .artwork { ··· 324 237 } 325 238 326 239 .likes { 327 - position: relative; 328 - cursor: help; 329 - transition: color 0.15s; 330 - } 331 - 332 - .likes:hover { 333 - color: var(--accent); 334 - } 335 - 336 - @media (max-width: 768px) { 337 - .likes { 338 - cursor: pointer; 339 - } 240 + display: inline-flex; 241 + align-items: center; 340 242 } 341 243 </style>
+10 -90
frontend/src/lib/components/TrackItem.svelte
··· 3 3 import ShareButton from './ShareButton.svelte'; 4 4 import AddToMenu from './AddToMenu.svelte'; 5 5 import TrackActionsMenu from './TrackActionsMenu.svelte'; 6 - import AvatarStack from './AvatarStack.svelte'; 7 - import LikersTooltip from './LikersTooltip.svelte'; 6 + import LikersStrip from './LikersStrip.svelte'; 8 7 import CommentersTooltip from './CommentersTooltip.svelte'; 9 8 import SensitiveImage from './SensitiveImage.svelte'; 10 9 import { hasPlayableLossless, isLosslessFormat } from '$lib/audio-support'; 11 - import { likersSheet } from '$lib/likers-sheet.svelte'; 12 10 import type { Track } from '$lib/types'; 13 11 import { queue } from '$lib/queue.svelte'; 14 12 import { toast } from '$lib/toast.svelte'; ··· 59 57 } 60 58 }); 61 59 62 - let showLikersTooltip = $state(false); 63 60 let showCommentersTooltip = $state(false); 64 61 // use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI 65 62 let likeCount = $derived(track.like_count || 0); ··· 119 116 toast.success(`queued ${track.title}`, 1800); 120 117 } 121 118 122 - let likersTooltipTimeout: ReturnType<typeof setTimeout> | null = null; 123 - 124 - function handleLikesMouseEnter() { 125 - if (isMobile) return; 126 - if (likersTooltipTimeout) { 127 - clearTimeout(likersTooltipTimeout); 128 - likersTooltipTimeout = null; 129 - } 130 - showLikersTooltip = true; 131 - } 132 - 133 - function handleLikesMouseLeave() { 134 - if (isMobile) return; 135 - likersTooltipTimeout = setTimeout(() => { 136 - showLikersTooltip = false; 137 - likersTooltipTimeout = null; 138 - }, 150); 139 - } 140 - 141 - function handleLikesClick(e: Event) { 142 - if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) { 143 - return; 144 - } 145 - e.stopPropagation(); 146 - if (isMobile) { 147 - likersSheet.open(track.id, likeCount); 148 - } 149 - } 150 - 151 - function handleLikesKeydown(event: KeyboardEvent) { 152 - if (event.key === 'Enter' || event.key === ' ') { 153 - event.preventDefault(); 154 - if (isMobile) { 155 - likersSheet.open(track.id, likeCount); 156 - } else { 157 - showLikersTooltip = true; 158 - } 159 - } 160 - if (event.key === 'Escape') { 161 - showLikersTooltip = false; 162 - } 163 - } 164 - 165 119 let commentersTooltipTimeout: ReturnType<typeof setTimeout> | null = null; 166 120 167 121 function handleCommentsMouseEnter() { ··· 200 154 class="track-container" 201 155 class:playing={isPlaying} 202 156 class:lossless={hasPlayableLossless(track.original_file_type) || isLosslessFormat(track.file_type)} 203 - class:likers-tooltip-open={showLikersTooltip} 204 157 title={hasPlayableLossless(track.original_file_type) || isLosslessFormat(track.file_type) ? 'lossless audio available' : undefined} 205 158 > 206 159 {#if showIndex} ··· 332 285 <span class="plays">{track.play_count} {track.play_count === 1 ? 'play' : 'plays'}</span> 333 286 {#if likeCount > 0} 334 287 <span class="meta-separator">•</span> 335 - <span 336 - class="likes" 337 - class:likes-with-stack={topLikers.length > 0} 338 - role="button" 339 - tabindex="0" 340 - aria-label={`${likeCount} ${likeCount === 1 ? 'like' : 'likes'} (focus to view users)`} 341 - aria-expanded={showLikersTooltip} 342 - onclick={handleLikesClick} 343 - onmouseenter={handleLikesMouseEnter} 344 - onmouseleave={handleLikesMouseLeave} 345 - onfocus={handleLikesMouseEnter} 346 - onblur={handleLikesMouseLeave} 347 - onkeydown={handleLikesKeydown} 348 - > 288 + <span class="likes"> 349 289 {#if topLikers.length > 0} 350 - <AvatarStack 351 - users={topLikers} 352 - total={likeCount} 290 + <LikersStrip 291 + trackId={track.id} 292 + {likeCount} 293 + {topLikers} 353 294 size={isMobile ? 20 : 22} 354 295 borderColor="var(--track-bg, var(--bg-secondary))" 355 - ariaLabel={`${likeCount} ${likeCount === 1 ? 'like' : 'likes'}`} 356 296 /> 357 297 {:else} 358 298 {likeCount} {likeCount === 1 ? 'like' : 'likes'} 359 299 {/if} 360 - {#if showLikersTooltip && !isMobile} 361 - <LikersTooltip 362 - trackId={track.id} 363 - likeCount={likeCount} 364 - onMouseEnter={handleLikesMouseEnter} 365 - onMouseLeave={handleLikesMouseLeave} 366 - /> 367 - {/if} 368 - </span> 369 - {/if} 300 + </span> 301 + {/if} 370 302 {#if commentCount > 0} 371 303 <span class="meta-separator">•</span> 372 304 <span ··· 521 453 0 1px 3px rgba(0, 0, 0, 0.06), 522 454 0 0 8px color-mix(in srgb, var(--accent) 8%, transparent), 523 455 inset 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent); 524 - } 525 - 526 - /* elevate entire track container when likers tooltip is open 527 - z-index: 60 is above header (50) and sibling tracks */ 528 - .track-container.likers-tooltip-open { 529 - position: relative; 530 - z-index: 60; 531 456 } 532 457 533 458 .track { ··· 854 779 .likes { 855 780 color: var(--text-tertiary); 856 781 font-family: inherit; 857 - position: relative; 858 - cursor: help; 859 - transition: color 0.2s; 860 - } 861 - 862 - .likes:hover { 863 - color: var(--accent); 782 + display: inline-flex; 783 + align-items: center; 864 784 } 865 785 866 786 .comments-wrapper {
-55
frontend/src/lib/likers-sheet.svelte.ts
··· 1 - import { API_URL } from '$lib/config'; 2 - import { getLikers, setLikers, type LikerData } from '$lib/tooltip-cache.svelte'; 3 - 4 - class LikersSheetState { 5 - isOpen = $state(false); 6 - trackId = $state<number | null>(null); 7 - likeCount = $state(0); 8 - likers = $state<LikerData[]>([]); 9 - loading = $state(false); 10 - error = $state<string | null>(null); 11 - 12 - open(trackId: number, likeCount: number) { 13 - this.trackId = trackId; 14 - this.likeCount = likeCount; 15 - this.isOpen = true; 16 - this.error = null; 17 - 18 - const cached = getLikers(trackId); 19 - if (cached) { 20 - this.likers = cached; 21 - this.loading = false; 22 - return; 23 - } 24 - 25 - this.likers = []; 26 - this.loading = true; 27 - this.fetchLikers(trackId); 28 - } 29 - 30 - close() { 31 - this.isOpen = false; 32 - } 33 - 34 - private async fetchLikers(trackId: number) { 35 - try { 36 - const response = await fetch(`${API_URL}/tracks/${trackId}/likes`); 37 - if (!response.ok) throw new Error(`failed to fetch likers: ${response.status}`); 38 - const data = await response.json(); 39 - const users: LikerData[] = data.users || []; 40 - 41 - // stale guard — sheet may have been closed/reopened for a different track 42 - if (this.trackId !== trackId) return; 43 - 44 - this.likers = users; 45 - setLikers(trackId, users); 46 - } catch { 47 - if (this.trackId !== trackId) return; 48 - this.error = 'failed to load'; 49 - } finally { 50 - if (this.trackId === trackId) this.loading = false; 51 - } 52 - } 53 - } 54 - 55 - export const likersSheet = new LikersSheetState();
-2
frontend/src/routes/+layout.svelte
··· 12 12 import LogoutModal from '$lib/components/LogoutModal.svelte'; 13 13 import FeedbackModal from '$lib/components/FeedbackModal.svelte'; 14 14 import TermsOverlay from '$lib/components/TermsOverlay.svelte'; 15 - import LikersSheet from '$lib/components/LikersSheet.svelte'; 16 15 import { onMount, onDestroy, untrack } from 'svelte'; 17 16 import { page } from '$app/stores'; 18 17 import { afterNavigate } from '$app/navigation'; ··· 500 499 <SearchModal /> 501 500 <LogoutModal /> 502 501 <FeedbackModal /> 503 - <LikersSheet /> 504 502 {#if showTermsOverlay} 505 503 <TermsOverlay /> 506 504 {/if}
+9 -95
frontend/src/routes/track/[id]/+page.svelte
··· 10 10 import AddToMenu from '$lib/components/AddToMenu.svelte'; 11 11 import TagEffects from '$lib/components/TagEffects.svelte'; 12 12 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 13 - import AvatarStack from '$lib/components/AvatarStack.svelte'; 14 - import LikersTooltip from '$lib/components/LikersTooltip.svelte'; 15 - import { likersSheet } from '$lib/likers-sheet.svelte'; 13 + import LikersStrip from '$lib/components/LikersStrip.svelte'; 16 14 import LosslessBadge from '$lib/components/LosslessBadge.svelte'; 17 15 import RichText from '$lib/components/RichText.svelte'; 18 16 import ShareButton from '$lib/components/ShareButton.svelte'; ··· 76 74 player.currentTrack?.id === track.id && !player.paused 77 75 ); 78 76 79 - // mobile detection 80 - let isMobile = $state(false); 81 - 82 - $effect(() => { 83 - if (browser) { 84 - const mq = window.matchMedia('(max-width: 768px)'); 85 - isMobile = mq.matches; 86 - const handler = (e: MediaQueryListEvent) => (isMobile = e.matches); 87 - mq.addEventListener('change', handler); 88 - return () => mq.removeEventListener('change', handler); 89 - } 90 - }); 91 - 92 77 // metadata disclosure panel 93 78 let metadataOpen = $state(false); 94 79 95 - // likers tooltip state 96 - let showLikersTooltip = $state(false); 97 - let likersTooltipTimeout: ReturnType<typeof setTimeout> | null = null; 98 - 99 - function handleLikesMouseEnter() { 100 - if (isMobile) return; 101 - if (likersTooltipTimeout) { 102 - clearTimeout(likersTooltipTimeout); 103 - likersTooltipTimeout = null; 104 - } 105 - showLikersTooltip = true; 106 - } 107 - 108 - function handleLikesMouseLeave() { 109 - if (isMobile) return; 110 - likersTooltipTimeout = setTimeout(() => { 111 - showLikersTooltip = false; 112 - likersTooltipTimeout = null; 113 - }, 150); 114 - } 115 - 116 - function handleLikesClick() { 117 - if (isMobile && track.like_count) { 118 - likersSheet.open(track.id, track.like_count); 119 - } 120 - } 121 - 122 - function handleLikesKeydown(event: KeyboardEvent) { 123 - if (event.key === 'Enter' || event.key === ' ') { 124 - event.preventDefault(); 125 - if (isMobile && track.like_count) { 126 - likersSheet.open(track.id, track.like_count); 127 - } else { 128 - showLikersTooltip = true; 129 - } 130 - } 131 - if (event.key === 'Escape') { 132 - showLikersTooltip = false; 133 - } 134 - } 135 - 136 80 137 81 async function loadLikedState() { 138 82 try { ··· 598 542 <LosslessBadge originalFileType={track.original_file_type} fileType={track.file_type} withSeparator separatorClass="separator" /> 599 543 {#if track.like_count && track.like_count > 0} 600 544 <span class="separator">•</span> 601 - <span 602 - class="likes" 603 - role="button" 604 - tabindex="0" 605 - aria-label={`${track.like_count} ${track.like_count === 1 ? 'like' : 'likes'} (focus to view users)`} 606 - aria-expanded={showLikersTooltip} 607 - onclick={handleLikesClick} 608 - onmouseenter={handleLikesMouseEnter} 609 - onmouseleave={handleLikesMouseLeave} 610 - onfocus={handleLikesMouseEnter} 611 - onblur={handleLikesMouseLeave} 612 - onkeydown={handleLikesKeydown} 613 - > 545 + <span class="likes"> 614 546 {#if track.top_likers && track.top_likers.length > 0} 615 - <AvatarStack 616 - users={track.top_likers} 617 - total={track.like_count} 547 + <LikersStrip 548 + trackId={track.id} 549 + likeCount={track.like_count} 550 + topLikers={track.top_likers} 618 551 size={24} 619 552 borderColor="var(--bg-primary)" 620 - ariaLabel={`${track.like_count} ${track.like_count === 1 ? 'like' : 'likes'}`} 553 + maxScrollWidth="22rem" 621 554 /> 622 555 {:else} 623 556 {track.like_count} {track.like_count === 1 ? 'like' : 'likes'} 624 - {/if} 625 - {#if showLikersTooltip && !isMobile} 626 - <LikersTooltip 627 - trackId={track.id} 628 - likeCount={track.like_count} 629 - onMouseEnter={handleLikesMouseEnter} 630 - onMouseLeave={handleLikesMouseLeave} 631 - /> 632 557 {/if} 633 558 </span> 634 559 {/if} ··· 1025 950 } 1026 951 1027 952 .track-stats .likes { 1028 - position: relative; 1029 - cursor: pointer; 1030 - padding: 0.125rem 0.25rem; 1031 - margin: -0.125rem -0.25rem; 1032 - border-radius: var(--radius-sm); 1033 - transition: background 0.15s, color 0.15s; 1034 - } 1035 - 1036 - .track-stats .likes:hover, 1037 - .track-stats .likes:focus { 1038 - background: color-mix(in srgb, var(--accent) 15%, transparent); 1039 - color: var(--accent); 1040 - outline: none; 953 + display: inline-flex; 954 + align-items: center; 1041 955 } 1042 956 1043 957 .metadata-toggle {