Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d667062ec2 | |||
| 0bfc38f290 | |||
| 943ce838af | |||
| f7ad7001d7 |
@@ -852,7 +852,6 @@ function App() {
|
|||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && (
|
||||||
<SettingsForm
|
<SettingsForm
|
||||||
logbookId={activeLogbookId}
|
logbookId={activeLogbookId}
|
||||||
onLogbookRestored={selectLogbook}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Archive, Download, Upload, Check, AlertTriangle } from 'lucide-react'
|
import { Archive, Download, Check, AlertTriangle } from 'lucide-react'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
|
||||||
import {
|
import {
|
||||||
downloadBackupBlob,
|
downloadBackupBlob,
|
||||||
exportLogbookBackup,
|
exportLogbookBackup
|
||||||
formatBackupBytes,
|
|
||||||
parseLogbookBackupFile,
|
|
||||||
previewLogbookBackup,
|
|
||||||
restoreLogbookBackup,
|
|
||||||
BACKUP_SIZE_CONFIRM_BYTES,
|
|
||||||
type ParsedLogbookBackup,
|
|
||||||
type LogbookBackupPreview
|
|
||||||
} from '../services/logbookBackup.js'
|
} from '../services/logbookBackup.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
|
||||||
|
|
||||||
interface LogbookBackupPanelProps {
|
interface LogbookBackupPanelProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
onRestored?: (logbookId: string, title: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapBackupError(code: string, t: (key: string) => string): string {
|
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) {
|
export default function LogbookBackupPanel({ logbookId }: LogbookBackupPanelProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
const [exportPassphrase, setExportPassphrase] = useState('')
|
const [exportPassphrase, setExportPassphrase] = useState('')
|
||||||
const [exportConfirm, setExportConfirm] = useState('')
|
const [exportConfirm, setExportConfirm] = useState('')
|
||||||
const [exporting, setExporting] = useState(false)
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
const [importPassphrase, setImportPassphrase] = useState('')
|
|
||||||
const [importFile, setImportFile] = useState<File | null>(null)
|
|
||||||
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
|
|
||||||
const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
|
|
||||||
const [importing, setImporting] = useState(false)
|
|
||||||
const [previewing, setPreviewing] = useState(false)
|
|
||||||
const [exportProgress, setExportProgress] = useState<string | null>(null)
|
const [exportProgress, setExportProgress] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
@@ -76,11 +57,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
await handleExport()
|
await handleExport()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImportSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
await handleRestore()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
@@ -128,105 +104,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
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 (
|
return (
|
||||||
<div className="member-editor-card glass mt-6 backup-panel" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
<div className="member-editor-card glass mt-6 backup-panel" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||||
@@ -306,93 +183,6 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
|
|
||||||
<h4 id="backup-import-heading" className="backup-section-title">
|
|
||||||
<Upload size={16} aria-hidden="true" />
|
|
||||||
{t('settings.backup_restore_title')}
|
|
||||||
</h4>
|
|
||||||
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleImportSubmit} className="backup-import-form">
|
|
||||||
<div className="input-group">
|
|
||||||
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
|
|
||||||
<input
|
|
||||||
id="backup-import-file"
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".daagbok,application/zip"
|
|
||||||
className="input-text"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
disabled={importing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{importFile && (
|
|
||||||
<>
|
|
||||||
<div className="input-group">
|
|
||||||
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
|
|
||||||
<input
|
|
||||||
id="backup-import-passphrase"
|
|
||||||
name="backup-import-passphrase"
|
|
||||||
type="password"
|
|
||||||
className="input-text"
|
|
||||||
value={importPassphrase}
|
|
||||||
onChange={(e) => {
|
|
||||||
setImportPassphrase(e.target.value)
|
|
||||||
setImportPreview(null)
|
|
||||||
}}
|
|
||||||
autoComplete="current-password"
|
|
||||||
disabled={importing}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="backup-actions-row">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary"
|
|
||||||
onClick={handlePreviewImport}
|
|
||||||
disabled={previewing || importing || !importPassphrase}
|
|
||||||
>
|
|
||||||
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn primary"
|
|
||||||
disabled={importing || !importPassphrase}
|
|
||||||
>
|
|
||||||
<Upload size={16} />
|
|
||||||
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{importPreview && (
|
|
||||||
<div className="backup-preview glass">
|
|
||||||
<p className="backup-preview-title">{importPreview.title}</p>
|
|
||||||
<ul className="backup-preview-stats">
|
|
||||||
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
|
|
||||||
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
|
|
||||||
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
|
|
||||||
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
|
|
||||||
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
|
|
||||||
<li className="text-muted">
|
|
||||||
{t('settings.backup_stat_size', {
|
|
||||||
size: formatBackupBytes(importPreview.totalUncompressedBytes)
|
|
||||||
})}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-muted backup-preview-date">
|
|
||||||
{t('settings.backup_exported_at', {
|
|
||||||
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
|||||||
import { getErrorMessage } from '../utils/errors.js'
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import { logoutUser } from '../services/auth.js'
|
import { logoutUser } from '../services/auth.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
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 DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||||
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||||
import AdminHeaderButton from './AdminHeaderButton.tsx'
|
import AdminHeaderButton from './AdminHeaderButton.tsx'
|
||||||
|
import LogbookRestorePanel from './LogbookRestorePanel.tsx'
|
||||||
|
|
||||||
interface LogbookDashboardProps {
|
interface LogbookDashboardProps {
|
||||||
onSelectLogbook: (id: string, title: string) => void
|
onSelectLogbook: (id: string, title: string) => void
|
||||||
@@ -67,6 +68,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
||||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [online, setOnline] = useState(navigator.onLine)
|
const [online, setOnline] = useState(navigator.onLine)
|
||||||
|
const [showRestore, setShowRestore] = useState(false)
|
||||||
|
|
||||||
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
||||||
|
|
||||||
@@ -434,6 +436,24 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{error && <div className="auth-error mt-4">{error}</div>}
|
{error && <div className="auth-error mt-4">{error}</div>}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '20px', borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px', textAlign: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link"
|
||||||
|
style={{ fontSize: '13.5px', color: 'var(--app-text-muted)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||||
|
onClick={() => setShowRestore(!showRestore)}
|
||||||
|
>
|
||||||
|
<Upload size={14} />
|
||||||
|
{t('settings.backup_restore_title')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showRestore && (
|
||||||
|
<div style={{ marginTop: '16px', textAlign: 'left' }}>
|
||||||
|
<LogbookRestorePanel onRestored={onSelectLogbook} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Right Side: Logbooks list */}
|
{/* Right Side: Logbooks list */}
|
||||||
|
|||||||
@@ -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<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const [importPassphrase, setImportPassphrase] = useState('')
|
||||||
|
const [importFile, setImportFile] = useState<File | null>(null)
|
||||||
|
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
|
||||||
|
const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleImportSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
await handleRestore()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="backup-section backup-section--import" aria-labelledby="backup-import-heading" style={{ marginTop: '8px' }}>
|
||||||
|
<p className="text-muted backup-section-desc" style={{ fontSize: '13px', margin: '0 0 16px 0', textAlign: 'left', lineHeight: '1.4' }}>
|
||||||
|
{t('settings.backup_restore_desc')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="auth-error mb-4" role="alert" style={{ textAlign: 'left' }}>
|
||||||
|
<AlertTriangle size={16} style={{ display: 'inline', marginRight: 6, verticalAlign: 'text-bottom' }} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="success-toast mb-4" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Check size={16} />
|
||||||
|
<span>{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleImportSubmit} className="backup-import-form" style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<div className="input-group" style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
<label htmlFor="backup-import-file" style={{ fontSize: '12px', fontWeight: 600, color: 'var(--app-text-muted)', textAlign: 'left' }}>
|
||||||
|
{t('settings.backup_file_label')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="backup-import-file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".daagbok,application/zip"
|
||||||
|
className="input-text"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={importing}
|
||||||
|
style={{ width: '100%', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importFile && (
|
||||||
|
<>
|
||||||
|
<div className="input-group" style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
<label htmlFor="backup-import-passphrase" style={{ fontSize: '12px', fontWeight: 600, color: 'var(--app-text-muted)', textAlign: 'left' }}>
|
||||||
|
{t('settings.backup_passphrase')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="backup-import-passphrase"
|
||||||
|
name="backup-import-passphrase"
|
||||||
|
type="password"
|
||||||
|
className="input-text"
|
||||||
|
value={importPassphrase}
|
||||||
|
onChange={(e) => {
|
||||||
|
setImportPassphrase(e.target.value)
|
||||||
|
setImportPreview(null)
|
||||||
|
}}
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={importing}
|
||||||
|
required
|
||||||
|
style={{ width: '100%', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="backup-actions-row" style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={handlePreviewImport}
|
||||||
|
disabled={previewing || importing || !importPassphrase}
|
||||||
|
style={{ flex: 1, padding: '10px' }}
|
||||||
|
>
|
||||||
|
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn primary"
|
||||||
|
disabled={importing || !importPassphrase}
|
||||||
|
style={{ flex: 1, padding: '10px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{importPreview && (
|
||||||
|
<div className="backup-preview glass" style={{ marginTop: '16px', padding: '16px', borderRadius: '12px', border: '1px solid var(--app-border-subtle)', background: 'var(--app-surface-inset, rgba(0, 0, 0, 0.2))', textAlign: 'left' }}>
|
||||||
|
<p className="backup-preview-title" style={{ fontWeight: 600, margin: '0 0 10px 0', fontSize: '14px', color: 'var(--app-text-heading)' }}>{importPreview.title}</p>
|
||||||
|
<ul className="backup-preview-stats" style={{ listStyle: 'none', padding: 0, margin: '0 0 10px 0', display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '13px', color: 'var(--app-text)' }}>
|
||||||
|
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
|
||||||
|
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
|
||||||
|
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
|
||||||
|
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
|
||||||
|
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
|
||||||
|
<li style={{ color: 'var(--app-text-muted)' }}>
|
||||||
|
{t('settings.backup_stat_size', {
|
||||||
|
size: formatBackupBytes(importPreview.totalUncompressedBytes)
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-muted backup-preview-date" style={{ fontSize: '11px', margin: 0, color: 'var(--app-text-muted)' }}>
|
||||||
|
{t('settings.backup_exported_at', {
|
||||||
|
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
|||||||
|
|
||||||
interface SettingsFormProps {
|
interface SettingsFormProps {
|
||||||
logbookId?: string | null
|
logbookId?: string | null
|
||||||
onLogbookRestored?: (logbookId: string, title: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Collaborator {
|
interface Collaborator {
|
||||||
@@ -34,7 +33,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
|
|||||||
.join('')
|
.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
|
export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm, showAlert } = useDialog()
|
const { showConfirm, showAlert } = useDialog()
|
||||||
|
|
||||||
@@ -374,7 +373,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{logbookId && isOwner && (
|
{logbookId && isOwner && (
|
||||||
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
|
<LogbookBackupPanel logbookId={logbookId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{logbookId && isOwner && (
|
{logbookId && isOwner && (
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ else
|
|||||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||||
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
|
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
|
||||||
APP_URL="${APP_URL:-https://kapteins-daagbok.eu}"
|
APP_URL="${APP_URL:-https://kapteins-daagbok.eu}"
|
||||||
DEPLOY_BRANCH=""
|
DEPLOY_BRANCH="none"
|
||||||
ENV_LABEL="Production"
|
ENV_LABEL="Production"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user