diff --git a/client/src/App.tsx b/client/src/App.tsx index a83a013..407158a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -852,7 +852,6 @@ function App() { {activeTab === 'settings' && ( )} diff --git a/client/src/components/LogbookBackupPanel.tsx b/client/src/components/LogbookBackupPanel.tsx index 7db7fc3..2b4b1f4 100644 --- a/client/src/components/LogbookBackupPanel.tsx +++ b/client/src/components/LogbookBackupPanel.tsx @@ -1,24 +1,14 @@ -import { useRef, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { Archive, Download, Upload, Check, AlertTriangle } from 'lucide-react' -import { useDialog } from './ModalDialog.tsx' +import { Archive, Download, Check, AlertTriangle } from 'lucide-react' import { downloadBackupBlob, - exportLogbookBackup, - formatBackupBytes, - parseLogbookBackupFile, - previewLogbookBackup, - restoreLogbookBackup, - BACKUP_SIZE_CONFIRM_BYTES, - type ParsedLogbookBackup, - type LogbookBackupPreview + exportLogbookBackup } from '../services/logbookBackup.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' -import { formatAppDateTime } from '../utils/dateTimeFormat.js' interface LogbookBackupPanelProps { logbookId: string - onRestored?: (logbookId: string, title: string) => void } function mapBackupError(code: string, t: (key: string) => string): string { @@ -49,21 +39,12 @@ function mapBackupError(code: string, t: (key: string) => string): string { } } -export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) { - const { t, i18n } = useTranslation() - const { showConfirm } = useDialog() - const fileInputRef = useRef(null) +export default function LogbookBackupPanel({ logbookId }: LogbookBackupPanelProps) { + const { t } = useTranslation() const [exportPassphrase, setExportPassphrase] = useState('') const [exportConfirm, setExportConfirm] = useState('') const [exporting, setExporting] = useState(false) - - 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 [exportProgress, setExportProgress] = useState(null) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) @@ -76,11 +57,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac await handleExport() } - const handleImportSubmit = async (e: React.FormEvent) => { - e.preventDefault() - await handleRestore() - } - const handleExport = async () => { setError(null) setSuccess(null) @@ -128,105 +104,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac } } - 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 (
@@ -306,93 +183,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac )} - -
-

-

-

{t('settings.backup_restore_desc')}

- -
-
- - -
- - {importFile && ( - <> -
- - { - setImportPassphrase(e.target.value) - setImportPreview(null) - }} - autoComplete="current-password" - disabled={importing} - required - /> -
- -
- - -
- - )} -
- - {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 && ( +
+ + {error} +
+ )} + + {success && ( +
+ + {success} +
+ )} + +
+
+ + +
+ + {importFile && ( + <> +
+ + { + setImportPassphrase(e.target.value) + setImportPreview(null) + }} + autoComplete="current-password" + disabled={importing} + required + style={{ width: '100%', boxSizing: 'border-box' }} + /> +
+ +
+ + +
+ + )} +
+ + {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 && (