feat: consolidate user preferences into dedicated settings page (#496)

* feat: consolidate user preferences into dedicated settings page

- create /settings route with all preferences consolidated:
- appearance (theme, accent color)
- playback (auto-advance)
- privacy & display (sensitive artwork, timed comments)
- integrations (teal.fm scrobbling)
- developer (API tokens with OAuth flow)
- account (delete with confirmation)

- slim down portal to focus on content management:
- profile settings, tracks, albums, export
- remove preference toggles (moved to /settings)
- remove dev tokens section (moved to /settings)
- remove account deletion (moved to /settings)

- add "all settings →" link to both SettingsMenu and ProfileMenu
- update SensitiveImage tooltip from "enable in portal" to "enable in settings"
- add TokenInfo type for developer tokens
- clean up ~500 lines of unused CSS from portal

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

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

* fix: add font-family inherit to buttons, rename profile → portal in mobile menu

- add font-family: inherit to all buttons in settings page:
revoke-btn, copy-btn, dismiss-btn, create-token-btn,
delete-account-btn, cancel-btn, confirm-delete-btn
- rename mobile menu item from "profile" to "portal" to match route
- update icon to grid layout to better represent portal concept

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

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

* refactor: move delete account from settings to portal

Delete account belongs in portal's "your data" section because:
- It's a destructive action on your data, not a preference
- It has an option about AT Protocol records (your data)
- Export is already in portal - delete is the inverse operation

Settings = preferences (theme, colors, toggles, API access)
Portal = your content and data (profile, tracks, albums, export, delete)

🤖 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 0336f311 64ad9d72

Changed files
+1122 -806
frontend
+26 -4
frontend/src/lib/components/ProfileMenu.svelte
··· 134 134 <nav class="menu-items"> 135 135 {#if !isOnPortal} 136 136 <a href="/portal" class="menu-item" onclick={closeMenu}> 137 - <svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> 138 - <circle cx="8" cy="5" r="3" fill="none" /> 139 - <path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" /> 137 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 138 + <rect x="3" y="3" width="7" height="7"></rect> 139 + <rect x="14" y="3" width="7" height="7"></rect> 140 + <rect x="14" y="14" width="7" height="7"></rect> 141 + <rect x="3" y="14" width="7" height="7"></rect> 140 142 </svg> 141 143 <div class="item-content"> 142 - <span class="item-title">profile</span> 144 + <span class="item-title">portal</span> 143 145 <span class="item-subtitle">@{user?.handle}</span> 144 146 </div> 145 147 </a> ··· 250 252 <span class="toggle-text">auto-play next</span> 251 253 </label> 252 254 </section> 255 + 256 + <a href="/settings" class="all-settings-link" onclick={closeMenu}> 257 + all settings → 258 + </a> 253 259 </div> 254 260 {/if} 255 261 </div> ··· 618 624 619 625 .toggle-text { 620 626 white-space: nowrap; 627 + } 628 + 629 + .all-settings-link { 630 + display: block; 631 + text-align: center; 632 + padding: 1rem; 633 + margin-top: 0.5rem; 634 + border-top: 1px solid var(--border-subtle); 635 + color: var(--text-secondary); 636 + text-decoration: none; 637 + font-size: 0.9rem; 638 + transition: color 0.15s; 639 + } 640 + 641 + .all-settings-link:hover { 642 + color: var(--accent); 621 643 } 622 644 623 645 @keyframes fadeIn {
+1 -1
frontend/src/lib/components/SensitiveImage.svelte
··· 23 23 {@render children()} 24 24 {#if shouldBlur && !compact} 25 25 <div class="sensitive-tooltip"> 26 - <span>sensitive - enable in portal</span> 26 + <span>sensitive - enable in settings</span> 27 27 </div> 28 28 {/if} 29 29 </div>
+19
frontend/src/lib/components/SettingsMenu.svelte
··· 164 164 <p class="toggle-hint">when a track ends, start the next item in your queue</p> 165 165 </section> 166 166 167 + <a href="/settings" class="all-settings-link" onclick={toggleSettings}> 168 + all settings → 169 + </a> 167 170 </div> 168 171 {/if} 169 172 </div> ··· 402 405 color: var(--text-tertiary); 403 406 font-size: 0.8rem; 404 407 line-height: 1.3; 408 + } 409 + 410 + .all-settings-link { 411 + display: block; 412 + text-align: center; 413 + padding: 0.75rem; 414 + margin-top: 0.5rem; 415 + border-top: 1px solid var(--border-subtle); 416 + color: var(--text-secondary); 417 + text-decoration: none; 418 + font-size: 0.85rem; 419 + transition: color 0.15s; 420 + } 421 + 422 + .all-settings-link:hover { 423 + color: var(--accent); 405 424 } 406 425 407 426 @media (max-width: 768px) {
+7
frontend/src/lib/types.ts
··· 92 92 93 93 export interface ArtistAlbumSummary extends AlbumSummary {} 94 94 95 + export interface TokenInfo { 96 + session_id: string; 97 + name: string | null; 98 + created_at: string; 99 + expires_at: string | null; 100 + } 101 +
+85 -801
frontend/src/routes/portal/+page.svelte
··· 32 32 let displayName = $state(''); 33 33 let bio = $state(''); 34 34 let avatarUrl = $state(''); 35 - // derive from preferences store 36 - let allowComments = $derived(preferences.allowComments); 37 - let enableTealScrobbling = $derived(preferences.enableTealScrobbling); 38 - let tealNeedsReauth = $derived(preferences.tealNeedsReauth); 39 - let showSensitiveArtwork = $derived(preferences.showSensitiveArtwork); 40 35 let savingProfile = $state(false); 41 36 let profileSuccess = $state(''); 42 37 let profileError = $state(''); ··· 56 51 let deleteAtprotoRecords = $state(false); 57 52 let deleting = $state(false); 58 53 59 - // developer token state 60 - let creatingToken = $state(false); 61 - let developerToken = $state<string | null>(null); 62 - let tokenExpiresDays = $state(90); 63 - let tokenName = $state(''); 64 - let tokenCopied = $state(false); 65 - 66 - // existing tokens list 67 - interface TokenInfo { 68 - session_id: string; 69 - name: string | null; 70 - created_at: string; 71 - expires_at: string | null; 72 - } 73 - let existingTokens = $state<TokenInfo[]>([]); 74 - let loadingTokens = $state(false); 75 - let revokingToken = $state<string | null>(null); 76 - 77 54 onMount(async () => { 78 - // check if exchange_token is in URL (from OAuth callback) 55 + // check if exchange_token is in URL (from OAuth callback for regular login) 79 56 const params = new URLSearchParams(window.location.search); 80 57 const exchangeToken = params.get('exchange_token'); 81 58 const isDevToken = params.get('dev_token') === 'true'; 82 59 60 + // redirect dev token callbacks to settings page 61 + if (exchangeToken && isDevToken) { 62 + window.location.href = `/settings?exchange_token=${exchangeToken}&dev_token=true`; 63 + return; 64 + } 65 + 83 66 if (exchangeToken) { 84 - // exchange token for session_id 67 + // regular login - exchange token for session 85 68 try { 86 69 const exchangeResponse = await fetch(`${API_URL}/auth/exchange`, { 87 70 method: 'POST', ··· 91 74 }); 92 75 93 76 if (exchangeResponse.ok) { 94 - const data = await exchangeResponse.json(); 95 - 96 - if (isDevToken) { 97 - // this is a developer token - display it to the user 98 - developerToken = data.session_id; 99 - toast.success('developer token created - save it now!'); 100 - } else { 101 - // regular login - initialize auth and refresh preferences 102 - await auth.initialize(); 103 - await preferences.fetch(); 104 - } 77 + await auth.initialize(); 78 + await preferences.fetch(); 105 79 } 106 80 } catch (_e) { 107 81 console.error('failed to exchange token:', _e); 108 82 } 109 83 110 - // remove exchange_token from URL 111 84 replaceState('/portal', {}); 112 85 } 113 86 114 - // wait for auth to finish loading (synced from layout) 87 + // wait for auth to finish loading 115 88 while (auth.loading) { 116 89 await new Promise(resolve => setTimeout(resolve, 50)); 117 90 } ··· 125 98 await loadMyTracks(); 126 99 await loadArtistProfile(); 127 100 await loadMyAlbums(); 128 - await loadDeveloperTokens(); 129 101 } catch (_e) { 130 102 console.error('error loading portal data:', _e); 131 103 error = 'failed to load portal data'; ··· 133 105 loading = false; 134 106 } 135 107 }); 136 - 137 - async function loadDeveloperTokens() { 138 - loadingTokens = true; 139 - try { 140 - const response = await fetch(`${API_URL}/auth/developer-tokens`, { 141 - credentials: 'include' 142 - }); 143 - if (response.ok) { 144 - const data = await response.json(); 145 - existingTokens = data.tokens; 146 - } 147 - } catch (_e) { 148 - console.error('failed to load developer tokens:', _e); 149 - } finally { 150 - loadingTokens = false; 151 - } 152 - } 153 108 154 109 async function loadMyTracks() { 155 110 loadingTracks = true; ··· 197 152 console.error('failed to load albums:', _e); 198 153 } finally { 199 154 loadingAlbums = false; 200 - } 201 - } 202 - 203 - async function saveAllowComments(enabled: boolean) { 204 - try { 205 - await preferences.update({ allow_comments: enabled }); 206 - toast.success(enabled ? 'comments enabled on your tracks' : 'comments disabled'); 207 - } catch (_e) { 208 - console.error('failed to save preference:', _e); 209 - toast.error('failed to update preference'); 210 - } 211 - } 212 - 213 - async function saveTealScrobbling(enabled: boolean) { 214 - try { 215 - await preferences.update({ enable_teal_scrobbling: enabled }); 216 - await preferences.fetch(); // refetch to get updated teal_needs_reauth status 217 - toast.success(enabled ? 'teal.fm scrobbling enabled' : 'teal.fm scrobbling disabled'); 218 - } catch (_e) { 219 - console.error('failed to save preference:', _e); 220 - toast.error('failed to update preference'); 221 - } 222 - } 223 - 224 - async function saveShowSensitiveArtwork(enabled: boolean) { 225 - try { 226 - await preferences.update({ show_sensitive_artwork: enabled }); 227 - toast.success(enabled ? 'sensitive artwork shown' : 'sensitive artwork hidden'); 228 - } catch (_e) { 229 - console.error('failed to save preference:', _e); 230 - toast.error('failed to update preference'); 231 155 } 232 156 } 233 157 ··· 482 406 } 483 407 } 484 408 485 - async function createDeveloperToken() { 486 - creatingToken = true; 487 - developerToken = null; 488 - tokenCopied = false; 489 - 490 - try { 491 - // start OAuth flow for dev token - this returns an auth URL 492 - const response = await fetch(`${API_URL}/auth/developer-token/start`, { 493 - method: 'POST', 494 - headers: { 'Content-Type': 'application/json' }, 495 - credentials: 'include', 496 - body: JSON.stringify({ 497 - name: tokenName || null, 498 - expires_in_days: tokenExpiresDays 499 - }) 500 - }); 501 - 502 - if (!response.ok) { 503 - const error = await response.json(); 504 - toast.error(error.detail || 'failed to start token creation'); 505 - creatingToken = false; 506 - return; 507 - } 508 - 509 - const result = await response.json(); 510 - tokenName = ''; // clear the name field 511 - 512 - // redirect to PDS for authorization 513 - // on callback, user will return with dev_token=true and the token will be displayed 514 - window.location.href = result.auth_url; 515 - } catch (e) { 516 - console.error('failed to create token:', e); 517 - toast.error('failed to create token'); 518 - creatingToken = false; 519 - } 520 - // note: we don't set creatingToken = false here because we're redirecting 521 - } 522 - 523 - async function revokeToken(tokenId: string, name: string | null) { 524 - if (!confirm(`revoke token "${name || tokenId}"?`)) return; 525 - 526 - revokingToken = tokenId; 527 - try { 528 - const response = await fetch(`${API_URL}/auth/developer-tokens/${tokenId}`, { 529 - method: 'DELETE', 530 - credentials: 'include' 531 - }); 532 - 533 - if (!response.ok) { 534 - const error = await response.json(); 535 - toast.error(error.detail || 'failed to revoke token'); 536 - return; 537 - } 538 - 539 - toast.success('token revoked'); 540 - await loadDeveloperTokens(); 541 - } catch (e) { 542 - console.error('failed to revoke token:', e); 543 - toast.error('failed to revoke token'); 544 - } finally { 545 - revokingToken = null; 546 - } 547 - } 548 - 549 - async function copyToken() { 550 - if (!developerToken) return; 551 - try { 552 - await navigator.clipboard.writeText(developerToken); 553 - tokenCopied = true; 554 - toast.success('token copied to clipboard'); 555 - setTimeout(() => { tokenCopied = false; }, 2000); 556 - } catch (e) { 557 - console.error('failed to copy:', e); 558 - toast.error('failed to copy token'); 559 - } 560 - } 561 - 562 409 async function deleteAccount() { 563 410 if (!auth.user || deleteConfirmText !== auth.user.handle) return; 564 411 ··· 587 434 const result = await response.json(); 588 435 toast.dismiss(toastId); 589 436 590 - // show summary of what was deleted 591 437 const { deleted } = result; 592 438 const summary = [ 593 439 deleted.tracks && `${deleted.tracks} tracks`, ··· 599 445 600 446 toast.success(`account deleted: ${summary || 'all data removed'}`); 601 447 602 - // redirect to home after a moment 603 448 setTimeout(() => { 604 449 window.location.href = '/'; 605 450 }, 2000); ··· 611 456 deleting = false; 612 457 } 613 458 } 459 + 614 460 </script> 615 461 616 462 {#if loading} ··· 1020 866 </section> 1021 867 1022 868 <section class="data-section"> 1023 - <h2>your data</h2> 1024 - 1025 - <div class="data-control"> 1026 - <div class="control-info"> 1027 - <h3>teal.fm scrobbling</h3> 1028 - <p class="control-description"> 1029 - track your listens as <a href="https://pdsls.dev/at://{auth.user?.did}/fm.teal.alpha.feed.play" target="_blank" rel="noopener">fm.teal.alpha.feed.play</a> records 1030 - </p> 1031 - </div> 1032 - <label class="toggle-switch"> 1033 - <input 1034 - type="checkbox" 1035 - aria-label="Enable teal.fm scrobbling" 1036 - checked={enableTealScrobbling} 1037 - onchange={(e) => saveTealScrobbling((e.target as HTMLInputElement).checked)} 1038 - /> 1039 - <span class="toggle-slider"></span> 1040 - <span class="toggle-label">{enableTealScrobbling ? 'enabled' : 'disabled'}</span> 1041 - </label> 1042 - </div> 1043 - {#if tealNeedsReauth} 1044 - <div class="reauth-notice"> 1045 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 1046 - <circle cx="12" cy="12" r="10" /> 1047 - <path d="M12 16v-4M12 8h.01" /> 1048 - </svg> 1049 - <span>please log out and back in to connect teal.fm</span> 1050 - </div> 1051 - {/if} 1052 - 1053 - <div class="data-control"> 1054 - <div class="control-info"> 1055 - <h3>timed comments</h3> 1056 - <p class="control-description"> 1057 - allow other users to leave comments on your tracks 1058 - </p> 1059 - </div> 1060 - <label class="toggle-switch"> 1061 - <input 1062 - type="checkbox" 1063 - aria-label="Allow timed comments on your tracks" 1064 - checked={allowComments} 1065 - onchange={(e) => saveAllowComments((e.target as HTMLInputElement).checked)} 1066 - /> 1067 - <span class="toggle-slider"></span> 1068 - <span class="toggle-label">{allowComments ? 'enabled' : 'disabled'}</span> 1069 - </label> 1070 - </div> 1071 - 1072 - <div class="data-control"> 1073 - <div class="control-info"> 1074 - <h3>sensitive artwork</h3> 1075 - <p class="control-description"> 1076 - show artwork that has been flagged as sensitive (nudity, etc.) 1077 - </p> 1078 - </div> 1079 - <label class="toggle-switch"> 1080 - <input 1081 - type="checkbox" 1082 - aria-label="Show sensitive artwork" 1083 - checked={showSensitiveArtwork} 1084 - onchange={(e) => saveShowSensitiveArtwork((e.target as HTMLInputElement).checked)} 1085 - /> 1086 - <span class="toggle-slider"></span> 1087 - <span class="toggle-label">{showSensitiveArtwork ? 'shown' : 'hidden'}</span> 1088 - </label> 869 + <div class="section-header"> 870 + <h2>your data</h2> 871 + <a href="/settings" class="settings-link">all settings →</a> 1089 872 </div> 1090 873 1091 874 {#if tracks.length > 0} ··· 1106 889 </div> 1107 890 {/if} 1108 891 1109 - <div class="data-control developer-section"> 1110 - <div class="control-info"> 1111 - <h3>developer tokens</h3> 1112 - <p class="control-description"> 1113 - create tokens for programmatic API access (uploads, track management). 1114 - use with the <a href="https://github.com/zzstoatzz/plyr-python-client" target="_blank" rel="noopener">python SDK</a> 1115 - </p> 1116 - </div> 1117 - 1118 - {#if loadingTokens} 1119 - <p class="loading-tokens">loading tokens...</p> 1120 - {:else if existingTokens.length > 0} 1121 - <div class="existing-tokens"> 1122 - <h4 class="tokens-header">active tokens</h4> 1123 - <div class="tokens-list"> 1124 - {#each existingTokens as token} 1125 - <div class="token-item"> 1126 - <div class="token-info"> 1127 - <span class="token-name">{token.name || `token_${token.session_id}`}</span> 1128 - <span class="token-meta"> 1129 - created {new Date(token.created_at).toLocaleDateString()} 1130 - {#if token.expires_at} 1131 - · expires {new Date(token.expires_at).toLocaleDateString()} 1132 - {:else} 1133 - · never expires 1134 - {/if} 1135 - </span> 1136 - </div> 1137 - <button 1138 - class="revoke-btn" 1139 - onclick={() => revokeToken(token.session_id, token.name)} 1140 - disabled={revokingToken === token.session_id} 1141 - title="revoke token" 1142 - > 1143 - {revokingToken === token.session_id ? '...' : 'revoke'} 1144 - </button> 1145 - </div> 1146 - {/each} 1147 - </div> 1148 - </div> 1149 - {/if} 1150 - 1151 - {#if developerToken} 1152 - <div class="token-display"> 1153 - <code class="token-value">{developerToken}</code> 1154 - <button 1155 - class="copy-btn" 1156 - onclick={copyToken} 1157 - title="copy token" 1158 - > 1159 - {tokenCopied ? '✓' : 'copy'} 1160 - </button> 1161 - <button 1162 - class="dismiss-btn" 1163 - onclick={() => developerToken = null} 1164 - title="dismiss" 1165 - > 1166 - 1167 - </button> 1168 - </div> 1169 - <p class="token-warning"> 1170 - save this token now - you won't be able to see it again 1171 - </p> 1172 - {:else} 1173 - <div class="token-form"> 1174 - <input 1175 - type="text" 1176 - class="token-name-input" 1177 - bind:value={tokenName} 1178 - placeholder="token name (optional)" 1179 - disabled={creatingToken} 1180 - /> 1181 - <label class="expires-label"> 1182 - <span>expires in</span> 1183 - <select bind:value={tokenExpiresDays} class="expires-select"> 1184 - <option value={30}>30 days</option> 1185 - <option value={90}>90 days</option> 1186 - <option value={180}>180 days</option> 1187 - <option value={365}>1 year</option> 1188 - <option value={0}>never</option> 1189 - </select> 1190 - </label> 1191 - <button 1192 - class="create-token-btn" 1193 - onclick={createDeveloperToken} 1194 - disabled={creatingToken} 1195 - > 1196 - {creatingToken ? 'creating...' : 'create token'} 1197 - </button> 1198 - </div> 1199 - {/if} 1200 - </div> 1201 - 1202 892 <div class="data-control danger-zone"> 1203 893 <div class="control-info"> 1204 894 <h3>delete account</h3> ··· 1222 912 1223 913 <div class="atproto-section"> 1224 914 <label class="atproto-option"> 1225 - <input 1226 - type="checkbox" 1227 - bind:checked={deleteAtprotoRecords} 1228 - /> 915 + <input type="checkbox" bind:checked={deleteAtprotoRecords} /> 1229 916 <span>also delete records from my ATProto repo</span> 1230 917 </label> 1231 918 <p class="atproto-note"> ··· 1233 920 </p> 1234 921 {#if deleteAtprotoRecords} 1235 922 <p class="atproto-warning"> 1236 - this removes track, like, and comment records from your PDS. other users' likes and comments that reference your tracks will become orphaned (pointing to records that no longer exist). 923 + this removes track, like, and comment records from your PDS. other users' likes and comments that reference your tracks will become orphaned. 1237 924 </p> 1238 925 {/if} 1239 926 </div> ··· 1251 938 1252 939 <div class="delete-actions"> 1253 940 <button 1254 - class="cancel-btn" 941 + class="cancel-delete-btn" 1255 942 onclick={() => { 1256 943 showDeleteConfirm = false; 1257 944 deleteConfirmText = ''; ··· 1347 1034 } 1348 1035 1349 1036 .view-profile-link:hover { 1037 + border-color: var(--accent); 1038 + color: var(--accent); 1039 + background: var(--bg-hover); 1040 + } 1041 + 1042 + .settings-link { 1043 + color: var(--text-secondary); 1044 + text-decoration: none; 1045 + font-size: 0.8rem; 1046 + padding: 0.35rem 0.6rem; 1047 + background: var(--bg-tertiary); 1048 + border-radius: 5px; 1049 + border: 1px solid var(--border-default); 1050 + transition: all 0.15s; 1051 + white-space: nowrap; 1052 + } 1053 + 1054 + .settings-link:hover { 1350 1055 border-color: var(--accent); 1351 1056 color: var(--accent); 1352 1057 background: var(--bg-hover); ··· 2146 1851 line-height: 1.4; 2147 1852 } 2148 1853 2149 - .control-description a { 2150 - color: var(--accent); 2151 - } 2152 - 2153 - .reauth-notice { 2154 - display: flex; 2155 - align-items: center; 2156 - gap: 0.5rem; 2157 - padding: 0.6rem 0.75rem; 2158 - background: color-mix(in srgb, var(--accent) 12%, transparent); 2159 - border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); 2160 - border-radius: 6px; 2161 - color: var(--accent); 2162 - font-size: 0.8rem; 2163 - margin-top: -0.5rem; 2164 - } 2165 - 2166 - .reauth-notice svg { 2167 - flex-shrink: 0; 2168 - } 2169 - 2170 1854 .export-btn { 2171 1855 padding: 0.6rem 1.25rem; 2172 1856 background: var(--accent); ··· 2195 1879 2196 1880 /* danger zone / account deletion */ 2197 1881 .danger-zone { 2198 - border-color: color-mix(in srgb, var(--error) 30%, var(--bg-tertiary)); 2199 - background: color-mix(in srgb, var(--error) 8%, var(--bg-tertiary)); 1882 + border-color: color-mix(in srgb, var(--error) 30%, transparent); 2200 1883 flex-direction: column; 2201 1884 align-items: stretch; 2202 1885 } ··· 2207 1890 2208 1891 .danger-zone .control-description a { 2209 1892 color: var(--text-tertiary); 2210 - text-decoration: underline; 2211 1893 } 2212 1894 2213 1895 .danger-zone .control-description a:hover { ··· 2220 1902 color: var(--error); 2221 1903 border: 1px solid var(--error); 2222 1904 border-radius: 6px; 1905 + font-family: inherit; 2223 1906 font-size: 0.9rem; 2224 1907 font-weight: 600; 2225 1908 cursor: pointer; ··· 2228 1911 } 2229 1912 2230 1913 .delete-account-btn:hover { 2231 - background: var(--error); 2232 - color: var(--text-primary); 1914 + background: color-mix(in srgb, var(--error) 10%, transparent); 2233 1915 } 2234 1916 2235 1917 .delete-confirm-panel { 2236 1918 margin-top: 1rem; 2237 - padding-top: 1rem; 2238 - border-top: 1px solid color-mix(in srgb, var(--error) 25%, var(--bg-tertiary)); 1919 + padding: 1rem; 1920 + background: var(--bg-primary); 1921 + border: 1px solid var(--border-default); 1922 + border-radius: 8px; 2239 1923 } 2240 1924 2241 1925 .delete-warning { 2242 - color: color-mix(in srgb, var(--error) 80%, white); 1926 + margin: 0 0 1rem; 1927 + color: var(--error); 2243 1928 font-size: 0.9rem; 2244 - margin: 0 0 1rem 0; 2245 1929 line-height: 1.5; 2246 1930 } 2247 1931 2248 1932 .atproto-section { 2249 1933 margin-bottom: 1rem; 1934 + padding: 0.75rem; 1935 + background: var(--bg-tertiary); 1936 + border-radius: 6px; 2250 1937 } 2251 1938 2252 1939 .atproto-option { ··· 2254 1941 align-items: center; 2255 1942 gap: 0.5rem; 2256 1943 font-size: 0.9rem; 2257 - color: var(--text-secondary); 1944 + color: var(--text-primary); 2258 1945 cursor: pointer; 2259 1946 } 2260 1947 2261 1948 .atproto-option input { 2262 1949 width: 16px; 2263 1950 height: 16px; 2264 - cursor: pointer; 1951 + accent-color: var(--accent); 2265 1952 } 2266 1953 2267 1954 .atproto-note { 2268 - margin: 0.5rem 0 0 0; 2269 - font-size: 0.85rem; 2270 - color: var(--text-muted); 1955 + margin: 0.5rem 0 0; 1956 + font-size: 0.8rem; 1957 + color: var(--text-tertiary); 2271 1958 } 2272 1959 2273 1960 .atproto-note a { 2274 - color: var(--text-tertiary); 2275 - text-decoration: underline; 1961 + color: var(--accent); 1962 + text-decoration: none; 2276 1963 } 2277 1964 2278 1965 .atproto-note a:hover { 2279 - color: var(--text-secondary); 1966 + text-decoration: underline; 2280 1967 } 2281 1968 2282 1969 .atproto-warning { 2283 - margin: 0.75rem 0 0 0; 2284 - padding: 0.75rem; 2285 - background: color-mix(in srgb, var(--error) 10%, transparent); 2286 - border-left: 2px solid var(--error); 2287 - font-size: 0.85rem; 2288 - color: color-mix(in srgb, var(--error) 70%, var(--text-secondary)); 2289 - line-height: 1.5; 1970 + margin: 0.5rem 0 0; 1971 + padding: 0.5rem; 1972 + background: color-mix(in srgb, var(--warning) 10%, transparent); 1973 + border-radius: 4px; 1974 + font-size: 0.8rem; 1975 + color: var(--warning); 2290 1976 } 2291 1977 2292 1978 .confirm-prompt { 1979 + margin: 0 0 0.5rem; 2293 1980 font-size: 0.9rem; 2294 - color: var(--text-tertiary); 2295 - margin: 0 0 0.5rem 0; 2296 - } 2297 - 2298 - .confirm-prompt strong { 2299 - color: var(--text-primary); 2300 - font-family: monospace; 1981 + color: var(--text-secondary); 2301 1982 } 2302 1983 2303 1984 .confirm-input { 2304 1985 width: 100%; 2305 - padding: 0.75rem; 2306 - background: color-mix(in srgb, var(--error) 5%, var(--bg-primary)); 2307 - border: 1px solid color-mix(in srgb, var(--error) 25%, var(--bg-tertiary)); 1986 + padding: 0.6rem 0.75rem; 1987 + background: var(--bg-tertiary); 1988 + border: 1px solid var(--border-default); 2308 1989 border-radius: 6px; 2309 1990 color: var(--text-primary); 2310 1991 font-size: 0.9rem; 2311 - font-family: monospace; 1992 + font-family: inherit; 2312 1993 margin-bottom: 1rem; 2313 1994 } 2314 1995 2315 1996 .confirm-input:focus { 2316 1997 outline: none; 2317 - border-color: var(--error); 2318 - } 2319 - 2320 - .confirm-input::placeholder { 2321 - color: var(--text-muted); 1998 + border-color: var(--accent); 2322 1999 } 2323 2000 2324 2001 .delete-actions { 2325 2002 display: flex; 2326 2003 gap: 0.75rem; 2327 - justify-content: flex-end; 2328 2004 } 2329 2005 2330 - .cancel-btn { 2331 - padding: 0.6rem 1.25rem; 2006 + .cancel-delete-btn { 2007 + flex: 1; 2008 + padding: 0.6rem; 2332 2009 background: transparent; 2333 - color: var(--text-tertiary); 2334 - border: 1px solid var(--border-emphasis); 2010 + border: 1px solid var(--border-default); 2335 2011 border-radius: 6px; 2012 + color: var(--text-secondary); 2013 + font-family: inherit; 2336 2014 font-size: 0.9rem; 2337 2015 cursor: pointer; 2338 - transition: all 0.2s; 2016 + transition: all 0.15s; 2339 2017 } 2340 2018 2341 - .cancel-btn:hover:not(:disabled) { 2342 - border-color: var(--text-muted); 2343 - color: var(--text-secondary); 2019 + .cancel-delete-btn:hover:not(:disabled) { 2020 + border-color: var(--text-secondary); 2344 2021 } 2345 2022 2346 - .cancel-btn:disabled { 2023 + .cancel-delete-btn:disabled { 2347 2024 opacity: 0.5; 2348 2025 cursor: not-allowed; 2349 2026 } 2350 2027 2351 2028 .confirm-delete-btn { 2352 - padding: 0.6rem 1.25rem; 2029 + flex: 1; 2030 + padding: 0.6rem; 2353 2031 background: var(--error); 2354 - color: var(--text-primary); 2355 2032 border: none; 2356 2033 border-radius: 6px; 2357 - font-size: 0.9rem; 2358 - font-weight: 600; 2359 - cursor: pointer; 2360 - transition: all 0.2s; 2361 - } 2362 - 2363 - .confirm-delete-btn:hover:not(:disabled) { 2364 - background: color-mix(in srgb, var(--error) 80%, black); 2365 - } 2366 - 2367 - .confirm-delete-btn:disabled { 2368 - opacity: 0.5; 2369 - cursor: not-allowed; 2370 - } 2371 - 2372 - .toggle-switch { 2373 - display: flex; 2374 - align-items: center; 2375 - gap: 0.75rem; 2376 - cursor: pointer; 2377 - flex-shrink: 0; 2378 - } 2379 - 2380 - .toggle-switch input { 2381 - display: none; 2382 - } 2383 - 2384 - .toggle-slider { 2385 - width: 44px; 2386 - height: 24px; 2387 - background: var(--border-default); 2388 - border-radius: 12px; 2389 - position: relative; 2390 - transition: background 0.2s; 2391 - } 2392 - 2393 - .toggle-slider::after { 2394 - content: ''; 2395 - position: absolute; 2396 - top: 2px; 2397 - left: 2px; 2398 - width: 20px; 2399 - height: 20px; 2400 - background: var(--text-tertiary); 2401 - border-radius: 50%; 2402 - transition: all 0.2s; 2403 - } 2404 - 2405 - .toggle-switch input:checked + .toggle-slider { 2406 - background: var(--accent); 2407 - } 2408 - 2409 - .toggle-switch input:checked + .toggle-slider::after { 2410 - left: 22px; 2411 - background: var(--text-primary); 2412 - } 2413 - 2414 - .toggle-label { 2415 - font-size: 0.85rem; 2416 - color: var(--text-tertiary); 2417 - min-width: 60px; 2418 - } 2419 - 2420 - /* developer token section */ 2421 - .developer-section { 2422 - flex-direction: column; 2423 - align-items: stretch; 2424 - gap: 1rem; 2425 - } 2426 - 2427 - .developer-section .control-info h3 { 2428 - color: var(--accent); 2429 - } 2430 - 2431 - .token-form { 2432 - display: flex; 2433 - align-items: center; 2434 - gap: 0.75rem; 2435 - flex-wrap: wrap; 2436 - width: 100%; 2437 - } 2438 - 2439 - .token-form .create-token-btn { 2440 - margin-left: auto; 2441 - } 2442 - 2443 - .expires-label { 2444 - display: flex; 2445 - align-items: center; 2446 - gap: 0.5rem; 2447 - font-size: 0.9rem; 2448 - color: var(--text-tertiary); 2449 - } 2450 - 2451 - .expires-select { 2452 - padding: 0.5rem 0.75rem; 2453 - background: var(--bg-primary); 2454 - border: 1px solid var(--border-default); 2455 - border-radius: 4px; 2456 - color: var(--text-primary); 2457 - font-size: 0.9rem; 2458 - font-family: inherit; 2459 - cursor: pointer; 2460 - } 2461 - 2462 - .expires-select:focus { 2463 - outline: none; 2464 - border-color: var(--accent); 2465 - } 2466 - 2467 - .create-token-btn { 2468 - padding: 0.6rem 1.25rem; 2469 - background: var(--accent); 2470 2034 color: white; 2471 - border: none; 2472 - border-radius: 6px; 2035 + font-family: inherit; 2473 2036 font-size: 0.9rem; 2474 2037 font-weight: 600; 2475 2038 cursor: pointer; 2476 - transition: all 0.2s; 2477 - white-space: nowrap; 2478 - width: auto; 2039 + transition: all 0.15s; 2479 2040 } 2480 2041 2481 - .create-token-btn:hover:not(:disabled) { 2042 + .confirm-delete-btn:hover:not(:disabled) { 2482 2043 filter: brightness(1.1); 2483 - transform: translateY(-1px); 2484 2044 } 2485 2045 2486 - .create-token-btn:disabled { 2046 + .confirm-delete-btn:disabled { 2487 2047 opacity: 0.5; 2488 2048 cursor: not-allowed; 2489 - transform: none; 2490 - } 2491 - 2492 - .token-display { 2493 - display: flex; 2494 - align-items: center; 2495 - gap: 0.5rem; 2496 - background: var(--bg-primary); 2497 - border: 1px solid var(--border-default); 2498 - border-radius: 6px; 2499 - padding: 0.75rem; 2500 - overflow: hidden; 2501 - } 2502 - 2503 - .token-value { 2504 - flex: 1; 2505 - font-family: monospace; 2506 - font-size: 0.85rem; 2507 - color: var(--success); 2508 - word-break: break-all; 2509 - user-select: all; 2510 - } 2511 - 2512 - .copy-btn, 2513 - .dismiss-btn { 2514 - padding: 0.4rem 0.75rem; 2515 - background: var(--border-subtle); 2516 - border: 1px solid var(--border-emphasis); 2517 - border-radius: 4px; 2518 - color: var(--text-tertiary); 2519 - font-size: 0.85rem; 2520 - cursor: pointer; 2521 - transition: all 0.2s; 2522 - width: auto; 2523 - } 2524 - 2525 - .copy-btn:hover { 2526 - background: var(--border-emphasis); 2527 - border-color: var(--accent); 2528 - color: var(--accent); 2529 - } 2530 - 2531 - .dismiss-btn:hover { 2532 - background: var(--border-emphasis); 2533 - border-color: var(--text-muted); 2534 - color: var(--text-secondary); 2535 - } 2536 - 2537 - .token-warning { 2538 - font-size: 0.85rem; 2539 - color: var(--warning); 2540 - margin: 0; 2541 - } 2542 - 2543 - /* existing tokens list */ 2544 - .existing-tokens { 2545 - width: 100%; 2546 - margin-bottom: 1rem; 2547 - } 2548 - 2549 - .tokens-header { 2550 - font-size: 0.9rem; 2551 - font-weight: 600; 2552 - color: var(--text-tertiary); 2553 - margin: 0 0 0.75rem 0; 2554 - } 2555 - 2556 - .tokens-list { 2557 - display: flex; 2558 - flex-direction: column; 2559 - gap: 0.5rem; 2560 - } 2561 - 2562 - .token-item { 2563 - display: flex; 2564 - justify-content: space-between; 2565 - align-items: center; 2566 - gap: 1rem; 2567 - padding: 0.75rem; 2568 - background: var(--bg-primary); 2569 - border: 1px solid var(--border-subtle); 2570 - border-radius: 6px; 2571 - } 2572 - 2573 - .token-info { 2574 - display: flex; 2575 - flex-direction: column; 2576 - gap: 0.25rem; 2577 - min-width: 0; 2578 - flex: 1; 2579 - } 2580 - 2581 - .token-name { 2582 - font-family: monospace; 2583 - font-size: 0.9rem; 2584 - color: var(--text-primary); 2585 - white-space: nowrap; 2586 - overflow: hidden; 2587 - text-overflow: ellipsis; 2588 - } 2589 - 2590 - .token-meta { 2591 - font-size: 0.8rem; 2592 - color: var(--text-muted); 2593 - } 2594 - 2595 - .revoke-btn { 2596 - padding: 0.4rem 0.75rem; 2597 - background: transparent; 2598 - border: 1px solid color-mix(in srgb, var(--error) 30%, var(--bg-tertiary)); 2599 - border-radius: 4px; 2600 - color: var(--error); 2601 - font-size: 0.85rem; 2602 - cursor: pointer; 2603 - transition: all 0.2s; 2604 - width: auto; 2605 - flex-shrink: 0; 2606 - } 2607 - 2608 - .revoke-btn:hover:not(:disabled) { 2609 - background: color-mix(in srgb, var(--error) 10%, transparent); 2610 - border-color: var(--error); 2611 - } 2612 - 2613 - .revoke-btn:disabled { 2614 - opacity: 0.5; 2615 - cursor: not-allowed; 2616 - } 2617 - 2618 - .token-name-input { 2619 - padding: 0.5rem 0.75rem; 2620 - background: var(--bg-primary); 2621 - border: 1px solid var(--border-default); 2622 - border-radius: 4px; 2623 - color: var(--text-primary); 2624 - font-size: 0.9rem; 2625 - font-family: inherit; 2626 - min-width: 150px; 2627 - } 2628 - 2629 - .token-name-input:focus { 2630 - outline: none; 2631 - border-color: var(--accent); 2632 - } 2633 - 2634 - .token-name-input:disabled { 2635 - opacity: 0.5; 2636 - cursor: not-allowed; 2637 - } 2638 - 2639 - .loading-tokens { 2640 - font-size: 0.9rem; 2641 - color: var(--text-muted); 2642 - margin: 0; 2643 2049 } 2644 2050 2645 2051 /* mobile responsive */ ··· 2827 2233 } 2828 2234 2829 2235 .export-btn { 2830 - padding: 0.5rem 0.85rem; 2831 - font-size: 0.8rem; 2832 - } 2833 - 2834 - .toggle-switch { 2835 - gap: 0.5rem; 2836 - } 2837 - 2838 - .toggle-slider { 2839 - width: 40px; 2840 - height: 22px; 2841 - } 2842 - 2843 - .toggle-slider::after { 2844 - width: 18px; 2845 - height: 18px; 2846 - } 2847 - 2848 - .toggle-switch input:checked + .toggle-slider::after { 2849 - left: 20px; 2850 - } 2851 - 2852 - .toggle-label { 2853 - font-size: 0.75rem; 2854 - min-width: auto; 2855 - } 2856 - 2857 - /* developer section mobile */ 2858 - .token-form { 2859 - gap: 0.75rem; 2860 - } 2861 - 2862 - .token-name-input { 2863 - min-width: 100px; 2864 - font-size: 0.85rem; 2865 - padding: 0.45rem 0.6rem; 2866 - } 2867 - 2868 - .expires-label { 2869 - font-size: 0.8rem; 2870 - } 2871 - 2872 - .expires-select { 2873 - font-size: 0.8rem; 2874 - padding: 0.4rem 0.6rem; 2875 - } 2876 - 2877 - .create-token-btn { 2878 - padding: 0.5rem 0.85rem; 2879 - font-size: 0.8rem; 2880 - } 2881 - 2882 - .token-display { 2883 - gap: 0.5rem; 2884 - } 2885 - 2886 - .token-value { 2887 - font-size: 0.75rem; 2888 - padding: 0.5rem; 2889 - } 2890 - 2891 - .copy-btn, 2892 - .dismiss-btn { 2893 - padding: 0.35rem 0.6rem; 2894 - font-size: 0.75rem; 2895 - } 2896 - 2897 - .token-warning { 2898 - font-size: 0.75rem; 2899 - } 2900 - 2901 - .tokens-header { 2902 - font-size: 0.8rem; 2903 - } 2904 - 2905 - .token-item { 2906 - padding: 0.6rem; 2907 - gap: 0.5rem; 2908 - } 2909 - 2910 - .token-name { 2911 - font-size: 0.8rem; 2912 - } 2913 - 2914 - .token-meta { 2915 - font-size: 0.7rem; 2916 - } 2917 - 2918 - .revoke-btn { 2919 - padding: 0.35rem 0.6rem; 2920 - font-size: 0.75rem; 2921 - } 2922 - 2923 - /* danger zone mobile */ 2924 - .delete-account-btn { 2925 - padding: 0.5rem 0.85rem; 2926 - font-size: 0.8rem; 2927 - } 2928 - 2929 - .delete-warning { 2930 - font-size: 0.8rem; 2931 - } 2932 - 2933 - .atproto-option { 2934 - font-size: 0.8rem; 2935 - } 2936 - 2937 - .atproto-note, 2938 - .atproto-warning { 2939 - font-size: 0.75rem; 2940 - } 2941 - 2942 - .confirm-prompt { 2943 - font-size: 0.8rem; 2944 - } 2945 - 2946 - .confirm-input { 2947 - font-size: 0.85rem; 2948 - padding: 0.6rem; 2949 - } 2950 - 2951 - .delete-actions button { 2952 2236 padding: 0.5rem 0.85rem; 2953 2237 font-size: 0.8rem; 2954 2238 }
+984
frontend/src/routes/settings/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import Header from '$lib/components/Header.svelte'; 4 + import WaveLoading from '$lib/components/WaveLoading.svelte'; 5 + import type { TokenInfo } from '$lib/types'; 6 + import { API_URL } from '$lib/config'; 7 + import { toast } from '$lib/toast.svelte'; 8 + import { auth } from '$lib/auth.svelte'; 9 + import { preferences, type Theme } from '$lib/preferences.svelte'; 10 + import { queue } from '$lib/queue.svelte'; 11 + 12 + let loading = $state(true); 13 + 14 + // derive from preferences store 15 + let allowComments = $derived(preferences.allowComments); 16 + let enableTealScrobbling = $derived(preferences.enableTealScrobbling); 17 + let tealNeedsReauth = $derived(preferences.tealNeedsReauth); 18 + let showSensitiveArtwork = $derived(preferences.showSensitiveArtwork); 19 + let currentTheme = $derived(preferences.theme); 20 + let currentColor = $derived(preferences.accentColor ?? '#6a9fff'); 21 + let autoAdvance = $derived(preferences.autoAdvance); 22 + 23 + // developer token state 24 + let creatingToken = $state(false); 25 + let developerToken = $state<string | null>(null); 26 + let tokenExpiresDays = $state(90); 27 + let tokenName = $state(''); 28 + let tokenCopied = $state(false); 29 + let existingTokens = $state<TokenInfo[]>([]); 30 + let loadingTokens = $state(false); 31 + let revokingToken = $state<string | null>(null); 32 + 33 + const presetColors = [ 34 + { name: 'blue', value: '#6a9fff' }, 35 + { name: 'purple', value: '#a78bfa' }, 36 + { name: 'pink', value: '#f472b6' }, 37 + { name: 'green', value: '#4ade80' }, 38 + { name: 'orange', value: '#fb923c' }, 39 + { name: 'red', value: '#ef4444' } 40 + ]; 41 + 42 + const themes: { value: Theme; label: string; icon: string }[] = [ 43 + { value: 'dark', label: 'dark', icon: 'moon' }, 44 + { value: 'light', label: 'light', icon: 'sun' }, 45 + { value: 'system', label: 'auto', icon: 'auto' } 46 + ]; 47 + 48 + onMount(async () => { 49 + // check if exchange_token is in URL (from OAuth callback for dev token) 50 + const params = new URLSearchParams(window.location.search); 51 + const exchangeToken = params.get('exchange_token'); 52 + const isDevToken = params.get('dev_token') === 'true'; 53 + 54 + if (exchangeToken && isDevToken) { 55 + try { 56 + const exchangeResponse = await fetch(`${API_URL}/auth/exchange`, { 57 + method: 'POST', 58 + headers: { 'Content-Type': 'application/json' }, 59 + credentials: 'include', 60 + body: JSON.stringify({ exchange_token: exchangeToken }) 61 + }); 62 + 63 + if (exchangeResponse.ok) { 64 + const data = await exchangeResponse.json(); 65 + developerToken = data.session_id; 66 + toast.success('developer token created - save it now!'); 67 + } 68 + } catch (_e) { 69 + console.error('failed to exchange token:', _e); 70 + } 71 + 72 + // remove exchange_token from URL 73 + window.history.replaceState({}, '', '/settings'); 74 + } 75 + 76 + // wait for auth to finish loading 77 + while (auth.loading) { 78 + await new Promise(resolve => setTimeout(resolve, 50)); 79 + } 80 + 81 + if (!auth.isAuthenticated) { 82 + window.location.href = '/login'; 83 + return; 84 + } 85 + 86 + await loadDeveloperTokens(); 87 + loading = false; 88 + }); 89 + 90 + async function loadDeveloperTokens() { 91 + loadingTokens = true; 92 + try { 93 + const response = await fetch(`${API_URL}/auth/developer-tokens`, { 94 + credentials: 'include' 95 + }); 96 + if (response.ok) { 97 + const data = await response.json(); 98 + existingTokens = data.tokens; 99 + } 100 + } catch (_e) { 101 + console.error('failed to load developer tokens:', _e); 102 + } finally { 103 + loadingTokens = false; 104 + } 105 + } 106 + 107 + // appearance 108 + function applyColorLocally(color: string) { 109 + document.documentElement.style.setProperty('--accent', color); 110 + const r = parseInt(color.slice(1, 3), 16); 111 + const g = parseInt(color.slice(3, 5), 16); 112 + const b = parseInt(color.slice(5, 7), 16); 113 + const hover = `rgb(${Math.min(255, r + 30)}, ${Math.min(255, g + 30)}, ${Math.min(255, b + 30)})`; 114 + document.documentElement.style.setProperty('--accent-hover', hover); 115 + } 116 + 117 + async function applyColor(color: string) { 118 + applyColorLocally(color); 119 + localStorage.setItem('accentColor', color); 120 + await preferences.update({ accent_color: color }); 121 + } 122 + 123 + function handleColorInput(event: Event) { 124 + const input = event.target as HTMLInputElement; 125 + applyColor(input.value); 126 + } 127 + 128 + function selectPreset(color: string) { 129 + applyColor(color); 130 + } 131 + 132 + function selectTheme(theme: Theme) { 133 + preferences.setTheme(theme); 134 + } 135 + 136 + async function handleAutoAdvanceToggle(event: Event) { 137 + const input = event.target as HTMLInputElement; 138 + const value = input.checked; 139 + queue.setAutoAdvance(value); 140 + localStorage.setItem('autoAdvance', value ? '1' : '0'); 141 + await preferences.update({ auto_advance: value }); 142 + } 143 + 144 + // preferences 145 + async function saveAllowComments(enabled: boolean) { 146 + try { 147 + await preferences.update({ allow_comments: enabled }); 148 + toast.success(enabled ? 'comments enabled on your tracks' : 'comments disabled'); 149 + } catch (_e) { 150 + console.error('failed to save preference:', _e); 151 + toast.error('failed to update preference'); 152 + } 153 + } 154 + 155 + async function saveTealScrobbling(enabled: boolean) { 156 + try { 157 + await preferences.update({ enable_teal_scrobbling: enabled }); 158 + await preferences.fetch(); 159 + toast.success(enabled ? 'teal.fm scrobbling enabled' : 'teal.fm scrobbling disabled'); 160 + } catch (_e) { 161 + console.error('failed to save preference:', _e); 162 + toast.error('failed to update preference'); 163 + } 164 + } 165 + 166 + async function saveShowSensitiveArtwork(enabled: boolean) { 167 + try { 168 + await preferences.update({ show_sensitive_artwork: enabled }); 169 + toast.success(enabled ? 'sensitive artwork shown' : 'sensitive artwork hidden'); 170 + } catch (_e) { 171 + console.error('failed to save preference:', _e); 172 + toast.error('failed to update preference'); 173 + } 174 + } 175 + 176 + // developer tokens 177 + async function createDeveloperToken() { 178 + creatingToken = true; 179 + developerToken = null; 180 + tokenCopied = false; 181 + 182 + try { 183 + const response = await fetch(`${API_URL}/auth/developer-token/start`, { 184 + method: 'POST', 185 + headers: { 'Content-Type': 'application/json' }, 186 + credentials: 'include', 187 + body: JSON.stringify({ 188 + name: tokenName || null, 189 + expires_in_days: tokenExpiresDays 190 + }) 191 + }); 192 + 193 + if (!response.ok) { 194 + const error = await response.json(); 195 + toast.error(error.detail || 'failed to start token creation'); 196 + creatingToken = false; 197 + return; 198 + } 199 + 200 + const result = await response.json(); 201 + tokenName = ''; 202 + window.location.href = result.auth_url; 203 + } catch (e) { 204 + console.error('failed to create token:', e); 205 + toast.error('failed to create token'); 206 + creatingToken = false; 207 + } 208 + } 209 + 210 + async function revokeToken(tokenId: string, name: string | null) { 211 + if (!confirm(`revoke token "${name || tokenId}"?`)) return; 212 + 213 + revokingToken = tokenId; 214 + try { 215 + const response = await fetch(`${API_URL}/auth/developer-tokens/${tokenId}`, { 216 + method: 'DELETE', 217 + credentials: 'include' 218 + }); 219 + 220 + if (!response.ok) { 221 + const error = await response.json(); 222 + toast.error(error.detail || 'failed to revoke token'); 223 + return; 224 + } 225 + 226 + toast.success('token revoked'); 227 + await loadDeveloperTokens(); 228 + } catch (e) { 229 + console.error('failed to revoke token:', e); 230 + toast.error('failed to revoke token'); 231 + } finally { 232 + revokingToken = null; 233 + } 234 + } 235 + 236 + async function copyToken() { 237 + if (!developerToken) return; 238 + try { 239 + await navigator.clipboard.writeText(developerToken); 240 + tokenCopied = true; 241 + toast.success('token copied to clipboard'); 242 + setTimeout(() => { tokenCopied = false; }, 2000); 243 + } catch (e) { 244 + console.error('failed to copy:', e); 245 + toast.error('failed to copy token'); 246 + } 247 + } 248 + 249 + async function logout() { 250 + await auth.logout(); 251 + window.location.href = '/'; 252 + } 253 + </script> 254 + 255 + {#if loading} 256 + <div class="loading"> 257 + <WaveLoading size="lg" message="loading..." /> 258 + </div> 259 + {:else if auth.user} 260 + <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={logout} /> 261 + <main> 262 + <div class="page-header"> 263 + <h1>settings</h1> 264 + <a href="/portal" class="portal-link">manage your content →</a> 265 + </div> 266 + 267 + <section class="settings-section"> 268 + <h2>appearance</h2> 269 + <div class="settings-card"> 270 + <div class="setting-row"> 271 + <div class="setting-info"> 272 + <h3>theme</h3> 273 + <p>choose your preferred color scheme</p> 274 + </div> 275 + <div class="theme-buttons"> 276 + {#each themes as theme} 277 + <button 278 + class="theme-btn" 279 + class:active={currentTheme === theme.value} 280 + onclick={() => selectTheme(theme.value)} 281 + title={theme.label} 282 + > 283 + {#if theme.icon === 'moon'} 284 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 285 + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> 286 + </svg> 287 + {:else if theme.icon === 'sun'} 288 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 289 + <circle cx="12" cy="12" r="5" /> 290 + <path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72 1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /> 291 + </svg> 292 + {:else} 293 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 294 + <circle cx="12" cy="12" r="9" /> 295 + <path d="M12 3v18" /> 296 + <path d="M12 3a9 9 0 0 1 0 18" fill="currentColor" opacity="0.3" /> 297 + </svg> 298 + {/if} 299 + <span>{theme.label}</span> 300 + </button> 301 + {/each} 302 + </div> 303 + </div> 304 + 305 + <div class="setting-row"> 306 + <div class="setting-info"> 307 + <h3>accent color</h3> 308 + <p>customize the accent color throughout the app</p> 309 + </div> 310 + <div class="color-controls"> 311 + <input type="color" value={currentColor} oninput={handleColorInput} class="color-input" /> 312 + <div class="preset-grid"> 313 + {#each presetColors as preset} 314 + <button 315 + class="preset-btn" 316 + class:active={currentColor.toLowerCase() === preset.value.toLowerCase()} 317 + style="background: {preset.value}" 318 + onclick={() => selectPreset(preset.value)} 319 + title={preset.name} 320 + ></button> 321 + {/each} 322 + </div> 323 + </div> 324 + </div> 325 + </div> 326 + </section> 327 + 328 + <section class="settings-section"> 329 + <h2>playback</h2> 330 + <div class="settings-card"> 331 + <div class="setting-row"> 332 + <div class="setting-info"> 333 + <h3>auto-play next</h3> 334 + <p>when a track ends, automatically play the next item in your queue</p> 335 + </div> 336 + <label class="toggle-switch"> 337 + <input 338 + type="checkbox" 339 + checked={autoAdvance} 340 + onchange={handleAutoAdvanceToggle} 341 + /> 342 + <span class="toggle-slider"></span> 343 + </label> 344 + </div> 345 + </div> 346 + </section> 347 + 348 + <section class="settings-section"> 349 + <h2>privacy & display</h2> 350 + <div class="settings-card"> 351 + <div class="setting-row"> 352 + <div class="setting-info"> 353 + <h3>sensitive artwork</h3> 354 + <p>show artwork that has been flagged as sensitive (nudity, etc.)</p> 355 + </div> 356 + <label class="toggle-switch"> 357 + <input 358 + type="checkbox" 359 + checked={showSensitiveArtwork} 360 + onchange={(e) => saveShowSensitiveArtwork((e.target as HTMLInputElement).checked)} 361 + /> 362 + <span class="toggle-slider"></span> 363 + </label> 364 + </div> 365 + 366 + <div class="setting-row"> 367 + <div class="setting-info"> 368 + <h3>timed comments</h3> 369 + <p>allow other users to leave comments on your tracks</p> 370 + </div> 371 + <label class="toggle-switch"> 372 + <input 373 + type="checkbox" 374 + checked={allowComments} 375 + onchange={(e) => saveAllowComments((e.target as HTMLInputElement).checked)} 376 + /> 377 + <span class="toggle-slider"></span> 378 + </label> 379 + </div> 380 + </div> 381 + </section> 382 + 383 + <section class="settings-section"> 384 + <h2>integrations</h2> 385 + <div class="settings-card"> 386 + <div class="setting-row"> 387 + <div class="setting-info"> 388 + <h3>teal.fm scrobbling</h3> 389 + <p> 390 + track your listens as <a href="https://pdsls.dev/at://{auth.user?.did}/fm.teal.alpha.feed.play" target="_blank" rel="noopener">fm.teal.alpha.feed.play</a> records 391 + </p> 392 + </div> 393 + <label class="toggle-switch"> 394 + <input 395 + type="checkbox" 396 + checked={enableTealScrobbling} 397 + onchange={(e) => saveTealScrobbling((e.target as HTMLInputElement).checked)} 398 + /> 399 + <span class="toggle-slider"></span> 400 + </label> 401 + </div> 402 + {#if tealNeedsReauth} 403 + <div class="reauth-notice"> 404 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 405 + <circle cx="12" cy="12" r="10" /> 406 + <path d="M12 16v-4M12 8h.01" /> 407 + </svg> 408 + <span>please log out and back in to connect teal.fm</span> 409 + </div> 410 + {/if} 411 + </div> 412 + </section> 413 + 414 + <section class="settings-section"> 415 + <h2>developer</h2> 416 + <div class="settings-card"> 417 + <div class="setting-info full-width"> 418 + <h3>developer tokens</h3> 419 + <p> 420 + create tokens for programmatic API access (uploads, track management). 421 + use with the <a href="https://github.com/zzstoatzz/plyr-python-client" target="_blank" rel="noopener">python SDK</a> 422 + </p> 423 + </div> 424 + 425 + {#if loadingTokens} 426 + <p class="loading-tokens">loading tokens...</p> 427 + {:else if existingTokens.length > 0} 428 + <div class="existing-tokens"> 429 + <h4 class="tokens-header">active tokens</h4> 430 + <div class="tokens-list"> 431 + {#each existingTokens as token} 432 + <div class="token-item"> 433 + <div class="token-info"> 434 + <span class="token-name">{token.name || `token_${token.session_id}`}</span> 435 + <span class="token-meta"> 436 + created {new Date(token.created_at).toLocaleDateString()} 437 + {#if token.expires_at} 438 + · expires {new Date(token.expires_at).toLocaleDateString()} 439 + {:else} 440 + · never expires 441 + {/if} 442 + </span> 443 + </div> 444 + <button 445 + class="revoke-btn" 446 + onclick={() => revokeToken(token.session_id, token.name)} 447 + disabled={revokingToken === token.session_id} 448 + title="revoke token" 449 + > 450 + {revokingToken === token.session_id ? '...' : 'revoke'} 451 + </button> 452 + </div> 453 + {/each} 454 + </div> 455 + </div> 456 + {/if} 457 + 458 + {#if developerToken} 459 + <div class="token-display"> 460 + <code class="token-value">{developerToken}</code> 461 + <button class="copy-btn" onclick={copyToken} title="copy token"> 462 + {tokenCopied ? '✓' : 'copy'} 463 + </button> 464 + <button class="dismiss-btn" onclick={() => developerToken = null} title="dismiss"> 465 + 466 + </button> 467 + </div> 468 + <p class="token-warning">save this token now - you won't be able to see it again</p> 469 + {:else} 470 + <div class="token-form"> 471 + <input 472 + type="text" 473 + class="token-name-input" 474 + bind:value={tokenName} 475 + placeholder="token name (optional)" 476 + disabled={creatingToken} 477 + /> 478 + <label class="expires-label"> 479 + <span>expires in</span> 480 + <select bind:value={tokenExpiresDays} class="expires-select"> 481 + <option value={30}>30 days</option> 482 + <option value={90}>90 days</option> 483 + <option value={180}>180 days</option> 484 + <option value={365}>1 year</option> 485 + <option value={0}>never</option> 486 + </select> 487 + </label> 488 + <button 489 + class="create-token-btn" 490 + onclick={createDeveloperToken} 491 + disabled={creatingToken} 492 + > 493 + {creatingToken ? 'creating...' : 'create token'} 494 + </button> 495 + </div> 496 + {/if} 497 + </div> 498 + </section> 499 + </main> 500 + {/if} 501 + 502 + <style> 503 + .loading { 504 + display: flex; 505 + flex-direction: column; 506 + align-items: center; 507 + justify-content: center; 508 + min-height: 100vh; 509 + color: var(--text-tertiary); 510 + gap: 1rem; 511 + } 512 + 513 + main { 514 + max-width: 700px; 515 + margin: 0 auto; 516 + padding: 0 1rem calc(var(--player-height, 120px) + 2rem + env(safe-area-inset-bottom, 0px)); 517 + } 518 + 519 + .page-header { 520 + display: flex; 521 + justify-content: space-between; 522 + align-items: center; 523 + margin-bottom: 2rem; 524 + gap: 1rem; 525 + flex-wrap: wrap; 526 + } 527 + 528 + .page-header h1 { 529 + font-size: var(--text-page-heading); 530 + margin: 0; 531 + } 532 + 533 + .portal-link { 534 + color: var(--text-secondary); 535 + text-decoration: none; 536 + font-size: 0.85rem; 537 + padding: 0.4rem 0.75rem; 538 + background: var(--bg-tertiary); 539 + border-radius: 6px; 540 + border: 1px solid var(--border-default); 541 + transition: all 0.15s; 542 + } 543 + 544 + .portal-link:hover { 545 + border-color: var(--accent); 546 + color: var(--accent); 547 + } 548 + 549 + .settings-section { 550 + margin-bottom: 2rem; 551 + } 552 + 553 + .settings-section h2 { 554 + font-size: 0.8rem; 555 + text-transform: uppercase; 556 + letter-spacing: 0.08em; 557 + color: var(--text-tertiary); 558 + margin-bottom: 0.75rem; 559 + } 560 + 561 + .settings-card { 562 + background: var(--bg-tertiary); 563 + border: 1px solid var(--border-subtle); 564 + border-radius: 10px; 565 + padding: 1rem 1.25rem; 566 + } 567 + 568 + .setting-row { 569 + display: flex; 570 + justify-content: space-between; 571 + align-items: flex-start; 572 + gap: 1.5rem; 573 + padding: 0.75rem 0; 574 + } 575 + 576 + .setting-row:not(:last-child) { 577 + border-bottom: 1px solid var(--border-subtle); 578 + } 579 + 580 + .setting-info { 581 + flex: 1; 582 + min-width: 0; 583 + } 584 + 585 + .setting-info.full-width { 586 + margin-bottom: 1rem; 587 + } 588 + 589 + .setting-info h3 { 590 + margin: 0 0 0.25rem; 591 + font-size: 0.95rem; 592 + font-weight: 600; 593 + color: var(--text-primary); 594 + } 595 + 596 + .setting-info p { 597 + margin: 0; 598 + font-size: 0.8rem; 599 + color: var(--text-tertiary); 600 + line-height: 1.4; 601 + } 602 + 603 + .setting-info a { 604 + color: var(--accent); 605 + text-decoration: none; 606 + } 607 + 608 + .setting-info a:hover { 609 + text-decoration: underline; 610 + } 611 + 612 + /* theme buttons */ 613 + .theme-buttons { 614 + display: flex; 615 + gap: 0.5rem; 616 + flex-shrink: 0; 617 + } 618 + 619 + .theme-btn { 620 + display: flex; 621 + flex-direction: column; 622 + align-items: center; 623 + gap: 0.3rem; 624 + padding: 0.6rem 0.75rem; 625 + background: var(--bg-primary); 626 + border: 1px solid var(--border-default); 627 + border-radius: 8px; 628 + color: var(--text-secondary); 629 + cursor: pointer; 630 + transition: all 0.15s; 631 + min-width: 60px; 632 + } 633 + 634 + .theme-btn:hover { 635 + border-color: var(--accent); 636 + color: var(--accent); 637 + } 638 + 639 + .theme-btn.active { 640 + background: color-mix(in srgb, var(--accent) 15%, transparent); 641 + border-color: var(--accent); 642 + color: var(--accent); 643 + } 644 + 645 + .theme-btn svg { 646 + width: 18px; 647 + height: 18px; 648 + } 649 + 650 + .theme-btn span { 651 + font-size: 0.65rem; 652 + text-transform: uppercase; 653 + letter-spacing: 0.05em; 654 + } 655 + 656 + /* color controls */ 657 + .color-controls { 658 + display: flex; 659 + align-items: center; 660 + gap: 0.75rem; 661 + flex-shrink: 0; 662 + } 663 + 664 + .color-input { 665 + width: 40px; 666 + height: 40px; 667 + border: 1px solid var(--border-default); 668 + border-radius: 8px; 669 + cursor: pointer; 670 + background: transparent; 671 + } 672 + 673 + .color-input::-webkit-color-swatch-wrapper { 674 + padding: 3px; 675 + } 676 + 677 + .color-input::-webkit-color-swatch { 678 + border-radius: 4px; 679 + border: none; 680 + } 681 + 682 + .preset-grid { 683 + display: flex; 684 + gap: 0.4rem; 685 + } 686 + 687 + .preset-btn { 688 + width: 32px; 689 + height: 32px; 690 + border-radius: 6px; 691 + border: 2px solid transparent; 692 + cursor: pointer; 693 + transition: all 0.15s; 694 + padding: 0; 695 + } 696 + 697 + .preset-btn:hover { 698 + transform: scale(1.1); 699 + } 700 + 701 + .preset-btn.active { 702 + border-color: var(--text-primary); 703 + box-shadow: 0 0 0 1px var(--bg-secondary); 704 + } 705 + 706 + /* toggle switch */ 707 + .toggle-switch { 708 + position: relative; 709 + display: inline-block; 710 + flex-shrink: 0; 711 + } 712 + 713 + .toggle-switch input { 714 + opacity: 0; 715 + width: 0; 716 + height: 0; 717 + position: absolute; 718 + } 719 + 720 + .toggle-slider { 721 + display: block; 722 + width: 48px; 723 + height: 28px; 724 + background: var(--border-default); 725 + border-radius: 999px; 726 + position: relative; 727 + cursor: pointer; 728 + transition: background 0.2s; 729 + } 730 + 731 + .toggle-slider::after { 732 + content: ''; 733 + position: absolute; 734 + top: 4px; 735 + left: 4px; 736 + width: 20px; 737 + height: 20px; 738 + border-radius: 50%; 739 + background: var(--text-secondary); 740 + transition: transform 0.2s, background 0.2s; 741 + } 742 + 743 + .toggle-switch input:checked + .toggle-slider { 744 + background: color-mix(in srgb, var(--accent) 65%, transparent); 745 + } 746 + 747 + .toggle-switch input:checked + .toggle-slider::after { 748 + transform: translateX(20px); 749 + background: var(--accent); 750 + } 751 + 752 + /* reauth notice */ 753 + .reauth-notice { 754 + display: flex; 755 + align-items: center; 756 + gap: 0.5rem; 757 + padding: 0.75rem; 758 + background: color-mix(in srgb, var(--warning) 10%, transparent); 759 + border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); 760 + border-radius: 6px; 761 + margin-top: 0.75rem; 762 + font-size: 0.8rem; 763 + color: var(--warning); 764 + } 765 + 766 + /* developer tokens */ 767 + .loading-tokens { 768 + color: var(--text-tertiary); 769 + font-size: 0.85rem; 770 + } 771 + 772 + .existing-tokens { 773 + margin-top: 1rem; 774 + } 775 + 776 + .tokens-header { 777 + font-size: 0.75rem; 778 + text-transform: uppercase; 779 + letter-spacing: 0.05em; 780 + color: var(--text-tertiary); 781 + margin: 0 0 0.75rem; 782 + } 783 + 784 + .tokens-list { 785 + display: flex; 786 + flex-direction: column; 787 + gap: 0.5rem; 788 + } 789 + 790 + .token-item { 791 + display: flex; 792 + justify-content: space-between; 793 + align-items: center; 794 + gap: 1rem; 795 + padding: 0.75rem; 796 + background: var(--bg-primary); 797 + border: 1px solid var(--border-default); 798 + border-radius: 6px; 799 + } 800 + 801 + .token-info { 802 + display: flex; 803 + flex-direction: column; 804 + gap: 0.2rem; 805 + min-width: 0; 806 + } 807 + 808 + .token-name { 809 + font-weight: 500; 810 + color: var(--text-primary); 811 + font-size: 0.9rem; 812 + } 813 + 814 + .token-meta { 815 + font-size: 0.75rem; 816 + color: var(--text-tertiary); 817 + } 818 + 819 + .revoke-btn { 820 + padding: 0.4rem 0.75rem; 821 + background: transparent; 822 + border: 1px solid var(--border-emphasis); 823 + border-radius: 4px; 824 + color: var(--text-secondary); 825 + font-family: inherit; 826 + font-size: 0.8rem; 827 + cursor: pointer; 828 + transition: all 0.15s; 829 + white-space: nowrap; 830 + } 831 + 832 + .revoke-btn:hover:not(:disabled) { 833 + border-color: var(--error); 834 + color: var(--error); 835 + } 836 + 837 + .revoke-btn:disabled { 838 + opacity: 0.5; 839 + cursor: not-allowed; 840 + } 841 + 842 + .token-display { 843 + display: flex; 844 + align-items: center; 845 + gap: 0.5rem; 846 + margin-top: 1rem; 847 + padding: 0.75rem; 848 + background: var(--bg-primary); 849 + border: 1px solid var(--border-default); 850 + border-radius: 6px; 851 + } 852 + 853 + .token-value { 854 + flex: 1; 855 + font-size: 0.8rem; 856 + word-break: break-all; 857 + color: var(--accent); 858 + } 859 + 860 + .copy-btn, 861 + .dismiss-btn { 862 + padding: 0.4rem 0.6rem; 863 + background: var(--bg-tertiary); 864 + border: 1px solid var(--border-default); 865 + border-radius: 4px; 866 + color: var(--text-secondary); 867 + font-family: inherit; 868 + font-size: 0.8rem; 869 + cursor: pointer; 870 + transition: all 0.15s; 871 + } 872 + 873 + .copy-btn:hover, 874 + .dismiss-btn:hover { 875 + border-color: var(--accent); 876 + color: var(--accent); 877 + } 878 + 879 + .token-warning { 880 + margin-top: 0.5rem; 881 + font-size: 0.8rem; 882 + color: var(--warning); 883 + } 884 + 885 + .token-form { 886 + display: flex; 887 + flex-wrap: wrap; 888 + gap: 0.75rem; 889 + margin-top: 1rem; 890 + } 891 + 892 + .token-name-input { 893 + flex: 1; 894 + min-width: 150px; 895 + padding: 0.6rem 0.75rem; 896 + background: var(--bg-primary); 897 + border: 1px solid var(--border-default); 898 + border-radius: 6px; 899 + color: var(--text-primary); 900 + font-size: 0.9rem; 901 + font-family: inherit; 902 + } 903 + 904 + .token-name-input:focus { 905 + outline: none; 906 + border-color: var(--accent); 907 + } 908 + 909 + .expires-label { 910 + display: flex; 911 + align-items: center; 912 + gap: 0.5rem; 913 + font-size: 0.85rem; 914 + color: var(--text-secondary); 915 + } 916 + 917 + .expires-select { 918 + padding: 0.5rem 0.75rem; 919 + background: var(--bg-primary); 920 + border: 1px solid var(--border-default); 921 + border-radius: 6px; 922 + color: var(--text-primary); 923 + font-size: 0.85rem; 924 + font-family: inherit; 925 + cursor: pointer; 926 + } 927 + 928 + .expires-select:focus { 929 + outline: none; 930 + border-color: var(--accent); 931 + } 932 + 933 + .create-token-btn { 934 + padding: 0.6rem 1rem; 935 + background: var(--accent); 936 + border: none; 937 + border-radius: 6px; 938 + color: var(--text-primary); 939 + font-family: inherit; 940 + font-size: 0.9rem; 941 + font-weight: 600; 942 + cursor: pointer; 943 + transition: all 0.15s; 944 + white-space: nowrap; 945 + } 946 + 947 + .create-token-btn:hover:not(:disabled) { 948 + background: var(--accent-hover); 949 + } 950 + 951 + .create-token-btn:disabled { 952 + opacity: 0.5; 953 + cursor: not-allowed; 954 + } 955 + 956 + @media (max-width: 600px) { 957 + .setting-row { 958 + flex-direction: column; 959 + gap: 1rem; 960 + } 961 + 962 + .theme-buttons { 963 + width: 100%; 964 + } 965 + 966 + .theme-btn { 967 + flex: 1; 968 + } 969 + 970 + .color-controls { 971 + width: 100%; 972 + justify-content: flex-start; 973 + } 974 + 975 + .token-form { 976 + flex-direction: column; 977 + } 978 + 979 + .expires-label { 980 + width: 100%; 981 + justify-content: space-between; 982 + } 983 + } 984 + </style>