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