Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 943ce838af | |||
| f7ad7001d7 | |||
| 444d347c56 | |||
| a185bbaf27 | |||
| 864d45714c |
@@ -3336,6 +3336,51 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.photo-maximized-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #f1f5f9;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 11005;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-nav:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: #ffffff;
|
||||||
|
transform: translateY(-50%) scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-prev {
|
||||||
|
left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-next {
|
||||||
|
right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.photo-maximized-nav {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.photo-maximized-prev {
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
.photo-maximized-next {
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom Dialog Modals Styling */
|
/* Custom Dialog Modals Styling */
|
||||||
.custom-dialog-overlay {
|
.custom-dialog-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -852,7 +852,6 @@ function App() {
|
|||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && (
|
||||||
<SettingsForm
|
<SettingsForm
|
||||||
logbookId={activeLogbookId}
|
logbookId={activeLogbookId}
|
||||||
onLogbookRestored={selectLogbook}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { getActiveMasterKey } from '../services/auth.js'
|
import { getActiveMasterKey, hasUnlockedLocalCrypto } from '../services/auth.js'
|
||||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
import { encryptJson } from '../services/crypto.js'
|
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
|
import { buildZipArchive } from '../services/logbookBackup/zipArchive.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { getErrorMessage } from '../utils/errors.js'
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||||
@@ -59,6 +60,42 @@ interface DecryptedEntryItem {
|
|||||||
skipperSignStatus: SkipperSignStatus
|
skipperSignStatus: SkipperSignStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to convert data URL to Uint8Array for zip packaging
|
||||||
|
function dataUrlToUint8Array(dataUrl: string): { data: Uint8Array; ext: string } {
|
||||||
|
const parts = dataUrl.split(',')
|
||||||
|
if (parts.length < 2) {
|
||||||
|
throw new Error('Invalid data URL')
|
||||||
|
}
|
||||||
|
const meta = parts[0]
|
||||||
|
const base64Data = parts[1]
|
||||||
|
|
||||||
|
let ext = 'jpg'
|
||||||
|
const mimeMatch = meta.match(/data:([^;]+)/)
|
||||||
|
if (mimeMatch) {
|
||||||
|
const mime = mimeMatch[1]
|
||||||
|
if (mime === 'image/png') ext = 'png'
|
||||||
|
else if (mime === 'image/gif') ext = 'gif'
|
||||||
|
else if (mime === 'image/webp') ext = 'webp'
|
||||||
|
else if (mime === 'image/heic') ext = 'heic'
|
||||||
|
else if (mime === 'image/heif') ext = 'heif'
|
||||||
|
}
|
||||||
|
|
||||||
|
const binaryString = atob(base64Data)
|
||||||
|
const bytes = new Uint8Array(binaryString.length)
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return { data: bytes, ext }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFilename(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/[^\w\s-]/gi, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.slice(0, 30)
|
||||||
|
}
|
||||||
|
|
||||||
export default function LogEntriesList({
|
export default function LogEntriesList({
|
||||||
logbookId,
|
logbookId,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
@@ -257,6 +294,90 @@ export default function LogEntriesList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDownloadPhotosZip = async () => {
|
||||||
|
setExporting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
|
// Fetch all photos for this logbook from IndexedDB
|
||||||
|
const localPhotos = await db.photos.where({ logbookId }).toArray()
|
||||||
|
if (localPhotos.length === 0) {
|
||||||
|
setError(t('logs.no_photos_to_download'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map of entry ID to entry info for filename lookup
|
||||||
|
const entryMap = new Map<string, DecryptedEntryItem>()
|
||||||
|
entries.forEach((e) => entryMap.set(e.id, e))
|
||||||
|
|
||||||
|
const files: Record<string, Uint8Array> = {}
|
||||||
|
const usedNames = new Set<string>()
|
||||||
|
|
||||||
|
for (const photo of localPhotos) {
|
||||||
|
// Decrypt photo payload (contains base64 image data and caption)
|
||||||
|
const decrypted = await decryptJson(photo.encryptedData, photo.iv, photo.tag, masterKey)
|
||||||
|
if (!decrypted || !decrypted.image) continue
|
||||||
|
|
||||||
|
const { data, ext } = dataUrlToUint8Array(decrypted.image)
|
||||||
|
|
||||||
|
// Construct unique, friendly filename
|
||||||
|
let fileBase = `photo_${photo.payloadId}`
|
||||||
|
const entry = entryMap.get(photo.entryId)
|
||||||
|
if (entry) {
|
||||||
|
const dateStr = entry.date || 'unknown-date'
|
||||||
|
const travelDay = entry.dayOfTravel ? `day-${entry.dayOfTravel}` : ''
|
||||||
|
const sanitizedCaption = decrypted.caption ? sanitizeFilename(decrypted.caption) : ''
|
||||||
|
|
||||||
|
const parts = [dateStr]
|
||||||
|
if (travelDay) parts.push(travelDay)
|
||||||
|
if (sanitizedCaption) parts.push(sanitizedCaption)
|
||||||
|
|
||||||
|
fileBase = parts.join('_')
|
||||||
|
} else if (decrypted.caption) {
|
||||||
|
fileBase = `photo_${sanitizeFilename(decrypted.caption)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-duplicate name
|
||||||
|
let candidate = `${fileBase}.${ext}`
|
||||||
|
let counter = 1
|
||||||
|
while (usedNames.has(candidate.toLowerCase())) {
|
||||||
|
candidate = `${fileBase}_${counter}.${ext}`
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
usedNames.add(candidate.toLowerCase())
|
||||||
|
|
||||||
|
files[candidate] = data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(files).length === 0) {
|
||||||
|
setError(t('logs.no_photos_to_download'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipBytes = buildZipArchive(files)
|
||||||
|
const blob = new Blob([zipBytes as any], { type: 'application/zip' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
const yachtName = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||||
|
const safeTitle = yachtName.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
||||||
|
const datePart = new Date().toISOString().slice(0, 10)
|
||||||
|
const filename = `${safeTitle}-photos-${datePart}.zip`
|
||||||
|
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = filename
|
||||||
|
anchor.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to download photos ZIP:', err)
|
||||||
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -488,6 +609,21 @@ export default function LogEntriesList({
|
|||||||
<span className="hide-mobile">{t('logs.share_csv')}</span>
|
<span className="hide-mobile">{t('logs.share_csv')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{hasUnlockedLocalCrypto() && (
|
||||||
|
<button
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={handleDownloadPhotosZip}
|
||||||
|
disabled={loading || exporting || entries.length === 0}
|
||||||
|
style={{ width: 'auto', padding: '8px 16px' }}
|
||||||
|
title={t('logs.export_photos_zip')}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
<span className="hide-mobile">
|
||||||
|
{exporting ? t('logs.exporting_photos_zip') : t('logs.export_photos_zip')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
|
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
|
|||||||
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { Camera, Image, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react'
|
import { Camera, Image, Trash2, X, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
|
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
|
||||||
|
|
||||||
interface PhotoCaptureProps {
|
interface PhotoCaptureProps {
|
||||||
@@ -39,6 +39,46 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const cameraInputRef = useRef<HTMLInputElement>(null)
|
const cameraInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const touchStartX = useRef<number>(0)
|
||||||
|
const touchEndX = useRef<number>(0)
|
||||||
|
|
||||||
|
const goToNext = () => {
|
||||||
|
if (!maximizedPhoto || decryptedPhotos.length <= 1) return
|
||||||
|
const currentIndex = decryptedPhotos.findIndex(p => p.payloadId === maximizedPhoto.payloadId)
|
||||||
|
if (currentIndex === -1) return
|
||||||
|
const nextIndex = (currentIndex + 1) % decryptedPhotos.length
|
||||||
|
setMaximizedPhoto(decryptedPhotos[nextIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToPrev = () => {
|
||||||
|
if (!maximizedPhoto || decryptedPhotos.length <= 1) return
|
||||||
|
const currentIndex = decryptedPhotos.findIndex(p => p.payloadId === maximizedPhoto.payloadId)
|
||||||
|
if (currentIndex === -1) return
|
||||||
|
const prevIndex = (currentIndex - 1 + decryptedPhotos.length) % decryptedPhotos.length
|
||||||
|
setMaximizedPhoto(decryptedPhotos[prevIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
touchStartX.current = e.targetTouches[0].clientX
|
||||||
|
touchEndX.current = e.targetTouches[0].clientX
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
touchEndX.current = e.targetTouches[0].clientX
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (!touchStartX.current || !touchEndX.current) return
|
||||||
|
const diffX = touchStartX.current - touchEndX.current
|
||||||
|
const threshold = 50
|
||||||
|
if (diffX > threshold) {
|
||||||
|
goToNext()
|
||||||
|
} else if (diffX < -threshold) {
|
||||||
|
goToPrev()
|
||||||
|
}
|
||||||
|
touchStartX.current = 0
|
||||||
|
touchEndX.current = 0
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!maximizedPhoto) return
|
if (!maximizedPhoto) return
|
||||||
@@ -46,6 +86,10 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
setMaximizedPhoto(null)
|
setMaximizedPhoto(null)
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'Left') {
|
||||||
|
goToPrev()
|
||||||
|
} else if (e.key === 'ArrowRight' || e.key === 'Right') {
|
||||||
|
goToNext()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +97,7 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown)
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
}
|
}
|
||||||
}, [maximizedPhoto])
|
}, [maximizedPhoto, decryptedPhotos])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -323,7 +367,37 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
<div
|
<div
|
||||||
className="photo-maximized-overlay"
|
className="photo-maximized-overlay"
|
||||||
onClick={() => setMaximizedPhoto(null)}
|
onClick={() => setMaximizedPhoto(null)}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
>
|
>
|
||||||
|
{decryptedPhotos.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="photo-maximized-nav photo-maximized-prev"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
goToPrev()
|
||||||
|
}}
|
||||||
|
aria-label={t('common.previous') || 'Previous'}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={32} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="photo-maximized-nav photo-maximized-next"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
goToNext()
|
||||||
|
}}
|
||||||
|
aria-label={t('common.next') || 'Next'}
|
||||||
|
>
|
||||||
|
<ChevronRight size={32} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
|
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon, Share2 } from 'lucide-react'
|
||||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||||
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||||
import LinkQrCode from './LinkQrCode.tsx'
|
import LinkQrCode from './LinkQrCode.tsx'
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -131,6 +130,24 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isShareSupported = typeof navigator !== 'undefined' && !!navigator.share
|
||||||
|
|
||||||
|
const handleShareLink = async () => {
|
||||||
|
if (shareLink) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: t('seo.title') || 'Kapteins Daagbok',
|
||||||
|
text: t('settings.share_desc'),
|
||||||
|
url: shareLink
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && err.name !== 'AbortError') {
|
||||||
|
console.error('Sharing link failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadCollaborators = async () => {
|
const loadCollaborators = async () => {
|
||||||
setLoadingCollabs(true)
|
setLoadingCollabs(true)
|
||||||
setCollabError(null)
|
setCollabError(null)
|
||||||
@@ -337,6 +354,17 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
>
|
>
|
||||||
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||||
</button>
|
</button>
|
||||||
|
{isShareSupported && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void handleShareLink()}
|
||||||
|
style={{ width: 'auto', padding: '10px' }}
|
||||||
|
title={t('settings.share_btn')}
|
||||||
|
>
|
||||||
|
<Share2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<LinkQrCode value={shareLink} />
|
<LinkQrCode value={shareLink} />
|
||||||
</div>
|
</div>
|
||||||
@@ -345,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 && (
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
"unsaved_changes_stay": "Blive",
|
"unsaved_changes_stay": "Blive",
|
||||||
"unsaved_changes_save_leave": "Gem og afslut",
|
"unsaved_changes_save_leave": "Gem og afslut",
|
||||||
"unsaved_changes_discard": "Afvis",
|
"unsaved_changes_discard": "Afvis",
|
||||||
"unsaved_changes_leave": "Forladt"
|
"unsaved_changes_leave": "Forladt",
|
||||||
|
"previous": "Forrige",
|
||||||
|
"next": "Næste"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -445,6 +447,9 @@
|
|||||||
"ai_summary_error_forbidden": "Kun skipperen må generere AI-opsummeringer.",
|
"ai_summary_error_forbidden": "Kun skipperen må generere AI-opsummeringer.",
|
||||||
"ai_summary_offline": "AI-opsummeringen kræver en internetforbindelse. Du er i øjeblikket offline.",
|
"ai_summary_offline": "AI-opsummeringen kræver en internetforbindelse. Du er i øjeblikket offline.",
|
||||||
"photos_title": "Fotobilag",
|
"photos_title": "Fotobilag",
|
||||||
|
"export_photos_zip": "Download fotos (ZIP)",
|
||||||
|
"exporting_photos_zip": "Opretter ZIP...",
|
||||||
|
"no_photos_to_download": "Ingen fotos fundet i denne logbog.",
|
||||||
"photo_caption_label": "Billedbeskrivelse / Etiket (valgfrit)",
|
"photo_caption_label": "Billedbeskrivelse / Etiket (valgfrit)",
|
||||||
"photo_caption_placeholder": "f.eks. sætte sejl tæt på havneindsejlingen",
|
"photo_caption_placeholder": "f.eks. sætte sejl tæt på havneindsejlingen",
|
||||||
"photo_btn": "Tag/upload et billede",
|
"photo_btn": "Tag/upload et billede",
|
||||||
@@ -820,6 +825,7 @@
|
|||||||
"share_enable": "Aktivér offentligt link",
|
"share_enable": "Aktivér offentligt link",
|
||||||
"share_copied": "Linket er kopieret!",
|
"share_copied": "Linket er kopieret!",
|
||||||
"share_copy_btn": "Kopier link",
|
"share_copy_btn": "Kopier link",
|
||||||
|
"share_btn": "Del link",
|
||||||
"link_qr_hint": "QR-kode til scanning med en smartphone",
|
"link_qr_hint": "QR-kode til scanning med en smartphone",
|
||||||
"link_qr_alt": "QR-kode til linket",
|
"link_qr_alt": "QR-kode til linket",
|
||||||
"danger_zone_title": "Farezone",
|
"danger_zone_title": "Farezone",
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
"unsaved_changes_stay": "Bleiben",
|
"unsaved_changes_stay": "Bleiben",
|
||||||
"unsaved_changes_save_leave": "Speichern & verlassen",
|
"unsaved_changes_save_leave": "Speichern & verlassen",
|
||||||
"unsaved_changes_discard": "Verwerfen",
|
"unsaved_changes_discard": "Verwerfen",
|
||||||
"unsaved_changes_leave": "Verlassen"
|
"unsaved_changes_leave": "Verlassen",
|
||||||
|
"previous": "Zurück",
|
||||||
|
"next": "Weiter"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -445,6 +447,9 @@
|
|||||||
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
||||||
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||||
"photos_title": "Foto-Anhänge",
|
"photos_title": "Foto-Anhänge",
|
||||||
|
"export_photos_zip": "Fotos herunterladen (ZIP)",
|
||||||
|
"exporting_photos_zip": "ZIP wird erstellt...",
|
||||||
|
"no_photos_to_download": "Keine Fotos in diesem Logbuch vorhanden.",
|
||||||
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
||||||
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
||||||
"photo_btn": "Foto aufnehmen / Hochladen",
|
"photo_btn": "Foto aufnehmen / Hochladen",
|
||||||
@@ -820,6 +825,7 @@
|
|||||||
"share_enable": "Öffentlichen Link aktivieren",
|
"share_enable": "Öffentlichen Link aktivieren",
|
||||||
"share_copied": "Link kopiert!",
|
"share_copied": "Link kopiert!",
|
||||||
"share_copy_btn": "Link kopieren",
|
"share_copy_btn": "Link kopieren",
|
||||||
|
"share_btn": "Link teilen",
|
||||||
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
|
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
|
||||||
"link_qr_alt": "QR-Code für den Link",
|
"link_qr_alt": "QR-Code für den Link",
|
||||||
"danger_zone_title": "Gefahrenzone",
|
"danger_zone_title": "Gefahrenzone",
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
"unsaved_changes_stay": "Stay",
|
"unsaved_changes_stay": "Stay",
|
||||||
"unsaved_changes_save_leave": "Save & leave",
|
"unsaved_changes_save_leave": "Save & leave",
|
||||||
"unsaved_changes_discard": "Discard",
|
"unsaved_changes_discard": "Discard",
|
||||||
"unsaved_changes_leave": "Leave"
|
"unsaved_changes_leave": "Leave",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -445,6 +447,9 @@
|
|||||||
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
|
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
|
||||||
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
||||||
"photos_title": "Photo Attachments",
|
"photos_title": "Photo Attachments",
|
||||||
|
"export_photos_zip": "Download Photos (ZIP)",
|
||||||
|
"exporting_photos_zip": "Creating ZIP...",
|
||||||
|
"no_photos_to_download": "No photos found in this logbook.",
|
||||||
"photo_caption_label": "Photo Caption / Label (Optional)",
|
"photo_caption_label": "Photo Caption / Label (Optional)",
|
||||||
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
||||||
"photo_btn": "Take Photo / Upload",
|
"photo_btn": "Take Photo / Upload",
|
||||||
@@ -820,6 +825,7 @@
|
|||||||
"share_enable": "Enable Public Link",
|
"share_enable": "Enable Public Link",
|
||||||
"share_copied": "Link copied!",
|
"share_copied": "Link copied!",
|
||||||
"share_copy_btn": "Copy Link",
|
"share_copy_btn": "Copy Link",
|
||||||
|
"share_btn": "Share Link",
|
||||||
"link_qr_hint": "Scan this QR code with your phone",
|
"link_qr_hint": "Scan this QR code with your phone",
|
||||||
"link_qr_alt": "QR code for the link",
|
"link_qr_alt": "QR code for the link",
|
||||||
"danger_zone_title": "Danger Zone",
|
"danger_zone_title": "Danger Zone",
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
"unsaved_changes_stay": "Quedarse",
|
"unsaved_changes_stay": "Quedarse",
|
||||||
"unsaved_changes_save_leave": "Guardar y salir",
|
"unsaved_changes_save_leave": "Guardar y salir",
|
||||||
"unsaved_changes_discard": "Descartar",
|
"unsaved_changes_discard": "Descartar",
|
||||||
"unsaved_changes_leave": "Abandonado"
|
"unsaved_changes_leave": "Abandonado",
|
||||||
|
"previous": "Anterior",
|
||||||
|
"next": "Siguiente"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Panel de control",
|
"dashboard": "Panel de control",
|
||||||
@@ -445,6 +447,9 @@
|
|||||||
"ai_summary_error_forbidden": "Solo el capitán puede generar resúmenes de IA.",
|
"ai_summary_error_forbidden": "Solo el capitán puede generar resúmenes de IA.",
|
||||||
"ai_summary_offline": "El resumen generado por IA requiere una conexión a Internet. Actualmente no tienes conexión.",
|
"ai_summary_offline": "El resumen generado por IA requiere una conexión a Internet. Actualmente no tienes conexión.",
|
||||||
"photos_title": "Archivos adjuntos con fotos",
|
"photos_title": "Archivos adjuntos con fotos",
|
||||||
|
"export_photos_zip": "Descargar fotos (ZIP)",
|
||||||
|
"exporting_photos_zip": "Creando archivo ZIP...",
|
||||||
|
"no_photos_to_download": "No hay fotos disponibles en este cuaderno de bitácora.",
|
||||||
"photo_caption_label": "Descripción de la foto / Etiqueta (opcional)",
|
"photo_caption_label": "Descripción de la foto / Etiqueta (opcional)",
|
||||||
"photo_caption_placeholder": "p. ej., izar las velas cerca de la entrada del puerto",
|
"photo_caption_placeholder": "p. ej., izar las velas cerca de la entrada del puerto",
|
||||||
"photo_btn": "Hacer una foto / Subir una foto",
|
"photo_btn": "Hacer una foto / Subir una foto",
|
||||||
@@ -820,6 +825,7 @@
|
|||||||
"share_enable": "Activar enlace público",
|
"share_enable": "Activar enlace público",
|
||||||
"share_copied": "¡Enlace copiado!",
|
"share_copied": "¡Enlace copiado!",
|
||||||
"share_copy_btn": "Copiar enlace",
|
"share_copy_btn": "Copiar enlace",
|
||||||
|
"share_btn": "Compartir enlace",
|
||||||
"link_qr_hint": "Código QR para escanear con el smartphone",
|
"link_qr_hint": "Código QR para escanear con el smartphone",
|
||||||
"link_qr_alt": "Código QR del enlace",
|
"link_qr_alt": "Código QR del enlace",
|
||||||
"danger_zone_title": "Zona de peligro",
|
"danger_zone_title": "Zona de peligro",
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
"unsaved_changes_stay": "Rester",
|
"unsaved_changes_stay": "Rester",
|
||||||
"unsaved_changes_save_leave": "Enregistrer et quitter",
|
"unsaved_changes_save_leave": "Enregistrer et quitter",
|
||||||
"unsaved_changes_discard": "Rejeter",
|
"unsaved_changes_discard": "Rejeter",
|
||||||
"unsaved_changes_leave": "Quitter"
|
"unsaved_changes_leave": "Quitter",
|
||||||
|
"previous": "Précédent",
|
||||||
|
"next": "Suivant"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
@@ -445,6 +447,9 @@
|
|||||||
"ai_summary_error_forbidden": "Seul le skipper est autorisé à générer des résumés basés sur l'IA.",
|
"ai_summary_error_forbidden": "Seul le skipper est autorisé à générer des résumés basés sur l'IA.",
|
||||||
"ai_summary_offline": "Le résumé généré par l'IA nécessite une connexion Internet. Tu es actuellement hors ligne.",
|
"ai_summary_offline": "Le résumé généré par l'IA nécessite une connexion Internet. Tu es actuellement hors ligne.",
|
||||||
"photos_title": "Pièces jointes (photos)",
|
"photos_title": "Pièces jointes (photos)",
|
||||||
|
"export_photos_zip": "Télécharger les photos (ZIP)",
|
||||||
|
"exporting_photos_zip": "Création du fichier ZIP...",
|
||||||
|
"no_photos_to_download": "Aucune photo disponible dans ce journal.",
|
||||||
"photo_caption_label": "Description de la photo / Étiquette (facultatif)",
|
"photo_caption_label": "Description de la photo / Étiquette (facultatif)",
|
||||||
"photo_caption_placeholder": "par exemple, hisser les voiles près de l'entrée du port",
|
"photo_caption_placeholder": "par exemple, hisser les voiles près de l'entrée du port",
|
||||||
"photo_btn": "Prendre une photo / Télécharger une photo",
|
"photo_btn": "Prendre une photo / Télécharger une photo",
|
||||||
@@ -820,6 +825,7 @@
|
|||||||
"share_enable": "Activer le lien public",
|
"share_enable": "Activer le lien public",
|
||||||
"share_copied": "Lien copié !",
|
"share_copied": "Lien copié !",
|
||||||
"share_copy_btn": "Copier le lien",
|
"share_copy_btn": "Copier le lien",
|
||||||
|
"share_btn": "Partager le lien",
|
||||||
"link_qr_hint": "Code QR à scanner avec un smartphone",
|
"link_qr_hint": "Code QR à scanner avec un smartphone",
|
||||||
"link_qr_alt": "Code QR pour le lien",
|
"link_qr_alt": "Code QR pour le lien",
|
||||||
"danger_zone_title": "Zone dangereuse",
|
"danger_zone_title": "Zone dangereuse",
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
"unsaved_changes_stay": "Bli",
|
"unsaved_changes_stay": "Bli",
|
||||||
"unsaved_changes_save_leave": "Lagre og avslutt",
|
"unsaved_changes_save_leave": "Lagre og avslutt",
|
||||||
"unsaved_changes_discard": "Avvis",
|
"unsaved_changes_discard": "Avvis",
|
||||||
"unsaved_changes_leave": "Forlatt"
|
"unsaved_changes_leave": "Forlatt",
|
||||||
|
"previous": "Forrige",
|
||||||
|
"next": "Neste"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashbord",
|
"dashboard": "Dashbord",
|
||||||
@@ -445,6 +447,9 @@
|
|||||||
"ai_summary_error_forbidden": "Bare skipperen har lov til å generere AI-sammendrag.",
|
"ai_summary_error_forbidden": "Bare skipperen har lov til å generere AI-sammendrag.",
|
||||||
"ai_summary_offline": "AI-sammendraget krever en internettforbindelse. Du er for øyeblikket frakoblet.",
|
"ai_summary_offline": "AI-sammendraget krever en internettforbindelse. Du er for øyeblikket frakoblet.",
|
||||||
"photos_title": "Bildevedlegg",
|
"photos_title": "Bildevedlegg",
|
||||||
|
"export_photos_zip": "Last ned bilder (ZIP)",
|
||||||
|
"exporting_photos_zip": "Oppretter ZIP...",
|
||||||
|
"no_photos_to_download": "Ingen bilder i denne loggboken.",
|
||||||
"photo_caption_label": "Bildetekst / Etikett (valgfritt)",
|
"photo_caption_label": "Bildetekst / Etikett (valgfritt)",
|
||||||
"photo_caption_placeholder": "f.eks. sette seil nær havneinnløpet",
|
"photo_caption_placeholder": "f.eks. sette seil nær havneinnløpet",
|
||||||
"photo_btn": "Ta bilde / Last opp",
|
"photo_btn": "Ta bilde / Last opp",
|
||||||
@@ -820,6 +825,7 @@
|
|||||||
"share_enable": "Aktiver offentlig lenke",
|
"share_enable": "Aktiver offentlig lenke",
|
||||||
"share_copied": "Koblingen er kopiert!",
|
"share_copied": "Koblingen er kopiert!",
|
||||||
"share_copy_btn": "Kopier lenken",
|
"share_copy_btn": "Kopier lenken",
|
||||||
|
"share_btn": "Del lenke",
|
||||||
"link_qr_hint": "QR-kode som kan skannes med smarttelefonen",
|
"link_qr_hint": "QR-kode som kan skannes med smarttelefonen",
|
||||||
"link_qr_alt": "QR-kode for lenken",
|
"link_qr_alt": "QR-kode for lenken",
|
||||||
"danger_zone_title": "Fareområde",
|
"danger_zone_title": "Fareområde",
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
"unsaved_changes_stay": "Stanna kvar",
|
"unsaved_changes_stay": "Stanna kvar",
|
||||||
"unsaved_changes_save_leave": "Spara och avsluta",
|
"unsaved_changes_save_leave": "Spara och avsluta",
|
||||||
"unsaved_changes_discard": "Avvisa",
|
"unsaved_changes_discard": "Avvisa",
|
||||||
"unsaved_changes_leave": "Lämna"
|
"unsaved_changes_leave": "Lämna",
|
||||||
|
"previous": "Föregående",
|
||||||
|
"next": "Nästa"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Instrumentpanelen",
|
"dashboard": "Instrumentpanelen",
|
||||||
@@ -445,6 +447,9 @@
|
|||||||
"ai_summary_error_forbidden": "Endast skepparen får skapa AI-sammanfattningar.",
|
"ai_summary_error_forbidden": "Endast skepparen får skapa AI-sammanfattningar.",
|
||||||
"ai_summary_offline": "AI-sammanfattningen kräver en internetanslutning. Du är för närvarande offline.",
|
"ai_summary_offline": "AI-sammanfattningen kräver en internetanslutning. Du är för närvarande offline.",
|
||||||
"photos_title": "Bilagor med bilder",
|
"photos_title": "Bilagor med bilder",
|
||||||
|
"export_photos_zip": "Ladda ner bilder (ZIP)",
|
||||||
|
"exporting_photos_zip": "Skapar ZIP...",
|
||||||
|
"no_photos_to_download": "Inga bilder i denna loggbok.",
|
||||||
"photo_caption_label": "Bildbeskrivning / Etikett (valfritt)",
|
"photo_caption_label": "Bildbeskrivning / Etikett (valfritt)",
|
||||||
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
||||||
"photo_btn": "Ta en bild / Ladda upp",
|
"photo_btn": "Ta en bild / Ladda upp",
|
||||||
@@ -820,6 +825,7 @@
|
|||||||
"share_enable": "Aktivera offentlig länk",
|
"share_enable": "Aktivera offentlig länk",
|
||||||
"share_copied": "Länken har kopierats!",
|
"share_copied": "Länken har kopierats!",
|
||||||
"share_copy_btn": "Kopiera länken",
|
"share_copy_btn": "Kopiera länken",
|
||||||
|
"share_btn": "Dela länk",
|
||||||
"link_qr_hint": "QR-kod att skanna med smarttelefonen",
|
"link_qr_hint": "QR-kod att skanna med smarttelefonen",
|
||||||
"link_qr_alt": "QR-kod för länken",
|
"link_qr_alt": "QR-kod för länken",
|
||||||
"danger_zone_title": "Farlig zon",
|
"danger_zone_title": "Farlig zon",
|
||||||
|
|||||||
Reference in New Issue
Block a user