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 249 return response 250 250 251 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 + 252 271 @router.get("/{artist_did}/analytics") 253 272 async def get_artist_analytics( 254 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 15 import { auth } from '$lib/auth.svelte'; 16 16 import { fetchLikedTracks, fetchUserLikes } from '$lib/tracks.svelte'; 17 17 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 18 + import { getAtprotofansProfile, getAtprotofansSupporters, type Supporter } from '$lib/atprotofans'; 18 19 import type { PageData } from './$types'; 19 20 20 21 ··· 71 72 72 73 // supporter status - true if logged-in viewer supports this artist via atprotofans 73 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[]>([]); 74 79 75 80 // track which artist we've loaded data for to detect navigation 76 81 let loadedForDid = $state<string | null>(null); ··· 136 141 } 137 142 138 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 + /** 139 171 * check if the logged-in viewer supports this artist via atprotofans. 140 172 * only called when: 141 173 * 1. viewer is authenticated ··· 186 218 likedTracksCount = null; 187 219 publicPlaylists = []; 188 220 isSupporter = false; 221 + supporterCount = null; 222 + supporters = []; 189 223 190 224 // sync tracks and pagination from server data 191 225 tracks = data.tracks ?? []; ··· 202 236 void loadLikedTracksCount(); 203 237 void loadPublicPlaylists(); 204 238 void checkSupporterStatus(); 239 + void loadAtprotofansData(); 205 240 } 206 241 }); 207 242 ··· 391 426 </div> 392 427 </section> 393 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 + 394 463 <section class="analytics"> 395 464 <h2>analytics</h2> 396 465 <div class="analytics-grid"> ··· 710 779 margin: 0; 711 780 } 712 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 + 713 852 .analytics { 714 853 margin-bottom: 3rem; 715 854 } ··· 1122 1261 1123 1262 .album-card-meta p { 1124 1263 font-size: var(--text-sm); 1264 + } 1265 + 1266 + .supporter-circle { 1267 + width: 28px; 1268 + height: 28px; 1269 + margin-left: -6px; 1125 1270 } 1126 1271 } 1127 1272