Files
donetick/src/views/Modals/Inputs/BackupRestoreModal.jsx
everysingletear 0e98a556da i18n: extract the settings screens your pass didn't reach (settings)
Part of #145.

`8c9bf0d` localized the settings views themselves; these eight files sit
in the same zone but were not in that pass: the subscription modal, the
child-user creation and password-change modals, both payment result
views, developer settings, and the backup/restore and account-deletion
modals.

Extends the `settings` namespace you established, following its key
naming — 59 keys added to `en/settings.json`. `src/i18n/config.js` is
untouched.

English only — no translations, no behaviour change. Every t() value is
checked against this branch's base: the string must appear
character-for-character in the code it replaces (71 call sites).

One deliberate structural detail: in `BackupRestoreModal` the warning
reads `<strong>Warning:</strong> Restoring a backup will…`. The label and
the sentence are separate keys and the `<strong>` stays in the JSX, so the
emphasis survives and no markup ends up in the dictionary.

If you'd rather keep developer settings hardcoded, say so and I'll drop
that file — it is the one screen here an end user never sees.
2026-08-13 10:18:10 +08:00

346 lines
8.8 KiB
JavaScript

import {
Box,
Checkbox,
CircularProgress,
FormControl,
FormLabel,
Input,
Tab,
TabList,
TabPanel,
Tabs,
Typography,
} from '@mui/joy'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useRef, useState } from 'react'
import ModalActions from '../../../components/common/ModalActions'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { CreateBackup, RestoreBackup } from '../../../utils/Fetcher'
function BackupRestoreModal({ isOpen, onClose, showNotification }) {
const { t } = useTranslation('settings')
const { ResponsiveModal } = useResponsiveModal()
const [activeTab, setActiveTab] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
// Backup state
const [encryptionKey, setEncryptionKey] = useState('')
const [backupName, setBackupName] = useState('')
const [includeAssets, setIncludeAssets] = useState(true)
// Restore state
const [restoreEncryptionKey, setRestoreEncryptionKey] = useState('')
const [backupFile, setBackupFile] = useState(null)
const fileInputRef = useRef(null)
const resetModal = useCallback(() => {
setActiveTab(0)
setEncryptionKey('')
setBackupName('')
setIncludeAssets(true)
setRestoreEncryptionKey('')
setBackupFile(null)
setError('')
setLoading(false)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}, [])
const handleClose = useCallback(() => {
resetModal()
onClose()
}, [onClose, resetModal])
const downloadFile = (data, filename) => {
const blob = new Blob([data], { type: 'application/octet-stream' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
const handleCreateBackup = async () => {
if (!encryptionKey.trim()) {
setError(t('backup.keyRequired'))
return
}
setLoading(true)
setError('')
try {
const response = await CreateBackup(
encryptionKey,
includeAssets,
backupName,
)
if (response.ok) {
const data = await response.json()
const timestamp = new Date()
.toISOString()
.slice(0, 19)
.replace(/:/g, '-')
const filename = backupName
? `${backupName}-${timestamp}.backup`
: `donetick-backup-${timestamp}.backup`
// Download the backup file
downloadFile(data.backup_data, filename)
showNotification({
type: 'success',
message: t('backup.created'),
})
handleClose()
} else {
const errorData = await response.json()
setError(errorData.message || 'Failed to create backup')
}
} catch (err) {
setError(t('backup.createFailed'))
} finally {
setLoading(false)
}
}
const handleFileUpload = event => {
const file = event.target.files[0]
if (file) {
setBackupFile(file)
setError('')
}
}
const handleRestore = async () => {
if (!restoreEncryptionKey.trim()) {
setError(t('backup.keyRequired'))
return
}
if (!backupFile) {
setError(t('backup.selectFile'))
return
}
setLoading(true)
setError('')
try {
const reader = new FileReader()
reader.onload = async e => {
try {
const backupData = e.target.result
const response = await RestoreBackup(restoreEncryptionKey, backupData)
if (response.ok) {
showNotification({
type: 'success',
message: t('backup.restored'),
})
// Refresh the page after a short delay to allow user to see the message
setTimeout(() => {
window.location.reload()
}, 2000)
handleClose()
} else {
const errorData = await response.json()
setError(errorData.message || 'Failed to restore backup')
}
} catch (err) {
setError(t('backup.restoreFailed'))
} finally {
setLoading(false)
}
}
reader.onerror = () => {
setError(t('backup.readFailed'))
setLoading(false)
}
reader.readAsText(backupFile)
} catch (err) {
setError(t('backup.restoreFailed'))
setLoading(false)
}
}
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = event => {
if (!isOpen) return
if (event.key === 'Escape') {
event.preventDefault()
handleClose()
return
}
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, handleClose])
const renderBackupTab = () => (
<Box>
<Typography level='body-md' mb={3}>
{t('backup.createIntro')}
</Typography>
<FormControl sx={{ mb: 2 }}>
<FormLabel>Encryption Key *</FormLabel>
<Input
type='password'
value={encryptionKey}
onChange={e => setEncryptionKey(e.target.value)}
placeholder={t('backup.keyPlaceholder')}
/>
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Keep this key safeyou&apos;ll need it to restore your backup
</Typography>
</FormControl>
<FormControl sx={{ mb: 2 }}>
<FormLabel>Backup Name (Optional)</FormLabel>
<Input
value={backupName}
onChange={e => setBackupName(e.target.value)}
placeholder='e.g., weekly-backup'
/>
</FormControl>
<FormControl sx={{ mb: 3 }}>
<Checkbox
checked={includeAssets}
onChange={e => setIncludeAssets(e.target.checked)}
label={t('backup.includeAssets')}
/>
</FormControl>
{error && (
<Typography level='body-sm' color='danger' mb={2}>
{error}
</Typography>
)}
</Box>
)
const renderRestoreTab = () => (
<Box>
<Typography level='body-md' mb={3} color='warning'>
<strong>{t('backup.warningLabel')}</strong>{' '}
{t('backup.restoreWarning')}
</Typography>
<FormControl sx={{ mb: 2 }}>
<FormLabel>Backup File *</FormLabel>
<Input
type='file'
accept='.backup'
onChange={handleFileUpload}
ref={fileInputRef}
/>
{backupFile && (
<Typography level='body-xs' sx={{ mt: 0.5 }}>
Selected: {backupFile.name}
</Typography>
)}
</FormControl>
<FormControl sx={{ mb: 3 }}>
<FormLabel>Encryption Key *</FormLabel>
<Input
type='password'
value={restoreEncryptionKey}
onChange={e => setRestoreEncryptionKey(e.target.value)}
placeholder={t('backup.restoreKeyPlaceholder')}
/>
</FormControl>
{error && (
<Typography level='body-sm' color='danger' mb={2}>
{error}
</Typography>
)}
</Box>
)
return (
<ResponsiveModal
open={isOpen}
onClose={handleClose}
size='lg'
fullWidth={true}
unmountDelay={250}
title='Backup & Restore'
closeOnBackdrop={!loading}
closeOnEscape={!loading}
footer={
<ModalActions
secondary={{
label: t('accountSettings.cancel'),
onClick: handleClose,
disabled: loading,
}}
primary={{
label: activeTab === 0 ? 'Create Backup' : 'Restore Backup',
color: activeTab === 0 ? 'primary' : 'warning',
onClick: activeTab === 0 ? handleCreateBackup : handleRestore,
loading,
disabled:
activeTab === 0
? !encryptionKey.trim()
: !restoreEncryptionKey.trim() || !backupFile,
}}
/>
}
>
{loading ? (
<Box
display='flex'
justifyContent='center'
alignItems='center'
minHeight={200}
>
<CircularProgress />
<Typography level='body-md' sx={{ ml: 2 }}>
{activeTab === 0 ? 'Creating backup...' : 'Restoring backup...'}
</Typography>
</Box>
) : (
<Tabs
value={activeTab}
onChange={(event, newValue) => setActiveTab(newValue)}
>
<TabList>
<Tab>Create Backup</Tab>
<Tab>Restore Backup</Tab>
</TabList>
<TabPanel value={0}>{renderBackupTab()}</TabPanel>
<TabPanel value={1}>{renderRestoreTab()}</TabPanel>
</Tabs>
)}
</ResponsiveModal>
)
}
export default BackupRestoreModal