vod frog, frog with the vods
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add atproto OAuth login flow

- Add static client-metadata.json for vods.sky.boo with granular scopes
(atproto + repo:sky.boo.vods.watchlist)
- Add auth.svelte.ts with BrowserOAuthClient, reactive session state,
and dev loopback support
- Update LoginButton with handle input, avatar display, and logout
- Add /oauth/callback route (redirects home immediately)
- Initialize auth in root layout

goose.art cdca29f7 f1224876

+517 -28
+183
package-lock.json
··· 9 9 "version": "0.0.1", 10 10 "dependencies": { 11 11 "@atproto/api": "^0.19.5", 12 + "@atproto/oauth-client-browser": "^0.3.41", 12 13 "hls.js": "^1.6.15" 13 14 }, 14 15 "devDependencies": { ··· 22 23 "vite": "^7.2.6" 23 24 } 24 25 }, 26 + "node_modules/@atproto-labs/did-resolver": { 27 + "version": "0.2.6", 28 + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.6.tgz", 29 + "integrity": "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==", 30 + "license": "MIT", 31 + "dependencies": { 32 + "@atproto-labs/fetch": "0.2.3", 33 + "@atproto-labs/pipe": "0.1.1", 34 + "@atproto-labs/simple-store": "0.3.0", 35 + "@atproto-labs/simple-store-memory": "0.1.4", 36 + "@atproto/did": "0.3.0", 37 + "zod": "^3.23.8" 38 + } 39 + }, 40 + "node_modules/@atproto-labs/fetch": { 41 + "version": "0.2.3", 42 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.2.3.tgz", 43 + "integrity": "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==", 44 + "license": "MIT", 45 + "dependencies": { 46 + "@atproto-labs/pipe": "0.1.1" 47 + } 48 + }, 49 + "node_modules/@atproto-labs/handle-resolver": { 50 + "version": "0.3.6", 51 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.6.tgz", 52 + "integrity": "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==", 53 + "license": "MIT", 54 + "dependencies": { 55 + "@atproto-labs/simple-store": "0.3.0", 56 + "@atproto-labs/simple-store-memory": "0.1.4", 57 + "@atproto/did": "0.3.0", 58 + "zod": "^3.23.8" 59 + } 60 + }, 61 + "node_modules/@atproto-labs/identity-resolver": { 62 + "version": "0.3.6", 63 + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.6.tgz", 64 + "integrity": "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==", 65 + "license": "MIT", 66 + "dependencies": { 67 + "@atproto-labs/did-resolver": "0.2.6", 68 + "@atproto-labs/handle-resolver": "0.3.6" 69 + } 70 + }, 71 + "node_modules/@atproto-labs/pipe": { 72 + "version": "0.1.1", 73 + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.1.tgz", 74 + "integrity": "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==", 75 + "license": "MIT" 76 + }, 77 + "node_modules/@atproto-labs/simple-store": { 78 + "version": "0.3.0", 79 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.3.0.tgz", 80 + "integrity": "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==", 81 + "license": "MIT" 82 + }, 83 + "node_modules/@atproto-labs/simple-store-memory": { 84 + "version": "0.1.4", 85 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.4.tgz", 86 + "integrity": "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==", 87 + "license": "MIT", 88 + "dependencies": { 89 + "@atproto-labs/simple-store": "0.3.0", 90 + "lru-cache": "^10.2.0" 91 + } 92 + }, 25 93 "node_modules/@atproto/api": { 26 94 "version": "0.19.5", 27 95 "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.19.5.tgz", ··· 50 118 "zod": "^3.23.8" 51 119 } 52 120 }, 121 + "node_modules/@atproto/did": { 122 + "version": "0.3.0", 123 + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.3.0.tgz", 124 + "integrity": "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==", 125 + "license": "MIT", 126 + "dependencies": { 127 + "zod": "^3.23.8" 128 + } 129 + }, 130 + "node_modules/@atproto/jwk": { 131 + "version": "0.6.0", 132 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.6.0.tgz", 133 + "integrity": "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==", 134 + "license": "MIT", 135 + "dependencies": { 136 + "multiformats": "^9.9.0", 137 + "zod": "^3.23.8" 138 + } 139 + }, 140 + "node_modules/@atproto/jwk-jose": { 141 + "version": "0.1.11", 142 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz", 143 + "integrity": "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==", 144 + "license": "MIT", 145 + "dependencies": { 146 + "@atproto/jwk": "0.6.0", 147 + "jose": "^5.2.0" 148 + } 149 + }, 150 + "node_modules/@atproto/jwk-webcrypto": { 151 + "version": "0.2.0", 152 + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.2.0.tgz", 153 + "integrity": "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==", 154 + "license": "MIT", 155 + "dependencies": { 156 + "@atproto/jwk": "0.6.0", 157 + "@atproto/jwk-jose": "0.1.11", 158 + "zod": "^3.23.8" 159 + } 160 + }, 53 161 "node_modules/@atproto/lex-data": { 54 162 "version": "0.0.14", 55 163 "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.14.tgz", ··· 82 190 "@atproto/syntax": "^0.5.0", 83 191 "iso-datestring-validator": "^2.2.2", 84 192 "multiformats": "^9.9.0", 193 + "zod": "^3.23.8" 194 + } 195 + }, 196 + "node_modules/@atproto/oauth-client": { 197 + "version": "0.6.0", 198 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.6.0.tgz", 199 + "integrity": "sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q==", 200 + "license": "MIT", 201 + "dependencies": { 202 + "@atproto-labs/did-resolver": "^0.2.6", 203 + "@atproto-labs/fetch": "^0.2.3", 204 + "@atproto-labs/handle-resolver": "^0.3.6", 205 + "@atproto-labs/identity-resolver": "^0.3.6", 206 + "@atproto-labs/simple-store": "^0.3.0", 207 + "@atproto-labs/simple-store-memory": "^0.1.4", 208 + "@atproto/did": "^0.3.0", 209 + "@atproto/jwk": "^0.6.0", 210 + "@atproto/oauth-types": "^0.6.3", 211 + "@atproto/xrpc": "^0.7.7", 212 + "core-js": "^3", 213 + "multiformats": "^9.9.0", 214 + "zod": "^3.23.8" 215 + } 216 + }, 217 + "node_modules/@atproto/oauth-client-browser": { 218 + "version": "0.3.41", 219 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.3.41.tgz", 220 + "integrity": "sha512-4QTm8zPgm08vl53flrVmL+MS5IOhvWWctNZmEnPbvQ2t1ISw9Q5m815m2Sszi5ULMFjOqvT7lhKB7zQUn5gq5g==", 221 + "license": "MIT", 222 + "dependencies": { 223 + "@atproto-labs/did-resolver": "^0.2.6", 224 + "@atproto-labs/handle-resolver": "^0.3.6", 225 + "@atproto-labs/simple-store": "^0.3.0", 226 + "@atproto/did": "^0.3.0", 227 + "@atproto/jwk": "^0.6.0", 228 + "@atproto/jwk-webcrypto": "^0.2.0", 229 + "@atproto/oauth-client": "^0.6.0", 230 + "@atproto/oauth-types": "^0.6.3", 231 + "core-js": "^3" 232 + } 233 + }, 234 + "node_modules/@atproto/oauth-types": { 235 + "version": "0.6.3", 236 + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.6.3.tgz", 237 + "integrity": "sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==", 238 + "license": "MIT", 239 + "dependencies": { 240 + "@atproto/did": "^0.3.0", 241 + "@atproto/jwk": "^0.6.0", 85 242 "zod": "^3.23.8" 86 243 } 87 244 }, ··· 1172 1329 "node": ">= 0.6" 1173 1330 } 1174 1331 }, 1332 + "node_modules/core-js": { 1333 + "version": "3.49.0", 1334 + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", 1335 + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", 1336 + "hasInstallScript": true, 1337 + "license": "MIT", 1338 + "funding": { 1339 + "type": "opencollective", 1340 + "url": "https://opencollective.com/core-js" 1341 + } 1342 + }, 1175 1343 "node_modules/deepmerge": { 1176 1344 "version": "4.3.1", 1177 1345 "dev": true, ··· 1287 1455 "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", 1288 1456 "license": "MIT" 1289 1457 }, 1458 + "node_modules/jose": { 1459 + "version": "5.10.0", 1460 + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", 1461 + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", 1462 + "license": "MIT", 1463 + "funding": { 1464 + "url": "https://github.com/sponsors/panva" 1465 + } 1466 + }, 1290 1467 "node_modules/kleur": { 1291 1468 "version": "4.1.5", 1292 1469 "dev": true, ··· 1299 1476 "version": "3.0.0", 1300 1477 "dev": true, 1301 1478 "license": "MIT" 1479 + }, 1480 + "node_modules/lru-cache": { 1481 + "version": "10.4.3", 1482 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 1483 + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 1484 + "license": "ISC" 1302 1485 }, 1303 1486 "node_modules/magic-string": { 1304 1487 "version": "0.30.21",
+1
package.json
··· 23 23 }, 24 24 "dependencies": { 25 25 "@atproto/api": "^0.19.5", 26 + "@atproto/oauth-client-browser": "^0.3.41", 26 27 "hls.js": "^1.6.15" 27 28 } 28 29 }
+1 -1
src/lib/FrogHeader.svelte
··· 13 13 14 14 <header class="frog-header"> 15 15 <div class="login-area"> 16 - <LoginButton onclick={() => { /* TODO: implement login */ }} /> 16 + <LoginButton /> 17 17 </div> 18 18 <div class="title-area"> 19 19 <a href="/" class="logo-link" onclick={handleClick}><h1 class="logo-text">vod frog</h1></a>
+159 -27
src/lib/LoginButton.svelte
··· 1 1 <!-- 2 - LoginButton: An oval wavy button with "login" text. 3 - Uses the same sine-wave wobble approach as WavyCircle but stretched into an oval. 2 + LoginButton: Shows a login button, or the logged-in user's handle with logout. 3 + When clicked, shows a handle input field to start the OAuth flow. 4 4 --> 5 5 <script lang="ts"> 6 6 import { seededRandom } from './theme'; 7 + import { getAuthState, signIn, signOut } from './auth.svelte'; 8 + import WavyCircle from './WavyCircle.svelte'; 7 9 8 - let { onclick }: { onclick?: () => void } = $props(); 10 + const auth = getAuthState(); 11 + 12 + let showInput = $state(false); 13 + let handleValue = $state(''); 14 + let submitting = $state(false); 15 + let inputEl: HTMLInputElement | undefined = $state(); 9 16 10 17 const seed = 'login-btn'; 11 18 const { svgPath, clipPolygon } = generateWavyOval(seed); 19 + const logoutShape = generateWavyOval('logout-btn'); 12 20 13 21 function generateWavyOval(s: string): { svgPath: string; clipPolygon: string } { 14 22 const cx = 50, cy = 50; 15 - const rx = 44; // horizontal radius 16 - const ry = 36; // vertical radius — squished for oval 23 + const rx = 44; 24 + const ry = 36; 17 25 const amp = 3; 18 26 const points = 48; 19 27 ··· 36 44 ]); 37 45 } 38 46 39 - // SVG path with Catmull-Rom splines 40 47 let d = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`; 41 48 for (let i = 0; i < pts.length; i++) { 42 49 const p0 = pts[(i - 1 + pts.length) % pts.length]; ··· 54 61 55 62 const clipPts = pts.map(([x, y]) => `${x.toFixed(2)}% ${y.toFixed(2)}%`).join(', '); 56 63 return { svgPath: d, clipPolygon: `polygon(${clipPts})` }; 64 + } 65 + 66 + function handleLoginClick() { 67 + if (auth.session) return; 68 + showInput = true; 69 + // Focus the input after it renders 70 + requestAnimationFrame(() => inputEl?.focus()); 71 + } 72 + 73 + async function handleSubmit(e: Event) { 74 + e.preventDefault(); 75 + const input = handleValue.trim(); 76 + if (!input) return; 77 + 78 + submitting = true; 79 + try { 80 + await signIn(input); 81 + // signIn redirects the browser — we won't reach here 82 + } catch { 83 + submitting = false; 84 + } 85 + } 86 + 87 + function handleKeydown(e: KeyboardEvent) { 88 + if (e.key === 'Escape') { 89 + showInput = false; 90 + handleValue = ''; 91 + } 57 92 } 58 93 </script> 59 94 60 - <button class="login-btn" {onclick}> 61 - <div class="btn-clipped" style="clip-path: {clipPolygon};"> 62 - <div class="btn-fill"></div> 63 - <span class="btn-text">login</span> 95 + {#if auth.loading} 96 + <!-- Loading, show nothing --> 97 + {:else if auth.session} 98 + <div class="logged-in"> 99 + {#if auth.avatar} 100 + <div class="avatar-wrap"> 101 + <WavyCircle seed={auth.did || 'user'} fill="#FFDEED" strokeColor="transparent" strokeWidth={0} size={36}> 102 + <img src={auth.avatar} alt="" class="avatar-img" /> 103 + </WavyCircle> 104 + </div> 105 + {/if} 106 + <span class="handle-text">@{auth.handle}</span> 107 + <button class="logout-btn" onclick={signOut}> 108 + <div class="btn-clipped" style="clip-path: {logoutShape.clipPolygon};"> 109 + <div class="btn-fill logout-fill"></div> 110 + <span class="btn-text">logout</span> 111 + </div> 112 + </button> 64 113 </div> 65 - <svg class="btn-stroke" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" overflow="visible"> 66 - <path 67 - d={svgPath} 68 - fill="none" 69 - stroke="#0A182B" 70 - stroke-width="2" 71 - stroke-linejoin="round" 114 + {:else if showInput} 115 + <form class="login-form" onsubmit={handleSubmit}> 116 + <input 117 + bind:this={inputEl} 118 + bind:value={handleValue} 119 + onkeydown={handleKeydown} 120 + type="text" 121 + placeholder="handle.bsky.social" 122 + class="handle-input" 123 + disabled={submitting} 72 124 /> 73 - </svg> 74 - </button> 125 + <button class="login-btn" type="submit" disabled={submitting}> 126 + <div class="btn-clipped" style="clip-path: {clipPolygon};"> 127 + <div class="btn-fill"></div> 128 + <span class="btn-text">{submitting ? '...' : 'go'}</span> 129 + </div> 130 + </button> 131 + </form> 132 + {#if auth.error} 133 + <p class="login-error">{auth.error}</p> 134 + {/if} 135 + {:else} 136 + <button class="login-btn" onclick={handleLoginClick}> 137 + <div class="btn-clipped" style="clip-path: {clipPolygon};"> 138 + <div class="btn-fill"></div> 139 + <span class="btn-text">login</span> 140 + </div> 141 + </button> 142 + {/if} 75 143 76 144 <style> 77 - .login-btn { 145 + .login-btn, .logout-btn { 78 146 all: unset; 79 147 position: relative; 80 148 width: clamp(100px, 12vw, 140px); ··· 83 151 transition: transform 0.15s ease; 84 152 } 85 153 86 - .login-btn:hover { 154 + .login-btn:hover, .logout-btn:hover { 87 155 transform: scale(1.05); 88 156 } 89 157 158 + .login-btn:disabled { 159 + opacity: 0.6; 160 + } 161 + 90 162 .btn-clipped { 91 163 position: relative; 92 164 width: 100%; ··· 102 174 background: #0A182B; 103 175 } 104 176 177 + .logout-fill { 178 + background: #FF3992; 179 + } 180 + 105 181 .btn-text { 106 182 position: relative; 107 183 z-index: 1; ··· 111 187 letter-spacing: 1px; 112 188 } 113 189 114 - .btn-stroke { 115 - position: absolute; 116 - inset: 0; 190 + /* Logged-in state */ 191 + .logged-in { 192 + display: flex; 193 + align-items: center; 194 + gap: 8px; 195 + } 196 + 197 + .avatar-wrap { 198 + flex-shrink: 0; 199 + overflow: hidden; 200 + } 201 + 202 + .avatar-img { 117 203 width: 100%; 118 204 height: 100%; 119 - z-index: 2; 120 - pointer-events: none; 121 - overflow: visible; 205 + object-fit: cover; 206 + } 207 + 208 + .handle-text { 209 + font-family: 'Fang', system-ui, sans-serif; 210 + font-size: clamp(0.75rem, 1.2vw, 0.9rem); 211 + color: #0A182B; 212 + opacity: 0.85; 213 + max-width: 160px; 214 + overflow: hidden; 215 + text-overflow: ellipsis; 216 + white-space: nowrap; 217 + } 218 + 219 + /* Login form */ 220 + .login-form { 221 + display: flex; 222 + align-items: center; 223 + gap: 8px; 224 + } 225 + 226 + .handle-input { 227 + font-family: 'Fang', system-ui, sans-serif; 228 + font-size: 0.9rem; 229 + padding: 8px 12px; 230 + border: 2px solid #0A182B; 231 + border-radius: 20px; 232 + background: #FFDEED; 233 + color: #0A182B; 234 + width: clamp(140px, 18vw, 220px); 235 + outline: none; 236 + transition: border-color 0.15s; 237 + } 238 + 239 + .handle-input::placeholder { 240 + color: #0A182B; 241 + opacity: 0.4; 242 + } 243 + 244 + .handle-input:focus { 245 + border-color: #FF3992; 246 + } 247 + 248 + .login-error { 249 + font-family: 'Fang', system-ui, sans-serif; 250 + font-size: 0.75rem; 251 + color: #FF3992; 252 + margin: 4px 0 0; 253 + text-align: right; 122 254 } 123 255 </style>
+146
src/lib/auth.svelte.ts
··· 1 + /** 2 + * AT Protocol OAuth client for browser-based authentication. 3 + * 4 + * Uses @atproto/oauth-client-browser with a static client-metadata.json. 5 + * Scopes: atproto (base) + repo:sky.boo.vods.watchlist (write watchlist records). 6 + * Reading public records is done via unauthenticated API calls. 7 + */ 8 + 9 + import { BrowserOAuthClient, type OAuthSession } from '@atproto/oauth-client-browser'; 10 + 11 + const PROD_CLIENT_ID = 'https://vods.sky.boo/client-metadata.json'; 12 + 13 + function isDev(): boolean { 14 + return typeof window !== 'undefined' && ( 15 + window.location.hostname === 'localhost' || 16 + window.location.hostname === '127.0.0.1' 17 + ); 18 + } 19 + 20 + /** Reactive auth state */ 21 + let session: OAuthSession | null = $state(null); 22 + let did: string | null = $state(null); 23 + let handle: string | null = $state(null); 24 + let avatar: string | null = $state(null); 25 + let loading: boolean = $state(true); 26 + let error: string | null = $state(null); 27 + 28 + let _client: BrowserOAuthClient | null = null; 29 + 30 + export function getAuthState() { 31 + return { 32 + get session() { return session; }, 33 + get did() { return did; }, 34 + get handle() { return handle; }, 35 + get avatar() { return avatar; }, 36 + get loading() { return loading; }, 37 + get error() { return error; }, 38 + }; 39 + } 40 + 41 + /** Create or return the cached OAuth client */ 42 + async function getClient(): Promise<BrowserOAuthClient> { 43 + if (_client) return _client; 44 + 45 + if (isDev()) { 46 + // In dev mode, use loopback client metadata with our scopes declared 47 + const port = window.location.port || '5173'; 48 + const redirect = `http://127.0.0.1:${port}/oauth/callback` as const; 49 + _client = new BrowserOAuthClient({ 50 + handleResolver: 'https://bsky.social', 51 + clientMetadata: { 52 + client_id: `http://localhost?redirect_uri=${encodeURIComponent(redirect)}&scope=${encodeURIComponent('atproto repo:sky.boo.vods.watchlist')}`, 53 + client_name: 'Vod Frog (dev)', 54 + client_uri: `http://localhost:${port}`, 55 + redirect_uris: [redirect], 56 + scope: 'atproto repo:sky.boo.vods.watchlist', 57 + grant_types: ['authorization_code', 'refresh_token'], 58 + response_types: ['code'], 59 + token_endpoint_auth_method: 'none', 60 + application_type: 'native', 61 + dpop_bound_access_tokens: true, 62 + }, 63 + }); 64 + } else { 65 + _client = await BrowserOAuthClient.load({ 66 + clientId: PROD_CLIENT_ID, 67 + handleResolver: 'https://bsky.social', 68 + }); 69 + } 70 + 71 + return _client; 72 + } 73 + 74 + /** Initialize auth: restore existing session or process OAuth callback. */ 75 + export async function initAuth(): Promise<void> { 76 + loading = true; 77 + error = null; 78 + 79 + try { 80 + const client = await getClient(); 81 + const result = await client.init(); 82 + 83 + if (result?.session) { 84 + await setSession(result.session); 85 + } 86 + } catch (e: any) { 87 + console.error('Auth init failed:', e); 88 + error = e.message || 'Auth initialization failed'; 89 + } finally { 90 + loading = false; 91 + } 92 + } 93 + 94 + /** Start the OAuth sign-in flow by redirecting to the user's PDS. */ 95 + export async function signIn(input: string): Promise<void> { 96 + error = null; 97 + 98 + try { 99 + const client = await getClient(); 100 + await client.signIn(input, { 101 + scope: 'atproto repo:sky.boo.vods.watchlist' 102 + }); 103 + // signIn will redirect the browser — execution won't continue past here 104 + } catch (e: any) { 105 + console.error('Sign in failed:', e); 106 + error = e.message || 'Sign in failed'; 107 + throw e; 108 + } 109 + } 110 + 111 + /** Sign out and revoke the current session. */ 112 + export async function signOut(): Promise<void> { 113 + if (!did) return; 114 + 115 + try { 116 + const client = await getClient(); 117 + await client.revoke(did); 118 + } catch (e: any) { 119 + console.error('Sign out error:', e); 120 + } finally { 121 + session = null; 122 + did = null; 123 + handle = null; 124 + avatar = null; 125 + error = null; 126 + } 127 + } 128 + 129 + async function setSession(s: OAuthSession): Promise<void> { 130 + session = s; 131 + did = s.did; 132 + handle = s.did; // placeholder until resolved 133 + 134 + // Resolve DID → human-readable handle, then fetch profile for avatar 135 + try { 136 + const { resolveHandle, getProfile } = await import('./api'); 137 + const resolved = await resolveHandle(s.did); 138 + const h = resolved.startsWith('@') ? resolved.slice(1) : resolved; 139 + handle = h; 140 + 141 + const profile = await getProfile(h); 142 + if (profile.avatar) avatar = profile.avatar; 143 + } catch { 144 + // keep DID as fallback 145 + } 146 + }
+6
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 + import { onMount } from 'svelte'; 2 3 import MeshBackground from '$lib/MeshBackground.svelte'; 3 4 import PlantOverlay from '$lib/PlantOverlay.svelte'; 4 5 import FlySpawner from '$lib/FlySpawner.svelte'; 6 + import { initAuth } from '$lib/auth.svelte'; 5 7 6 8 let { children } = $props(); 9 + 10 + onMount(() => { 11 + initAuth(); 12 + }); 7 13 </script> 8 14 9 15 <svelte:head>
+8
src/routes/oauth/callback/+page.svelte
··· 1 + <!-- 2 + OAuth callback page. initAuth() in the root layout processes the callback 3 + params automatically. This page just redirects home immediately. 4 + --> 5 + <script lang="ts"> 6 + import { goto } from '$app/navigation'; 7 + goto('/', { replaceState: true }); 8 + </script>
+13
static/client-metadata.json
··· 1 + { 2 + "client_id": "https://vods.sky.boo/client-metadata.json", 3 + "client_name": "Vod Frog", 4 + "client_uri": "https://vods.sky.boo", 5 + "logo_uri": "https://vods.sky.boo/frogicon.png", 6 + "redirect_uris": ["https://vods.sky.boo/oauth/callback"], 7 + "scope": "atproto repo:sky.boo.vods.watchlist", 8 + "grant_types": ["authorization_code", "refresh_token"], 9 + "response_types": ["code"], 10 + "token_endpoint_auth_method": "none", 11 + "application_type": "web", 12 + "dpop_bound_access_tokens": true 13 + }