audio streaming app plyr.fm

fix: activity feed polish — verb styling, artist avatar, sparkline, pagination (#1008)

- revert sparkline to 7-day window (was accidentally changed to 30)
- wrap verb text in accent-tinted spans (smaller, subtly colored)
- add artist_avatar_url to ActivityTrack model + inline avatar rendering
- fix pagination by encoding cursor (+ in UTC offset decoded as space)

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

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
e857f039 49680b32

+79 -8
+11 -1
backend/src/backend/api/activity.py
··· 39 39 artist_handle: str 40 40 image_url: str | None 41 41 thumbnail_url: str | None 42 + artist_avatar_url: str | None = None 43 + 44 + @field_validator("artist_avatar_url", mode="before") 45 + @classmethod 46 + def normalize_avatar(cls, v: str | None) -> str | None: 47 + return normalize_avatar_url(v) 42 48 43 49 44 50 class ActivityEvent(BaseModel): ··· 85 91 t.title AS track_title, 86 92 ta.handle AS track_artist_handle, 87 93 t.image_url AS track_image_url, 88 - t.thumbnail_url AS track_thumbnail_url 94 + t.thumbnail_url AS track_thumbnail_url, 95 + ta.avatar_url AS track_artist_avatar_url 89 96 """ 90 97 91 98 _LIKE_QUERY = f""" ··· 110 117 a.handle AS track_artist_handle, 111 118 t.image_url AS track_image_url, 112 119 t.thumbnail_url AS track_thumbnail_url, 120 + a.avatar_url AS track_artist_avatar_url, 113 121 NULL AS comment_text, 114 122 t.created_at AS created_at 115 123 FROM tracks t ··· 140 148 NULL AS track_artist_handle, 141 149 NULL AS track_image_url, 142 150 NULL AS track_thumbnail_url, 151 + NULL AS track_artist_avatar_url, 143 152 NULL AS comment_text, 144 153 a.created_at AS created_at 145 154 FROM artists a ··· 220 229 artist_handle=row.track_artist_handle, 221 230 image_url=row.track_image_url, 222 231 thumbnail_url=row.track_thumbnail_url, 232 + artist_avatar_url=row.track_artist_avatar_url, 223 233 ) 224 234 if row.track_id is not None 225 235 else None,
+1
frontend/src/lib/types.ts
··· 179 179 artist_handle: string; 180 180 image_url: string | null; 181 181 thumbnail_url: string | null; 182 + artist_avatar_url: string | null; 182 183 } 183 184 184 185 export interface ActivityEvent {
+67 -7
frontend/src/routes/activity/+page.svelte
··· 38 38 initialLoad = false; 39 39 40 40 try { 41 - const res = await fetch(`${API_URL}/activity/histogram?days=30`); 41 + const res = await fetch(`${API_URL}/activity/histogram?days=7`); 42 42 if (res.ok) histogram = (await res.json()).buckets; 43 43 } catch (e) { 44 44 console.error('failed to load activity histogram:', e); ··· 63 63 if (!hasMore || !nextCursor || loadingMore) return; 64 64 loadingMore = true; 65 65 try { 66 - const response = await fetch(`${API_URL}/activity/?cursor=${nextCursor}`); 66 + const response = await fetch(`${API_URL}/activity/?cursor=${encodeURIComponent(nextCursor)}`); 67 67 if (response.ok) { 68 68 const result = await response.json(); 69 69 events = [...events, ...result.events]; ··· 123 123 124 124 {#if histogram.some(b => b.count > 0)} 125 125 <div class="sparkline-container"> 126 - <span class="sparkline-label">last 30 days</span> 126 + <span class="sparkline-label">last 7 days</span> 127 127 <svg class="sparkline" viewBox="0 0 100 32" preserveAspectRatio="none"> 128 128 <defs> 129 129 <linearGradient id="spark-fill" x1="0" y1="0" x2="0" y2="1"> ··· 190 190 {#if event.type === 'like' && event.track} 191 191 <p class="event-action"> 192 192 <svg class="action-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg> 193 - liked <a href="/track/{event.track.id}" class="track-link">{event.track.title}</a> 193 + <span class="verb">liked</span> <a href="/track/{event.track.id}" class="track-link">{event.track.title}</a> 194 + {#if event.track.artist_handle} 195 + <span class="by-artist">by</span> 196 + <a href="/u/{event.track.artist_handle}" class="artist-avatar-link" title={event.track.artist_handle}> 197 + {#if event.track.artist_avatar_url} 198 + <img src={event.track.artist_avatar_url} alt={event.track.artist_handle} class="inline-avatar" /> 199 + {:else} 200 + <span class="inline-avatar inline-avatar-placeholder"> 201 + <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> 202 + <circle cx="8" cy="5" r="3" fill="none" /><path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke-linecap="round" /> 203 + </svg> 204 + </span> 205 + {/if} 206 + </a> 207 + {/if} 194 208 </p> 195 209 {:else if event.type === 'track' && event.track} 196 210 <p class="event-action"> 197 211 <svg class="action-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> 198 - shared <a href="/track/{event.track.id}" class="track-link">{event.track.title}</a> 212 + <span class="verb">shared</span> <a href="/track/{event.track.id}" class="track-link">{event.track.title}</a> 213 + {#if event.track.artist_handle} 214 + <span class="by-artist">by</span> 215 + <a href="/u/{event.track.artist_handle}" class="artist-avatar-link" title={event.track.artist_handle}> 216 + {#if event.track.artist_avatar_url} 217 + <img src={event.track.artist_avatar_url} alt={event.track.artist_handle} class="inline-avatar" /> 218 + {:else} 219 + <span class="inline-avatar inline-avatar-placeholder"> 220 + <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> 221 + <circle cx="8" cy="5" r="3" fill="none" /><path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke-linecap="round" /> 222 + </svg> 223 + </span> 224 + {/if} 225 + </a> 226 + {/if} 199 227 </p> 200 228 {:else if event.type === 'comment' && event.track} 201 229 <p class="event-action"> 202 230 <svg class="action-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> 203 - commented on <a href="/track/{event.track.id}" class="track-link">{event.track.title}</a> 231 + <span class="verb">commented on</span> <a href="/track/{event.track.id}" class="track-link">{event.track.title}</a> 232 + {#if event.track.artist_handle} 233 + <span class="by-artist">by</span> 234 + <a href="/u/{event.track.artist_handle}" class="artist-avatar-link" title={event.track.artist_handle}> 235 + {#if event.track.artist_avatar_url} 236 + <img src={event.track.artist_avatar_url} alt={event.track.artist_handle} class="inline-avatar" /> 237 + {:else} 238 + <span class="inline-avatar inline-avatar-placeholder"> 239 + <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> 240 + <circle cx="8" cy="5" r="3" fill="none" /><path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke-linecap="round" /> 241 + </svg> 242 + </span> 243 + {/if} 244 + </a> 245 + {/if} 204 246 </p> 205 247 {:else if event.type === 'join'} 206 248 <p class="event-action"> 207 249 <svg class="action-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg> 208 - joined plyr.fm 250 + <span class="verb">joined</span> plyr.fm 209 251 </p> 210 252 {/if} 211 253 {#if event.type === 'comment' && event.comment_text} ··· 374 416 line-height: 1.4; display: flex; align-items: center; gap: 0.375rem; 375 417 } 376 418 .action-icon { flex-shrink: 0; opacity: 0.6; color: var(--type-color); } 419 + .verb { 420 + font-size: var(--text-xs); 421 + color: color-mix(in srgb, var(--text-tertiary) 70%, var(--accent)); 422 + } 377 423 .track-link { color: var(--text-secondary); text-decoration: none; font-weight: 500; } 378 424 .track-link:hover { color: var(--accent); } 425 + .by-artist { 426 + font-size: var(--text-xs); 427 + color: color-mix(in srgb, var(--text-tertiary) 70%, var(--accent)); 428 + } 429 + .artist-avatar-link { text-decoration: none; flex-shrink: 0; } 430 + .inline-avatar { 431 + width: 16px; height: 16px; border-radius: 50%; 432 + object-fit: cover; display: inline-block; vertical-align: middle; 433 + border: 1px solid var(--border-subtle); 434 + } 435 + .inline-avatar-placeholder { 436 + background: var(--bg-tertiary); display: inline-flex; 437 + align-items: center; justify-content: center; color: var(--text-muted); 438 + } 379 439 .comment-preview { 380 440 font-size: var(--text-xs); color: var(--text-tertiary); margin: 0.375rem 0 0 0; 381 441 line-height: 1.4; font-style: italic;