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