feat: add artist support link setting (#532)

* feat: add artist support link setting

allows artists to set a support URL (Ko-fi, Patreon, etc.) in their
settings that displays as a button on their public profile page.

- add support_url field to UserPreferences model with migration
- update preferences API to handle support_url get/update
- expose support_url on public artist profile endpoints
- add settings UI with https:// validation
- display support button next to share on artist profiles
- add backend tests for support_url functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* refactor: move support link to portal profile section

- moved support URL field from settings page to portal profile section
- renamed "profile settings" to "profile" in portal
- aligned support button with share button on artist profile (32px height)
- cleaned up unused CSS from settings page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

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

authored by zzstoatzz.io Claude and committed by GitHub 74e5381d e72feae5

Changed files
+254 -37
backend
frontend
+31
backend/alembic/versions/2025_12_08_170130_a6069b752a90_add_support_url_to_user_preferences.py
··· 1 + """add support_url to user_preferences 2 + 3 + Revision ID: a6069b752a90 4 + Revises: 0ccb2cff4cec 5 + Create Date: 2025-12-08 17:01:30.727995 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "a6069b752a90" 17 + down_revision: str | Sequence[str] | None = "0ccb2cff4cec" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Upgrade schema.""" 24 + op.add_column( 25 + "user_preferences", sa.Column("support_url", sa.String(), nullable=True) 26 + ) 27 + 28 + 29 + def downgrade() -> None: 30 + """Downgrade schema.""" 31 + op.drop_column("user_preferences", "support_url")
+7 -6
backend/src/backend/api/artists.py
··· 52 52 created_at: datetime 53 53 updated_at: datetime 54 54 show_liked_on_profile: bool = False 55 + support_url: str | None = None 55 56 56 57 @field_validator("avatar_url", mode="before") 57 58 @classmethod ··· 214 215 if not artist: 215 216 raise HTTPException(status_code=404, detail="artist not found") 216 217 217 - # fetch user preference for showing liked tracks 218 + # fetch user preferences for public profile fields 218 219 prefs_result = await db.execute( 219 220 select(UserPreferences).where(UserPreferences.did == artist.did) 220 221 ) 221 222 prefs = prefs_result.scalar_one_or_none() 222 - show_liked = prefs.show_liked_on_profile if prefs else False 223 223 224 224 response = ArtistResponse.model_validate(artist) 225 - response.show_liked_on_profile = show_liked 225 + response.show_liked_on_profile = prefs.show_liked_on_profile if prefs else False 226 + response.support_url = prefs.support_url if prefs else None 226 227 return response 227 228 228 229 ··· 236 237 if not artist: 237 238 raise HTTPException(status_code=404, detail="artist not found") 238 239 239 - # fetch user preference for showing liked tracks 240 + # fetch user preferences for public profile fields 240 241 prefs_result = await db.execute( 241 242 select(UserPreferences).where(UserPreferences.did == artist.did) 242 243 ) 243 244 prefs = prefs_result.scalar_one_or_none() 244 - show_liked = prefs.show_liked_on_profile if prefs else False 245 245 246 246 response = ArtistResponse.model_validate(artist) 247 - response.show_liked_on_profile = show_liked 247 + response.show_liked_on_profile = prefs.show_liked_on_profile if prefs else False 248 + response.support_url = prefs.support_url if prefs else None 248 249 return response 249 250 250 251
+8
backend/src/backend/api/preferences.py
··· 27 27 teal_needs_reauth: bool = False 28 28 show_sensitive_artwork: bool = False 29 29 show_liked_on_profile: bool = False 30 + support_url: str | None = None 30 31 31 32 32 33 class PreferencesUpdate(BaseModel): ··· 39 40 enable_teal_scrobbling: bool | None = None 40 41 show_sensitive_artwork: bool | None = None 41 42 show_liked_on_profile: bool | None = None 43 + support_url: str | None = None 42 44 43 45 44 46 def _has_teal_scope(session: Session) -> bool: ··· 84 86 teal_needs_reauth=teal_needs_reauth, 85 87 show_sensitive_artwork=prefs.show_sensitive_artwork, 86 88 show_liked_on_profile=prefs.show_liked_on_profile, 89 + support_url=prefs.support_url, 87 90 ) 88 91 89 92 ··· 122 125 show_liked_on_profile=update.show_liked_on_profile 123 126 if update.show_liked_on_profile is not None 124 127 else False, 128 + support_url=update.support_url, 125 129 ) 126 130 db.add(prefs) 127 131 else: ··· 140 144 prefs.show_sensitive_artwork = update.show_sensitive_artwork 141 145 if update.show_liked_on_profile is not None: 142 146 prefs.show_liked_on_profile = update.show_liked_on_profile 147 + if update.support_url is not None: 148 + # allow clearing by setting to empty string 149 + prefs.support_url = update.support_url if update.support_url else None 143 150 144 151 await db.commit() 145 152 await db.refresh(prefs) ··· 157 164 teal_needs_reauth=teal_needs_reauth, 158 165 show_sensitive_artwork=prefs.show_sensitive_artwork, 159 166 show_liked_on_profile=prefs.show_liked_on_profile, 167 + support_url=prefs.support_url, 160 168 )
+3
backend/src/backend/models/preferences.py
··· 62 62 liked_list_uri: Mapped[str | None] = mapped_column(String, nullable=True) 63 63 liked_list_cid: Mapped[str | None] = mapped_column(String, nullable=True) 64 64 65 + # artist support link (Ko-fi, Patreon, etc.) 66 + support_url: Mapped[str | None] = mapped_column(String, nullable=True) 67 + 65 68 # metadata 66 69 created_at: Mapped[datetime] = mapped_column( 67 70 DateTime(timezone=True),
+71
backend/tests/api/test_preferences.py
··· 150 150 assert data["enable_teal_scrobbling"] is True 151 151 # should NOT need reauth since session has teal scopes 152 152 assert data["teal_needs_reauth"] is False 153 + 154 + 155 + async def test_get_preferences_includes_support_url( 156 + client_no_teal: AsyncClient, 157 + ): 158 + """should return support_url field in preferences response.""" 159 + response = await client_no_teal.get("/preferences/") 160 + assert response.status_code == 200 161 + 162 + data = response.json() 163 + assert "support_url" in data 164 + # default should be None 165 + assert data["support_url"] is None 166 + 167 + 168 + async def test_set_support_url( 169 + client_no_teal: AsyncClient, 170 + ): 171 + """should update support_url preference.""" 172 + response = await client_no_teal.post( 173 + "/preferences/", 174 + json={"support_url": "https://ko-fi.com/testartist"}, 175 + ) 176 + assert response.status_code == 200 177 + 178 + data = response.json() 179 + assert data["support_url"] == "https://ko-fi.com/testartist" 180 + 181 + 182 + async def test_clear_support_url_with_empty_string( 183 + client_no_teal: AsyncClient, 184 + ): 185 + """should clear support_url when set to empty string.""" 186 + # first set a URL 187 + await client_no_teal.post( 188 + "/preferences/", 189 + json={"support_url": "https://ko-fi.com/testartist"}, 190 + ) 191 + 192 + # then clear it with empty string 193 + response = await client_no_teal.post( 194 + "/preferences/", 195 + json={"support_url": ""}, 196 + ) 197 + assert response.status_code == 200 198 + 199 + data = response.json() 200 + assert data["support_url"] is None 201 + 202 + 203 + async def test_support_url_persists_after_update( 204 + client_no_teal: AsyncClient, 205 + ): 206 + """support_url should persist when updating other preferences.""" 207 + # set support_url 208 + await client_no_teal.post( 209 + "/preferences/", 210 + json={"support_url": "https://patreon.com/testartist"}, 211 + ) 212 + 213 + # update a different preference 214 + response = await client_no_teal.post( 215 + "/preferences/", 216 + json={"auto_advance": False}, 217 + ) 218 + assert response.status_code == 200 219 + 220 + data = response.json() 221 + # support_url should still be set 222 + assert data["support_url"] == "https://patreon.com/testartist" 223 + assert data["auto_advance"] is False
+9 -2
frontend/src/lib/preferences.svelte.ts
··· 15 15 teal_needs_reauth: boolean; 16 16 show_sensitive_artwork: boolean; 17 17 show_liked_on_profile: boolean; 18 + support_url: string | null; 18 19 } 19 20 20 21 const DEFAULT_PREFERENCES: Preferences = { ··· 26 27 enable_teal_scrobbling: false, 27 28 teal_needs_reauth: false, 28 29 show_sensitive_artwork: false, 29 - show_liked_on_profile: false 30 + show_liked_on_profile: false, 31 + support_url: null 30 32 }; 31 33 32 34 class PreferencesManager { ··· 74 76 return this.data?.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile; 75 77 } 76 78 79 + get supportUrl(): string | null { 80 + return this.data?.support_url ?? DEFAULT_PREFERENCES.support_url; 81 + } 82 + 77 83 setTheme(theme: Theme): void { 78 84 if (browser) { 79 85 localStorage.setItem('theme', theme); ··· 125 131 enable_teal_scrobbling: data.enable_teal_scrobbling ?? DEFAULT_PREFERENCES.enable_teal_scrobbling, 126 132 teal_needs_reauth: data.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth, 127 133 show_sensitive_artwork: data.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork, 128 - show_liked_on_profile: data.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile 134 + show_liked_on_profile: data.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile, 135 + support_url: data.support_url ?? DEFAULT_PREFERENCES.support_url 129 136 }; 130 137 } else { 131 138 this.data = { ...DEFAULT_PREFERENCES, theme: currentTheme };
+1
frontend/src/lib/types.ts
··· 64 64 avatar_url?: string; 65 65 bio?: string; 66 66 show_liked_on_profile?: boolean; 67 + support_url?: string; 67 68 } 68 69 69 70 export interface QueueState {
+4 -2
frontend/src/routes/+layout.ts
··· 21 21 enable_teal_scrobbling: false, 22 22 teal_needs_reauth: false, 23 23 show_sensitive_artwork: false, 24 - show_liked_on_profile: false 24 + show_liked_on_profile: false, 25 + support_url: null 25 26 }; 26 27 27 28 export async function load({ fetch, data }: LoadEvent): Promise<LayoutData> { ··· 61 62 enable_teal_scrobbling: prefsData.enable_teal_scrobbling ?? false, 62 63 teal_needs_reauth: prefsData.teal_needs_reauth ?? false, 63 64 show_sensitive_artwork: prefsData.show_sensitive_artwork ?? false, 64 - show_liked_on_profile: prefsData.show_liked_on_profile ?? false 65 + show_liked_on_profile: prefsData.show_liked_on_profile ?? false, 66 + support_url: prefsData.support_url ?? null 65 67 }; 66 68 } 67 69 } catch (e) {
+58 -20
frontend/src/routes/portal/+page.svelte
··· 33 33 let displayName = $state(''); 34 34 let bio = $state(''); 35 35 let avatarUrl = $state(''); 36 + let supportUrl = $state(''); 36 37 let savingProfile = $state(false); 38 + let savingSupportUrl = $state(false); 37 39 38 40 // album management state 39 41 let albums = $state<AlbumSummary[]>([]); ··· 131 133 132 134 async function loadArtistProfile() { 133 135 try { 134 - const response = await fetch(`${API_URL}/artists/me`, { 135 - credentials: 'include' 136 - }); 137 - if (response.ok) { 138 - const artist = await response.json(); 136 + const [artistRes, prefsRes] = await Promise.all([ 137 + fetch(`${API_URL}/artists/me`, { credentials: 'include' }), 138 + fetch(`${API_URL}/preferences/`, { credentials: 'include' }) 139 + ]); 140 + 141 + if (artistRes.ok) { 142 + const artist = await artistRes.json(); 139 143 displayName = artist.display_name; 140 144 bio = artist.bio || ''; 141 145 avatarUrl = artist.avatar_url || ''; 146 + } 147 + 148 + if (prefsRes.ok) { 149 + const prefs = await prefsRes.json(); 150 + supportUrl = prefs.support_url || ''; 142 151 } 143 152 } catch (_e) { 144 153 console.error('failed to load artist profile:', _e); ··· 223 232 savingProfile = true; 224 233 225 234 try { 226 - const response = await fetch(`${API_URL}/artists/me`, { 227 - method: 'PUT', 228 - headers: { 229 - 'Content-Type': 'application/json' 230 - }, 231 - credentials: 'include', 232 - body: JSON.stringify({ 233 - display_name: displayName, 234 - bio: bio || null, 235 - avatar_url: avatarUrl || null 235 + // validate support URL 236 + const trimmedSupportUrl = supportUrl.trim(); 237 + if (trimmedSupportUrl && !trimmedSupportUrl.startsWith('https://')) { 238 + toast.error('support link must start with https://'); 239 + savingProfile = false; 240 + return; 241 + } 242 + 243 + // save artist profile and support URL in parallel 244 + const [artistRes, prefsRes] = await Promise.all([ 245 + fetch(`${API_URL}/artists/me`, { 246 + method: 'PUT', 247 + headers: { 'Content-Type': 'application/json' }, 248 + credentials: 'include', 249 + body: JSON.stringify({ 250 + display_name: displayName, 251 + bio: bio || null, 252 + avatar_url: avatarUrl || null 253 + }) 254 + }), 255 + fetch(`${API_URL}/preferences/`, { 256 + method: 'POST', 257 + headers: { 'Content-Type': 'application/json' }, 258 + credentials: 'include', 259 + body: JSON.stringify({ support_url: trimmedSupportUrl || '' }) 236 260 }) 237 - }); 261 + ]); 238 262 239 - if (response.ok) { 263 + if (artistRes.ok && prefsRes.ok) { 240 264 toast.success('profile updated'); 241 - } else { 242 - const errorData = await response.json(); 265 + } else if (!artistRes.ok) { 266 + const errorData = await artistRes.json(); 243 267 toast.error(errorData.detail || 'failed to update profile'); 268 + } else { 269 + toast.error('failed to update support link'); 244 270 } 245 271 } catch (e) { 246 272 toast.error(`network error: ${e instanceof Error ? e.message : 'unknown error'}`); ··· 499 525 500 526 <section class="profile-section"> 501 527 <div class="section-header"> 502 - <h2>profile settings</h2> 528 + <h2>profile</h2> 503 529 <a href="/u/{auth.user.handle}" class="view-profile-link">view public profile</a> 504 530 </div> 505 531 ··· 542 568 <img src={avatarUrl} alt="avatar preview" /> 543 569 </div> 544 570 {/if} 571 + </div> 572 + 573 + <div class="form-group"> 574 + <label for="support-url">support link (optional)</label> 575 + <input 576 + id="support-url" 577 + type="url" 578 + bind:value={supportUrl} 579 + disabled={savingSupportUrl} 580 + placeholder="https://ko-fi.com/yourname" 581 + /> 582 + <p class="hint">link to Ko-fi, Patreon, or similar - shown on your profile</p> 545 583 </div> 546 584 547 585 <button type="submit" disabled={savingProfile || !displayName}>
+1 -1
frontend/src/routes/settings/+page.svelte
··· 21 21 let currentTheme = $derived(preferences.theme); 22 22 let currentColor = $derived(preferences.accentColor ?? '#6a9fff'); 23 23 let autoAdvance = $derived(preferences.autoAdvance); 24 - 25 24 // developer token state 26 25 let creatingToken = $state(false); 27 26 let developerToken = $state<string | null>(null); ··· 487 486 <span class="toggle-slider"></span> 488 487 </label> 489 488 </div> 489 + 490 490 </div> 491 491 </section> 492 492
+61 -6
frontend/src/routes/u/[handle]/+page.svelte
··· 271 271 <p class="bio">{artist.bio}</p> 272 272 {/if} 273 273 </div> 274 - <div class="artist-share-desktop"> 274 + <div class="artist-actions-desktop"> 275 + {#if artist.support_url} 276 + <a href={artist.support_url} target="_blank" rel="noopener" class="support-btn"> 277 + <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> 278 + <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> 279 + </svg> 280 + support 281 + </a> 282 + {/if} 275 283 <ShareButton url={shareUrl} title="share artist" /> 276 284 </div> 277 285 </div> 278 - <div class="artist-share-mobile"> 286 + <div class="artist-actions-mobile"> 287 + {#if artist.support_url} 288 + <a href={artist.support_url} target="_blank" rel="noopener" class="support-btn"> 289 + <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> 290 + <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> 291 + </svg> 292 + support 293 + </a> 294 + {/if} 279 295 <ShareButton url={shareUrl} title="share artist" /> 280 296 </div> 281 297 </section> ··· 498 514 flex: 1; 499 515 } 500 516 501 - .artist-share-desktop { 517 + .artist-actions-desktop { 502 518 display: flex; 503 519 align-items: flex-start; 504 520 justify-content: center; 521 + gap: 0.75rem; 505 522 } 506 523 507 - .artist-share-mobile { 524 + .artist-actions-mobile { 508 525 display: none; 509 526 width: 100%; 510 527 justify-content: center; 528 + gap: 0.75rem; 511 529 margin-top: 0.5rem; 530 + } 531 + 532 + .support-btn { 533 + display: inline-flex; 534 + align-items: center; 535 + justify-content: center; 536 + gap: 0.4rem; 537 + height: 32px; 538 + padding: 0 0.75rem; 539 + background: color-mix(in srgb, var(--accent) 15%, transparent); 540 + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); 541 + border-radius: 4px; 542 + color: var(--accent); 543 + font-size: 0.85rem; 544 + text-decoration: none; 545 + transition: all 0.2s ease; 546 + } 547 + 548 + .support-btn:hover { 549 + background: color-mix(in srgb, var(--accent) 25%, transparent); 550 + border-color: var(--accent); 551 + transform: translateY(-1px); 552 + } 553 + 554 + .support-btn svg { 555 + flex-shrink: 0; 512 556 } 513 557 514 558 .artist-avatar { ··· 868 912 text-align: center; 869 913 } 870 914 871 - .artist-share-desktop { 915 + .artist-actions-desktop { 872 916 display: none; 873 917 } 874 918 875 - .artist-share-mobile { 919 + .artist-actions-mobile { 876 920 display: flex; 921 + } 922 + 923 + .support-btn { 924 + height: 28px; 925 + font-size: 0.8rem; 926 + padding: 0 0.6rem; 927 + } 928 + 929 + .support-btn svg { 930 + width: 14px; 931 + height: 14px; 877 932 } 878 933 879 934 .artist-avatar {