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>