audio streaming app plyr.fm
38
fork

Configure Feed

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

feat: add hover tooltip to show who liked a track (#233)

* docs: add frontend data loading patterns and architecture

Documents the shift from client-side onMount to server-side load functions:
- server-side loading (+page.server.ts) for SEO/performance
- client-side loading (+page.ts) for auth-dependent data
- layout loading (+layout.ts) for shared state
- when to use each pattern
- anti-patterns to avoid
- migration history from PRs #210, #227

References issue #225 (auto-play investigation) as example of
understanding client vs server state boundaries.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add hover tooltip to show who liked a track

adds delightful UX feature - hover over like count to see who liked it

backend:
- add GET /tracks/{track_id}/likes endpoint
- returns list of users with display info (handle, display_name, avatar_url)
- ordered by most recent likes first

frontend:
- create LikersTooltip component with lazy loading on hover
- 500ms hover delay prevents accidental tooltips
- shows up to 10 likers with avatars and relative timestamps
- client-side caching to avoid redundant fetches
- smooth hover interactions with visual feedback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove 'no likes yet' text and add debug logging

* fix: use API_URL config for likers endpoint

* fix: fetch likers when tooltip mounts instead of on hover

* fix: use $effect to fetch likers properly instead of calling in template

* fix: update like count immediately when liking/unliking track

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by

nate nowack
Claude
and committed by
GitHub
d01208cf 45c9b1d1

+327 -3
+5 -1
frontend/src/lib/components/LikeButton.svelte
··· 8 8 initialLiked?: boolean; 9 9 disabled?: boolean; 10 10 disabledReason?: string; 11 + onLikeChange?: (liked: boolean) => void; 11 12 } 12 13 13 - let { trackId, trackTitle, initialLiked = false, disabled = false, disabledReason }: Props = $props(); 14 + let { trackId, trackTitle, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props(); 14 15 15 16 let liked = $state(initialLiked); 16 17 let loading = $state(false); ··· 41 42 liked = previousState; 42 43 toast.error('failed to update like'); 43 44 } else { 45 + // notify parent of like change 46 + onLikeChange?.(liked); 47 + 44 48 // show success feedback 45 49 if (liked) { 46 50 toast.success(`liked ${trackTitle}`);
+237
frontend/src/lib/components/LikersTooltip.svelte
··· 1 + <script lang="ts"> 2 + import { API_URL } from '$lib/config'; 3 + 4 + interface Liker { 5 + did: string; 6 + handle: string; 7 + display_name: string; 8 + avatar_url?: string; 9 + liked_at: string; 10 + } 11 + 12 + interface Props { 13 + trackId: number; 14 + likeCount: number; 15 + } 16 + 17 + let { trackId, likeCount }: Props = $props(); 18 + 19 + let likers = $state<Liker[]>([]); 20 + let loading = $state(true); // start as loading 21 + let error = $state<string | null>(null); 22 + 23 + $effect(() => { 24 + if (likeCount === 0) { 25 + loading = false; 26 + return; 27 + } 28 + 29 + console.log('fetching likers for track', trackId); 30 + 31 + const fetchLikers = async () => { 32 + try { 33 + const url = `${API_URL}/tracks/${trackId}/likes`; 34 + console.log('fetching from:', url); 35 + const response = await fetch(url); 36 + 37 + if (!response.ok) { 38 + const text = await response.text(); 39 + console.error('failed to fetch likers:', response.status, text); 40 + throw new Error(`failed to fetch likers: ${response.status}`); 41 + } 42 + 43 + const data = await response.json(); 44 + console.log('received likers data:', data); 45 + likers = data.users || []; 46 + } catch (err) { 47 + error = 'failed to load'; 48 + console.error('error fetching likers:', err); 49 + } finally { 50 + loading = false; 51 + } 52 + }; 53 + 54 + fetchLikers(); 55 + }); 56 + 57 + // format relative time 58 + function formatTime(isoString: string): string { 59 + const date = new Date(isoString); 60 + const now = new Date(); 61 + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); 62 + 63 + if (seconds < 60) return 'just now'; 64 + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 65 + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 66 + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; 67 + return `${Math.floor(seconds / 604800)}w ago`; 68 + } 69 + </script> 70 + 71 + <div 72 + class="likers-tooltip" 73 + role="tooltip" 74 + > 75 + {#if loading} 76 + <div class="loading">loading...</div> 77 + {:else if error} 78 + <div class="error">{error}</div> 79 + {:else if likers.length > 0} 80 + <div class="likers-list"> 81 + {#each likers.slice(0, 10) as liker} 82 + <a 83 + href="/u/{liker.handle}" 84 + class="liker" 85 + > 86 + {#if liker.avatar_url} 87 + <img src={liker.avatar_url} alt={liker.display_name} class="avatar" /> 88 + {:else} 89 + <div class="avatar-placeholder"> 90 + {liker.display_name.charAt(0).toUpperCase()} 91 + </div> 92 + {/if} 93 + <div class="liker-info"> 94 + <div class="display-name">{liker.display_name}</div> 95 + <div class="handle">@{liker.handle}</div> 96 + </div> 97 + <div class="liked-time">{formatTime(liker.liked_at)}</div> 98 + </a> 99 + {/each} 100 + {#if likers.length > 10} 101 + <div class="more">+{likers.length - 10} more</div> 102 + {/if} 103 + </div> 104 + {/if} 105 + </div> 106 + 107 + <style> 108 + .likers-tooltip { 109 + position: absolute; 110 + bottom: 100%; 111 + left: 50%; 112 + transform: translateX(-50%); 113 + margin-bottom: 0.5rem; 114 + background: #1a1a1a; 115 + border: 1px solid #333; 116 + border-radius: 8px; 117 + padding: 0.75rem; 118 + min-width: 240px; 119 + max-width: 320px; 120 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 121 + z-index: 1000; 122 + pointer-events: auto; 123 + } 124 + 125 + .loading, 126 + .error, 127 + .empty { 128 + color: #888; 129 + font-size: 0.85rem; 130 + text-align: center; 131 + padding: 0.5rem; 132 + } 133 + 134 + .error { 135 + color: #e74c3c; 136 + } 137 + 138 + .likers-list { 139 + display: flex; 140 + flex-direction: column; 141 + gap: 0.5rem; 142 + max-height: 300px; 143 + overflow-y: auto; 144 + } 145 + 146 + .liker { 147 + display: flex; 148 + align-items: center; 149 + gap: 0.75rem; 150 + padding: 0.5rem; 151 + border-radius: 6px; 152 + text-decoration: none; 153 + transition: background 0.2s; 154 + } 155 + 156 + .liker:hover { 157 + background: #252525; 158 + } 159 + 160 + .avatar, 161 + .avatar-placeholder { 162 + width: 32px; 163 + height: 32px; 164 + border-radius: 50%; 165 + flex-shrink: 0; 166 + } 167 + 168 + .avatar { 169 + object-fit: cover; 170 + border: 1px solid #333; 171 + } 172 + 173 + .avatar-placeholder { 174 + background: #333; 175 + display: flex; 176 + align-items: center; 177 + justify-content: center; 178 + color: #888; 179 + font-weight: 600; 180 + font-size: 0.9rem; 181 + } 182 + 183 + .liker-info { 184 + flex: 1; 185 + min-width: 0; 186 + } 187 + 188 + .display-name { 189 + color: #e8e8e8; 190 + font-weight: 500; 191 + font-size: 0.9rem; 192 + white-space: nowrap; 193 + overflow: hidden; 194 + text-overflow: ellipsis; 195 + } 196 + 197 + .handle { 198 + color: #888; 199 + font-size: 0.8rem; 200 + white-space: nowrap; 201 + overflow: hidden; 202 + text-overflow: ellipsis; 203 + } 204 + 205 + .liked-time { 206 + color: #666; 207 + font-size: 0.75rem; 208 + flex-shrink: 0; 209 + } 210 + 211 + .more { 212 + color: #888; 213 + font-size: 0.85rem; 214 + text-align: center; 215 + padding: 0.5rem; 216 + border-top: 1px solid #282828; 217 + margin-top: 0.25rem; 218 + } 219 + 220 + /* custom scrollbar */ 221 + .likers-list::-webkit-scrollbar { 222 + width: 6px; 223 + } 224 + 225 + .likers-list::-webkit-scrollbar-track { 226 + background: #1a1a1a; 227 + } 228 + 229 + .likers-list::-webkit-scrollbar-thumb { 230 + background: #333; 231 + border-radius: 3px; 232 + } 233 + 234 + .likers-list::-webkit-scrollbar-thumb:hover { 235 + background: #444; 236 + } 237 + </style>
+44 -2
frontend/src/lib/components/TrackItem.svelte
··· 2 2 import ShareButton from './ShareButton.svelte'; 3 3 import LikeButton from './LikeButton.svelte'; 4 4 import TrackActionsMenu from './TrackActionsMenu.svelte'; 5 + import LikersTooltip from './LikersTooltip.svelte'; 5 6 import type { Track } from '$lib/types'; 6 7 import { queue } from '$lib/queue.svelte'; 7 8 import { toast } from '$lib/toast.svelte'; ··· 16 17 17 18 let { track, isPlaying = false, onPlay, isAuthenticated = false, hideAlbum = false }: Props = $props(); 18 19 20 + let showLikersTooltip = $state(false); 21 + let likeCount = $state(track.like_count || 0); 22 + 23 + // sync likeCount when track changes 24 + $effect(() => { 25 + likeCount = track.like_count || 0; 26 + }); 27 + 19 28 // construct shareable URL - use /track/[id] for link previews 20 29 // the track page will redirect to home with query param for actual playback 21 30 const shareUrl = typeof window !== 'undefined' ··· 32 41 queue.addTracks([track]); 33 42 toast.success(`queued ${track.title}`, 1800); 34 43 } 44 + 45 + function handleLikesMouseEnter() { 46 + // show tooltip immediately, fetch will handle its own delay 47 + showLikersTooltip = true; 48 + } 49 + 50 + function handleLikesMouseLeave() { 51 + showLikersTooltip = false; 52 + } 53 + 54 + function handleLikeChange(liked: boolean) { 55 + // update like count immediately 56 + likeCount = liked ? likeCount + 1 : likeCount - 1; 57 + // also update the track object itself 58 + track.like_count = likeCount; 59 + } 35 60 </script> 36 61 37 62 <div class="track-container" class:playing={isPlaying}> ··· 99 124 </div> 100 125 <div class="track-meta"> 101 126 <span class="plays">{track.play_count} {track.play_count === 1 ? 'play' : 'plays'}</span> 102 - {#if track.like_count && track.like_count > 0} 127 + {#if likeCount > 0} 103 128 <span class="meta-separator">•</span> 104 - <span class="likes">{track.like_count} {track.like_count === 1 ? 'like' : 'likes'}</span> 129 + <span 130 + class="likes" 131 + onmouseenter={handleLikesMouseEnter} 132 + onmouseleave={handleLikesMouseLeave} 133 + > 134 + {likeCount} {likeCount === 1 ? 'like' : 'likes'} 135 + {#if showLikersTooltip} 136 + <LikersTooltip trackId={track.id} likeCount={likeCount} /> 137 + {/if} 138 + </span> 105 139 {/if} 106 140 </div> 107 141 </div> ··· 116 150 initialLiked={track.is_liked || false} 117 151 disabled={!track.atproto_record_uri} 118 152 disabledReason={!track.atproto_record_uri ? "track's record is unavailable and cannot be liked" : undefined} 153 + onLikeChange={handleLikeChange} 119 154 /> 120 155 {/if} 121 156 <button ··· 380 415 .likes { 381 416 color: #999; 382 417 font-family: inherit; 418 + position: relative; 419 + cursor: help; 420 + transition: color 0.2s; 421 + } 422 + 423 + .likes:hover { 424 + color: var(--accent); 383 425 } 384 426 385 427 .action-button {
+41
src/backend/api/tracks.py
··· 1503 1503 ) from e 1504 1504 1505 1505 return {"liked": False} 1506 + 1507 + 1508 + @router.get("/{track_id}/likes") 1509 + async def get_track_likes( 1510 + track_id: int, 1511 + db: Annotated[AsyncSession, Depends(get_db)], 1512 + ) -> dict: 1513 + """get users who liked a track. 1514 + 1515 + returns a list of users with their display info (handle, display_name, avatar_url). 1516 + this endpoint is public - no auth required to see who liked a track. 1517 + """ 1518 + # verify track exists 1519 + track_exists = await db.scalar(select(Track.id).where(Track.id == track_id)) 1520 + if not track_exists: 1521 + raise HTTPException(status_code=404, detail="track not found") 1522 + 1523 + # fetch likes with artist info (for display names/avatars) 1524 + stmt = ( 1525 + select(TrackLike, Artist) 1526 + .join(Artist, Artist.did == TrackLike.user_did) 1527 + .where(TrackLike.track_id == track_id) 1528 + .order_by(TrackLike.created_at.desc()) 1529 + ) 1530 + 1531 + result = await db.execute(stmt) 1532 + likes_with_artists = result.all() 1533 + 1534 + # build response with user display info 1535 + users = [ 1536 + { 1537 + "did": artist.did, 1538 + "handle": artist.handle, 1539 + "display_name": artist.display_name, 1540 + "avatar_url": artist.avatar_url, 1541 + "liked_at": like.created_at.isoformat(), 1542 + } 1543 + for like, artist in likes_with_artists 1544 + ] 1545 + 1546 + return {"users": users, "count": len(users)}