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