Our Personal Data Server from scratch!
0

Configure Feed

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

tranquil-pds / frontend / src / routes / Settings.svelte
35 kB 1208 lines
1<script lang="ts"> 2 import { onMount } from 'svelte' 3 import { getAuthState, logout, refreshSession } from '../lib/auth.svelte' 4 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 5 import { api, ApiError } from '../lib/api' 6 import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n' 7 import { isOk } from '../lib/types/result' 8 import { unsafeAsHandle } from '../lib/types/branded' 9 import type { Session } from '../lib/types/api' 10 import { toast } from '../lib/toast.svelte' 11 import ReauthModal from '../components/ReauthModal.svelte' 12 13 const auth = $derived(getAuthState()) 14 const supportedLocales = getSupportedLocales() 15 let pdsHostname = $state<string | null>(null) 16 17 function getSession(): Session | null { 18 return auth.kind === 'authenticated' ? auth.session : null 19 } 20 21 function isLoading(): boolean { 22 return auth.kind === 'loading' 23 } 24 25 const session = $derived(getSession()) 26 const loading = $derived(isLoading()) 27 28 onMount(() => { 29 api.describeServer().then(info => { 30 if (info.availableUserDomains?.length) { 31 pdsHostname = info.availableUserDomains[0] 32 } 33 }).catch(() => {}) 34 35 return () => { 36 stopEmailPolling() 37 } 38 }) 39 40 let localeLoading = $state(false) 41 async function handleLocaleChange(newLocale: SupportedLocale) { 42 if (!session) return 43 setLocale(newLocale) 44 localeLoading = true 45 try { 46 await api.updateLocale(session.accessJwt, newLocale) 47 } catch (e) { 48 console.error('Failed to save locale preference:', e) 49 } finally { 50 localeLoading = false 51 } 52 } 53 54 let emailLoading = $state(false) 55 let newEmail = $state('') 56 let emailToken = $state('') 57 let emailTokenRequired = $state(false) 58 let emailUpdateAuthorized = $state(false) 59 let emailPollingInterval = $state<ReturnType<typeof setInterval> | null>(null) 60 let newEmailInUse = $state(false) 61 62 async function checkNewEmailInUse() { 63 if (!newEmail.trim() || !newEmail.includes('@')) { 64 newEmailInUse = false 65 return 66 } 67 try { 68 const result = await api.checkEmailInUse(newEmail.trim()) 69 newEmailInUse = result.inUse 70 } catch { 71 newEmailInUse = false 72 } 73 } 74 let handleLoading = $state(false) 75 let newHandle = $state('') 76 let deleteLoading = $state(false) 77 let deletePassword = $state('') 78 let deleteToken = $state('') 79 let deleteTokenSent = $state(false) 80 let exportLoading = $state(false) 81 let exportBlobsLoading = $state(false) 82 let passwordLoading = $state(false) 83 let currentPassword = $state('') 84 let newPassword = $state('') 85 let confirmNewPassword = $state('') 86 let showBYOHandle = $state(false) 87 let hasPassword = $state(true) 88 let passwordStatusLoading = $state(true) 89 let setPasswordLoading = $state(false) 90 let showReauthModal = $state(false) 91 let reauthMethods = $state<string[]>(['passkey']) 92 let pendingAction = $state<(() => Promise<void>) | null>(null) 93 94 $effect(() => { 95 if (!loading && !session) { 96 navigate(routes.login) 97 } 98 }) 99 100 $effect(() => { 101 if (session) { 102 loadPasswordStatus() 103 } 104 }) 105 106 async function loadPasswordStatus() { 107 if (!session) return 108 passwordStatusLoading = true 109 try { 110 const status = await api.getPasswordStatus(session.accessJwt) 111 hasPassword = status.hasPassword 112 } catch { 113 hasPassword = true 114 } finally { 115 passwordStatusLoading = false 116 } 117 } 118 119 async function handleRequestEmailUpdate() { 120 if (!session || !newEmail.trim()) return 121 emailLoading = true 122 try { 123 const result = await api.requestEmailUpdate(session.accessJwt, newEmail.trim()) 124 emailTokenRequired = result.tokenRequired 125 if (emailTokenRequired) { 126 toast.success($_('settings.messages.emailCodeSentToCurrent')) 127 startEmailPolling() 128 } else { 129 emailTokenRequired = true 130 } 131 } catch (e) { 132 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) 133 } finally { 134 emailLoading = false 135 } 136 } 137 138 function startEmailPolling() { 139 if (emailPollingInterval) return 140 emailPollingInterval = setInterval(async () => { 141 if (!session) return 142 try { 143 const status = await api.checkEmailUpdateStatus(session.accessJwt) 144 if (status.authorized) { 145 emailUpdateAuthorized = true 146 stopEmailPolling() 147 await completeAuthorizedEmailUpdate() 148 } 149 } catch { 150 } 151 }, 3000) 152 } 153 154 function stopEmailPolling() { 155 if (emailPollingInterval) { 156 clearInterval(emailPollingInterval) 157 emailPollingInterval = null 158 } 159 } 160 161 async function completeAuthorizedEmailUpdate() { 162 if (!session || !newEmail.trim()) return 163 emailLoading = true 164 try { 165 await api.updateEmail(session.accessJwt, newEmail.trim()) 166 await refreshSession() 167 toast.success($_('settings.messages.emailUpdated')) 168 newEmail = '' 169 emailToken = '' 170 emailTokenRequired = false 171 emailUpdateAuthorized = false 172 } catch (e) { 173 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) 174 } finally { 175 emailLoading = false 176 } 177 } 178 179 async function handleConfirmEmailUpdate(e: Event) { 180 e.preventDefault() 181 if (!session || !newEmail || !emailToken) return 182 emailLoading = true 183 try { 184 await api.updateEmail(session.accessJwt, newEmail, emailToken) 185 await refreshSession() 186 toast.success($_('settings.messages.emailUpdated')) 187 newEmail = '' 188 emailToken = '' 189 emailTokenRequired = false 190 } catch (e) { 191 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) 192 } finally { 193 emailLoading = false 194 } 195 } 196 197 async function handleUpdateHandle(e: Event) { 198 e.preventDefault() 199 if (!session || !newHandle) return 200 handleLoading = true 201 try { 202 const fullHandle = showBYOHandle 203 ? newHandle 204 : `${newHandle}.${pdsHostname}` 205 await api.updateHandle(session.accessJwt, unsafeAsHandle(fullHandle)) 206 await refreshSession() 207 toast.success($_('settings.messages.handleUpdated')) 208 newHandle = '' 209 } catch (e) { 210 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.handleUpdateFailed')) 211 } finally { 212 handleLoading = false 213 } 214 } 215 216 async function handleRequestDelete() { 217 if (!session) return 218 deleteLoading = true 219 try { 220 await api.requestAccountDelete(session.accessJwt) 221 deleteTokenSent = true 222 toast.success($_('settings.messages.deletionConfirmationSent')) 223 } catch (e) { 224 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.deletionRequestFailed')) 225 } finally { 226 deleteLoading = false 227 } 228 } 229 230 async function handleConfirmDelete(e: Event) { 231 e.preventDefault() 232 if (!session || !deletePassword || !deleteToken) return 233 if (!confirm($_('settings.messages.deleteConfirmation'))) { 234 return 235 } 236 deleteLoading = true 237 try { 238 await api.deleteAccount(session.did, deletePassword, deleteToken) 239 await logout() 240 navigate(routes.login) 241 } catch (e) { 242 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.deletionFailed')) 243 } finally { 244 deleteLoading = false 245 } 246 } 247 248 async function handleExportRepo() { 249 if (!session) return 250 exportLoading = true 251 try { 252 const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(session.did)}`, { 253 headers: { 254 'Authorization': `Bearer ${session.accessJwt}` 255 } 256 }) 257 if (!response.ok) { 258 const err = await response.json().catch(() => ({ message: 'Export failed' })) 259 throw new Error(err.message || 'Export failed') 260 } 261 const blob = await response.blob() 262 const url = URL.createObjectURL(blob) 263 const a = document.createElement('a') 264 a.href = url 265 a.download = `${session.handle}-repo.car` 266 document.body.appendChild(a) 267 a.click() 268 document.body.removeChild(a) 269 URL.revokeObjectURL(url) 270 toast.success($_('settings.messages.repoExported')) 271 } catch (e) { 272 toast.error(e instanceof Error ? e.message : $_('settings.messages.exportFailed')) 273 } finally { 274 exportLoading = false 275 } 276 } 277 278 async function handleExportBlobs() { 279 if (!session) return 280 exportBlobsLoading = true 281 try { 282 const response = await fetch('/xrpc/_backup.exportBlobs', { 283 headers: { 284 'Authorization': `Bearer ${session.accessJwt}` 285 } 286 }) 287 if (!response.ok) { 288 const err = await response.json().catch(() => ({ message: 'Export failed' })) 289 throw new Error(err.message || 'Export failed') 290 } 291 const blob = await response.blob() 292 if (blob.size === 0) { 293 toast.success($_('settings.messages.noBlobsToExport')) 294 return 295 } 296 const url = URL.createObjectURL(blob) 297 const a = document.createElement('a') 298 a.href = url 299 a.download = `${session.handle}-blobs.zip` 300 document.body.appendChild(a) 301 a.click() 302 document.body.removeChild(a) 303 URL.revokeObjectURL(url) 304 toast.success($_('settings.messages.blobsExported')) 305 } catch (e) { 306 toast.error(e instanceof Error ? e.message : $_('settings.messages.exportFailed')) 307 } finally { 308 exportBlobsLoading = false 309 } 310 } 311 312 interface BackupInfo { 313 id: string 314 repoRev: string 315 repoRootCid: string 316 blockCount: number 317 sizeBytes: number 318 createdAt: string 319 } 320 let backups = $state<BackupInfo[]>([]) 321 let backupEnabled = $state(true) 322 let backupsLoading = $state(false) 323 let createBackupLoading = $state(false) 324 let restoreFile = $state<File | null>(null) 325 let restoreLoading = $state(false) 326 327 async function loadBackups() { 328 if (!session) return 329 backupsLoading = true 330 try { 331 const result = await api.listBackups(session.accessJwt) 332 backups = result.backups 333 backupEnabled = result.backupEnabled 334 } catch (e) { 335 console.error('Failed to load backups:', e) 336 } finally { 337 backupsLoading = false 338 } 339 } 340 341 onMount(() => { 342 loadBackups() 343 }) 344 345 async function handleToggleBackup() { 346 if (!session) return 347 const newEnabled = !backupEnabled 348 backupsLoading = true 349 try { 350 await api.setBackupEnabled(session.accessJwt, newEnabled) 351 backupEnabled = newEnabled 352 toast.success(newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled')) 353 } catch (e) { 354 toast.error(e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed')) 355 } finally { 356 backupsLoading = false 357 } 358 } 359 360 async function handleCreateBackup() { 361 if (!session) return 362 createBackupLoading = true 363 try { 364 await api.createBackup(session.accessJwt) 365 await loadBackups() 366 toast.success($_('settings.backups.created')) 367 } catch (e) { 368 toast.error(e instanceof ApiError ? e.message : $_('settings.backups.createFailed')) 369 } finally { 370 createBackupLoading = false 371 } 372 } 373 374 async function handleDownloadBackup(id: string, rev: string) { 375 if (!session) return 376 try { 377 const blob = await api.getBackup(session.accessJwt, id) 378 const url = URL.createObjectURL(blob) 379 const a = document.createElement('a') 380 a.href = url 381 a.download = `${session.handle}-${rev}.car` 382 document.body.appendChild(a) 383 a.click() 384 document.body.removeChild(a) 385 URL.revokeObjectURL(url) 386 } catch (e) { 387 toast.error(e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed')) 388 } 389 } 390 391 async function handleDeleteBackup(id: string) { 392 if (!session) return 393 try { 394 await api.deleteBackup(session.accessJwt, id) 395 await loadBackups() 396 toast.success($_('settings.backups.deleted')) 397 } catch (e) { 398 toast.error(e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed')) 399 } 400 } 401 402 function handleFileSelect(e: Event) { 403 const input = e.target as HTMLInputElement 404 if (input.files && input.files.length > 0) { 405 restoreFile = input.files[0] 406 } 407 } 408 409 async function handleRestore() { 410 if (!session || !restoreFile) return 411 restoreLoading = true 412 try { 413 const buffer = await restoreFile.arrayBuffer() 414 const car = new Uint8Array(buffer) 415 await api.importRepo(session.accessJwt, car) 416 toast.success($_('settings.backups.restored')) 417 restoreFile = null 418 } catch (e) { 419 toast.error(e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed')) 420 } finally { 421 restoreLoading = false 422 } 423 } 424 425 function formatBytes(bytes: number): string { 426 if (bytes < 1024) return `${bytes} B` 427 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` 428 return `${(bytes / (1024 * 1024)).toFixed(1)} MB` 429 } 430 431 function formatDate(iso: string): string { 432 return new Date(iso).toLocaleDateString(undefined, { 433 year: 'numeric', 434 month: 'short', 435 day: 'numeric', 436 hour: '2-digit', 437 minute: '2-digit' 438 }) 439 } 440 441 async function handleChangePassword(e: Event) { 442 e.preventDefault() 443 if (!session || !currentPassword || !newPassword || !confirmNewPassword) return 444 if (newPassword !== confirmNewPassword) { 445 toast.error($_('settings.messages.passwordsDoNotMatch')) 446 return 447 } 448 if (newPassword.length < 8) { 449 toast.error($_('settings.messages.passwordTooShort')) 450 return 451 } 452 passwordLoading = true 453 try { 454 await api.changePassword(session.accessJwt, currentPassword, newPassword) 455 toast.success($_('settings.messages.passwordChanged')) 456 currentPassword = '' 457 newPassword = '' 458 confirmNewPassword = '' 459 } catch (e) { 460 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.passwordChangeFailed')) 461 } finally { 462 passwordLoading = false 463 } 464 } 465 466 async function handleSetPassword(e: Event) { 467 e.preventDefault() 468 if (!session || !newPassword || !confirmNewPassword) return 469 if (newPassword !== confirmNewPassword) { 470 toast.error($_('settings.messages.passwordsDoNotMatch')) 471 return 472 } 473 if (newPassword.length < 8) { 474 toast.error($_('settings.messages.passwordTooShort')) 475 return 476 } 477 setPasswordLoading = true 478 try { 479 await api.setPassword(session.accessJwt, newPassword) 480 toast.success($_('settings.messages.passwordSet')) 481 hasPassword = true 482 newPassword = '' 483 confirmNewPassword = '' 484 } catch (e) { 485 if (e instanceof ApiError) { 486 if (e.error === 'ReauthRequired') { 487 reauthMethods = e.reauthMethods || ['passkey'] 488 pendingAction = () => handleSetPassword(new Event('submit')) 489 showReauthModal = true 490 } else { 491 toast.error(e.message) 492 } 493 } else { 494 toast.error($_('settings.messages.passwordSetFailed')) 495 } 496 } finally { 497 setPasswordLoading = false 498 } 499 } 500 501 function handleReauthSuccess() { 502 if (pendingAction) { 503 pendingAction() 504 pendingAction = null 505 } 506 } 507 508 function handleReauthCancel() { 509 pendingAction = null 510 } 511</script> 512<div class="page"> 513 <header> 514 <a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a> 515 <h1>{$_('settings.title')}</h1> 516 </header> 517 <div class="sections-grid"> 518 <section> 519 <h2>{$_('settings.language')}</h2> 520 <p class="description">{$_('settings.languageDescription')}</p> 521 <select 522 class="language-select" 523 value={$locale} 524 disabled={localeLoading} 525 onchange={(e) => handleLocaleChange(e.currentTarget.value as SupportedLocale)} 526 > 527 {#each supportedLocales as loc} 528 <option value={loc}>{localeNames[loc]}</option> 529 {/each} 530 </select> 531 </section> 532 <section> 533 <h2>{$_('settings.changeEmail')}</h2> 534 {#if session?.email} 535 <p class="current">{$_('settings.currentEmail', { values: { email: session.email } })}</p> 536 {/if} 537 {#if emailTokenRequired} 538 <form onsubmit={handleConfirmEmailUpdate}> 539 {#if emailUpdateAuthorized} 540 <p class="hint success">{$_('settings.emailUpdateAuthorized')}</p> 541 {:else} 542 <div class="field"> 543 <label for="email-token">{$_('settings.verificationCode')}</label> 544 <input 545 id="email-token" 546 type="text" 547 bind:value={emailToken} 548 placeholder={$_('settings.verificationCodePlaceholder')} 549 disabled={emailLoading} 550 /> 551 <p class="hint">{$_('settings.emailTokenHint')}</p> 552 </div> 553 {/if} 554 <div class="field"> 555 <label for="new-email">{$_('settings.newEmail')}</label> 556 <input 557 id="new-email" 558 type="email" 559 bind:value={newEmail} 560 onblur={checkNewEmailInUse} 561 placeholder={$_('settings.newEmailPlaceholder')} 562 disabled={emailLoading || emailUpdateAuthorized} 563 required 564 /> 565 {#if newEmailInUse} 566 <p class="hint warning">{$_('settings.emailInUseWarning')}</p> 567 {/if} 568 </div> 569 <div class="actions"> 570 <button type="submit" disabled={emailLoading || (!emailToken && !emailUpdateAuthorized) || !newEmail}> 571 {emailLoading ? $_('settings.updating') : $_('settings.confirmEmailChange')} 572 </button> 573 <button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = ''; newEmail = ''; emailUpdateAuthorized = false; stopEmailPolling() }}> 574 {$_('common.cancel')} 575 </button> 576 </div> 577 </form> 578 {:else} 579 <form onsubmit={(e) => { e.preventDefault(); handleRequestEmailUpdate() }}> 580 <div class="field"> 581 <label for="new-email">{$_('settings.newEmail')}</label> 582 <input 583 id="new-email" 584 type="email" 585 bind:value={newEmail} 586 onblur={checkNewEmailInUse} 587 placeholder={$_('settings.newEmailPlaceholder')} 588 disabled={emailLoading} 589 required 590 /> 591 {#if newEmailInUse} 592 <p class="hint warning">{$_('settings.emailInUseWarning')}</p> 593 {/if} 594 </div> 595 <button type="submit" disabled={emailLoading || !newEmail.trim()}> 596 {emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')} 597 </button> 598 </form> 599 {/if} 600 </section> 601 <section> 602 <h2>{$_('settings.changeHandle')}</h2> 603 {#if session} 604 <p class="current">{$_('settings.currentHandle', { values: { handle: session.handle } })}</p> 605 {/if} 606 <div class="tabs"> 607 <button 608 type="button" 609 class="tab" 610 class:active={!showBYOHandle} 611 onclick={() => showBYOHandle = false} 612 > 613 {$_('settings.pdsHandle')} 614 </button> 615 <button 616 type="button" 617 class="tab" 618 class:active={showBYOHandle} 619 onclick={() => showBYOHandle = true} 620 > 621 {$_('settings.customDomain')} 622 </button> 623 </div> 624 {#if showBYOHandle} 625 <div class="byo-handle"> 626 <p class="description">{$_('settings.customDomainDescription')}</p> 627 {#if session} 628 <div class="verification-info"> 629 <h3>{$_('settings.setupInstructions')}</h3> 630 <p>{$_('settings.setupMethodsIntro')}</p> 631 <div class="method"> 632 <h4>{$_('settings.dnsMethod')}</h4> 633 <p>{$_('settings.dnsMethodDesc')}</p> 634 <code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={session.did}"</code> 635 </div> 636 <div class="method"> 637 <h4>{$_('settings.httpMethod')}</h4> 638 <p>{$_('settings.httpMethodDesc')}</p> 639 <code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code> 640 <p>{$_('settings.httpMethodContent')}</p> 641 <code class="record">{session.did}</code> 642 </div> 643 </div> 644 {/if} 645 <form onsubmit={handleUpdateHandle}> 646 <div class="field"> 647 <label for="new-handle-byo">{$_('settings.yourDomain')}</label> 648 <input 649 id="new-handle-byo" 650 type="text" 651 bind:value={newHandle} 652 placeholder={$_('settings.yourDomainPlaceholder')} 653 disabled={handleLoading} 654 required 655 /> 656 </div> 657 <button type="submit" disabled={handleLoading || !newHandle}> 658 {handleLoading ? $_('common.verifying') : $_('settings.verifyAndUpdate')} 659 </button> 660 </form> 661 </div> 662 {:else} 663 <form onsubmit={handleUpdateHandle}> 664 <div class="field"> 665 <label for="new-handle">{$_('settings.newHandle')}</label> 666 <div class="handle-input-wrapper"> 667 <input 668 id="new-handle" 669 type="text" 670 bind:value={newHandle} 671 placeholder={$_('settings.newHandlePlaceholder')} 672 disabled={handleLoading} 673 required 674 /> 675 <span class="handle-suffix">.{pdsHostname ?? '...'}</span> 676 </div> 677 </div> 678 <button type="submit" disabled={handleLoading || !newHandle || !pdsHostname}> 679 {handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')} 680 </button> 681 </form> 682 {/if} 683 </section> 684 {#if !passwordStatusLoading} 685 {#if hasPassword} 686 <section> 687 <h2>{$_('settings.changePassword')}</h2> 688 <form onsubmit={handleChangePassword}> 689 <div class="field"> 690 <label for="current-password">{$_('settings.currentPassword')}</label> 691 <input 692 id="current-password" 693 type="password" 694 bind:value={currentPassword} 695 placeholder={$_('settings.currentPasswordPlaceholder')} 696 disabled={passwordLoading} 697 required 698 /> 699 </div> 700 <div class="field"> 701 <label for="new-password">{$_('settings.newPassword')}</label> 702 <input 703 id="new-password" 704 type="password" 705 bind:value={newPassword} 706 placeholder={$_('settings.newPasswordPlaceholder')} 707 disabled={passwordLoading} 708 required 709 minlength="8" 710 /> 711 </div> 712 <div class="field"> 713 <label for="confirm-new-password">{$_('settings.confirmNewPassword')}</label> 714 <input 715 id="confirm-new-password" 716 type="password" 717 bind:value={confirmNewPassword} 718 placeholder={$_('settings.confirmNewPasswordPlaceholder')} 719 disabled={passwordLoading} 720 required 721 /> 722 </div> 723 <button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}> 724 {passwordLoading ? $_('settings.changing') : $_('settings.changePasswordButton')} 725 </button> 726 </form> 727 </section> 728 {:else} 729 <section> 730 <h2>{$_('settings.setPassword')}</h2> 731 <p class="description">{$_('settings.setPasswordDescription')}</p> 732 <form onsubmit={handleSetPassword}> 733 <div class="field"> 734 <label for="set-new-password">{$_('settings.newPassword')}</label> 735 <input 736 id="set-new-password" 737 type="password" 738 bind:value={newPassword} 739 placeholder={$_('settings.newPasswordPlaceholder')} 740 disabled={setPasswordLoading} 741 required 742 minlength="8" 743 /> 744 </div> 745 <div class="field"> 746 <label for="set-confirm-password">{$_('settings.confirmNewPassword')}</label> 747 <input 748 id="set-confirm-password" 749 type="password" 750 bind:value={confirmNewPassword} 751 placeholder={$_('settings.confirmNewPasswordPlaceholder')} 752 disabled={setPasswordLoading} 753 required 754 /> 755 </div> 756 <button type="submit" disabled={setPasswordLoading || !newPassword || !confirmNewPassword}> 757 {setPasswordLoading ? $_('settings.setting') : $_('settings.setPasswordButton')} 758 </button> 759 </form> 760 </section> 761 {/if} 762 {/if} 763 <section> 764 <h2>{$_('settings.exportData')}</h2> 765 <p class="description">{$_('settings.exportDataDescription')}</p> 766 <div class="export-buttons"> 767 <button onclick={handleExportRepo} disabled={exportLoading}> 768 {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 769 </button> 770 <button onclick={handleExportBlobs} disabled={exportBlobsLoading} class="secondary"> 771 {exportBlobsLoading ? $_('settings.exporting') : $_('settings.downloadBlobs')} 772 </button> 773 </div> 774 </section> 775 <section class="backups-section"> 776 <h2>{$_('settings.backups.title')}</h2> 777 <p class="description">{$_('settings.backups.description')}</p> 778 779 <label class="checkbox-label"> 780 <input type="checkbox" checked={backupEnabled} onchange={handleToggleBackup} disabled={backupsLoading} /> 781 <span>{$_('settings.backups.enableAutomatic')}</span> 782 </label> 783 784 {#if !backupsLoading && backups.length > 0} 785 <ul class="backup-list"> 786 {#each backups as backup} 787 <li class="backup-item"> 788 <div class="backup-info"> 789 <span class="backup-date">{formatDate(backup.createdAt)}</span> 790 <span class="backup-size">{formatBytes(backup.sizeBytes)}</span> 791 <span class="backup-blocks">{backup.blockCount} {$_('settings.backups.blocks')}</span> 792 </div> 793 <div class="backup-actions"> 794 <button class="small" onclick={() => handleDownloadBackup(backup.id, backup.repoRev)}> 795 {$_('settings.backups.download')} 796 </button> 797 <button class="small danger" onclick={() => handleDeleteBackup(backup.id)}> 798 {$_('settings.backups.delete')} 799 </button> 800 </div> 801 </li> 802 {/each} 803 </ul> 804 {:else} 805 <p class="empty">{$_('settings.backups.noBackups')}</p> 806 {/if} 807 808 <button onclick={handleCreateBackup} disabled={createBackupLoading || !backupEnabled}> 809 {createBackupLoading ? $_('common.creating') : $_('settings.backups.createNow')} 810 </button> 811 </section> 812 <section class="restore-section"> 813 <h2>{$_('settings.backups.restoreTitle')}</h2> 814 <p class="description">{$_('settings.backups.restoreDescription')}</p> 815 816 <div class="field"> 817 <label for="restore-file">{$_('settings.backups.selectFile')}</label> 818 <input 819 id="restore-file" 820 type="file" 821 accept=".car" 822 onchange={handleFileSelect} 823 disabled={restoreLoading} 824 /> 825 </div> 826 827 {#if restoreFile} 828 <div class="restore-preview"> 829 <p>{$_('settings.backups.selectedFile')}: {restoreFile.name} ({formatBytes(restoreFile.size)})</p> 830 <button onclick={handleRestore} disabled={restoreLoading} class="danger"> 831 {restoreLoading ? $_('settings.backups.restoring') : $_('settings.backups.restore')} 832 </button> 833 </div> 834 {/if} 835 </section> 836 </div> 837 <section class="danger-zone"> 838 <h2>{$_('settings.deleteAccount')}</h2> 839 <p class="warning">{$_('settings.deleteWarning')}</p> 840 {#if deleteTokenSent} 841 <form onsubmit={handleConfirmDelete}> 842 <div class="field"> 843 <label for="delete-token">{$_('settings.confirmationCode')}</label> 844 <input 845 id="delete-token" 846 type="text" 847 bind:value={deleteToken} 848 placeholder={$_('settings.confirmationCodePlaceholder')} 849 disabled={deleteLoading} 850 required 851 /> 852 </div> 853 <div class="field"> 854 <label for="delete-password">{$_('settings.yourPassword')}</label> 855 <input 856 id="delete-password" 857 type="password" 858 bind:value={deletePassword} 859 placeholder={$_('settings.yourPasswordPlaceholder')} 860 disabled={deleteLoading} 861 required 862 /> 863 </div> 864 <div class="actions"> 865 <button type="submit" class="danger" disabled={deleteLoading || !deleteToken || !deletePassword}> 866 {deleteLoading ? $_('settings.deleting') : $_('settings.permanentlyDelete')} 867 </button> 868 <button type="button" class="secondary" onclick={() => { deleteTokenSent = false; deleteToken = ''; deletePassword = '' }}> 869 {$_('common.cancel')} 870 </button> 871 </div> 872 </form> 873 {:else} 874 <button class="danger" onclick={handleRequestDelete} disabled={deleteLoading}> 875 {deleteLoading ? $_('settings.requesting') : $_('settings.requestDeletion')} 876 </button> 877 {/if} 878 </section> 879</div> 880 881{#if showReauthModal && session} 882 <ReauthModal 883 bind:show={showReauthModal} 884 availableMethods={reauthMethods} 885 onSuccess={handleReauthSuccess} 886 onCancel={handleReauthCancel} 887 /> 888{/if} 889<style> 890 .page { 891 max-width: var(--width-lg); 892 margin: 0 auto; 893 padding: var(--space-7); 894 } 895 896 header { 897 margin-bottom: var(--space-7); 898 } 899 900 .sections-grid { 901 display: flex; 902 flex-direction: column; 903 gap: var(--space-6); 904 } 905 906 @media (min-width: 800px) { 907 .sections-grid { 908 columns: 2; 909 column-gap: var(--space-6); 910 display: block; 911 } 912 913 .sections-grid section { 914 break-inside: avoid; 915 margin-bottom: var(--space-6); 916 } 917 } 918 919 .back { 920 color: var(--text-secondary); 921 text-decoration: none; 922 font-size: var(--text-sm); 923 } 924 925 .back:hover { 926 color: var(--accent); 927 } 928 929 h1 { 930 margin: var(--space-2) 0 0 0; 931 } 932 933 section { 934 padding: var(--space-6); 935 background: var(--bg-secondary); 936 border-radius: var(--radius-xl); 937 margin-bottom: var(--space-6); 938 height: fit-content; 939 } 940 941 .danger-zone { 942 margin-top: var(--space-6); 943 } 944 945 section h2 { 946 margin: 0 0 var(--space-2) 0; 947 font-size: var(--text-lg); 948 } 949 950 .current, 951 .description { 952 color: var(--text-secondary); 953 font-size: var(--text-sm); 954 margin-bottom: var(--space-4); 955 } 956 957 .language-select { 958 width: 100%; 959 } 960 961 form > button, 962 form > .actions { 963 margin-top: var(--space-4); 964 } 965 966 .actions { 967 display: flex; 968 gap: var(--space-2); 969 } 970 971 .danger-zone { 972 background: var(--error-bg); 973 border: 1px solid var(--error-border); 974 } 975 976 .danger-zone h2 { 977 color: var(--error-text); 978 } 979 980 .warning { 981 color: var(--error-text); 982 font-size: var(--text-sm); 983 margin-bottom: var(--space-4); 984 } 985 986 .tabs { 987 display: flex; 988 gap: var(--space-1); 989 margin-bottom: var(--space-4); 990 } 991 992 .tab { 993 flex: 1; 994 padding: var(--space-2) var(--space-4); 995 background: transparent; 996 border: 1px solid var(--border-color); 997 cursor: pointer; 998 font-size: var(--text-sm); 999 color: var(--text-secondary); 1000 } 1001 1002 .tab:first-child { 1003 border-radius: var(--radius-md) 0 0 var(--radius-md); 1004 } 1005 1006 .tab:last-child { 1007 border-radius: 0 var(--radius-md) var(--radius-md) 0; 1008 } 1009 1010 .tab.active { 1011 background: var(--accent); 1012 border-color: var(--accent); 1013 color: var(--text-inverse); 1014 } 1015 1016 .tab:hover:not(.active) { 1017 background: var(--bg-card); 1018 } 1019 1020 .byo-handle .description { 1021 margin-bottom: var(--space-4); 1022 } 1023 1024 .verification-info { 1025 background: var(--bg-card); 1026 border: 1px solid var(--border-color); 1027 border-radius: var(--radius-lg); 1028 padding: var(--space-4); 1029 margin-bottom: var(--space-4); 1030 } 1031 1032 .verification-info h3 { 1033 margin: 0 0 var(--space-2) 0; 1034 font-size: var(--text-base); 1035 } 1036 1037 .verification-info h4 { 1038 margin: var(--space-3) 0 var(--space-1) 0; 1039 font-size: var(--text-sm); 1040 color: var(--text-secondary); 1041 } 1042 1043 .verification-info p { 1044 margin: var(--space-1) 0; 1045 font-size: var(--text-xs); 1046 color: var(--text-secondary); 1047 } 1048 1049 .method { 1050 margin-top: var(--space-3); 1051 padding-top: var(--space-3); 1052 border-top: 1px solid var(--border-color); 1053 } 1054 1055 .method:first-of-type { 1056 margin-top: var(--space-2); 1057 padding-top: 0; 1058 border-top: none; 1059 } 1060 1061 code.record { 1062 display: block; 1063 background: var(--bg-input); 1064 padding: var(--space-2); 1065 border-radius: var(--radius-md); 1066 font-size: var(--text-xs); 1067 word-break: break-all; 1068 margin: var(--space-1) 0; 1069 } 1070 1071 .handle-input-wrapper { 1072 display: flex; 1073 align-items: center; 1074 background: var(--bg-input); 1075 border: 1px solid var(--border-color); 1076 border-radius: var(--radius-md); 1077 overflow: hidden; 1078 } 1079 1080 .handle-input-wrapper input { 1081 flex: 1; 1082 border: none; 1083 border-radius: 0; 1084 background: transparent; 1085 min-width: 0; 1086 } 1087 1088 .handle-input-wrapper input:focus { 1089 outline: none; 1090 box-shadow: none; 1091 } 1092 1093 .handle-input-wrapper:focus-within { 1094 border-color: var(--accent); 1095 box-shadow: 0 0 0 2px var(--accent-muted); 1096 } 1097 1098 .handle-suffix { 1099 padding: 0 var(--space-3); 1100 color: var(--text-secondary); 1101 font-size: var(--text-sm); 1102 white-space: nowrap; 1103 border-left: 1px solid var(--border-color); 1104 background: var(--bg-card); 1105 } 1106 1107 .checkbox-label { 1108 display: flex; 1109 align-items: center; 1110 gap: var(--space-2); 1111 cursor: pointer; 1112 margin-bottom: var(--space-4); 1113 } 1114 1115 .checkbox-label input[type="checkbox"] { 1116 width: 18px; 1117 height: 18px; 1118 cursor: pointer; 1119 } 1120 1121 .backup-list { 1122 list-style: none; 1123 padding: 0; 1124 margin: 0 0 var(--space-4) 0; 1125 display: flex; 1126 flex-direction: column; 1127 gap: var(--space-2); 1128 } 1129 1130 .backup-item { 1131 display: flex; 1132 justify-content: space-between; 1133 align-items: center; 1134 padding: var(--space-3); 1135 background: var(--bg-card); 1136 border: 1px solid var(--border-color); 1137 border-radius: var(--radius-md); 1138 gap: var(--space-4); 1139 } 1140 1141 .backup-info { 1142 display: flex; 1143 gap: var(--space-4); 1144 font-size: var(--text-sm); 1145 flex-wrap: wrap; 1146 } 1147 1148 .backup-date { 1149 font-weight: 500; 1150 } 1151 1152 .backup-size, 1153 .backup-blocks { 1154 color: var(--text-secondary); 1155 } 1156 1157 .backup-actions { 1158 display: flex; 1159 gap: var(--space-2); 1160 flex-shrink: 0; 1161 } 1162 1163 button.small { 1164 padding: var(--space-1) var(--space-2); 1165 font-size: var(--text-xs); 1166 } 1167 1168 .empty { 1169 color: var(--text-secondary); 1170 font-size: var(--text-sm); 1171 margin-bottom: var(--space-4); 1172 } 1173 1174 .restore-preview { 1175 background: var(--bg-card); 1176 border: 1px solid var(--border-color); 1177 border-radius: var(--radius-md); 1178 padding: var(--space-4); 1179 margin-top: var(--space-3); 1180 } 1181 1182 .restore-preview p { 1183 margin: 0 0 var(--space-3) 0; 1184 font-size: var(--text-sm); 1185 } 1186 1187 .export-buttons { 1188 display: flex; 1189 gap: var(--space-2); 1190 flex-wrap: wrap; 1191 } 1192 1193 @media (max-width: 640px) { 1194 .backup-item { 1195 flex-direction: column; 1196 align-items: flex-start; 1197 } 1198 1199 .backup-actions { 1200 width: 100%; 1201 margin-top: var(--space-2); 1202 } 1203 1204 .backup-actions button { 1205 flex: 1; 1206 } 1207 } 1208</style>