audio streaming app plyr.fm
38
fork

Configure Feed

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

feat: add interactive tooltip hover for likes and comments (#750)

- refine LikersTooltip: avatar-forward design with centered avatars,
horizontal scrolling for many likes, elegant styling
- add CommentersTooltip: show unique comment participants on hover
- add tooltip caching layer to avoid redundant API calls
- cache invalidation on like/unlike to keep data fresh
- add interactive like count to track detail page
- restructure liked tracks URL: /liked/[handle] → /u/[handle]/liked
- update internal links to use new URL structure
- add 301 redirect for old URLs

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

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

authored by

nate nowack
Claude Opus 4.5
and committed by
GitHub
5dce8ff2 405c7976

+616 -151
+257
frontend/src/lib/components/CommentersTooltip.svelte
··· 1 + <script lang="ts"> 2 + import { API_URL } from '$lib/config'; 3 + import { getCommenters, setCommenters, type CommenterData } from '$lib/tooltip-cache.svelte'; 4 + import SensitiveImage from './SensitiveImage.svelte'; 5 + 6 + interface Props { 7 + trackId: number; 8 + commentCount: number; 9 + onMouseEnter?: () => void; 10 + onMouseLeave?: () => void; 11 + } 12 + 13 + let { trackId, commentCount, onMouseEnter, onMouseLeave }: Props = $props(); 14 + 15 + let commenters = $state<CommenterData[]>([]); 16 + let loading = $state(true); 17 + let error = $state<string | null>(null); 18 + let tooltipElement: HTMLDivElement | null = $state(null); 19 + let positionBelow = $state(false); 20 + 21 + // check if tooltip should flip below based on viewport position 22 + $effect(() => { 23 + if (!tooltipElement) return; 24 + 25 + const parent = tooltipElement.parentElement; 26 + if (!parent) return; 27 + 28 + const parentRect = parent.getBoundingClientRect(); 29 + positionBelow = parentRect.top < 200; 30 + }); 31 + 32 + $effect(() => { 33 + if (commentCount === 0) { 34 + loading = false; 35 + return; 36 + } 37 + 38 + // check cache first 39 + const cached = getCommenters(trackId); 40 + if (cached) { 41 + commenters = cached; 42 + loading = false; 43 + return; 44 + } 45 + 46 + const fetchCommenters = async () => { 47 + try { 48 + const url = `${API_URL}/tracks/${trackId}/comments`; 49 + const response = await fetch(url); 50 + 51 + if (!response.ok) { 52 + throw new Error(`failed to fetch comments: ${response.status}`); 53 + } 54 + 55 + const data = await response.json(); 56 + const comments = data.comments || []; 57 + 58 + // extract unique commenters by did 59 + const uniqueMap = new Map<string, CommenterData>(); 60 + for (const comment of comments) { 61 + if (!uniqueMap.has(comment.user_did)) { 62 + uniqueMap.set(comment.user_did, { 63 + did: comment.user_did, 64 + handle: comment.user_handle, 65 + display_name: comment.user_display_name, 66 + avatar_url: comment.user_avatar_url 67 + }); 68 + } 69 + } 70 + const uniqueCommenters = Array.from(uniqueMap.values()); 71 + commenters = uniqueCommenters; 72 + setCommenters(trackId, uniqueCommenters); 73 + } catch (err) { 74 + error = 'failed to load'; 75 + console.error('error fetching commenters:', err); 76 + } finally { 77 + loading = false; 78 + } 79 + }; 80 + 81 + fetchCommenters(); 82 + }); 83 + </script> 84 + 85 + <div 86 + bind:this={tooltipElement} 87 + class="commenters-tooltip" 88 + class:position-below={positionBelow} 89 + role="tooltip" 90 + onmouseenter={onMouseEnter} 91 + onmouseleave={onMouseLeave} 92 + > 93 + {#if loading} 94 + <div class="loading"> 95 + <div class="loading-avatars"> 96 + {#each [1, 2, 3] as _} 97 + <div class="avatar-skeleton"></div> 98 + {/each} 99 + </div> 100 + </div> 101 + {:else if error} 102 + <div class="error">{error}</div> 103 + {:else if commenters.length > 0} 104 + <div class="commenters-avatars"> 105 + {#each commenters as commenter (commenter.did)} 106 + <a 107 + href="/u/{commenter.handle}" 108 + class="commenter-circle" 109 + title="{commenter.display_name || commenter.handle} (@{commenter.handle})" 110 + > 111 + {#if commenter.avatar_url} 112 + <SensitiveImage src={commenter.avatar_url} compact> 113 + <img src={commenter.avatar_url} alt="" /> 114 + </SensitiveImage> 115 + {:else} 116 + <span>{(commenter.display_name || commenter.handle).charAt(0).toUpperCase()}</span> 117 + {/if} 118 + </a> 119 + {/each} 120 + </div> 121 + {:else} 122 + <div class="empty">no comments yet</div> 123 + {/if} 124 + </div> 125 + 126 + <style> 127 + .commenters-tooltip { 128 + position: absolute; 129 + bottom: 100%; 130 + left: 50%; 131 + transform: translateX(-50%); 132 + margin-bottom: 0.625rem; 133 + background: var(--bg-secondary); 134 + border: 1px solid var(--border-subtle); 135 + border-radius: var(--radius-lg); 136 + padding: 0.5rem 0.625rem; 137 + box-shadow: 138 + 0 4px 16px rgba(0, 0, 0, 0.4), 139 + 0 0 0 1px rgba(255, 255, 255, 0.03); 140 + z-index: 1000; 141 + pointer-events: auto; 142 + } 143 + 144 + .commenters-tooltip.position-below { 145 + bottom: auto; 146 + top: 100%; 147 + margin-bottom: 0; 148 + margin-top: 0.625rem; 149 + } 150 + 151 + .loading, 152 + .error, 153 + .empty { 154 + color: var(--text-tertiary); 155 + font-size: var(--text-sm); 156 + text-align: center; 157 + padding: 0.25rem 0.5rem; 158 + white-space: nowrap; 159 + } 160 + 161 + .error { 162 + color: var(--error); 163 + } 164 + 165 + .loading-avatars { 166 + display: flex; 167 + justify-content: center; 168 + } 169 + 170 + .avatar-skeleton { 171 + width: 32px; 172 + height: 32px; 173 + border-radius: var(--radius-full); 174 + background: linear-gradient( 175 + 90deg, 176 + var(--bg-tertiary) 0%, 177 + var(--bg-hover) 50%, 178 + var(--bg-tertiary) 100% 179 + ); 180 + background-size: 200% 100%; 181 + animation: shimmer 1.5s ease-in-out infinite; 182 + border: 2px solid var(--bg-secondary); 183 + margin-left: -8px; 184 + flex-shrink: 0; 185 + } 186 + 187 + .avatar-skeleton:first-child { 188 + margin-left: 0; 189 + } 190 + 191 + @keyframes shimmer { 192 + 0% { background-position: 200% 0; } 193 + 100% { background-position: -200% 0; } 194 + } 195 + 196 + .commenters-avatars { 197 + display: flex; 198 + justify-content: center; 199 + overflow-x: auto; 200 + max-width: 240px; 201 + padding: 0.125rem 0; 202 + scrollbar-width: none; 203 + } 204 + 205 + .commenters-avatars::-webkit-scrollbar { 206 + display: none; 207 + } 208 + 209 + .commenter-circle { 210 + width: 32px; 211 + height: 32px; 212 + border-radius: var(--radius-full); 213 + border: 2px solid var(--bg-secondary); 214 + background: var(--bg-tertiary); 215 + display: flex; 216 + align-items: center; 217 + justify-content: center; 218 + overflow: hidden; 219 + margin-left: -8px; 220 + transition: 221 + transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), 222 + z-index 0s; 223 + position: relative; 224 + text-decoration: none; 225 + flex-shrink: 0; 226 + } 227 + 228 + .commenter-circle:first-child { 229 + margin-left: 0; 230 + } 231 + 232 + .commenter-circle:hover { 233 + transform: translateY(-3px) scale(1.2); 234 + z-index: 10; 235 + } 236 + 237 + .commenter-circle img { 238 + width: 100%; 239 + height: 100%; 240 + object-fit: cover; 241 + } 242 + 243 + .commenter-circle span { 244 + font-size: var(--text-xs); 245 + font-weight: 600; 246 + color: var(--text-secondary); 247 + } 248 + 249 + @media (prefers-reduced-motion: reduce) { 250 + .avatar-skeleton { 251 + animation: none; 252 + } 253 + .commenter-circle { 254 + transition: none; 255 + } 256 + } 257 + </style>
+116 -118
frontend/src/lib/components/LikersTooltip.svelte
··· 1 1 <script lang="ts"> 2 2 import { API_URL } from '$lib/config'; 3 + import { getLikers, setLikers, type LikerData } from '$lib/tooltip-cache.svelte'; 3 4 import SensitiveImage from './SensitiveImage.svelte'; 4 5 5 - interface Liker { 6 - did: string; 7 - handle: string; 8 - display_name: string; 9 - avatar_url?: string; 10 - liked_at: string; 11 - } 12 - 13 6 interface Props { 14 7 trackId: number; 15 8 likeCount: number; ··· 19 12 20 13 let { trackId, likeCount, onMouseEnter, onMouseLeave }: Props = $props(); 21 14 22 - let likers = $state<Liker[]>([]); 23 - let loading = $state(true); // start as loading 15 + let likers = $state<LikerData[]>([]); 16 + let loading = $state(true); 24 17 let error = $state<string | null>(null); 25 18 let tooltipElement: HTMLDivElement | null = $state(null); 26 19 let positionBelow = $state(false); ··· 29 22 $effect(() => { 30 23 if (!tooltipElement) return; 31 24 32 - // get the parent element's position (the .likes span) 33 25 const parent = tooltipElement.parentElement; 34 26 if (!parent) return; 35 27 36 28 const parentRect = parent.getBoundingClientRect(); 37 - // if less than 300px from viewport top, flip tooltip below 38 - // 300px accounts for tooltip max height (~240px) plus padding 39 - positionBelow = parentRect.top < 300; 29 + positionBelow = parentRect.top < 200; 40 30 }); 41 31 42 32 $effect(() => { ··· 45 35 return; 46 36 } 47 37 38 + // check cache first 39 + const cached = getLikers(trackId); 40 + if (cached) { 41 + likers = cached; 42 + loading = false; 43 + return; 44 + } 45 + 48 46 const fetchLikers = async () => { 49 47 try { 50 48 const url = `${API_URL}/tracks/${trackId}/likes`; 51 49 const response = await fetch(url); 52 50 53 51 if (!response.ok) { 54 - const text = await response.text(); 55 - console.error('failed to fetch likers:', response.status, text); 56 52 throw new Error(`failed to fetch likers: ${response.status}`); 57 53 } 58 54 59 55 const data = await response.json(); 60 - likers = data.users || []; 56 + const users = data.users || []; 57 + likers = users; 58 + setLikers(trackId, users); 61 59 } catch (err) { 62 60 error = 'failed to load'; 63 61 console.error('error fetching likers:', err); ··· 69 67 fetchLikers(); 70 68 }); 71 69 72 - // format relative time 73 70 function formatTime(isoString: string): string { 74 71 const date = new Date(isoString); 75 72 const now = new Date(); ··· 92 89 onmouseleave={onMouseLeave} 93 90 > 94 91 {#if loading} 95 - <div class="loading">loading...</div> 92 + <div class="loading"> 93 + <div class="loading-avatars"> 94 + {#each [1, 2, 3] as _} 95 + <div class="avatar-skeleton"></div> 96 + {/each} 97 + </div> 98 + </div> 96 99 {:else if error} 97 100 <div class="error">{error}</div> 98 - {:else if likers.length > 0} 99 - <div class="likers-list"> 100 - {#each likers as liker} 101 - <a 102 - href="/liked/{liker.handle}" 103 - class="liker" 104 - title="view {liker.display_name}'s liked tracks" 105 - > 106 - {#if liker.avatar_url} 107 - <SensitiveImage src={liker.avatar_url} compact> 108 - <img src={liker.avatar_url} alt={liker.display_name} class="avatar" /> 109 - </SensitiveImage> 110 - {:else} 111 - <div class="avatar-placeholder"> 112 - {liker.display_name.charAt(0).toUpperCase()} 113 - </div> 114 - {/if} 115 - <div class="liker-info"> 116 - <div class="display-name">{liker.display_name}</div> 117 - <div class="handle">@{liker.handle}</div> 118 - </div> 119 - <div class="liked-time">{formatTime(liker.liked_at)}</div> 120 - </a> 121 - {/each} 122 - </div> 123 - {:else} 124 - <div class="empty">be the first to like this</div> 125 - {/if} 101 + {:else if likers.length > 0} 102 + <div class="likers-avatars"> 103 + {#each likers as liker (liker.did)} 104 + <a 105 + href="/u/{liker.handle}/liked" 106 + class="liker-circle" 107 + title="{liker.display_name} (@{liker.handle}) • {formatTime(liker.liked_at)}" 108 + > 109 + {#if liker.avatar_url} 110 + <SensitiveImage src={liker.avatar_url} compact> 111 + <img src={liker.avatar_url} alt="" /> 112 + </SensitiveImage> 113 + {:else} 114 + <span>{liker.display_name.charAt(0).toUpperCase()}</span> 115 + {/if} 116 + </a> 117 + {/each} 118 + </div> 119 + {:else} 120 + <div class="empty">be the first to like this</div> 121 + {/if} 126 122 </div> 127 123 128 124 <style> ··· 131 127 bottom: 100%; 132 128 left: 50%; 133 129 transform: translateX(-50%); 134 - margin-bottom: 0.5rem; 130 + margin-bottom: 0.625rem; 135 131 background: var(--bg-secondary); 136 - border: 1px solid var(--border-default); 137 - border-radius: var(--radius-md); 138 - padding: 0.75rem; 139 - min-width: 240px; 140 - max-width: 320px; 141 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 132 + border: 1px solid var(--border-subtle); 133 + border-radius: var(--radius-lg); 134 + padding: 0.5rem 0.625rem; 135 + box-shadow: 136 + 0 4px 16px rgba(0, 0, 0, 0.4), 137 + 0 0 0 1px rgba(255, 255, 255, 0.03); 142 138 z-index: 1000; 143 139 pointer-events: auto; 144 140 } 145 141 146 - /* flip tooltip below when near viewport top */ 147 142 .likers-tooltip.position-below { 148 143 bottom: auto; 149 144 top: 100%; 150 145 margin-bottom: 0; 151 - margin-top: 0.5rem; 146 + margin-top: 0.625rem; 152 147 } 153 148 154 149 .loading, ··· 157 152 color: var(--text-tertiary); 158 153 font-size: var(--text-sm); 159 154 text-align: center; 160 - padding: 0.5rem; 155 + padding: 0.25rem 0.5rem; 156 + white-space: nowrap; 161 157 } 162 158 163 159 .error { 164 160 color: var(--error); 165 161 } 166 162 167 - .likers-list { 168 - display: flex; 169 - flex-direction: column; 170 - gap: 0.5rem; 171 - max-height: 240px; 172 - overflow-y: auto; 173 - } 174 - 175 - .liker { 163 + .loading-avatars { 176 164 display: flex; 177 - align-items: center; 178 - gap: 0.75rem; 179 - padding: 0.5rem; 180 - border-radius: var(--radius-base); 181 - text-decoration: none; 182 - transition: background 0.2s; 165 + justify-content: center; 183 166 } 184 167 185 - .liker:hover { 186 - background: var(--bg-hover); 187 - } 188 - 189 - .avatar, 190 - .avatar-placeholder { 168 + .avatar-skeleton { 191 169 width: 32px; 192 170 height: 32px; 193 171 border-radius: var(--radius-full); 172 + background: linear-gradient( 173 + 90deg, 174 + var(--bg-tertiary) 0%, 175 + var(--bg-hover) 50%, 176 + var(--bg-tertiary) 100% 177 + ); 178 + background-size: 200% 100%; 179 + animation: shimmer 1.5s ease-in-out infinite; 180 + border: 2px solid var(--bg-secondary); 181 + margin-left: -8px; 194 182 flex-shrink: 0; 195 183 } 196 184 197 - .avatar { 198 - object-fit: cover; 199 - border: 1px solid var(--border-default); 185 + .avatar-skeleton:first-child { 186 + margin-left: 0; 187 + } 188 + 189 + @keyframes shimmer { 190 + 0% { background-position: 200% 0; } 191 + 100% { background-position: -200% 0; } 200 192 } 201 193 202 - .avatar-placeholder { 203 - background: var(--border-default); 194 + .likers-avatars { 204 195 display: flex; 205 - align-items: center; 206 196 justify-content: center; 207 - color: var(--text-tertiary); 208 - font-weight: 600; 209 - font-size: var(--text-base); 197 + overflow-x: auto; 198 + max-width: 240px; 199 + padding: 0.125rem 0; 200 + scrollbar-width: none; 210 201 } 211 202 212 - .liker-info { 213 - flex: 1; 214 - min-width: 0; 203 + .likers-avatars::-webkit-scrollbar { 204 + display: none; 215 205 } 216 206 217 - .display-name { 218 - color: var(--text-primary); 219 - font-weight: 500; 220 - font-size: var(--text-base); 221 - white-space: nowrap; 207 + .liker-circle { 208 + width: 32px; 209 + height: 32px; 210 + border-radius: var(--radius-full); 211 + border: 2px solid var(--bg-secondary); 212 + background: var(--bg-tertiary); 213 + display: flex; 214 + align-items: center; 215 + justify-content: center; 222 216 overflow: hidden; 223 - text-overflow: ellipsis; 217 + margin-left: -8px; 218 + transition: 219 + transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), 220 + z-index 0s; 221 + position: relative; 222 + text-decoration: none; 223 + flex-shrink: 0; 224 224 } 225 225 226 - .handle { 227 - color: var(--text-tertiary); 228 - font-size: var(--text-sm); 229 - white-space: nowrap; 230 - overflow: hidden; 231 - text-overflow: ellipsis; 232 - } 233 - 234 - .liked-time { 235 - color: var(--text-muted); 236 - font-size: var(--text-xs); 237 - flex-shrink: 0; 226 + .liker-circle:first-child { 227 + margin-left: 0; 238 228 } 239 229 240 - /* custom scrollbar */ 241 - .likers-list::-webkit-scrollbar { 242 - width: 6px; 230 + .liker-circle:hover { 231 + transform: translateY(-3px) scale(1.2); 232 + z-index: 10; 243 233 } 244 234 245 - .likers-list::-webkit-scrollbar-track { 246 - background: var(--bg-tertiary); 235 + .liker-circle img { 236 + width: 100%; 237 + height: 100%; 238 + object-fit: cover; 247 239 } 248 240 249 - .likers-list::-webkit-scrollbar-thumb { 250 - background: var(--border-default); 251 - border-radius: var(--radius-sm); 241 + .liker-circle span { 242 + font-size: var(--text-xs); 243 + font-weight: 600; 244 + color: var(--text-secondary); 252 245 } 253 246 254 - .likers-list::-webkit-scrollbar-thumb:hover { 255 - background: var(--border-emphasis); 247 + @media (prefers-reduced-motion: reduce) { 248 + .avatar-skeleton { 249 + animation: none; 250 + } 251 + .liker-circle { 252 + transition: none; 253 + } 256 254 } 257 255 </style>
+72 -9
frontend/src/lib/components/TrackItem.svelte
··· 3 3 import AddToMenu from './AddToMenu.svelte'; 4 4 import TrackActionsMenu from './TrackActionsMenu.svelte'; 5 5 import LikersTooltip from './LikersTooltip.svelte'; 6 + import CommentersTooltip from './CommentersTooltip.svelte'; 6 7 import SensitiveImage from './SensitiveImage.svelte'; 7 8 import type { Track } from '$lib/types'; 8 9 import { queue } from '$lib/queue.svelte'; ··· 38 39 const imageFetchPriority = index < 2 ? 'high' : undefined; 39 40 40 41 let showLikersTooltip = $state(false); 42 + let showCommentersTooltip = $state(false); 41 43 // use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI 42 44 let likeCount = $derived(track.like_count || 0); 43 45 let commentCount = $derived(track.comment_count || 0); ··· 112 114 } 113 115 if (event.key === 'Escape') { 114 116 showLikersTooltip = false; 117 + } 118 + } 119 + 120 + let commentersTooltipTimeout: ReturnType<typeof setTimeout> | null = null; 121 + 122 + function handleCommentsMouseEnter() { 123 + if (commentersTooltipTimeout) { 124 + clearTimeout(commentersTooltipTimeout); 125 + commentersTooltipTimeout = null; 126 + } 127 + showCommentersTooltip = true; 128 + } 129 + 130 + function handleCommentsMouseLeave() { 131 + commentersTooltipTimeout = setTimeout(() => { 132 + showCommentersTooltip = false; 133 + commentersTooltipTimeout = null; 134 + }, 150); 135 + } 136 + 137 + function handleCommentsKeydown(event: KeyboardEvent) { 138 + if (event.key === 'Enter' || event.key === ' ') { 139 + // don't prevent default - let the link navigate 140 + } 141 + if (event.key === 'Escape') { 142 + showCommentersTooltip = false; 115 143 } 116 144 } 117 145 ··· 282 310 {/if} 283 311 {#if commentCount > 0} 284 312 <span class="meta-separator">•</span> 285 - <a 286 - href="/track/{track.id}" 287 - class="comments" 288 - title="view comments" 313 + <span 314 + class="comments-wrapper" 315 + role="button" 316 + tabindex="0" 317 + aria-label="{commentCount} {commentCount === 1 ? 'comment' : 'comments'} (focus to view participants)" 318 + aria-expanded={showCommentersTooltip} 319 + onmouseenter={handleCommentsMouseEnter} 320 + onmouseleave={handleCommentsMouseLeave} 321 + onfocus={handleCommentsMouseEnter} 322 + onblur={handleCommentsMouseLeave} 323 + onkeydown={handleCommentsKeydown} 289 324 > 290 - <svg class="comment-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 291 - <path d="M2 3h12v8H5l-3 3V3z" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linejoin="round"/> 292 - </svg> 293 - <span class="comment-count">{commentCount}</span> 294 - </a> 325 + <a 326 + href="/track/{track.id}" 327 + class="comments" 328 + title="view comments" 329 + > 330 + <svg class="comment-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 331 + <path d="M2 3h12v8H5l-3 3V3z" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linejoin="round"/> 332 + </svg> 333 + <span class="comment-count">{commentCount}</span> 334 + </a> 335 + {#if showCommentersTooltip} 336 + <CommentersTooltip 337 + trackId={track.id} 338 + commentCount={commentCount} 339 + onMouseEnter={handleCommentsMouseEnter} 340 + onMouseLeave={handleCommentsMouseLeave} 341 + /> 342 + {/if} 343 + </span> 295 344 {/if} 296 345 </div> 297 346 </div> ··· 722 771 723 772 .likes:hover { 724 773 color: var(--accent); 774 + } 775 + 776 + .comments-wrapper { 777 + position: relative; 778 + cursor: help; 779 + } 780 + 781 + .comments-wrapper:hover .comments, 782 + .comments-wrapper:focus .comments { 783 + color: var(--accent); 784 + } 785 + 786 + .comments-wrapper:focus { 787 + outline: none; 725 788 } 726 789 727 790 .comments {
+70
frontend/src/lib/tooltip-cache.svelte.ts
··· 1 + /** 2 + * Simple in-memory cache for tooltip data (likers, commenters). 3 + * - Lazy: only fetched on hover 4 + * - Cached: subsequent hovers use cached data 5 + * - Invalidatable: cache entries can be cleared when data changes 6 + */ 7 + 8 + interface CacheEntry<T> { 9 + data: T; 10 + timestamp: number; 11 + } 12 + 13 + const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes 14 + 15 + // separate caches for different data types 16 + const likersCache = new Map<number, CacheEntry<LikerData[]>>(); 17 + const commentersCache = new Map<number, CacheEntry<CommenterData[]>>(); 18 + 19 + export interface LikerData { 20 + did: string; 21 + handle: string; 22 + display_name: string; 23 + avatar_url?: string; 24 + liked_at: string; 25 + } 26 + 27 + export interface CommenterData { 28 + did: string; 29 + handle: string; 30 + display_name: string | null; 31 + avatar_url: string | null; 32 + } 33 + 34 + function isExpired(entry: CacheEntry<unknown>): boolean { 35 + return Date.now() - entry.timestamp > CACHE_TTL_MS; 36 + } 37 + 38 + // likers cache 39 + export function getLikers(trackId: number): LikerData[] | null { 40 + const entry = likersCache.get(trackId); 41 + if (!entry || isExpired(entry)) { 42 + return null; 43 + } 44 + return entry.data; 45 + } 46 + 47 + export function setLikers(trackId: number, data: LikerData[]): void { 48 + likersCache.set(trackId, { data, timestamp: Date.now() }); 49 + } 50 + 51 + export function invalidateLikers(trackId: number): void { 52 + likersCache.delete(trackId); 53 + } 54 + 55 + // commenters cache 56 + export function getCommenters(trackId: number): CommenterData[] | null { 57 + const entry = commentersCache.get(trackId); 58 + if (!entry || isExpired(entry)) { 59 + return null; 60 + } 61 + return entry.data; 62 + } 63 + 64 + export function setCommenters(trackId: number, data: CommenterData[]): void { 65 + commentersCache.set(trackId, { data, timestamp: Date.now() }); 66 + } 67 + 68 + export function invalidateCommenters(trackId: number): void { 69 + commentersCache.delete(trackId); 70 + }
+5 -2
frontend/src/lib/tracks.svelte.ts
··· 2 2 import type { Track } from './types'; 3 3 import { preferences } from './preferences.svelte'; 4 4 import { downloadAudio, isDownloaded } from './storage'; 5 + import { invalidateLikers } from './tooltip-cache.svelte'; 5 6 6 7 interface TracksApiResponse { 7 8 tracks: Track[]; ··· 145 146 throw new Error(`failed to like track: ${response.statusText}`); 146 147 } 147 148 148 - // invalidate cache so next fetch gets updated like status 149 + // invalidate caches so next fetch gets updated like status 149 150 tracksCache.invalidate(); 151 + invalidateLikers(trackId); 150 152 151 153 // auto-download if preference is enabled and file_id provided 152 154 // skip download only if track is gated AND viewer lacks access (gated === true) ··· 182 184 throw new Error(`failed to unlike track: ${response.statusText}`); 183 185 } 184 186 185 - // invalidate cache so next fetch gets updated like status 187 + // invalidate caches so next fetch gets updated like status 186 188 tracksCache.invalidate(); 189 + invalidateLikers(trackId); 187 190 188 191 return true; 189 192 } catch (e) {
frontend/src/routes/liked/[handle]/+page.svelte frontend/src/routes/u/[handle]/liked/+page.svelte
+3 -20
frontend/src/routes/liked/[handle]/+page.ts
··· 1 - import { browser } from '$app/environment'; 2 - import { error } from '@sveltejs/kit'; 3 - import { fetchUserLikes, type UserLikesResponse } from '$lib/tracks.svelte'; 1 + import { redirect } from '@sveltejs/kit'; 4 2 import type { PageLoad } from './$types'; 5 3 6 - export interface PageData { 7 - userLikes: UserLikesResponse; 8 - } 9 - 10 - export const ssr = false; 11 - 4 + // redirect old /liked/[handle] URLs to new /u/[handle]/liked 12 5 export const load: PageLoad = async ({ params }) => { 13 - if (!browser) { 14 - return { userLikes: null }; 15 - } 16 - 17 - const userLikes = await fetchUserLikes(params.handle); 18 - 19 - if (!userLikes) { 20 - throw error(404, 'user not found'); 21 - } 22 - 23 - return { userLikes }; 6 + throw redirect(301, `/u/${params.handle}/liked`); 24 7 };
+68 -1
frontend/src/routes/track/[id]/+page.svelte
··· 11 11 import ShareButton from '$lib/components/ShareButton.svelte'; 12 12 import TagEffects from '$lib/components/TagEffects.svelte'; 13 13 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 14 + import LikersTooltip from '$lib/components/LikersTooltip.svelte'; 14 15 import { checkImageSensitive } from '$lib/moderation.svelte'; 15 16 import { player } from '$lib/player.svelte'; 16 17 import { queue } from '$lib/queue.svelte'; ··· 55 56 let isCurrentlyPlaying = $derived( 56 57 player.currentTrack?.id === track.id && !player.paused 57 58 ); 59 + 60 + // likers tooltip state 61 + let showLikersTooltip = $state(false); 62 + let likersTooltipTimeout: ReturnType<typeof setTimeout> | null = null; 63 + 64 + function handleLikesMouseEnter() { 65 + if (likersTooltipTimeout) { 66 + clearTimeout(likersTooltipTimeout); 67 + likersTooltipTimeout = null; 68 + } 69 + showLikersTooltip = true; 70 + } 71 + 72 + function handleLikesMouseLeave() { 73 + likersTooltipTimeout = setTimeout(() => { 74 + showLikersTooltip = false; 75 + likersTooltipTimeout = null; 76 + }, 150); 77 + } 78 + 79 + function handleLikesKeydown(event: KeyboardEvent) { 80 + if (event.key === 'Enter' || event.key === ' ') { 81 + event.preventDefault(); 82 + showLikersTooltip = true; 83 + } 84 + if (event.key === 'Escape') { 85 + showLikersTooltip = false; 86 + } 87 + } 58 88 59 89 // URL regex pattern for linkifying comment text 60 90 const urlPattern = /https?:\/\/[^\s<>"{}|\\^`[\]]+/gi; ··· 538 568 <span class="plays">{track.play_count} {track.play_count === 1 ? 'play' : 'plays'}</span> 539 569 {#if track.like_count && track.like_count > 0} 540 570 <span class="separator">•</span> 541 - <span class="likes">{track.like_count} {track.like_count === 1 ? 'like' : 'likes'}</span> 571 + <span 572 + class="likes" 573 + role="button" 574 + tabindex="0" 575 + aria-label={`${track.like_count} ${track.like_count === 1 ? 'like' : 'likes'} (focus to view users)`} 576 + aria-expanded={showLikersTooltip} 577 + onmouseenter={handleLikesMouseEnter} 578 + onmouseleave={handleLikesMouseLeave} 579 + onfocus={handleLikesMouseEnter} 580 + onblur={handleLikesMouseLeave} 581 + onkeydown={handleLikesKeydown} 582 + > 583 + {track.like_count} {track.like_count === 1 ? 'like' : 'likes'} 584 + {#if showLikersTooltip} 585 + <LikersTooltip 586 + trackId={track.id} 587 + likeCount={track.like_count} 588 + onMouseEnter={handleLikesMouseEnter} 589 + onMouseLeave={handleLikesMouseLeave} 590 + /> 591 + {/if} 592 + </span> 542 593 {/if} 543 594 </div> 544 595 ··· 911 962 912 963 .track-stats .separator { 913 964 font-size: var(--text-xs); 965 + } 966 + 967 + .track-stats .likes { 968 + position: relative; 969 + cursor: pointer; 970 + padding: 0.125rem 0.25rem; 971 + margin: -0.125rem -0.25rem; 972 + border-radius: var(--radius-sm); 973 + transition: background 0.15s, color 0.15s; 974 + } 975 + 976 + .track-stats .likes:hover, 977 + .track-stats .likes:focus { 978 + background: color-mix(in srgb, var(--accent) 15%, transparent); 979 + color: var(--accent); 980 + outline: none; 914 981 } 915 982 916 983 .track-tags {
+1 -1
frontend/src/routes/u/[handle]/+page.svelte
··· 655 655 </div> 656 656 <div class="collections-list"> 657 657 {#if artist.show_liked_on_profile} 658 - <a href="/liked/{artist.handle}" class="collection-link"> 658 + <a href="/u/{artist.handle}/liked" class="collection-link"> 659 659 <div class="collection-icon liked"> 660 660 <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> 661 661 <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
+24
frontend/src/routes/u/[handle]/liked/+page.ts
··· 1 + import { browser } from '$app/environment'; 2 + import { error } from '@sveltejs/kit'; 3 + import { fetchUserLikes, type UserLikesResponse } from '$lib/tracks.svelte'; 4 + import type { PageLoad } from './$types'; 5 + 6 + export interface PageData { 7 + userLikes: UserLikesResponse; 8 + } 9 + 10 + export const ssr = false; 11 + 12 + export const load: PageLoad = async ({ params }) => { 13 + if (!browser) { 14 + return { userLikes: null }; 15 + } 16 + 17 + const userLikes = await fetchUserLikes(params.handle); 18 + 19 + if (!userLikes) { 20 + throw error(404, 'user not found'); 21 + } 22 + 23 + return { userLikes }; 24 + };