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