Our Personal Data Server from scratch!
0
fork

Configure Feed

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

at main 378 lines 9.2 kB view raw
1<script lang="ts"> 2 import { 3 loginWithOAuth, 4 confirmSignup, 5 resendVerification, 6 getAuthState, 7 switchAccount, 8 forgetAccount, 9 clearError, 10 matchAuthState, 11 type SavedAccount, 12 type AuthError, 13 } from '../lib/auth.svelte' 14 import { navigate, routes } from '../lib/router.svelte' 15 import { _ } from '../lib/i18n' 16 import { isOk, isErr } from '../lib/types/result' 17 import { unsafeAsDid, type Did } from '../lib/types/branded' 18 import { toast } from '../lib/toast.svelte' 19 20 type PageState = 21 | { kind: 'login' } 22 | { kind: 'verification'; did: Did } 23 24 let pageState = $state<PageState>({ kind: 'login' }) 25 let submitting = $state(false) 26 let verificationCode = $state('') 27 let resendingCode = $state(false) 28 let resendMessage = $state<string | null>(null) 29 let autoRedirectAttempted = $state(false) 30 31 const auth = $derived(getAuthState()) 32 33 function getSavedAccounts(): readonly SavedAccount[] { 34 return auth.savedAccounts 35 } 36 37 function isLoading(): boolean { 38 return auth.kind === 'loading' 39 } 40 41 $effect(() => { 42 if (auth.kind === 'error') { 43 toast.error(auth.error.message) 44 clearError() 45 } 46 }) 47 48 $effect(() => { 49 const accounts = getSavedAccounts() 50 const loading = isLoading() 51 const hasError = auth.kind === 'error' 52 53 if (!loading && !hasError && accounts.length === 0 && pageState.kind === 'login' && !autoRedirectAttempted) { 54 autoRedirectAttempted = true 55 loginWithOAuth() 56 } 57 }) 58 59 async function handleSwitchAccount(did: Did) { 60 submitting = true 61 const result = await switchAccount(did) 62 if (isOk(result)) { 63 navigate(routes.dashboard) 64 } else { 65 submitting = false 66 } 67 } 68 69 function handleForgetAccount(did: Did, e: Event) { 70 e.stopPropagation() 71 forgetAccount(did) 72 } 73 74 async function handleOAuthLogin() { 75 submitting = true 76 const result = await loginWithOAuth() 77 if (isErr(result)) { 78 submitting = false 79 } 80 } 81 82 async function handleVerification(e: Event) { 83 e.preventDefault() 84 if (pageState.kind !== 'verification' || !verificationCode.trim()) return 85 86 submitting = true 87 const result = await confirmSignup(pageState.did, verificationCode.trim()) 88 if (isOk(result)) { 89 navigate(routes.dashboard) 90 } else { 91 submitting = false 92 } 93 } 94 95 async function handleResendCode() { 96 if (pageState.kind !== 'verification' || resendingCode) return 97 98 resendingCode = true 99 resendMessage = null 100 const result = await resendVerification(pageState.did) 101 if (isOk(result)) { 102 resendMessage = $_('verification.resent') 103 } 104 resendingCode = false 105 } 106 107 function backToLogin() { 108 pageState = { kind: 'login' } 109 verificationCode = '' 110 resendMessage = null 111 } 112 113 const savedAccounts = $derived(getSavedAccounts()) 114 const loading = $derived(isLoading()) 115</script> 116 117<div class="login-page"> 118 {#if pageState.kind === 'verification'} 119 <header class="page-header"> 120 <h1>{$_('verification.title')}</h1> 121 <p class="subtitle">{$_('verification.subtitle')}</p> 122 </header> 123 124 {#if resendMessage} 125 <div class="message success">{resendMessage}</div> 126 {/if} 127 128 <form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}> 129 <div class="field"> 130 <label for="verification-code">{$_('verification.codeLabel')}</label> 131 <input 132 id="verification-code" 133 type="text" 134 bind:value={verificationCode} 135 placeholder={$_('verification.codePlaceholder')} 136 disabled={submitting} 137 required 138 maxlength="6" 139 pattern="[0-9]{6}" 140 autocomplete="one-time-code" 141 /> 142 </div> 143 <div class="actions"> 144 <button type="submit" disabled={submitting || !verificationCode.trim()}> 145 {submitting ? $_('common.verifying') : $_('common.verify')} 146 </button> 147 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 148 {resendingCode ? $_('common.sending') : $_('common.resendCode')} 149 </button> 150 <button type="button" class="tertiary" onclick={backToLogin}> 151 {$_('common.backToLogin')} 152 </button> 153 </div> 154 </form> 155 156 {:else} 157 <header class="page-header"> 158 <h1>{$_('login.title')}</h1> 159 {#if savedAccounts.length > 0} 160 <p class="subtitle">{$_('login.chooseAccount')}</p> 161 {/if} 162 </header> 163 164 <div class="login-content"> 165 {#if savedAccounts.length > 0} 166 <div class="saved-accounts" class:grid={savedAccounts.length > 1}> 167 {#each savedAccounts as account} 168 <div 169 class="account-item" 170 class:disabled={submitting} 171 role="button" 172 tabindex="0" 173 onclick={() => !submitting && handleSwitchAccount(account.did)} 174 onkeydown={(e) => e.key === 'Enter' && !submitting && handleSwitchAccount(account.did)} 175 > 176 <div class="account-info"> 177 <span class="account-handle">@{account.handle}</span> 178 <span class="account-did">{account.did}</span> 179 </div> 180 <button 181 type="button" 182 class="forget-btn" 183 onclick={(e) => handleForgetAccount(account.did, e)} 184 title={$_('login.removeAccount')} 185 > 186 &times; 187 </button> 188 </div> 189 {/each} 190 </div> 191 192 <p class="or-divider">{$_('login.signInToAnother')}</p> 193 {/if} 194 195 <button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || loading}> 196 {submitting ? $_('login.redirecting') : $_('login.button')} 197 </button> 198 199 <p class="forgot-links"> 200 <a href="/app/reset-password">{$_('login.forgotPassword')}</a> 201 <span class="separator">&middot;</span> 202 <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a> 203 </p> 204 205 <p class="link-text"> 206 {$_('login.noAccount')} <a href="/app/register">{$_('login.createAccount')}</a> 207 </p> 208 </div> 209 {/if} 210</div> 211 212<style> 213 .login-page { 214 max-width: var(--width-lg); 215 margin: var(--space-9) auto; 216 padding: var(--space-7); 217 } 218 219 .page-header { 220 margin-bottom: var(--space-6); 221 text-align: center; 222 } 223 224 h1 { 225 margin: 0 0 var(--space-3) 0; 226 } 227 228 .subtitle { 229 color: var(--text-secondary); 230 margin: 0; 231 } 232 233 .login-content { 234 max-width: var(--width-md); 235 margin: 0 auto; 236 } 237 238 form { 239 display: flex; 240 flex-direction: column; 241 gap: var(--space-4); 242 max-width: var(--width-sm); 243 margin: 0 auto; 244 } 245 246 .actions { 247 display: flex; 248 flex-direction: column; 249 gap: var(--space-3); 250 margin-top: var(--space-3); 251 } 252 253 @media (min-width: 600px) { 254 .actions { 255 flex-direction: row; 256 } 257 258 .actions button { 259 flex: 1; 260 } 261 } 262 263 .oauth-btn { 264 width: 100%; 265 padding: var(--space-5); 266 font-size: var(--text-lg); 267 } 268 269 .forgot-links { 270 margin-top: var(--space-4); 271 font-size: var(--text-sm); 272 color: var(--text-secondary); 273 text-align: center; 274 } 275 276 .forgot-links a { 277 color: var(--accent); 278 } 279 280 .separator { 281 margin: 0 var(--space-2); 282 } 283 284 .link-text { 285 margin-top: var(--space-6); 286 font-size: var(--text-sm); 287 color: var(--text-secondary); 288 text-align: center; 289 } 290 291 .link-text a { 292 color: var(--accent); 293 } 294 295 .saved-accounts { 296 display: flex; 297 flex-direction: column; 298 gap: var(--space-3); 299 margin-bottom: var(--space-5); 300 } 301 302 .saved-accounts.grid { 303 display: grid; 304 grid-template-columns: 1fr; 305 } 306 307 @media (min-width: 700px) { 308 .saved-accounts.grid { 309 grid-template-columns: repeat(2, 1fr); 310 } 311 } 312 313 .account-item { 314 display: flex; 315 align-items: center; 316 justify-content: space-between; 317 padding: var(--space-5); 318 background: var(--bg-card); 319 border: 1px solid var(--border-color); 320 border-radius: var(--radius-xl); 321 cursor: pointer; 322 transition: border-color var(--transition-normal), box-shadow var(--transition-normal); 323 } 324 325 .account-item:hover:not(.disabled) { 326 border-color: var(--accent); 327 box-shadow: var(--shadow-md); 328 } 329 330 .account-item.disabled { 331 opacity: 0.6; 332 cursor: not-allowed; 333 } 334 335 .account-info { 336 display: flex; 337 flex-direction: column; 338 gap: var(--space-1); 339 min-width: 0; 340 } 341 342 .account-handle { 343 font-weight: var(--font-medium); 344 color: var(--text-primary); 345 } 346 347 .account-did { 348 font-size: var(--text-xs); 349 color: var(--text-muted); 350 font-family: var(--font-mono); 351 overflow: hidden; 352 text-overflow: ellipsis; 353 } 354 355 .forget-btn { 356 flex-shrink: 0; 357 padding: var(--space-2) var(--space-3); 358 background: transparent; 359 border: none; 360 color: var(--text-muted); 361 cursor: pointer; 362 font-size: var(--text-xl); 363 line-height: 1; 364 border-radius: var(--radius-md); 365 } 366 367 .forget-btn:hover { 368 background: var(--error-bg); 369 color: var(--error-text); 370 } 371 372 .or-divider { 373 text-align: center; 374 color: var(--text-muted); 375 font-size: var(--text-sm); 376 margin: var(--space-5) 0; 377 } 378</style>