this repo has no description
1<script lang="ts"> 2 import { getAuthState, logout, refreshSession } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { api, ApiError } from '../lib/api' 5 import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n' 6 const auth = getAuthState() 7 const supportedLocales = getSupportedLocales() 8 let localeLoading = $state(false) 9 async function handleLocaleChange(newLocale: SupportedLocale) { 10 if (!auth.session) return 11 setLocale(newLocale) 12 localeLoading = true 13 try { 14 await api.updateLocale(auth.session.accessJwt, newLocale) 15 } catch (e) { 16 console.error('Failed to save locale preference:', e) 17 } finally { 18 localeLoading = false 19 } 20 } 21 let message = $state<{ type: 'success' | 'error'; text: string } | null>(null) 22 let emailLoading = $state(false) 23 let newEmail = $state('') 24 let emailToken = $state('') 25 let emailTokenRequired = $state(false) 26 let handleLoading = $state(false) 27 let newHandle = $state('') 28 let deleteLoading = $state(false) 29 let deletePassword = $state('') 30 let deleteToken = $state('') 31 let deleteTokenSent = $state(false) 32 let exportLoading = $state(false) 33 let passwordLoading = $state(false) 34 let currentPassword = $state('') 35 let newPassword = $state('') 36 let confirmNewPassword = $state('') 37 let showBYOHandle = $state(false) 38 $effect(() => { 39 if (!auth.loading && !auth.session) { 40 navigate('/login') 41 } 42 }) 43 function showMessage(type: 'success' | 'error', text: string) { 44 message = { type, text } 45 setTimeout(() => { 46 if (message?.text === text) message = null 47 }, 5000) 48 } 49 async function handleRequestEmailUpdate(e: Event) { 50 e.preventDefault() 51 if (!auth.session || !newEmail) return 52 emailLoading = true 53 message = null 54 try { 55 const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail) 56 emailTokenRequired = result.tokenRequired 57 if (emailTokenRequired) { 58 showMessage('success', $_('settings.messages.verificationCodeSent')) 59 } else { 60 await api.updateEmail(auth.session.accessJwt, newEmail) 61 await refreshSession() 62 showMessage('success', $_('settings.messages.emailUpdated')) 63 newEmail = '' 64 } 65 } catch (e) { 66 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) 67 } finally { 68 emailLoading = false 69 } 70 } 71 async function handleConfirmEmailUpdate(e: Event) { 72 e.preventDefault() 73 if (!auth.session || !newEmail || !emailToken) return 74 emailLoading = true 75 message = null 76 try { 77 await api.updateEmail(auth.session.accessJwt, newEmail, emailToken) 78 await refreshSession() 79 showMessage('success', $_('settings.messages.emailUpdated')) 80 newEmail = '' 81 emailToken = '' 82 emailTokenRequired = false 83 } catch (e) { 84 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) 85 } finally { 86 emailLoading = false 87 } 88 } 89 async function handleUpdateHandle(e: Event) { 90 e.preventDefault() 91 if (!auth.session || !newHandle) return 92 handleLoading = true 93 message = null 94 try { 95 const fullHandle = showBYOHandle 96 ? newHandle 97 : `${newHandle}.${window.location.hostname}` 98 await api.updateHandle(auth.session.accessJwt, fullHandle) 99 await refreshSession() 100 showMessage('success', $_('settings.messages.handleUpdated')) 101 newHandle = '' 102 } catch (e) { 103 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.handleUpdateFailed')) 104 } finally { 105 handleLoading = false 106 } 107 } 108 async function handleRequestDelete() { 109 if (!auth.session) return 110 deleteLoading = true 111 message = null 112 try { 113 await api.requestAccountDelete(auth.session.accessJwt) 114 deleteTokenSent = true 115 showMessage('success', $_('settings.messages.deletionConfirmationSent')) 116 } catch (e) { 117 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionRequestFailed')) 118 } finally { 119 deleteLoading = false 120 } 121 } 122 async function handleConfirmDelete(e: Event) { 123 e.preventDefault() 124 if (!auth.session || !deletePassword || !deleteToken) return 125 if (!confirm($_('settings.messages.deleteConfirmation'))) { 126 return 127 } 128 deleteLoading = true 129 message = null 130 try { 131 await api.deleteAccount(auth.session.did, deletePassword, deleteToken) 132 await logout() 133 navigate('/login') 134 } catch (e) { 135 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionFailed')) 136 } finally { 137 deleteLoading = false 138 } 139 } 140 async function handleExportRepo() { 141 if (!auth.session) return 142 exportLoading = true 143 message = null 144 try { 145 const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(auth.session.did)}`, { 146 headers: { 147 'Authorization': `Bearer ${auth.session.accessJwt}` 148 } 149 }) 150 if (!response.ok) { 151 const err = await response.json().catch(() => ({ message: 'Export failed' })) 152 throw new Error(err.message || 'Export failed') 153 } 154 const blob = await response.blob() 155 const url = URL.createObjectURL(blob) 156 const a = document.createElement('a') 157 a.href = url 158 a.download = `${auth.session.handle}-repo.car` 159 document.body.appendChild(a) 160 a.click() 161 document.body.removeChild(a) 162 URL.revokeObjectURL(url) 163 showMessage('success', $_('settings.messages.repoExported')) 164 } catch (e) { 165 showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed')) 166 } finally { 167 exportLoading = false 168 } 169 } 170 async function handleChangePassword(e: Event) { 171 e.preventDefault() 172 if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return 173 if (newPassword !== confirmNewPassword) { 174 showMessage('error', $_('settings.messages.passwordsDoNotMatch')) 175 return 176 } 177 if (newPassword.length < 8) { 178 showMessage('error', $_('settings.messages.passwordTooShort')) 179 return 180 } 181 passwordLoading = true 182 message = null 183 try { 184 await api.changePassword(auth.session.accessJwt, currentPassword, newPassword) 185 showMessage('success', $_('settings.messages.passwordChanged')) 186 currentPassword = '' 187 newPassword = '' 188 confirmNewPassword = '' 189 } catch (e) { 190 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.passwordChangeFailed')) 191 } finally { 192 passwordLoading = false 193 } 194 } 195</script> 196<div class="page"> 197 <header> 198 <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 199 <h1>{$_('settings.title')}</h1> 200 </header> 201 {#if message} 202 <div class="message {message.type}">{message.text}</div> 203 {/if} 204 <section> 205 <h2>{$_('settings.language')}</h2> 206 <p class="description">{$_('settings.languageDescription')}</p> 207 <select 208 class="language-select" 209 value={$locale} 210 disabled={localeLoading} 211 onchange={(e) => handleLocaleChange(e.currentTarget.value as SupportedLocale)} 212 > 213 {#each supportedLocales as loc} 214 <option value={loc}>{localeNames[loc]}</option> 215 {/each} 216 </select> 217 </section> 218 <section> 219 <h2>{$_('settings.changeEmail')}</h2> 220 {#if auth.session?.email} 221 <p class="current">{$_('settings.currentEmail', { values: { email: auth.session.email } })}</p> 222 {/if} 223 {#if emailTokenRequired} 224 <form onsubmit={handleConfirmEmailUpdate}> 225 <div class="field"> 226 <label for="email-token">{$_('settings.verificationCode')}</label> 227 <input 228 id="email-token" 229 type="text" 230 bind:value={emailToken} 231 placeholder={$_('settings.verificationCodePlaceholder')} 232 disabled={emailLoading} 233 required 234 /> 235 </div> 236 <div class="actions"> 237 <button type="submit" disabled={emailLoading || !emailToken}> 238 {emailLoading ? $_('settings.updating') : $_('settings.confirmEmailChange')} 239 </button> 240 <button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = '' }}> 241 {$_('common.cancel')} 242 </button> 243 </div> 244 </form> 245 {:else} 246 <form onsubmit={handleRequestEmailUpdate}> 247 <div class="field"> 248 <label for="new-email">{$_('settings.newEmail')}</label> 249 <input 250 id="new-email" 251 type="email" 252 bind:value={newEmail} 253 placeholder={$_('settings.newEmailPlaceholder')} 254 disabled={emailLoading} 255 required 256 /> 257 </div> 258 <button type="submit" disabled={emailLoading || !newEmail}> 259 {emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')} 260 </button> 261 </form> 262 {/if} 263 </section> 264 <section> 265 <h2>{$_('settings.changeHandle')}</h2> 266 {#if auth.session} 267 <p class="current">{$_('settings.currentHandle', { values: { handle: auth.session.handle } })}</p> 268 {/if} 269 <div class="tabs"> 270 <button 271 type="button" 272 class="tab" 273 class:active={!showBYOHandle} 274 onclick={() => showBYOHandle = false} 275 > 276 {$_('settings.pdsHandle')} 277 </button> 278 <button 279 type="button" 280 class="tab" 281 class:active={showBYOHandle} 282 onclick={() => showBYOHandle = true} 283 > 284 {$_('settings.customDomain')} 285 </button> 286 </div> 287 {#if showBYOHandle} 288 <div class="byo-handle"> 289 <p class="description">{$_('settings.customDomainDescription')}</p> 290 {#if auth.session} 291 <div class="verification-info"> 292 <h3>{$_('settings.setupInstructions')}</h3> 293 <p>{$_('settings.setupMethodsIntro')}</p> 294 <div class="method"> 295 <h4>{$_('settings.dnsMethod')}</h4> 296 <p>{$_('settings.dnsMethodDesc')}</p> 297 <code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={auth.session.did}"</code> 298 </div> 299 <div class="method"> 300 <h4>{$_('settings.httpMethod')}</h4> 301 <p>{$_('settings.httpMethodDesc')}</p> 302 <code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code> 303 <p>{$_('settings.httpMethodContent')}</p> 304 <code class="record">{auth.session.did}</code> 305 </div> 306 </div> 307 {/if} 308 <form onsubmit={handleUpdateHandle}> 309 <div class="field"> 310 <label for="new-handle-byo">{$_('settings.yourDomain')}</label> 311 <input 312 id="new-handle-byo" 313 type="text" 314 bind:value={newHandle} 315 placeholder={$_('settings.yourDomainPlaceholder')} 316 disabled={handleLoading} 317 required 318 /> 319 </div> 320 <button type="submit" disabled={handleLoading || !newHandle}> 321 {handleLoading ? $_('settings.verifying') : $_('settings.verifyAndUpdate')} 322 </button> 323 </form> 324 </div> 325 {:else} 326 <form onsubmit={handleUpdateHandle}> 327 <div class="field"> 328 <label for="new-handle">{$_('settings.newHandle')}</label> 329 <div class="handle-input-wrapper"> 330 <input 331 id="new-handle" 332 type="text" 333 bind:value={newHandle} 334 placeholder={$_('settings.newHandlePlaceholder')} 335 disabled={handleLoading} 336 required 337 /> 338 <span class="handle-suffix">.{window.location.hostname}</span> 339 </div> 340 </div> 341 <button type="submit" disabled={handleLoading || !newHandle}> 342 {handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')} 343 </button> 344 </form> 345 {/if} 346 </section> 347 <section> 348 <h2>{$_('settings.changePassword')}</h2> 349 <form onsubmit={handleChangePassword}> 350 <div class="field"> 351 <label for="current-password">{$_('settings.currentPassword')}</label> 352 <input 353 id="current-password" 354 type="password" 355 bind:value={currentPassword} 356 placeholder={$_('settings.currentPasswordPlaceholder')} 357 disabled={passwordLoading} 358 required 359 /> 360 </div> 361 <div class="field"> 362 <label for="new-password">{$_('settings.newPassword')}</label> 363 <input 364 id="new-password" 365 type="password" 366 bind:value={newPassword} 367 placeholder={$_('settings.newPasswordPlaceholder')} 368 disabled={passwordLoading} 369 required 370 minlength="8" 371 /> 372 </div> 373 <div class="field"> 374 <label for="confirm-new-password">{$_('settings.confirmNewPassword')}</label> 375 <input 376 id="confirm-new-password" 377 type="password" 378 bind:value={confirmNewPassword} 379 placeholder={$_('settings.confirmNewPasswordPlaceholder')} 380 disabled={passwordLoading} 381 required 382 /> 383 </div> 384 <button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}> 385 {passwordLoading ? $_('settings.changing') : $_('settings.changePasswordButton')} 386 </button> 387 </form> 388 </section> 389 <section> 390 <h2>{$_('settings.exportData')}</h2> 391 <p class="description">{$_('settings.exportDataDescription')}</p> 392 <button onclick={handleExportRepo} disabled={exportLoading}> 393 {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 394 </button> 395 </section> 396 <section class="danger-zone"> 397 <h2>{$_('settings.deleteAccount')}</h2> 398 <p class="warning">{$_('settings.deleteWarning')}</p> 399 {#if deleteTokenSent} 400 <form onsubmit={handleConfirmDelete}> 401 <div class="field"> 402 <label for="delete-token">{$_('settings.confirmationCode')}</label> 403 <input 404 id="delete-token" 405 type="text" 406 bind:value={deleteToken} 407 placeholder={$_('settings.confirmationCodePlaceholder')} 408 disabled={deleteLoading} 409 required 410 /> 411 </div> 412 <div class="field"> 413 <label for="delete-password">{$_('settings.yourPassword')}</label> 414 <input 415 id="delete-password" 416 type="password" 417 bind:value={deletePassword} 418 placeholder={$_('settings.yourPasswordPlaceholder')} 419 disabled={deleteLoading} 420 required 421 /> 422 </div> 423 <div class="actions"> 424 <button type="submit" class="danger" disabled={deleteLoading || !deleteToken || !deletePassword}> 425 {deleteLoading ? $_('settings.deleting') : $_('settings.permanentlyDelete')} 426 </button> 427 <button type="button" class="secondary" onclick={() => { deleteTokenSent = false; deleteToken = ''; deletePassword = '' }}> 428 {$_('common.cancel')} 429 </button> 430 </div> 431 </form> 432 {:else} 433 <button class="danger" onclick={handleRequestDelete} disabled={deleteLoading}> 434 {deleteLoading ? $_('settings.requesting') : $_('settings.requestDeletion')} 435 </button> 436 {/if} 437 </section> 438</div> 439<style> 440 .page { 441 max-width: var(--width-md); 442 margin: 0 auto; 443 padding: var(--space-7); 444 } 445 446 header { 447 margin-bottom: var(--space-7); 448 } 449 450 .back { 451 color: var(--text-secondary); 452 text-decoration: none; 453 font-size: var(--text-sm); 454 } 455 456 .back:hover { 457 color: var(--accent); 458 } 459 460 h1 { 461 margin: var(--space-2) 0 0 0; 462 } 463 464 section { 465 padding: var(--space-6); 466 background: var(--bg-secondary); 467 border-radius: var(--radius-xl); 468 margin-bottom: var(--space-6); 469 } 470 471 section h2 { 472 margin: 0 0 var(--space-2) 0; 473 font-size: var(--text-lg); 474 } 475 476 .current, 477 .description { 478 color: var(--text-secondary); 479 font-size: var(--text-sm); 480 margin-bottom: var(--space-4); 481 } 482 483 .language-select { 484 width: 100%; 485 } 486 487 .actions { 488 display: flex; 489 gap: var(--space-2); 490 } 491 492 .danger-zone { 493 background: var(--error-bg); 494 border: 1px solid var(--error-border); 495 } 496 497 .danger-zone h2 { 498 color: var(--error-text); 499 } 500 501 .warning { 502 color: var(--error-text); 503 font-size: var(--text-sm); 504 margin-bottom: var(--space-4); 505 } 506 507 .tabs { 508 display: flex; 509 gap: var(--space-1); 510 margin-bottom: var(--space-4); 511 } 512 513 .tab { 514 flex: 1; 515 padding: var(--space-2) var(--space-4); 516 background: transparent; 517 border: 1px solid var(--border-color); 518 cursor: pointer; 519 font-size: var(--text-sm); 520 color: var(--text-secondary); 521 } 522 523 .tab:first-child { 524 border-radius: var(--radius-md) 0 0 var(--radius-md); 525 } 526 527 .tab:last-child { 528 border-radius: 0 var(--radius-md) var(--radius-md) 0; 529 } 530 531 .tab.active { 532 background: var(--accent); 533 border-color: var(--accent); 534 color: var(--text-inverse); 535 } 536 537 .tab:hover:not(.active) { 538 background: var(--bg-card); 539 } 540 541 .byo-handle .description { 542 margin-bottom: var(--space-4); 543 } 544 545 .verification-info { 546 background: var(--bg-card); 547 border: 1px solid var(--border-color); 548 border-radius: var(--radius-lg); 549 padding: var(--space-4); 550 margin-bottom: var(--space-4); 551 } 552 553 .verification-info h3 { 554 margin: 0 0 var(--space-2) 0; 555 font-size: var(--text-base); 556 } 557 558 .verification-info h4 { 559 margin: var(--space-3) 0 var(--space-1) 0; 560 font-size: var(--text-sm); 561 color: var(--text-secondary); 562 } 563 564 .verification-info p { 565 margin: var(--space-1) 0; 566 font-size: var(--text-xs); 567 color: var(--text-secondary); 568 } 569 570 .method { 571 margin-top: var(--space-3); 572 padding-top: var(--space-3); 573 border-top: 1px solid var(--border-color); 574 } 575 576 .method:first-of-type { 577 margin-top: var(--space-2); 578 padding-top: 0; 579 border-top: none; 580 } 581 582 code.record { 583 display: block; 584 background: var(--bg-input); 585 padding: var(--space-2); 586 border-radius: var(--radius-md); 587 font-size: var(--text-xs); 588 word-break: break-all; 589 margin: var(--space-1) 0; 590 } 591 592 .handle-input-wrapper { 593 display: flex; 594 align-items: center; 595 background: var(--bg-input); 596 border: 1px solid var(--border-color); 597 border-radius: var(--radius-md); 598 overflow: hidden; 599 } 600 601 .handle-input-wrapper input { 602 flex: 1; 603 border: none; 604 border-radius: 0; 605 background: transparent; 606 min-width: 0; 607 } 608 609 .handle-input-wrapper input:focus { 610 outline: none; 611 box-shadow: none; 612 } 613 614 .handle-input-wrapper:focus-within { 615 border-color: var(--accent); 616 box-shadow: 0 0 0 2px var(--accent-muted); 617 } 618 619 .handle-suffix { 620 padding: 0 var(--space-3); 621 color: var(--text-secondary); 622 font-size: var(--text-sm); 623 white-space: nowrap; 624 border-left: 1px solid var(--border-color); 625 background: var(--bg-card); 626 } 627</style>