feat: show atprotofans supporter count and list on artist pages (#695)

* feat: show atprotofans supporter count and list on artist pages

- add `getAtprotofansProfile` and `getAtprotofansSupporters` API calls
- display supporter count badge in support button
- add supporters section with avatar grid linking to bsky profiles
- responsive styling for mobile

uses atprotofans public endpoints:
- `com.atprotofans.getProfile` for supporter count
- `com.atprotofans.getSupporters` for list of supporters

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: remove count badge from support button, fix avatar handling

- remove supporter count from support button (unnecessary)
- properly check avatar field before rendering
- clean up unused CSS

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: enrich atprotofans supporters with avatars from Bluesky

Fetch each supporter's profile from Bluesky's public API
(app.bsky.actor.getProfile) to get their avatar URL, following
the same pattern used elsewhere in the app where backend
provides avatar_url for display.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

* refactor: remove frontend Bluesky API calls for avatar enrichment

Don't add external API surface area from the frontend - use what
atprotofans returns directly. If avatars aren't provided, the
placeholder will show. Backend should handle enrichment if needed.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

* feat: enrich supporters with avatar_url via backend batch endpoint

- Add POST /artists/batch endpoint to get artist data for multiple DIDs
- Frontend calls atprotofans for supporter DIDs, then enriches via our backend
- Uses same pattern as likers: avatar_url comes from Artist table
- Use SensitiveImage wrapper and initials placeholder (consistent with LikersTooltip)

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

* style: compact overlapping avatar circles for supporters

GitHub-sponsors style: small circles with negative margin overlap,
"+N" badge for overflow. Bounded height regardless of supporter count.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: link supporter avatars to plyr.fm artist pages

Keep users in the app instead of linking to Bluesky.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

---------

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

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub 982ea9a5 b39cbd10

Changed files
+270
backend
src
backend
frontend
src
lib
routes
u
[handle]
+19
backend/src/backend/api/artists.py
··· 249 return response 250 251 252 @router.get("/{artist_did}/analytics") 253 async def get_artist_analytics( 254 artist_did: str,
··· 249 return response 250 251 252 + @router.post("/batch") 253 + async def get_artists_batch( 254 + dids: list[str], 255 + db: Annotated[AsyncSession, Depends(get_db)], 256 + ) -> dict[str, ArtistResponse]: 257 + """get artist profiles for multiple DIDs (public endpoint). 258 + 259 + returns a dict mapping DID -> artist data for any DIDs that exist in our database. 260 + DIDs not found are simply omitted from the response. 261 + """ 262 + if not dids: 263 + return {} 264 + 265 + result = await db.execute(select(Artist).where(Artist.did.in_(dids))) 266 + artists = result.scalars().all() 267 + 268 + return {artist.did: ArtistResponse.model_validate(artist) for artist in artists} 269 + 270 + 271 @router.get("/{artist_did}/analytics") 272 async def get_artist_analytics( 273 artist_did: str,
+106
frontend/src/lib/atprotofans.ts
···
··· 1 + /** 2 + * atprotofans API client for frontend. 3 + * used for fetching supporter counts and lists. 4 + */ 5 + 6 + import { API_URL } from '$lib/config'; 7 + 8 + const ATPROTOFANS_BASE = 'https://atprotofans.com/xrpc'; 9 + 10 + export interface AtprotofansProfile { 11 + did: string; 12 + handle: string; 13 + displayName?: string; 14 + description?: string; 15 + acceptingSupporters: boolean; 16 + supporterCount: number; 17 + } 18 + 19 + export interface Supporter { 20 + did: string; 21 + handle: string; 22 + display_name?: string; 23 + avatar_url?: string; 24 + } 25 + 26 + export interface GetSupportersResponse { 27 + supporters: Supporter[]; 28 + cursor?: string; 29 + } 30 + 31 + /** 32 + * fetch atprotofans profile for an artist. 33 + * returns supporter count and whether they accept supporters. 34 + */ 35 + export async function getAtprotofansProfile(did: string): Promise<AtprotofansProfile | null> { 36 + try { 37 + const url = new URL(`${ATPROTOFANS_BASE}/com.atprotofans.getProfile`); 38 + url.searchParams.set('subject', did); 39 + 40 + const response = await fetch(url.toString()); 41 + if (!response.ok) { 42 + return null; 43 + } 44 + 45 + return await response.json(); 46 + } catch (_e) { 47 + console.error('failed to fetch atprotofans profile:', _e); 48 + return null; 49 + } 50 + } 51 + 52 + /** 53 + * fetch list of supporters for an artist. 54 + * enriches with avatar_url from our backend for supporters who are plyr.fm users. 55 + */ 56 + export async function getAtprotofansSupporters( 57 + did: string, 58 + limit = 50, 59 + cursor?: string 60 + ): Promise<GetSupportersResponse | null> { 61 + try { 62 + const url = new URL(`${ATPROTOFANS_BASE}/com.atprotofans.getSupporters`); 63 + url.searchParams.set('subject', did); 64 + url.searchParams.set('limit', limit.toString()); 65 + if (cursor) { 66 + url.searchParams.set('cursor', cursor); 67 + } 68 + 69 + const response = await fetch(url.toString()); 70 + if (!response.ok) { 71 + return null; 72 + } 73 + 74 + const data = await response.json(); 75 + 76 + // enrich with avatar data from our backend (for supporters who are plyr.fm users) 77 + if (data.supporters?.length > 0) { 78 + const dids = data.supporters.map((s: { did: string }) => s.did); 79 + const artistsResponse = await fetch(`${API_URL}/artists/batch`, { 80 + method: 'POST', 81 + headers: { 'Content-Type': 'application/json' }, 82 + body: JSON.stringify(dids) 83 + }); 84 + 85 + if (artistsResponse.ok) { 86 + const artistsMap = await artistsResponse.json(); 87 + data.supporters = data.supporters.map( 88 + (s: { did: string; handle: string; displayName?: string }) => { 89 + const artist = artistsMap[s.did]; 90 + return { 91 + did: s.did, 92 + handle: artist?.handle || s.handle, 93 + display_name: artist?.display_name || s.displayName || s.handle, 94 + avatar_url: artist?.avatar_url 95 + }; 96 + } 97 + ); 98 + } 99 + } 100 + 101 + return data; 102 + } catch (_e) { 103 + console.error('failed to fetch atprotofans supporters:', _e); 104 + return null; 105 + } 106 + }
+145
frontend/src/routes/u/[handle]/+page.svelte
··· 15 import { auth } from '$lib/auth.svelte'; 16 import { fetchLikedTracks, fetchUserLikes } from '$lib/tracks.svelte'; 17 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 18 import type { PageData } from './$types'; 19 20 ··· 71 72 // supporter status - true if logged-in viewer supports this artist via atprotofans 73 let isSupporter = $state(false); 74 75 // track which artist we've loaded data for to detect navigation 76 let loadedForDid = $state<string | null>(null); ··· 136 } 137 138 /** 139 * check if the logged-in viewer supports this artist via atprotofans. 140 * only called when: 141 * 1. viewer is authenticated ··· 186 likedTracksCount = null; 187 publicPlaylists = []; 188 isSupporter = false; 189 190 // sync tracks and pagination from server data 191 tracks = data.tracks ?? []; ··· 202 void loadLikedTracksCount(); 203 void loadPublicPlaylists(); 204 void checkSupporterStatus(); 205 } 206 }); 207 ··· 391 </div> 392 </section> 393 394 <section class="analytics"> 395 <h2>analytics</h2> 396 <div class="analytics-grid"> ··· 710 margin: 0; 711 } 712 713 .analytics { 714 margin-bottom: 3rem; 715 } ··· 1122 1123 .album-card-meta p { 1124 font-size: var(--text-sm); 1125 } 1126 } 1127
··· 15 import { auth } from '$lib/auth.svelte'; 16 import { fetchLikedTracks, fetchUserLikes } from '$lib/tracks.svelte'; 17 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 18 + import { getAtprotofansProfile, getAtprotofansSupporters, type Supporter } from '$lib/atprotofans'; 19 import type { PageData } from './$types'; 20 21 ··· 72 73 // supporter status - true if logged-in viewer supports this artist via atprotofans 74 let isSupporter = $state(false); 75 + 76 + // atprotofans data - supporter count and list 77 + let supporterCount = $state<number | null>(null); 78 + let supporters = $state<Supporter[]>([]); 79 80 // track which artist we've loaded data for to detect navigation 81 let loadedForDid = $state<string | null>(null); ··· 141 } 142 143 /** 144 + * load atprotofans profile and supporters for this artist. 145 + * only called when artist has atprotofans support enabled. 146 + */ 147 + async function loadAtprotofansData() { 148 + // only load if artist has atprotofans enabled 149 + if (artist?.support_url !== 'atprotofans' || !artist.did) return; 150 + 151 + try { 152 + // fetch profile (for supporter count) and supporters list in parallel 153 + const [profile, supportersData] = await Promise.all([ 154 + getAtprotofansProfile(artist.did), 155 + getAtprotofansSupporters(artist.did, 12) // show up to 12 supporters 156 + ]); 157 + 158 + if (profile) { 159 + supporterCount = profile.supporterCount; 160 + } 161 + 162 + if (supportersData) { 163 + supporters = supportersData.supporters; 164 + } 165 + } catch (_e) { 166 + console.error('failed to load atprotofans data:', _e); 167 + } 168 + } 169 + 170 + /** 171 * check if the logged-in viewer supports this artist via atprotofans. 172 * only called when: 173 * 1. viewer is authenticated ··· 218 likedTracksCount = null; 219 publicPlaylists = []; 220 isSupporter = false; 221 + supporterCount = null; 222 + supporters = []; 223 224 // sync tracks and pagination from server data 225 tracks = data.tracks ?? []; ··· 236 void loadLikedTracksCount(); 237 void loadPublicPlaylists(); 238 void checkSupporterStatus(); 239 + void loadAtprotofansData(); 240 } 241 }); 242 ··· 426 </div> 427 </section> 428 429 + {#if artist.support_url === 'atprotofans' && supporters.length > 0} 430 + <section class="supporters-section"> 431 + <div class="supporters-row"> 432 + <span class="supporters-label">{supporterCount ?? supporters.length} {(supporterCount ?? supporters.length) === 1 ? 'supporter' : 'supporters'}</span> 433 + <div class="supporters-avatars"> 434 + {#each supporters.slice(0, 20) as supporter} 435 + <a 436 + href="/u/{supporter.handle}" 437 + class="supporter-circle" 438 + title={supporter.display_name || supporter.handle} 439 + > 440 + {#if supporter.avatar_url} 441 + <img src={supporter.avatar_url} alt="" /> 442 + {:else} 443 + <span>{(supporter.display_name || supporter.handle).charAt(0).toUpperCase()}</span> 444 + {/if} 445 + </a> 446 + {/each} 447 + {#if (supporterCount ?? supporters.length) > 20} 448 + <a 449 + href={supportUrl()} 450 + target="_blank" 451 + rel="noopener" 452 + class="supporter-circle more" 453 + title="view all supporters" 454 + > 455 + +{(supporterCount ?? supporters.length) - 20} 456 + </a> 457 + {/if} 458 + </div> 459 + </div> 460 + </section> 461 + {/if} 462 + 463 <section class="analytics"> 464 <h2>analytics</h2> 465 <div class="analytics-grid"> ··· 779 margin: 0; 780 } 781 782 + .supporters-section { 783 + margin-bottom: 2rem; 784 + } 785 + 786 + .supporters-row { 787 + display: flex; 788 + align-items: center; 789 + gap: 1rem; 790 + flex-wrap: wrap; 791 + } 792 + 793 + .supporters-label { 794 + color: var(--text-tertiary); 795 + font-size: var(--text-sm); 796 + white-space: nowrap; 797 + } 798 + 799 + .supporters-avatars { 800 + display: flex; 801 + align-items: center; 802 + } 803 + 804 + .supporter-circle { 805 + width: 32px; 806 + height: 32px; 807 + border-radius: var(--radius-full); 808 + border: 2px solid var(--bg-primary); 809 + background: var(--bg-tertiary); 810 + display: flex; 811 + align-items: center; 812 + justify-content: center; 813 + overflow: hidden; 814 + margin-left: -8px; 815 + transition: transform 0.15s ease, z-index 0.15s ease; 816 + position: relative; 817 + text-decoration: none; 818 + } 819 + 820 + .supporter-circle:first-child { 821 + margin-left: 0; 822 + } 823 + 824 + .supporter-circle:hover { 825 + transform: translateY(-2px) scale(1.1); 826 + z-index: 10; 827 + } 828 + 829 + .supporter-circle img { 830 + width: 100%; 831 + height: 100%; 832 + object-fit: cover; 833 + } 834 + 835 + .supporter-circle span { 836 + font-size: var(--text-xs); 837 + font-weight: 600; 838 + color: var(--text-secondary); 839 + } 840 + 841 + .supporter-circle.more { 842 + background: var(--bg-secondary); 843 + font-size: var(--text-xs); 844 + font-weight: 600; 845 + color: var(--text-tertiary); 846 + } 847 + 848 + .supporter-circle.more:hover { 849 + color: var(--accent); 850 + } 851 + 852 .analytics { 853 margin-bottom: 3rem; 854 } ··· 1261 1262 .album-card-meta p { 1263 font-size: var(--text-sm); 1264 + } 1265 + 1266 + .supporter-circle { 1267 + width: 28px; 1268 + height: 28px; 1269 + margin-left: -6px; 1270 } 1271 } 1272