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