audio streaming app plyr.fm

feat: activity sparkline + neon glow accent + fix avatar square frame (#1006)

- add GET /activity/histogram endpoint (daily counts over last 7 days)
- SVG sparkline at top of activity page with accent gradient fill
- replace solid border-left with fuzzy neon glow (::before + box-shadow)
- smaller, less blurred lava blobs (5 blobs, 50px blur, ~180-280px max)
- fix TrackCard avatar fallback: container becomes circular when showing
artist avatar so no ugly square outline around circle

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

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
08e3d436 9dc4f5e8

+159 -31
+55 -1
backend/src/backend/api/activity.py
··· 1 1 """activity feed — platform-wide chronological event stream.""" 2 2 3 3 import logging 4 - from datetime import datetime 4 + from datetime import UTC, datetime, timedelta 5 5 from typing import Annotated, Literal 6 6 7 7 from fastapi import APIRouter, Depends, HTTPException, Query ··· 57 57 events: list[ActivityEvent] 58 58 next_cursor: str | None = None 59 59 has_more: bool = False 60 + 61 + 62 + class ActivityHistogramBucket(BaseModel): 63 + """single day in the activity histogram.""" 64 + 65 + date: str 66 + count: int 67 + 68 + 69 + class ActivityHistogramResponse(BaseModel): 70 + """activity counts per day over a time window.""" 71 + 72 + buckets: list[ActivityHistogramBucket] 60 73 61 74 62 75 # raw SQL for the UNION ALL query — each branch selects the same column shape ··· 223 236 next_cursor=next_cursor, 224 237 has_more=has_more, 225 238 ) 239 + 240 + 241 + _HISTOGRAM_QUERY = """ 242 + SELECT d::date AS bucket_date, COALESCE(c.total, 0) AS total 243 + FROM generate_series(:start::date, :end_date::date, '1 day') d 244 + LEFT JOIN ( 245 + SELECT created_at::date AS day, COUNT(*) AS total FROM ( 246 + SELECT tl.created_at FROM track_likes tl WHERE tl.created_at >= :start 247 + UNION ALL 248 + SELECT t.created_at FROM tracks t WHERE t.created_at >= :start 249 + UNION ALL 250 + SELECT tc.created_at FROM track_comments tc WHERE tc.created_at >= :start 251 + UNION ALL 252 + SELECT a.created_at FROM artists a 253 + WHERE a.handle != '' AND a.display_name != '' AND a.created_at >= :start 254 + ) events GROUP BY day 255 + ) c ON c.day = d::date 256 + ORDER BY bucket_date 257 + """ 258 + 259 + 260 + @router.get("/histogram") 261 + async def get_activity_histogram( 262 + db: Annotated[AsyncSession, Depends(get_db)], 263 + days: int = Query(7, ge=1, le=30), 264 + ) -> ActivityHistogramResponse: 265 + """get activity counts per day for the sparkline.""" 266 + now = datetime.now(UTC) 267 + start = now - timedelta(days=days - 1) 268 + 269 + result = await db.execute( 270 + text(_HISTOGRAM_QUERY), 271 + {"start": start, "end_date": now}, 272 + ) 273 + 274 + return ActivityHistogramResponse( 275 + buckets=[ 276 + ActivityHistogramBucket(date=str(row.bucket_date), count=row.total) 277 + for row in result.fetchall() 278 + ] 279 + )
+6 -2
frontend/src/lib/components/TrackCard.svelte
··· 86 86 onPlay(track); 87 87 }} 88 88 > 89 - <div class="artwork" class:gated={track.gated}> 89 + <div class="artwork" class:gated={track.gated} class:avatar-fallback={!track.image_url && track.artist_avatar_url}> 90 90 {#if track.image_url} 91 91 <SensitiveImage src={track.thumbnail_url ?? track.image_url}> 92 92 <img ··· 215 215 display: block; 216 216 } 217 217 218 - .artwork img.avatar { 218 + .artwork.avatar-fallback { 219 219 border-radius: var(--radius-full); 220 220 border: 1.5px solid var(--border-default); 221 + } 222 + 223 + .artwork.avatar-fallback img { 224 + border-radius: var(--radius-full); 221 225 } 222 226 223 227 .artwork.gated::after {
+5
frontend/src/lib/types.ts
··· 189 189 created_at: string; 190 190 } 191 191 192 + export interface ActivityHistogramBucket { 193 + date: string; 194 + count: number; 195 + } 196 + 192 197 export interface JamPlaybackState { 193 198 track_ids: string[]; 194 199 current_index: number;
+93 -28
frontend/src/routes/activity/+page.svelte
··· 6 6 import { API_URL } from '$lib/config'; 7 7 import { APP_NAME } from '$lib/branding'; 8 8 import { statsCache, formatDuration } from '$lib/stats.svelte'; 9 - import type { ActivityEvent } from '$lib/types'; 9 + import type { ActivityEvent, ActivityHistogramBucket } from '$lib/types'; 10 10 11 11 let { data } = $props(); 12 12 ··· 17 17 let initialLoad = $state(true); 18 18 let sentinelElement = $state<HTMLDivElement | null>(null); 19 19 let stats = $derived(statsCache.stats); 20 + let histogram = $state<ActivityHistogramBucket[]>([]); 20 21 21 - onMount(() => { 22 + const sparklinePath = $derived.by(() => { 23 + if (histogram.length === 0) return ''; 24 + const max = Math.max(...histogram.map((b) => b.count), 1); 25 + const w = 100; 26 + const h = 32; 27 + const step = w / (histogram.length - 1 || 1); 28 + const points = histogram.map((b, i) => `${i * step},${h - (b.count / max) * h * 0.85}`); 29 + return `M${points.join(' L')} L${w},${h} L0,${h} Z`; 30 + }); 31 + 32 + onMount(async () => { 22 33 auth.initialize(); 23 34 statsCache.fetch(); 24 35 events = data.events; 25 36 nextCursor = data.next_cursor; 26 37 hasMore = data.has_more; 27 38 initialLoad = false; 39 + 40 + try { 41 + const res = await fetch(`${API_URL}/activity/histogram?days=7`); 42 + if (res.ok) histogram = (await res.json()).buckets; 43 + } catch (e) { 44 + console.error('failed to load activity histogram:', e); 45 + } 28 46 }); 29 47 30 48 function timeAgo(iso: string): string { ··· 87 105 <div class="lava-blob b1"></div> 88 106 <div class="lava-blob b2"></div> 89 107 <div class="lava-blob b3"></div> 108 + <div class="lava-blob b4"></div> 109 + <div class="lava-blob b5"></div> 90 110 </div> 91 111 92 112 <main> ··· 101 121 {/if} 102 122 </div> 103 123 124 + {#if histogram.length > 0} 125 + <div class="sparkline-container"> 126 + <span class="sparkline-label">last 7 days</span> 127 + <svg class="sparkline" viewBox="0 0 100 32" preserveAspectRatio="none"> 128 + <defs> 129 + <linearGradient id="spark-fill" x1="0" y1="0" x2="0" y2="1"> 130 + <stop offset="0%" stop-color="var(--accent)" stop-opacity="0.3" /> 131 + <stop offset="100%" stop-color="var(--accent)" stop-opacity="0.02" /> 132 + </linearGradient> 133 + </defs> 134 + <path d={sparklinePath} fill="url(#spark-fill)" /> 135 + <path 136 + d={sparklinePath.replace(/ L\d+,32 L0,32 Z/, '')} 137 + fill="none" stroke="var(--accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" 138 + /> 139 + </svg> 140 + </div> 141 + {/if} 142 + 104 143 {#if initialLoad} 105 144 <div class="loading-container"> 106 145 <WaveLoading size="lg" message="loading activity..." /> ··· 191 230 192 231 <style> 193 232 .lava-bg { position: fixed; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; } 194 - 195 233 .lava-blob { 196 - position: absolute; border-radius: 50%; filter: blur(80px); 197 - opacity: 0.12; will-change: transform; 234 + position: absolute; border-radius: 50%; filter: blur(50px); 235 + opacity: 0.1; will-change: transform; 198 236 } 199 237 .b1 { 200 - width: 60vw; height: 60vw; max-width: 500px; max-height: 500px; 238 + width: 30vw; height: 30vw; max-width: 280px; max-height: 280px; 201 239 background: color-mix(in srgb, var(--accent) 60%, #e0607e); 202 - top: -10%; left: -5%; animation: lava1 28s ease-in-out infinite; 240 + top: 5%; left: 5%; animation: lava1 22s ease-in-out infinite; 203 241 } 204 242 .b2 { 205 - width: 50vw; height: 50vw; max-width: 420px; max-height: 420px; 243 + width: 25vw; height: 25vw; max-width: 240px; max-height: 240px; 206 244 background: color-mix(in srgb, #a78bfa 70%, var(--accent)); 207 - top: 35%; right: -8%; animation: lava2 34s ease-in-out infinite; 245 + top: 25%; right: 3%; animation: lava2 28s ease-in-out infinite; 208 246 } 209 247 .b3 { 210 - width: 55vw; height: 55vw; max-width: 460px; max-height: 460px; 248 + width: 28vw; height: 28vw; max-width: 260px; max-height: 260px; 211 249 background: color-mix(in srgb, #4ade80 50%, var(--accent)); 212 - bottom: -5%; left: 15%; animation: lava3 24s ease-in-out infinite; 250 + bottom: 15%; left: 10%; animation: lava3 18s ease-in-out infinite; 251 + } 252 + .b4 { 253 + width: 22vw; height: 22vw; max-width: 200px; max-height: 200px; 254 + background: color-mix(in srgb, var(--accent) 80%, #e0607e); 255 + top: 55%; right: 15%; animation: lava1 32s ease-in-out infinite reverse; 256 + } 257 + .b5 { 258 + width: 20vw; height: 20vw; max-width: 180px; max-height: 180px; 259 + background: color-mix(in srgb, #a78bfa 40%, #4ade80); 260 + top: 75%; left: 30%; animation: lava2 20s ease-in-out infinite reverse; 213 261 } 214 262 @keyframes lava1 { 215 263 0%, 100% { transform: translate(0, 0) scale(1); } 216 - 33% { transform: translate(80px, 80px) scale(1.1); } 217 - 66% { transform: translate(20px, 120px) scale(0.95); } 264 + 33% { transform: translate(50px, 40px) scale(1.1); } 265 + 66% { transform: translate(15px, 70px) scale(0.95); } 218 266 } 219 267 @keyframes lava2 { 220 268 0%, 100% { transform: translate(0, 0) scale(1); } 221 - 33% { transform: translate(-70px, 60px) scale(1.08); } 222 - 66% { transform: translate(-40px, -50px) scale(0.92); } 269 + 33% { transform: translate(-40px, 50px) scale(1.08); } 270 + 66% { transform: translate(-25px, -30px) scale(0.92); } 223 271 } 224 272 @keyframes lava3 { 225 273 0%, 100% { transform: translate(0, 0) scale(1); } 226 - 33% { transform: translate(60px, -80px) scale(1.12); } 227 - 66% { transform: translate(-40px, -30px) scale(0.9); } 274 + 33% { transform: translate(35px, -50px) scale(1.12); } 275 + 66% { transform: translate(-25px, -20px) scale(0.9); } 228 276 } 229 277 @media (prefers-reduced-motion: reduce) { 230 278 .lava-blob { animation: none !important; } ··· 234 282 max-width: 800px; margin: 0 auto; position: relative; 235 283 padding: 0 1rem calc(var(--player-height, 0px) + 2rem + env(safe-area-inset-bottom, 0px)); 236 284 } 237 - .page-header { margin-bottom: 1.5rem; } 285 + .page-header { margin-bottom: 1rem; } 238 286 h1 { font-size: var(--text-page-heading); font-weight: 700; color: var(--text-primary); margin: 0; } 239 287 .header-pulse { 240 288 font-size: var(--text-xs); color: var(--text-muted); 241 289 margin: 0.25rem 0 0 0; letter-spacing: 0.01em; 242 290 } 291 + 292 + .sparkline-container { 293 + margin-bottom: 1.25rem; position: relative; 294 + background: color-mix(in srgb, var(--track-bg) 70%, transparent); 295 + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); 296 + border: 1px solid var(--glass-border, var(--track-border)); 297 + border-radius: var(--radius-md); padding: 0.625rem 0.75rem 0.375rem; 298 + } 299 + .sparkline-label { 300 + font-size: var(--text-xs); color: var(--text-muted); 301 + position: absolute; top: 0.375rem; right: 0.625rem; 302 + } 303 + .sparkline { width: 100%; height: 32px; display: block; } 304 + 243 305 .loading-container { display: flex; justify-content: center; padding: 3rem 2rem; } 244 306 .empty { color: var(--text-tertiary); padding: 2rem; text-align: center; } 245 - 246 307 .event-list { display: flex; flex-direction: column; gap: 0.5rem; } 247 308 248 309 .event-item { 249 310 --type-color: var(--border-subtle); 250 311 display: flex; align-items: center; gap: 0.875rem; 251 - padding: 0.75rem 1rem; 312 + padding: 0.75rem 1rem; position: relative; 252 313 background: color-mix(in srgb, var(--track-bg) 85%, transparent); 253 314 backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); 254 315 border: 1px solid var(--glass-border, var(--track-border)); 255 316 border-radius: var(--radius-md); 256 - border-left: 3px solid var(--type-color); 257 317 transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease; 258 318 } 319 + /* neon glow accent — fuzzy light instead of solid border */ 320 + .event-item::before { 321 + content: ''; position: absolute; left: 0; top: 15%; bottom: 15%; 322 + width: 2px; border-radius: 2px; 323 + background: var(--type-color); 324 + box-shadow: 0 0 6px var(--type-color), 0 0 14px color-mix(in srgb, var(--type-color) 40%, transparent); 325 + } 259 326 .event-item:hover { 260 327 background: color-mix(in srgb, var(--track-bg-hover) 90%, transparent); 261 - border-color: color-mix(in srgb, var(--type-color) 30%, var(--glass-border, var(--track-border))); 328 + border-color: color-mix(in srgb, var(--type-color) 20%, var(--glass-border, var(--track-border))); 262 329 transform: translateY(-1px); 263 330 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 20px color-mix(in srgb, var(--type-color) 10%, transparent); 331 + } 332 + .event-item:hover::before { 333 + box-shadow: 0 0 8px var(--type-color), 0 0 20px color-mix(in srgb, var(--type-color) 60%, transparent); 264 334 } 265 335 .event-item.like { --type-color: #e0607e; } 266 336 .event-item.track { --type-color: var(--accent); } ··· 277 347 .art-placeholder { 278 348 display: flex; align-items: center; justify-content: center; color: var(--text-muted); 279 349 } 280 - 281 350 .avatar-badge { 282 351 position: absolute; bottom: -3px; right: -3px; 283 352 width: 20px; height: 20px; border-radius: 50%; ··· 300 369 } 301 370 .handle-link:hover { color: var(--accent); } 302 371 .event-time { flex-shrink: 0; font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; } 303 - 304 372 .event-action { 305 373 font-size: var(--text-sm); color: var(--text-tertiary); margin: 0; 306 374 line-height: 1.4; display: flex; align-items: center; gap: 0.375rem; 307 375 } 308 376 .action-icon { flex-shrink: 0; opacity: 0.6; color: var(--type-color); } 309 - 310 377 .track-link { color: var(--text-secondary); text-decoration: none; font-weight: 500; } 311 378 .track-link:hover { color: var(--accent); } 312 - 313 379 .comment-preview { 314 380 font-size: var(--text-xs); color: var(--text-tertiary); margin: 0.375rem 0 0 0; 315 381 line-height: 1.4; font-style: italic; ··· 317 383 border-left: 2px solid color-mix(in srgb, #a78bfa 40%, transparent); 318 384 padding: 0.375rem 0.625rem; border-radius: var(--radius-sm); 319 385 } 320 - 321 386 .scroll-sentinel { display: flex; justify-content: center; padding: 2rem 0; min-height: 60px; } 322 387 323 388 @media (max-width: 768px) { ··· 325 390 .left-col, .art-link, .art-img { width: 40px; height: 40px; } 326 391 .avatar-badge { width: 18px; height: 18px; bottom: -2px; right: -2px; } 327 392 .avatar-badge svg { width: 8px; height: 8px; } 328 - .lava-blob { opacity: 0.08; } 393 + .lava-blob { opacity: 0.07; } 329 394 .event-item { gap: 0.625rem; padding: 0.625rem 0.75rem; } 330 395 } 331 396 </style>