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 .loading {
965 color: var(--text-secondary);
966 font-size: var(--text-sm);
967 margin-bottom: var(--space-4);
968 }
969
970 .restore-preview {
971 background: var(--bg-card);
972 border: 1px solid var(--border-color);
973 border-radius: var(--radius-md);
974 padding: var(--space-4);
975 margin-top: var(--space-3);
976 }
977
978 .restore-preview p {
979 margin: 0 0 var(--space-3) 0;
980 font-size: var(--text-sm);
981 }
982
983 .export-buttons {
984 display: flex;
985 gap: var(--space-2);
986 flex-wrap: wrap;
987 }
988
989 @media (max-width: 640px) {
990 .backup-item {
991 flex-direction: column;
992 align-items: flex-start;
993 }
994
995 .backup-actions {
996 width: 100%;
997 margin-top: var(--space-2);
998 }
999
1000 .backup-actions button {
1001 flex: 1;
1002 }
1003 }
1004</style>