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>