audio streaming app plyr.fm

revert: remove activity integration from homepage (#1021)

restore homepage to pre-activity state (cd2e7b7) and restore the
standalone activity page to its polished version (3515b21). the
activity page remains functional at /activity but is unlisted —
no navigation links point to it.

removes activity-feed module, ActivityRow, and Sparkline components
added in #1020.

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

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
29f8d9e5 f7ca5134

+546 -341
-86
frontend/src/lib/activity-feed.svelte.ts
··· 1 - import { API_URL } from '$lib/config'; 2 - import type { ActivityEvent, ActivityHistogramBucket } from '$lib/types'; 3 - 4 - class ActivityFeedState { 5 - active = $state(false); 6 - events = $state<ActivityEvent[]>([]); 7 - cursor = $state<string | null>(null); 8 - hasMore = $state(false); 9 - loading = $state(false); 10 - histogram = $state<ActivityHistogramBucket[]>([]); 11 - 12 - toggle() { 13 - this.active = !this.active; 14 - if (this.active) { 15 - this.fetch(); 16 - } else { 17 - this.clear(); 18 - } 19 - } 20 - 21 - async fetch() { 22 - this.loading = true; 23 - try { 24 - const [feedRes, histRes] = await Promise.allSettled([ 25 - fetch(`${API_URL}/activity/`), 26 - fetch(`${API_URL}/activity/histogram?days=7`) 27 - ]); 28 - if (feedRes.status === 'fulfilled' && feedRes.value.ok) { 29 - const data = await feedRes.value.json(); 30 - this.events = data.events; 31 - this.cursor = data.next_cursor; 32 - this.hasMore = data.has_more; 33 - } 34 - if (histRes.status === 'fulfilled' && histRes.value.ok) { 35 - this.histogram = (await histRes.value.json()).buckets; 36 - } 37 - } catch (e) { 38 - console.error('failed to load activity:', e); 39 - } finally { 40 - this.loading = false; 41 - } 42 - } 43 - 44 - async loadMore() { 45 - if (!this.hasMore || !this.cursor || this.loading) return; 46 - this.loading = true; 47 - try { 48 - const res = await fetch( 49 - `${API_URL}/activity/?cursor=${encodeURIComponent(this.cursor)}` 50 - ); 51 - if (res.ok) { 52 - const data = await res.json(); 53 - this.events = [...this.events, ...data.events]; 54 - this.cursor = data.next_cursor; 55 - this.hasMore = data.has_more; 56 - } 57 - } catch (e) { 58 - console.error('failed to load more activity:', e); 59 - } finally { 60 - this.loading = false; 61 - } 62 - } 63 - 64 - clear() { 65 - this.events = []; 66 - this.cursor = null; 67 - this.hasMore = false; 68 - this.histogram = []; 69 - } 70 - } 71 - 72 - export const activityFeed = new ActivityFeedState(); 73 - 74 - export function timeAgo(iso: string): string { 75 - const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); 76 - if (seconds < 60) return `${seconds}s`; 77 - const minutes = Math.floor(seconds / 60); 78 - if (minutes < 60) return `${minutes}m`; 79 - const hours = Math.floor(minutes / 60); 80 - if (hours < 24) return `${hours}h`; 81 - const days = Math.floor(hours / 24); 82 - if (days < 30) return `${days}d`; 83 - const months = Math.floor(days / 30); 84 - if (months < 12) return `${months}mo`; 85 - return `${Math.floor(days / 365)}y`; 86 - }
-100
frontend/src/lib/components/ActivityRow.svelte
··· 1 - <script lang="ts"> 2 - import { timeAgo } from '$lib/activity-feed.svelte'; 3 - import type { ActivityEvent } from '$lib/types'; 4 - 5 - let { event }: { event: ActivityEvent } = $props(); 6 - </script> 7 - 8 - <div class="activity-row {event.type}"> 9 - <span class="activity-icon"> 10 - {#if event.type === 'like'} 11 - <svg width="12" height="12" 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> 12 - {:else if event.type === 'comment'} 13 - <svg width="12" height="12" 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> 14 - {:else if event.type === 'join'} 15 - <svg width="12" height="12" 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> 16 - {/if} 17 - </span> 18 - <span class="activity-text"> 19 - <a href="/u/{event.actor.handle}" class="activity-actor">{event.actor.display_name || event.actor.handle}</a> 20 - {#if event.type === 'like' && event.track} 21 - <span class="activity-verb">liked</span> 22 - <a href="/track/{event.track.id}" class="activity-track">{event.track.title}</a> 23 - {:else if event.type === 'comment' && event.track} 24 - <span class="activity-verb">commented on</span> 25 - <a href="/track/{event.track.id}" class="activity-track">{event.track.title}</a> 26 - {:else if event.type === 'join'} 27 - <span class="activity-verb">joined</span> 28 - <span class="activity-track">plyr.fm</span> 29 - {/if} 30 - </span> 31 - <span class="activity-time">{timeAgo(event.created_at)}</span> 32 - </div> 33 - 34 - <style> 35 - .activity-row { 36 - --type-color: var(--border-subtle); 37 - display: flex; 38 - align-items: center; 39 - gap: 0.5rem; 40 - padding: 0.375rem 0.75rem; 41 - font-size: var(--text-xs); 42 - border-left: 2px solid var(--type-color); 43 - border-radius: var(--radius-sm); 44 - transition: background 0.15s; 45 - } 46 - 47 - .activity-row:hover { 48 - background: var(--bg-hover, transparent); 49 - } 50 - 51 - .activity-row.like { --type-color: #e0607e; } 52 - .activity-row.comment { --type-color: #a78bfa; } 53 - .activity-row.join { --type-color: #4ade80; } 54 - 55 - .activity-icon { 56 - display: flex; 57 - align-items: center; 58 - flex-shrink: 0; 59 - color: var(--type-color); 60 - opacity: 0.7; 61 - } 62 - 63 - .activity-text { 64 - flex: 1; 65 - min-width: 0; 66 - white-space: nowrap; 67 - overflow: hidden; 68 - text-overflow: ellipsis; 69 - } 70 - 71 - .activity-actor { 72 - color: var(--text-primary); 73 - font-weight: 600; 74 - text-decoration: none; 75 - } 76 - 77 - .activity-actor:hover { 78 - color: var(--accent); 79 - } 80 - 81 - .activity-verb { 82 - color: var(--text-tertiary); 83 - } 84 - 85 - .activity-track { 86 - color: var(--text-secondary); 87 - text-decoration: none; 88 - font-weight: 500; 89 - } 90 - 91 - a.activity-track:hover { 92 - color: var(--accent); 93 - } 94 - 95 - .activity-time { 96 - flex-shrink: 0; 97 - color: var(--text-muted); 98 - font-size: var(--text-xs); 99 - } 100 - </style>
-59
frontend/src/lib/components/Sparkline.svelte
··· 1 - <script lang="ts"> 2 - import type { ActivityHistogramBucket } from '$lib/types'; 3 - 4 - let { histogram }: { histogram: ActivityHistogramBucket[] } = $props(); 5 - 6 - const sparklinePath = $derived.by(() => { 7 - if (histogram.length === 0) return ''; 8 - const max = Math.max(...histogram.map((b) => b.count), 1); 9 - const w = 100; 10 - const h = 32; 11 - const step = w / (histogram.length - 1 || 1); 12 - const points = histogram.map((b, i) => `${i * step},${h - (b.count / max) * h * 0.85}`); 13 - return `M${points.join(' L')} L${w},${h} L0,${h} Z`; 14 - }); 15 - </script> 16 - 17 - <div class="sparkline-container"> 18 - <span class="sparkline-label">last 7 days</span> 19 - <svg class="sparkline" viewBox="0 0 100 32" preserveAspectRatio="none"> 20 - <defs> 21 - <linearGradient id="spark-fill" x1="0" y1="0" x2="0" y2="1"> 22 - <stop offset="0%" stop-color="var(--accent)" stop-opacity="0.3" /> 23 - <stop offset="100%" stop-color="var(--accent)" stop-opacity="0.02" /> 24 - </linearGradient> 25 - </defs> 26 - <path d={sparklinePath} fill="url(#spark-fill)" /> 27 - <path 28 - d={sparklinePath.replace(/ L\d+,32 L0,32 Z/, '')} 29 - fill="none" stroke="var(--accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" 30 - /> 31 - </svg> 32 - </div> 33 - 34 - <style> 35 - .sparkline-container { 36 - margin-bottom: 1.25rem; 37 - position: relative; 38 - background: color-mix(in srgb, var(--track-bg) 70%, transparent); 39 - backdrop-filter: blur(8px); 40 - -webkit-backdrop-filter: blur(8px); 41 - border: 1px solid var(--glass-border, var(--track-border)); 42 - border-radius: var(--radius-md); 43 - padding: 0.625rem 0.75rem 0.375rem; 44 - } 45 - 46 - .sparkline-label { 47 - font-size: var(--text-xs); 48 - color: var(--text-muted); 49 - position: absolute; 50 - top: 0.375rem; 51 - right: 0.625rem; 52 - } 53 - 54 - .sparkline { 55 - width: 100%; 56 - height: 32px; 57 - display: block; 58 - } 59 - </style>
+40 -94
frontend/src/routes/+page.svelte
··· 6 6 import Header from '$lib/components/Header.svelte'; 7 7 import WaveLoading from '$lib/components/WaveLoading.svelte'; 8 8 import HiddenTagsFilter from '$lib/components/HiddenTagsFilter.svelte'; 9 - import ActivityRow from '$lib/components/ActivityRow.svelte'; 10 - import Sparkline from '$lib/components/Sparkline.svelte'; 11 9 import { player } from '$lib/player.svelte'; 12 10 import { queue } from '$lib/queue.svelte'; 13 11 import { tracksCache, fetchTopTracks } from '$lib/tracks.svelte'; 14 12 import { networkArtistsCache } from '$lib/network-artists.svelte'; 15 - import { activityFeed } from '$lib/activity-feed.svelte'; 16 - import type { Track, ActivityEvent } from '$lib/types'; 13 + import type { Track } from '$lib/types'; 17 14 import { auth } from '$lib/auth.svelte'; 18 15 import { fade } from 'svelte/transition'; 19 16 import { APP_NAME, APP_TAGLINE, APP_CANONICAL_URL } from '$lib/branding'; ··· 49 46 // infinite scroll sentinel element 50 47 let sentinelElement = $state<HTMLDivElement | null>(null); 51 48 52 - // merged feed: tracks + optional activity events 53 - type FeedItem = 54 - | { kind: 'track'; data: Track; sortTime: string; key: string } 55 - | { kind: 'activity'; data: ActivityEvent; sortTime: string; key: string }; 56 - 57 - let feedItems = $derived.by<FeedItem[]>(() => { 58 - const trackItems: FeedItem[] = tracks.map((t) => ({ 59 - kind: 'track' as const, 60 - data: t, 61 - sortTime: t.created_at ?? '', 62 - key: `track-${t.id}` 63 - })); 64 - if (!activityFeed.active) return trackItems; 65 - const activityItems: FeedItem[] = activityFeed.events 66 - .filter((e) => e.type !== 'track') 67 - .map((e) => ({ 68 - kind: 'activity' as const, 69 - data: e, 70 - sortTime: e.created_at, 71 - key: `activity-${e.type}-${e.actor.handle}-${e.created_at}` 72 - })); 73 - return [...trackItems, ...activityItems].sort((a, b) => b.sortTime.localeCompare(a.sortTime)); 74 - }); 75 - 76 - let combinedHasMore = $derived(hasMore || (activityFeed.active && activityFeed.hasMore)); 77 - 78 49 onMount(async () => { 79 50 const [topResult] = await Promise.all([fetchTopTracks(10), tracksCache.fetch()]); 80 51 topTracks = topResult; ··· 97 68 const observer = new IntersectionObserver( 98 69 (entries) => { 99 70 const entry = entries[0]; 100 - if (entry.isIntersecting && combinedHasMore && !loadingMore && !loadingTracks) { 71 + if (entry.isIntersecting && hasMore && !loadingMore && !loadingTracks) { 101 72 tracksCache.fetchMore(); 102 - if (activityFeed.active) activityFeed.loadMore(); 103 73 } 104 74 }, 105 75 { ··· 174 144 top tracks 175 145 </h2> 176 146 <div class="top-tracks-grid"> 177 - {#each topTracks as track, i (track.id)} 147 + {#each topTracks as track, i} 178 148 <TrackCard 179 149 {track} 180 150 index={i} ··· 192 162 <section class="network-artists" transition:fade={{ duration: 200 }}> 193 163 <h2>artists you know</h2> 194 164 <div class="artist-grid"> 195 - {#each networkArtists as artist (artist.did)} 165 + {#each networkArtists as artist} 196 166 {@const refreshedUrl = getRefreshedAvatar(artist.did)} 197 167 {@const displayUrl = refreshedUrl ?? artist.avatar_url} 198 168 <a href="/u/{artist.handle}" class="artist-card"> ··· 239 209 </button> 240 210 </h2> 241 211 <div class="header-actions"> 242 - <button 243 - type="button" 244 - class="activity-toggle" 245 - class:active={activityFeed.active} 246 - title={activityFeed.active ? 'hide activity' : 'show activity'} 247 - onclick={() => activityFeed.toggle()} 248 - > 249 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 250 - <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline> 251 - </svg> 252 - </button> 253 212 <HiddenTagsFilter /> 254 213 </div> 255 214 </div> 256 - {#if activityFeed.active && activityFeed.histogram.some((b) => b.count > 0)} 257 - <Sparkline histogram={activityFeed.histogram} /> 258 - {/if} 259 215 {#if showLoading} 260 216 <div class="loading-container"> 261 217 <WaveLoading size="lg" message="loading tracks..." /> ··· 264 220 <p class="empty">no tracks yet</p> 265 221 {:else} 266 222 <div class="track-list"> 267 - {#each feedItems as item, i (item.key)} 268 - {#if item.kind === 'track'} 269 - <TrackItem 270 - track={item.data} 271 - index={i} 272 - isPlaying={player.currentTrack?.id === item.data.id} 273 - onPlay={(t) => queue.playNow(t)} 274 - isAuthenticated={auth.isAuthenticated} 275 - /> 276 - {:else} 277 - <ActivityRow event={item.data} /> 278 - {/if} 223 + {#each tracks as track, i} 224 + <TrackItem 225 + {track} 226 + index={i} 227 + isPlaying={player.currentTrack?.id === track.id} 228 + onPlay={(t) => queue.playNow(t)} 229 + isAuthenticated={auth.isAuthenticated} 230 + /> 279 231 {/each} 280 232 </div> 281 233 <!-- infinite scroll sentinel --> 282 - {#if combinedHasMore} 234 + {#if hasMore} 283 235 <div bind:this={sentinelElement} class="scroll-sentinel"> 284 - {#if loadingMore || (activityFeed.active && activityFeed.loading)} 236 + {#if loadingMore} 285 237 <WaveLoading size="sm" message="loading more..." /> 286 238 {/if} 287 239 </div> ··· 301 253 padding: 1.5rem 1rem; 302 254 } 303 255 304 - .top-tracks, 256 + .top-tracks { 257 + margin-bottom: 2.5rem; 258 + } 259 + 260 + .top-tracks h2 { 261 + font-size: var(--text-page-heading); 262 + font-weight: 700; 263 + color: var(--text-primary); 264 + margin: 0 0 1rem 0; 265 + } 266 + 267 + .top-tracks-grid { 268 + display: flex; 269 + gap: 0.75rem; 270 + overflow-x: auto; 271 + padding-bottom: 0.5rem; 272 + scrollbar-width: none; 273 + scroll-snap-type: x proximity; 274 + scroll-padding-inline: 1rem; 275 + } 276 + 277 + .top-tracks-grid::-webkit-scrollbar { 278 + display: none; 279 + } 280 + 305 281 .network-artists { 306 282 margin-bottom: 2.5rem; 307 283 } 308 284 309 - .top-tracks h2, 310 285 .network-artists h2 { 311 286 font-size: var(--text-page-heading); 312 287 font-weight: 700; ··· 314 289 margin: 0 0 1rem 0; 315 290 } 316 291 317 - .top-tracks-grid, 318 292 .artist-grid { 319 293 display: flex; 320 294 gap: 0.75rem; ··· 325 299 scroll-padding-inline: 1rem; 326 300 } 327 301 328 - .top-tracks-grid::-webkit-scrollbar, 329 302 .artist-grid::-webkit-scrollbar { 330 303 display: none; 331 304 } ··· 411 384 .header-actions { 412 385 display: flex; 413 386 align-items: center; 414 - gap: 0.5rem; 415 - } 416 - 417 - .activity-toggle { 418 - display: inline-flex; 419 - align-items: center; 420 - padding: 0.35rem; 421 - border-radius: var(--radius-base); 422 - color: var(--text-tertiary); 423 - background: transparent; 424 - border: none; 425 - cursor: pointer; 426 - transition: all 0.15s; 427 - } 428 - 429 - .activity-toggle:hover { 430 - color: var(--text-secondary); 431 - background: var(--bg-hover, transparent); 432 - } 433 - 434 - .activity-toggle svg { 435 - width: 18px; 436 - height: 18px; 437 - } 438 - 439 - .activity-toggle.active { 440 - color: var(--accent); 441 - background: color-mix(in srgb, var(--accent) 10%, transparent); 387 + gap: 0.75rem; 442 388 } 443 389 444 390 .clickable-heading {
+465 -2
frontend/src/routes/activity/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { goto } from '$app/navigation'; 3 2 import { onMount } from 'svelte'; 3 + import { fly } from 'svelte/transition'; 4 + import { cubicOut } from 'svelte/easing'; 5 + import Header from '$lib/components/Header.svelte'; 6 + import WaveLoading from '$lib/components/WaveLoading.svelte'; 7 + import { auth } from '$lib/auth.svelte'; 8 + import { API_URL } from '$lib/config'; 9 + import { APP_NAME } from '$lib/branding'; 10 + import { statsCache, formatDuration } from '$lib/stats.svelte'; 11 + import type { ActivityEvent, ActivityHistogramBucket } from '$lib/types'; 4 12 5 - onMount(() => goto('/', { replaceState: true })); 13 + let { data } = $props(); 14 + 15 + let events = $state<ActivityEvent[]>([]); 16 + let nextCursor = $state<string | null>(null); 17 + let hasMore = $state(false); 18 + let loadingMore = $state(false); 19 + let initialLoad = $state(true); 20 + let sentinelElement = $state<HTMLDivElement | null>(null); 21 + let stats = $derived(statsCache.stats); 22 + let histogram = $state<ActivityHistogramBucket[]>([]); 23 + let previousCount = $state(0); 24 + let showSpinner = $state(false); 25 + 26 + $effect(() => { 27 + if (loadingMore) { 28 + const timer = setTimeout(() => { showSpinner = true; }, 400); 29 + return () => { clearTimeout(timer); showSpinner = false; }; 30 + } else { 31 + showSpinner = false; 32 + } 33 + }); 34 + 35 + const sparklinePath = $derived.by(() => { 36 + if (histogram.length === 0) return ''; 37 + const max = Math.max(...histogram.map((b) => b.count), 1); 38 + const w = 100; 39 + const h = 32; 40 + const step = w / (histogram.length - 1 || 1); 41 + const points = histogram.map((b, i) => `${i * step},${h - (b.count / max) * h * 0.85}`); 42 + return `M${points.join(' L')} L${w},${h} L0,${h} Z`; 43 + }); 44 + 45 + onMount(() => { 46 + auth.initialize(); 47 + statsCache.fetch(); 48 + previousCount = data.events.length; 49 + events = data.events; 50 + nextCursor = data.next_cursor; 51 + hasMore = data.has_more; 52 + histogram = data.histogram; 53 + initialLoad = false; 54 + }); 55 + 56 + function timeAgo(iso: string): string { 57 + const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); 58 + if (seconds < 60) return `${seconds}s ago`; 59 + const minutes = Math.floor(seconds / 60); 60 + if (minutes < 60) return `${minutes}m ago`; 61 + const hours = Math.floor(minutes / 60); 62 + if (hours < 24) return `${hours}h ago`; 63 + const days = Math.floor(hours / 24); 64 + if (days < 30) return `${days}d ago`; 65 + const months = Math.floor(days / 30); 66 + if (months < 12) return `${months}mo ago`; 67 + return `${Math.floor(days / 365)}y ago`; 68 + } 69 + 70 + async function loadMore() { 71 + if (!hasMore || !nextCursor || loadingMore) return; 72 + loadingMore = true; 73 + try { 74 + const response = await fetch(`${API_URL}/activity/?cursor=${encodeURIComponent(nextCursor)}`); 75 + if (response.ok) { 76 + const result = await response.json(); 77 + previousCount = events.length; 78 + events = [...events, ...result.events]; 79 + nextCursor = result.next_cursor; 80 + hasMore = result.has_more; 81 + } 82 + } catch (e) { 83 + console.error('failed to load more activity:', e); 84 + } finally { 85 + loadingMore = false; 86 + } 87 + } 88 + 89 + $effect(() => { 90 + if (!sentinelElement) return; 91 + const observer = new IntersectionObserver( 92 + (entries) => { 93 + if (entries[0].isIntersecting && hasMore && !loadingMore) loadMore(); 94 + }, 95 + { rootMargin: '200px' } 96 + ); 97 + observer.observe(sentinelElement); 98 + return () => observer.disconnect(); 99 + }); 100 + 101 + async function logout() { 102 + await auth.logout(); 103 + window.location.href = '/'; 104 + } 6 105 </script> 106 + 107 + <svelte:head> 108 + <title>activity - {APP_NAME}</title> 109 + </svelte:head> 110 + 111 + <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={logout} /> 112 + 113 + <div class="lava-bg" aria-hidden="true"> 114 + <div class="lava-blob b1"></div> 115 + <div class="lava-blob b2"></div> 116 + <div class="lava-blob b3"></div> 117 + <div class="lava-blob b4"></div> 118 + <div class="lava-blob b5"></div> 119 + </div> 120 + 121 + <main> 122 + <div class="page-header"> 123 + <h1>activity</h1> 124 + {#if stats} 125 + <p class="header-pulse"> 126 + {stats.total_tracks.toLocaleString()} tracks &middot; 127 + {stats.total_artists.toLocaleString()} artists &middot; 128 + {formatDuration(stats.total_duration_seconds)} of audio 129 + </p> 130 + {/if} 131 + </div> 132 + 133 + {#if histogram.some(b => b.count > 0)} 134 + <div class="sparkline-container"> 135 + <span class="sparkline-label">last 7 days</span> 136 + <svg class="sparkline" viewBox="0 0 100 32" preserveAspectRatio="none"> 137 + <defs> 138 + <linearGradient id="spark-fill" x1="0" y1="0" x2="0" y2="1"> 139 + <stop offset="0%" stop-color="var(--accent)" stop-opacity="0.3" /> 140 + <stop offset="100%" stop-color="var(--accent)" stop-opacity="0.02" /> 141 + </linearGradient> 142 + </defs> 143 + <path d={sparklinePath} fill="url(#spark-fill)" /> 144 + <path 145 + d={sparklinePath.replace(/ L\d+,32 L0,32 Z/, '')} 146 + fill="none" stroke="var(--accent)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" 147 + /> 148 + </svg> 149 + </div> 150 + {/if} 151 + 152 + {#if initialLoad} 153 + <div class="loading-container"> 154 + <WaveLoading size="lg" message="loading activity..." /> 155 + </div> 156 + {:else if events.length === 0} 157 + <p class="empty">no activity yet</p> 158 + {:else} 159 + <div class="event-list"> 160 + {#each events as event, i (event.created_at + event.actor.did + event.type)} 161 + {@const hasArt = event.track && (event.track.thumbnail_url || event.track.image_url)} 162 + {@const batchIndex = i >= previousCount ? i - previousCount : -1} 163 + {@const isSelfAction = event.track && event.actor.handle === event.track.artist_handle} 164 + <div 165 + class="event-item {event.type}" 166 + in:fly={{ y: 12, duration: batchIndex >= 0 ? 280 : 0, delay: batchIndex >= 0 ? batchIndex * 35 : 0, easing: cubicOut }} 167 + > 168 + <div class="left-col"> 169 + {#if hasArt && event.track} 170 + <a href="/track/{event.track.id}" class="art-link"> 171 + <img src={event.track.thumbnail_url || event.track.image_url} alt={event.track.title} class="art-img" /> 172 + </a> 173 + {:else} 174 + <a href="/u/{event.actor.handle}" class="art-link"> 175 + {#if event.actor.avatar_url} 176 + <img src={event.actor.avatar_url} alt={event.actor.display_name} class="art-img" /> 177 + {:else} 178 + <div class="art-img art-placeholder"> 179 + <svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> 180 + <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" /> 181 + </svg> 182 + </div> 183 + {/if} 184 + </a> 185 + {/if} 186 + </div> 187 + 188 + <div class="event-body"> 189 + <div class="event-header"> 190 + <div class="handle-group"> 191 + {#if hasArt} 192 + <a href="/u/{event.actor.handle}" class="header-avatar-link"> 193 + {#if event.actor.avatar_url} 194 + <img src={event.actor.avatar_url} alt={event.actor.display_name} class="header-avatar" /> 195 + {:else} 196 + <span class="header-avatar header-avatar-placeholder"> 197 + <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> 198 + <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" /> 199 + </svg> 200 + </span> 201 + {/if} 202 + </a> 203 + {/if} 204 + <a href="/u/{event.actor.handle}" class="handle-link"> 205 + {event.actor.display_name || event.actor.handle} 206 + </a> 207 + </div> 208 + <span class="event-time" title={new Date(event.created_at).toLocaleString()}>{timeAgo(event.created_at)}</span> 209 + </div> 210 + {#if event.type === 'like' && event.track} 211 + <p class="event-action"> 212 + <span class="icon-slot"><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></span> 213 + <span class="verb">liked</span> <a href="/track/{event.track.id}" class="track-link">{event.track.title}</a> 214 + {#if event.track.artist_handle && !isSelfAction} 215 + <span class="by-artist">by</span> 216 + <a href="/u/{event.track.artist_handle}" class="artist-avatar-link" title={event.track.artist_handle}> 217 + {#if event.track.artist_avatar_url} 218 + <img src={event.track.artist_avatar_url} alt={event.track.artist_handle} class="inline-avatar" /> 219 + {:else} 220 + <span class="inline-avatar inline-avatar-placeholder"> 221 + <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> 222 + <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" /> 223 + </svg> 224 + </span> 225 + {/if} 226 + </a> 227 + {/if} 228 + </p> 229 + {:else if event.type === 'track' && event.track} 230 + <p class="event-action"> 231 + <span class="icon-slot"><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></span> 232 + <span class="verb">uploaded</span> <a href="/track/{event.track.id}" class="track-link">{event.track.title}</a> 233 + </p> 234 + {:else if event.type === 'comment' && event.track} 235 + <p class="event-action"> 236 + <span class="icon-slot"><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></span> 237 + <span class="verb">commented on</span> <a href="/track/{event.track.id}" class="track-link">{event.track.title}</a> 238 + {#if event.track.artist_handle && !isSelfAction} 239 + <span class="by-artist">by</span> 240 + <a href="/u/{event.track.artist_handle}" class="artist-avatar-link" title={event.track.artist_handle}> 241 + {#if event.track.artist_avatar_url} 242 + <img src={event.track.artist_avatar_url} alt={event.track.artist_handle} class="inline-avatar" /> 243 + {:else} 244 + <span class="inline-avatar inline-avatar-placeholder"> 245 + <svg width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> 246 + <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" /> 247 + </svg> 248 + </span> 249 + {/if} 250 + </a> 251 + {/if} 252 + </p> 253 + {:else if event.type === 'join'} 254 + <p class="event-action"> 255 + <span class="icon-slot"><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></span> 256 + <span class="verb">joined</span> plyr.fm 257 + </p> 258 + {/if} 259 + {#if event.type === 'comment' && event.comment_text} 260 + <p class="comment-preview"> 261 + {event.comment_text.length > 120 ? event.comment_text.slice(0, 120) + '...' : event.comment_text} 262 + </p> 263 + {/if} 264 + </div> 265 + </div> 266 + {/each} 267 + </div> 268 + 269 + {#if hasMore} 270 + <div bind:this={sentinelElement} class="scroll-sentinel"> 271 + {#if showSpinner} 272 + <WaveLoading size="sm" message="loading more..." /> 273 + {/if} 274 + </div> 275 + {/if} 276 + {/if} 277 + </main> 278 + 279 + <style> 280 + .lava-bg { position: fixed; inset: 0; z-index: -1; overflow: hidden; pointer-events: none; } 281 + .lava-blob { 282 + position: absolute; border-radius: 50%; filter: blur(50px); 283 + opacity: 0.1; will-change: transform; 284 + } 285 + .b1 { 286 + width: 30vw; height: 30vw; max-width: 280px; max-height: 280px; 287 + background: color-mix(in srgb, var(--accent) 60%, #e0607e); 288 + top: 5%; left: 5%; animation: lava1 22s ease-in-out infinite; 289 + } 290 + .b2 { 291 + width: 25vw; height: 25vw; max-width: 240px; max-height: 240px; 292 + background: color-mix(in srgb, #a78bfa 70%, var(--accent)); 293 + top: 25%; right: 3%; animation: lava2 28s ease-in-out infinite; 294 + } 295 + .b3 { 296 + width: 28vw; height: 28vw; max-width: 260px; max-height: 260px; 297 + background: color-mix(in srgb, #4ade80 50%, var(--accent)); 298 + bottom: 15%; left: 10%; animation: lava3 18s ease-in-out infinite; 299 + } 300 + .b4 { 301 + width: 22vw; height: 22vw; max-width: 200px; max-height: 200px; 302 + background: color-mix(in srgb, var(--accent) 80%, #e0607e); 303 + top: 55%; right: 15%; animation: lava1 32s ease-in-out infinite reverse; 304 + } 305 + .b5 { 306 + width: 20vw; height: 20vw; max-width: 180px; max-height: 180px; 307 + background: color-mix(in srgb, #a78bfa 40%, #4ade80); 308 + top: 75%; left: 30%; animation: lava2 20s ease-in-out infinite reverse; 309 + } 310 + @keyframes lava1 { 311 + 0%, 100% { transform: translate(0, 0) scale(1); } 312 + 33% { transform: translate(50px, 40px) scale(1.1); } 313 + 66% { transform: translate(15px, 70px) scale(0.95); } 314 + } 315 + @keyframes lava2 { 316 + 0%, 100% { transform: translate(0, 0) scale(1); } 317 + 33% { transform: translate(-40px, 50px) scale(1.08); } 318 + 66% { transform: translate(-25px, -30px) scale(0.92); } 319 + } 320 + @keyframes lava3 { 321 + 0%, 100% { transform: translate(0, 0) scale(1); } 322 + 33% { transform: translate(35px, -50px) scale(1.12); } 323 + 66% { transform: translate(-25px, -20px) scale(0.9); } 324 + } 325 + @media (prefers-reduced-motion: reduce) { 326 + .lava-blob { animation: none !important; } 327 + } 328 + 329 + main { 330 + max-width: 800px; margin: 0 auto; position: relative; 331 + padding: 0 1rem calc(var(--player-height, 0px) + 2rem + env(safe-area-inset-bottom, 0px)); 332 + } 333 + .page-header { margin-bottom: 1rem; } 334 + h1 { font-size: var(--text-page-heading); font-weight: 700; color: var(--text-primary); margin: 0; } 335 + .header-pulse { 336 + font-size: var(--text-xs); color: var(--text-muted); 337 + margin: 0.25rem 0 0 0; letter-spacing: 0.01em; 338 + } 339 + 340 + .sparkline-container { 341 + margin-bottom: 1.25rem; position: relative; 342 + background: color-mix(in srgb, var(--track-bg) 70%, transparent); 343 + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); 344 + border: 1px solid var(--glass-border, var(--track-border)); 345 + border-radius: var(--radius-md); padding: 0.625rem 0.75rem 0.375rem; 346 + } 347 + .sparkline-label { 348 + font-size: var(--text-xs); color: var(--text-muted); 349 + position: absolute; top: 0.375rem; right: 0.625rem; 350 + } 351 + .sparkline { width: 100%; height: 32px; display: block; } 352 + 353 + .loading-container { display: flex; justify-content: center; padding: 3rem 2rem; } 354 + .empty { color: var(--text-tertiary); padding: 2rem; text-align: center; } 355 + .event-list { display: flex; flex-direction: column; gap: 0.5rem; } 356 + 357 + .event-item { 358 + --type-color: var(--border-subtle); 359 + display: flex; align-items: center; gap: 0.875rem; 360 + padding: 0.75rem 1rem; position: relative; 361 + background: color-mix(in srgb, var(--track-bg) 85%, transparent); 362 + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); 363 + border: 1px solid var(--glass-border, var(--track-border)); 364 + border-radius: var(--radius-md); 365 + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease; 366 + } 367 + /* neon glow accent — follows card border-radius */ 368 + .event-item::before { 369 + content: ''; position: absolute; inset: 0; 370 + border-radius: inherit; pointer-events: none; 371 + border-left: 2px solid var(--type-color); 372 + box-shadow: inset 4px 0 8px -2px color-mix(in srgb, var(--type-color) 30%, transparent); 373 + } 374 + .event-item:hover { 375 + background: color-mix(in srgb, var(--track-bg-hover) 90%, transparent); 376 + border-color: color-mix(in srgb, var(--type-color) 20%, var(--glass-border, var(--track-border))); 377 + transform: translateY(-1px); 378 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 20px color-mix(in srgb, var(--type-color) 10%, transparent); 379 + } 380 + .event-item:hover::before { 381 + box-shadow: inset 6px 0 12px -2px color-mix(in srgb, var(--type-color) 50%, transparent); 382 + } 383 + .event-item.like { --type-color: #e0607e; } 384 + .event-item.track { --type-color: var(--accent); } 385 + .event-item.comment { --type-color: #a78bfa; } 386 + .event-item.join { --type-color: #4ade80; } 387 + 388 + .left-col { flex-shrink: 0; position: relative; width: 44px; height: 44px; } 389 + .art-link { display: block; width: 44px; height: 44px; text-decoration: none; } 390 + .art-img { 391 + width: 44px; height: 44px; border-radius: var(--radius-sm); 392 + object-fit: cover; display: block; background: var(--bg-tertiary); 393 + border: 1px solid var(--border-subtle); 394 + } 395 + .art-placeholder { 396 + display: flex; align-items: center; justify-content: center; color: var(--text-muted); 397 + } 398 + .event-body { flex: 1; min-width: 0; } 399 + .event-header { 400 + display: flex; align-items: center; justify-content: space-between; 401 + gap: 0.75rem; margin-bottom: 0.125rem; 402 + } 403 + .handle-group { 404 + display: flex; align-items: center; gap: 0.375rem; 405 + min-width: 0; 406 + } 407 + .header-avatar-link { flex-shrink: 0; text-decoration: none; line-height: 0; } 408 + .header-avatar { 409 + width: 20px; height: 20px; border-radius: 50%; 410 + object-fit: cover; display: block; 411 + border: 1px solid var(--border-subtle); 412 + } 413 + .header-avatar-placeholder { 414 + background: var(--bg-tertiary); display: flex; 415 + align-items: center; justify-content: center; color: var(--text-muted); 416 + } 417 + .handle-link { 418 + color: var(--text-primary); font-weight: 600; font-size: var(--text-sm); 419 + text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; 420 + line-height: 1.2; 421 + } 422 + .handle-link:hover { color: var(--accent); } 423 + .event-time { flex-shrink: 0; font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; } 424 + .event-action { 425 + font-size: var(--text-sm); color: var(--text-tertiary); margin: 0; 426 + line-height: 1.2; display: flex; align-items: center; gap: 0.375rem; 427 + } 428 + .icon-slot { 429 + width: 20px; flex-shrink: 0; 430 + display: flex; align-items: center; justify-content: center; 431 + } 432 + .action-icon { opacity: 0.6; color: var(--type-color); } 433 + .verb { 434 + font-size: var(--text-xs); 435 + color: color-mix(in srgb, var(--text-tertiary) 70%, var(--accent)); 436 + } 437 + .track-link { color: var(--text-secondary); text-decoration: none; font-weight: 500; } 438 + .track-link:hover { color: var(--accent); } 439 + .by-artist { 440 + font-size: var(--text-xs); 441 + color: color-mix(in srgb, var(--text-tertiary) 70%, var(--accent)); 442 + } 443 + .artist-avatar-link { text-decoration: none; flex-shrink: 0; } 444 + .inline-avatar { 445 + width: 16px; height: 16px; border-radius: 50%; 446 + object-fit: cover; display: inline-block; vertical-align: middle; 447 + border: 1px solid var(--border-subtle); 448 + } 449 + .inline-avatar-placeholder { 450 + background: var(--bg-tertiary); display: inline-flex; 451 + align-items: center; justify-content: center; color: var(--text-muted); 452 + } 453 + .comment-preview { 454 + font-size: var(--text-xs); color: var(--text-tertiary); margin: 0.375rem 0 0 0; 455 + line-height: 1.4; font-style: italic; 456 + background: color-mix(in srgb, #a78bfa 6%, transparent); 457 + border-left: 2px solid color-mix(in srgb, #a78bfa 40%, transparent); 458 + padding: 0.375rem 0.625rem; border-radius: var(--radius-sm); 459 + } 460 + .scroll-sentinel { display: flex; justify-content: center; padding: 2rem 0; min-height: 60px; } 461 + 462 + @media (max-width: 768px) { 463 + main { padding: 0 0.75rem calc(var(--player-height, 0px) + 1.25rem + env(safe-area-inset-bottom, 0px)); } 464 + .left-col, .art-link, .art-img { width: 40px; height: 40px; } 465 + .header-avatar { width: 18px; height: 18px; } 466 + .lava-blob { opacity: 0.07; } 467 + .event-item { gap: 0.625rem; padding: 0.625rem 0.75rem; } 468 + } 469 + </style>
+41
frontend/src/routes/activity/+page.ts
··· 1 + import { browser } from '$app/environment'; 2 + import { API_URL } from '$lib/config'; 3 + import type { ActivityEvent, ActivityHistogramBucket } from '$lib/types'; 4 + 5 + export interface PageData { 6 + events: ActivityEvent[]; 7 + next_cursor: string | null; 8 + has_more: boolean; 9 + histogram: ActivityHistogramBucket[]; 10 + } 11 + 1 12 export const ssr = false; 13 + 14 + export async function load(): Promise<PageData> { 15 + if (!browser) { 16 + return { events: [], next_cursor: null, has_more: false, histogram: [] }; 17 + } 18 + 19 + const empty: PageData = { events: [], next_cursor: null, has_more: false, histogram: [] }; 20 + 21 + try { 22 + const [feedResult, histResult] = await Promise.allSettled([ 23 + fetch(`${API_URL}/activity/`), 24 + fetch(`${API_URL}/activity/histogram?days=7`) 25 + ]); 26 + 27 + let feed = { events: [] as ActivityEvent[], next_cursor: null as string | null, has_more: false }; 28 + if (feedResult.status === 'fulfilled' && feedResult.value.ok) { 29 + feed = await feedResult.value.json(); 30 + } 31 + 32 + let histogram: ActivityHistogramBucket[] = []; 33 + if (histResult.status === 'fulfilled' && histResult.value.ok) { 34 + histogram = (await histResult.value.json()).buckets; 35 + } 36 + 37 + return { ...feed, histogram }; 38 + } catch (e) { 39 + console.error('failed to load activity feed:', e); 40 + return empty; 41 + } 42 + }