@@ -306,93 +183,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
)}
-
-
-
-
- {t('settings.backup_restore_title')}
-
- {t('settings.backup_restore_desc')}
-
-
-
- {importPreview && (
-
-
{importPreview.title}
-
- - {t('settings.backup_stat_entries', { count: importPreview.counts.entries })}
- - {t('settings.backup_stat_photos', { count: importPreview.counts.photos })}
- - {t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}
- - {t('settings.backup_stat_crew', { count: importPreview.counts.crews })}
- - {t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}
- -
- {t('settings.backup_stat_size', {
- size: formatBackupBytes(importPreview.totalUncompressedBytes)
- })}
-
-
-
- {t('settings.backup_exported_at', {
- date: formatAppDateTime(importPreview.exportedAt, i18n.language)
- })}
-
-
- )}
-
)
}
diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx
index d0ffde3..dae3bc4 100644
--- a/client/src/components/LogbookDashboard.tsx
+++ b/client/src/components/LogbookDashboard.tsx
@@ -11,11 +11,12 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { logoutUser } from '../services/auth.js'
import { useDialog } from './ModalDialog.tsx'
-import { BookOpen, Plus, Trash2, LogOut, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
+import { BookOpen, Plus, Trash2, LogOut, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown, Upload } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
import AdminHeaderButton from './AdminHeaderButton.tsx'
+import LogbookRestorePanel from './LogbookRestorePanel.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
@@ -67,6 +68,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [sortDirection, setSortDirection] = useState
('desc')
const filterInputRef = useRef(null)
const [online, setOnline] = useState(navigator.onLine)
+ const [showRestore, setShowRestore] = useState(false)
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
@@ -434,6 +436,24 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
{error && {error}
}
+
+
+
+
+
+ {showRestore && (
+
+
+
+ )}
{/* Right Side: Logbooks list */}
diff --git a/client/src/components/LogbookRestorePanel.tsx b/client/src/components/LogbookRestorePanel.tsx
new file mode 100644
index 0000000..c7f7fb1
--- /dev/null
+++ b/client/src/components/LogbookRestorePanel.tsx
@@ -0,0 +1,275 @@
+import { useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Upload, Check, AlertTriangle } from 'lucide-react'
+import { useDialog } from './ModalDialog.tsx'
+import {
+ parseLogbookBackupFile,
+ previewLogbookBackup,
+ restoreLogbookBackup,
+ formatBackupBytes,
+ BACKUP_SIZE_CONFIRM_BYTES,
+ type ParsedLogbookBackup,
+ type LogbookBackupPreview
+} from '../services/logbookBackup.js'
+import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
+import { formatAppDateTime } from '../utils/dateTimeFormat.js'
+
+interface LogbookRestorePanelProps {
+ onRestored?: (logbookId: string, title: string) => void
+}
+
+function mapBackupError(code: string, t: (key: string) => string): string {
+ switch (code) {
+ case 'BACKUP_PASSPHRASE_TOO_SHORT':
+ return t('settings.backup_passphrase_short')
+ case 'BACKUP_NOT_OWNER':
+ return t('settings.backup_not_owner')
+ case 'BACKUP_INVALID_JSON':
+ return t('settings.backup_invalid_json')
+ case 'BACKUP_INVALID_ARCHIVE':
+ return t('settings.backup_invalid_archive')
+ case 'BACKUP_VERSION_UNSUPPORTED':
+ return t('settings.backup_version_unsupported')
+ case 'BACKUP_WRONG_PASSPHRASE':
+ return t('settings.backup_wrong_passphrase')
+ case 'BACKUP_INVALID_FORMAT':
+ return t('settings.backup_invalid_format')
+ case 'BACKUP_NOT_AUTHENTICATED':
+ return t('settings.backup_not_authenticated')
+ case 'BACKUP_ID_CONFLICT':
+ return t('settings.backup_id_conflict')
+ default:
+ if (code.includes('decrypt') || code.includes('operation')) {
+ return t('settings.backup_wrong_passphrase')
+ }
+ return code
+ }
+}
+
+export default function LogbookRestorePanel({ onRestored }: LogbookRestorePanelProps) {
+ const { t, i18n } = useTranslation()
+ const { showConfirm } = useDialog()
+ const fileInputRef = useRef(null)
+
+ const [importPassphrase, setImportPassphrase] = useState('')
+ const [importFile, setImportFile] = useState(null)
+ const [importPreview, setImportPreview] = useState(null)
+ const [parsedBackup, setParsedBackup] = useState(null)
+ const [importing, setImporting] = useState(false)
+ const [previewing, setPreviewing] = useState(false)
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(null)
+
+ const handleImportSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ await handleRestore()
+ }
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ setError(null)
+ setSuccess(null)
+ setImportPreview(null)
+ setParsedBackup(null)
+ const file = e.target.files?.[0]
+ setImportFile(file ?? null)
+ if (!file) return
+
+ try {
+ const backup = await parseLogbookBackupFile(file)
+ setParsedBackup(backup)
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err)
+ setError(mapBackupError(message, t))
+ setImportFile(null)
+ }
+ }
+
+ const handlePreviewImport = async () => {
+ if (!parsedBackup || !importPassphrase) return
+ setPreviewing(true)
+ setError(null)
+ try {
+ const preview = await previewLogbookBackup(parsedBackup, importPassphrase)
+ setImportPreview(preview)
+ } catch (err: unknown) {
+ setImportPreview(null)
+ setError(t('settings.backup_wrong_passphrase'))
+ } finally {
+ setPreviewing(false)
+ }
+ }
+
+ const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
+ if (!parsedBackup || !importPassphrase) return
+
+ if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
+ const ok = await showConfirm(
+ t('settings.backup_import_size_confirm', {
+ size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
+ }),
+ t('settings.backup_restore_title'),
+ t('logs.confirm_yes'),
+ t('logs.confirm_no')
+ )
+ if (!ok) return
+ }
+
+ setImporting(true)
+ setError(null)
+ try {
+ const result = await restoreLogbookBackup(parsedBackup, importPassphrase, options)
+ setSuccess(t('settings.backup_restore_success', { title: result.title }))
+ setImportFile(null)
+ setImportPassphrase('')
+ setImportPreview(null)
+ setParsedBackup(null)
+ if (fileInputRef.current) fileInputRef.current.value = ''
+ trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
+ entries: parsedBackup.manifest.counts.entries,
+ photos: parsedBackup.manifest.counts.photos,
+ voiceMemos: parsedBackup.manifest.counts.voiceMemos,
+ bytes: parsedBackup.manifest.totalUncompressedBytes,
+ mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
+ })
+ onRestored?.(result.logbookId, result.title)
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : String(err)
+ if (message === 'BACKUP_ID_CONFLICT') {
+ const overwrite = await showConfirm(
+ t('settings.backup_overwrite_confirm'),
+ t('settings.backup_restore_title'),
+ t('logs.confirm_yes'),
+ t('logs.confirm_no')
+ )
+ if (overwrite) {
+ setImporting(false)
+ return handleRestore({ overwrite: true })
+ }
+ const asNew = await showConfirm(
+ t('settings.backup_new_id_confirm'),
+ t('settings.backup_restore_title'),
+ t('logs.confirm_yes'),
+ t('logs.confirm_no')
+ )
+ if (asNew) {
+ setImporting(false)
+ return handleRestore({ assignNewId: true })
+ }
+ setError(t('settings.backup_restore_cancelled'))
+ } else {
+ setError(mapBackupError(message, t))
+ }
+ } finally {
+ setImporting(false)
+ }
+ }
+
+ return (
+
+
+ {t('settings.backup_restore_desc')}
+
+
+ {error && (
+
+ )}
+
+ {success && (
+
+
+ {success}
+
+ )}
+
+
+
+ {importPreview && (
+
+
{importPreview.title}
+
+ - {t('settings.backup_stat_entries', { count: importPreview.counts.entries })}
+ - {t('settings.backup_stat_photos', { count: importPreview.counts.photos })}
+ - {t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}
+ - {t('settings.backup_stat_crew', { count: importPreview.counts.crews })}
+ - {t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}
+ -
+ {t('settings.backup_stat_size', {
+ size: formatBackupBytes(importPreview.totalUncompressedBytes)
+ })}
+
+
+
+ {t('settings.backup_exported_at', {
+ date: formatAppDateTime(importPreview.exportedAt, i18n.language)
+ })}
+
+
+ )}
+
+ )
+}
diff --git a/client/src/components/SettingsForm.tsx b/client/src/components/SettingsForm.tsx
index 2ad979e..4f54862 100644
--- a/client/src/components/SettingsForm.tsx
+++ b/client/src/components/SettingsForm.tsx
@@ -17,7 +17,6 @@ import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
interface SettingsFormProps {
logbookId?: string | null
- onLogbookRestored?: (logbookId: string, title: string) => void
}
interface Collaborator {
@@ -34,7 +33,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
.join('')
}
-export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
+export default function SettingsForm({ logbookId }: SettingsFormProps) {
const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog()
@@ -374,7 +373,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
)}
{logbookId && isOwner && (
-
+
)}
{logbookId && isOwner && (