Our Personal Data Server from scratch!
0
fork

Configure Feed

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

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