feat(ux): Sprint 3 mobile nav, sync conflicts, and resilience
Improve mobile bottom navigation, accessible dialogs and cards, explicit sync conflict resolution, i18n error messages, encrypted draft autosave, and persistent storage hints for offline data safety. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+117
-2
@@ -1799,6 +1799,22 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logbook-card-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.logbook-card {
|
.logbook-card {
|
||||||
background: var(--app-surface-alt);
|
background: var(--app-surface-alt);
|
||||||
backdrop-filter: var(--app-backdrop);
|
backdrop-filter: var(--app-backdrop);
|
||||||
@@ -1809,18 +1825,61 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logbook-card:hover {
|
.logbook-card:hover,
|
||||||
|
.logbook-card:focus-within {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
border-color: var(--app-border);
|
border-color: var(--app-border);
|
||||||
box-shadow: var(--app-card-shadow);
|
box-shadow: var(--app-card-shadow);
|
||||||
background: var(--app-surface-hover);
|
background: var(--app-surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sync-conflict-banner {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--app-radius-card);
|
||||||
|
border: 1px solid var(--app-warning-border, #f59e0b);
|
||||||
|
background: var(--app-warning-bg, rgba(245, 158, 11, 0.12));
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-conflict-banner__body p {
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-conflict-banner__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-persist-hint {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--app-radius-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-persist-hint p {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.logbook-card--shared {
|
.logbook-card--shared {
|
||||||
border-left: 3px solid #38bdf8;
|
border-left: 3px solid #38bdf8;
|
||||||
}
|
}
|
||||||
@@ -2130,9 +2189,65 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-bottom-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-body {
|
.app-body {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bottom-nav {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 8px calc(8px + env(safe-area-inset-bottom, 0px));
|
||||||
|
background: var(--app-surface-alt);
|
||||||
|
backdrop-filter: var(--app-backdrop);
|
||||||
|
border-top: 1px solid var(--app-border-subtle);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn.active {
|
||||||
|
background: var(--app-sidebar-active-bg);
|
||||||
|
color: var(--app-sidebar-active-text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+79
-2
@@ -53,6 +53,8 @@ import {
|
|||||||
} from './services/demoLogbook.js'
|
} from './services/demoLogbook.js'
|
||||||
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
||||||
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
|
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
|
||||||
|
import SyncConflictBanner from './components/SyncConflictBanner.tsx'
|
||||||
|
import { requestPersistentStorage } from './utils/storagePersist.js'
|
||||||
|
|
||||||
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
||||||
|
|
||||||
@@ -71,6 +73,7 @@ function App() {
|
|||||||
const [isSyncing, setIsSyncing] = useState(false)
|
const [isSyncing, setIsSyncing] = useState(false)
|
||||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||||
const [showUserProfile, setShowUserProfile] = useState(false)
|
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||||
|
const [storagePersistHint, setStoragePersistHint] = useState(false)
|
||||||
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
||||||
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
||||||
id: activeLogbookId,
|
id: activeLogbookId,
|
||||||
@@ -428,10 +431,19 @@ function App() {
|
|||||||
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
|
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
|
||||||
}, [isAuthenticated, openLogbookById])
|
}, [isAuthenticated, openLogbookById])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return
|
||||||
|
if (sessionStorage.getItem('storage_persist_hint_dismissed')) return
|
||||||
|
void requestPersistentStorage().then(({ persisted, supported }) => {
|
||||||
|
if (supported && !persisted) setStoragePersistHint(true)
|
||||||
|
})
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
const handleAuthenticated = async () => {
|
const handleAuthenticated = async () => {
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
||||||
void ensurePushSubscriptionIfEnabled()
|
void ensurePushSubscriptionIfEnabled()
|
||||||
|
void requestPersistentStorage()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const demo = await seedDemoLogbookIfNeeded()
|
const demo = await seedDemoLogbookIfNeeded()
|
||||||
@@ -606,7 +618,7 @@ function App() {
|
|||||||
<p className="app-subtitle">
|
<p className="app-subtitle">
|
||||||
{activeAccessRole && activeAccessRole !== 'OWNER'
|
{activeAccessRole && activeAccessRole !== 'OWNER'
|
||||||
? t('dashboard.section_shared_hint')
|
? t('dashboard.section_shared_hint')
|
||||||
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
|
: t('app.tagline')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -646,10 +658,28 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SyncConflictBanner logbookId={activeLogbookId} />
|
||||||
|
|
||||||
|
{storagePersistHint && (
|
||||||
|
<div className="storage-persist-hint glass" role="status">
|
||||||
|
<p>{t('pwa.storage_persist_hint')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
sessionStorage.setItem('storage_persist_hint_dismissed', '1')
|
||||||
|
setStoragePersistHint(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('pwa.later')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Active Workspace */}
|
{/* Active Workspace */}
|
||||||
<div className="app-body">
|
<div className="app-body">
|
||||||
{/* Navigation Sidebar */}
|
{/* Navigation Sidebar */}
|
||||||
<aside className="app-sidebar">
|
<aside className="app-sidebar" aria-label={t('nav.dashboard')}>
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||||
onClick={() => void handleTabChange('logs')}
|
onClick={() => void handleTabChange('logs')}
|
||||||
@@ -746,6 +776,53 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<nav className="app-bottom-nav" aria-label={t('nav.dashboard')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('logs')}
|
||||||
|
data-tour="nav-logs"
|
||||||
|
>
|
||||||
|
<FileText size={20} />
|
||||||
|
<span>{t('nav.logs')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('vessel')}
|
||||||
|
data-tour="nav-vessel"
|
||||||
|
>
|
||||||
|
<Ship size={20} />
|
||||||
|
<span>{t('nav.vessel')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('crew')}
|
||||||
|
data-tour="nav-crew"
|
||||||
|
>
|
||||||
|
<Users size={20} />
|
||||||
|
<span>{t('nav.crew')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'stats' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('stats')}
|
||||||
|
data-tour="nav-stats"
|
||||||
|
>
|
||||||
|
<BarChart2 size={20} />
|
||||||
|
<span>{t('nav.stats')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'settings' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('settings')}
|
||||||
|
>
|
||||||
|
<Settings size={20} />
|
||||||
|
<span>{t('nav.settings')}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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 { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||||
import LiveLogView from './LiveLogView.tsx'
|
import LiveLogView from './LiveLogView.tsx'
|
||||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||||
@@ -142,7 +143,7 @@ export default function LogEntriesList({
|
|||||||
setEntries(list)
|
setEntries(list)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to load log entries:', err)
|
console.error('Failed to load log entries:', err)
|
||||||
setError(err.message || 'Decryption failed. Could not load journal list.')
|
setError(getErrorMessage(err, t('errors.load_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -176,7 +177,7 @@ export default function LogEntriesList({
|
|||||||
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to download CSV:', err)
|
console.error('Failed to download CSV:', err)
|
||||||
setError(err.message || 'Failed to generate CSV export.')
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
}
|
}
|
||||||
@@ -204,7 +205,7 @@ export default function LogEntriesList({
|
|||||||
setError(t('logs.share_unsupported'))
|
setError(t('logs.share_unsupported'))
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to share CSV:', err)
|
console.error('Failed to share CSV:', err)
|
||||||
setError(err.message || 'Failed to share CSV export.')
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
@@ -225,7 +226,7 @@ export default function LogEntriesList({
|
|||||||
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to download PDF:', err)
|
console.error('Failed to download PDF:', err)
|
||||||
setError(err.message || 'Failed to generate PDF export.')
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
}
|
}
|
||||||
@@ -317,7 +318,7 @@ export default function LogEntriesList({
|
|||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to create entry:', err)
|
console.error('Failed to create entry:', err)
|
||||||
setError(err.message || 'Failed to create new log entry.')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -347,7 +348,7 @@ export default function LogEntriesList({
|
|||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to delete log entry:', err)
|
console.error('Failed to delete log entry:', err)
|
||||||
setError(err.message || 'Failed to delete log entry.')
|
setError(getErrorMessage(err, t('errors.delete_failed')))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -460,8 +461,12 @@ export default function LogEntriesList({
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
className="logbook-card glass"
|
className="logbook-card glass"
|
||||||
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
|
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
|
||||||
onClick={() => setSelectedEntryId(item.id)}
|
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="logbook-card-select"
|
||||||
|
onClick={() => setSelectedEntryId(item.id)}
|
||||||
|
>
|
||||||
<div className="card-icon">
|
<div className="card-icon">
|
||||||
<FileText size={24} />
|
<FileText size={24} />
|
||||||
</div>
|
</div>
|
||||||
@@ -483,6 +488,9 @@ export default function LogEntriesList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
||||||
<Download size={18} />
|
<Download size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -492,8 +500,6 @@ export default function LogEntriesList({
|
|||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { getActiveMasterKey } from '../services/auth.js'
|
|||||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
|
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
|
||||||
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import PhotoCapture from './PhotoCapture.tsx'
|
import PhotoCapture from './PhotoCapture.tsx'
|
||||||
@@ -288,6 +290,14 @@ export default function LogEntryEditor({
|
|||||||
events
|
events
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (readOnly || loading || !date) return
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void saveEntryDraft(logbookId, entryId, buildPayloadForSigning())
|
||||||
|
}, 4000)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [readOnly, loading, logbookId, entryId, buildPayloadForSigning, date])
|
||||||
|
|
||||||
const fuelPerMotorHour = useMemo(
|
const fuelPerMotorHour = useMemo(
|
||||||
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
|
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
|
||||||
[fuelConsumption, motorHours]
|
[fuelConsumption, motorHours]
|
||||||
@@ -1208,15 +1218,17 @@ export default function LogEntryEditor({
|
|||||||
...signaturesForSave
|
...signaturesForSave
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await clearEntryDraft(logbookId, entryId)
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
onBack()
|
onBack()
|
||||||
}, 1500)
|
}, 1500)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to save entry details:', err)
|
console.error('Failed to save entry details:', err)
|
||||||
setError(err.message || 'Failed to save entry details.')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type D
|
|||||||
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||||
import BetaBadge from './BetaBadge.tsx'
|
import BetaBadge from './BetaBadge.tsx'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.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, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
@@ -102,8 +103,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
try {
|
try {
|
||||||
const data = await fetchLogbooks()
|
const data = await fetchLogbooks()
|
||||||
setLogbooks(data)
|
setLogbooks(data)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Failed to load logbooks')
|
setError(getErrorMessage(err, t('errors.load_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
@@ -121,8 +122,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
setLogbooks((prev) => [created, ...prev])
|
setLogbooks((prev) => [created, ...prev])
|
||||||
setNewTitle('')
|
setNewTitle('')
|
||||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Failed to create logbook')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -138,7 +139,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
await deleteLogbook(id)
|
await deleteLogbook(id)
|
||||||
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
|
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to delete logbook')
|
setError(getErrorMessage(err, t('errors.delete_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -182,7 +183,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to update logbook title')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -226,8 +227,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
<div
|
<div
|
||||||
key={lb.id}
|
key={lb.id}
|
||||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
||||||
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="logbook-card-select"
|
||||||
|
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
||||||
|
>
|
||||||
<div className="card-icon">
|
<div className="card-icon">
|
||||||
<BookOpen size={24} />
|
<BookOpen size={24} />
|
||||||
</div>
|
</div>
|
||||||
@@ -282,6 +287,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
{!lb.isShared && (
|
{!lb.isShared && (
|
||||||
<div className="logbook-card-actions">
|
<div className="logbook-card-actions">
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import React, { createContext, useContext, useState, useRef, useCallback, useMemo } from 'react'
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useEffect,
|
||||||
|
useId
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
interface DialogContextType {
|
interface DialogContextType {
|
||||||
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
||||||
@@ -16,6 +26,11 @@ export function useDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DialogProvider({ children }: { children: React.ReactNode }) {
|
export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const titleId = useId()
|
||||||
|
const messageId = useId()
|
||||||
|
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
@@ -23,19 +38,20 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [confirmLabel, setConfirmLabel] = useState('OK')
|
const [confirmLabel, setConfirmLabel] = useState('OK')
|
||||||
const [cancelLabel, setCancelLabel] = useState('Cancel')
|
const [cancelLabel, setCancelLabel] = useState('Cancel')
|
||||||
|
|
||||||
const resolveRef = useRef<((val: any) => void) | null>(null)
|
const alertResolveRef = useRef<(() => void) | null>(null)
|
||||||
|
const confirmResolveRef = useRef<((val: boolean) => void) | null>(null)
|
||||||
|
|
||||||
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
|
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
|
||||||
setMessage(msg)
|
setMessage(msg)
|
||||||
setTitle(headerTitle || '')
|
setTitle(headerTitle || '')
|
||||||
setType('alert')
|
setType('alert')
|
||||||
setConfirmLabel(btnText || 'OK')
|
setConfirmLabel(btnText || t('dialog.ok'))
|
||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
resolveRef.current = resolve
|
alertResolveRef.current = resolve
|
||||||
})
|
})
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
const showConfirm = useCallback((
|
const showConfirm = useCallback((
|
||||||
msg: string,
|
msg: string,
|
||||||
@@ -46,31 +62,47 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setMessage(msg)
|
setMessage(msg)
|
||||||
setTitle(headerTitle || '')
|
setTitle(headerTitle || '')
|
||||||
setType('confirm')
|
setType('confirm')
|
||||||
setConfirmLabel(btnConfirm || 'Yes')
|
setConfirmLabel(btnConfirm || t('dialog.yes'))
|
||||||
setCancelLabel(btnCancel || 'No')
|
setCancelLabel(btnCancel || t('dialog.no'))
|
||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
|
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
resolveRef.current = resolve
|
confirmResolveRef.current = resolve
|
||||||
})
|
})
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
const handleConfirm = useCallback(() => {
|
const handleConfirm = useCallback(() => {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
if (resolveRef.current) {
|
if (type === 'confirm' && confirmResolveRef.current) {
|
||||||
resolveRef.current(type === 'confirm' ? true : undefined)
|
confirmResolveRef.current(true)
|
||||||
resolveRef.current = null
|
confirmResolveRef.current = null
|
||||||
|
} else if (alertResolveRef.current) {
|
||||||
|
alertResolveRef.current()
|
||||||
|
alertResolveRef.current = null
|
||||||
}
|
}
|
||||||
}, [type])
|
}, [type])
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
if (resolveRef.current) {
|
if (confirmResolveRef.current) {
|
||||||
resolveRef.current(false)
|
confirmResolveRef.current(false)
|
||||||
resolveRef.current = null
|
confirmResolveRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
confirmRef.current?.focus()
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (type === 'confirm') handleCancel()
|
||||||
|
else handleConfirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [isOpen, type, handleCancel, handleConfirm])
|
||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({ showAlert, showConfirm }),
|
() => ({ showAlert, showConfirm }),
|
||||||
[showAlert, showConfirm]
|
[showAlert, showConfirm]
|
||||||
@@ -80,17 +112,44 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
|||||||
<DialogContext.Provider value={contextValue}>
|
<DialogContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
|
<div
|
||||||
<div className="custom-dialog-card glass scale-in" onClick={(e) => e.stopPropagation()}>
|
className="custom-dialog-overlay"
|
||||||
{title && <h3 className="custom-dialog-title">{title}</h3>}
|
onClick={type === 'confirm' ? handleCancel : handleConfirm}
|
||||||
<p className="custom-dialog-message">{message}</p>
|
>
|
||||||
|
<div
|
||||||
|
className="custom-dialog-card glass scale-in"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={title ? titleId : undefined}
|
||||||
|
aria-describedby={messageId}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<h3 id={titleId} className="custom-dialog-title">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<p id={messageId} className="custom-dialog-message">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
<div className="custom-dialog-actions">
|
<div className="custom-dialog-actions">
|
||||||
{type === 'confirm' && (
|
{type === 'confirm' && (
|
||||||
<button type="button" className="btn secondary" onClick={handleCancel} style={{ width: 'auto', padding: '8px 20px', margin: 0 }}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||||
|
>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="btn primary" onClick={handleConfirm} style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}>
|
<button
|
||||||
|
ref={confirmRef}
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
|
||||||
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
getSyncConflicts,
|
||||||
|
subscribeSyncConflicts,
|
||||||
|
type SyncConflict
|
||||||
|
} from '../services/syncConflicts.js'
|
||||||
|
import {
|
||||||
|
resolveSyncConflictKeepLocal,
|
||||||
|
resolveSyncConflictUseServer
|
||||||
|
} from '../services/sync.js'
|
||||||
|
|
||||||
|
interface SyncConflictBannerProps {
|
||||||
|
logbookId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SyncConflictBanner({ logbookId }: SyncConflictBannerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [items, setItems] = useState<SyncConflict[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const refresh = () => {
|
||||||
|
setItems(logbookId ? getSyncConflicts(logbookId) : getSyncConflicts())
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
return subscribeSyncConflicts(refresh)
|
||||||
|
}, [logbookId])
|
||||||
|
|
||||||
|
if (items.length === 0) return null
|
||||||
|
|
||||||
|
const first = items[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sync-conflict-banner" role="alert">
|
||||||
|
<AlertTriangle size={20} aria-hidden />
|
||||||
|
<div className="sync-conflict-banner__body">
|
||||||
|
<strong>{t('sync.conflict_title')}</strong>
|
||||||
|
<p>
|
||||||
|
{t('sync.conflict_message', {
|
||||||
|
count: items.length,
|
||||||
|
id: first.payloadId.slice(0, 8)
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="sync-conflict-banner__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void resolveSyncConflictUseServer(first)}
|
||||||
|
>
|
||||||
|
{t('sync.conflict_use_server')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={() => void resolveSyncConflictKeepLocal(first)}
|
||||||
|
>
|
||||||
|
{t('sync.conflict_keep_local')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nej"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Data kunne ikke indlæses.",
|
||||||
|
"save_failed": "Ændringer kunne ikke gemmes.",
|
||||||
|
"delete_failed": "Sletning mislykkedes.",
|
||||||
|
"export_failed": "Eksport mislykkedes."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Ikke gemte ændringer",
|
"unsaved_changes_title": "Ikke gemte ændringer",
|
||||||
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
|
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
|
||||||
@@ -92,13 +103,18 @@
|
|||||||
"update_title": "Opdatering tilgængelig",
|
"update_title": "Opdatering tilgængelig",
|
||||||
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
|
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
|
||||||
"update_now": "Opdater nu",
|
"update_now": "Opdater nu",
|
||||||
"update_reloading": "Indlæser..."
|
"update_reloading": "Indlæser...",
|
||||||
|
"storage_persist_hint": "Browseren kan slette offline-data. Tillad permanent lagring, så din logbog forbliver beskyttet."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synkroniseret",
|
"status_synced": "Synkroniseret",
|
||||||
"status_syncing": "Synkroniser...",
|
"status_syncing": "Synkroniser...",
|
||||||
"status_offline": "Offline-cache",
|
"status_offline": "Offline-cache",
|
||||||
"status_unsynced": "Usynkroniserede ændringer"
|
"status_unsynced": "Usynkroniserede ændringer",
|
||||||
|
"conflict_title": "Synkroniseringskonflikt",
|
||||||
|
"conflict_message": "{{count}} ændring(er) kunne ikke synkroniseres (post {{id}}…). Vælg hvilken version der skal gælde.",
|
||||||
|
"conflict_use_server": "Brug serverversion",
|
||||||
|
"conflict_keep_local": "Behold min version"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Skibets stamdata",
|
"title": "Skibets stamdata",
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nein"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Daten konnten nicht geladen werden.",
|
||||||
|
"save_failed": "Änderungen konnten nicht gespeichert werden.",
|
||||||
|
"delete_failed": "Löschen fehlgeschlagen.",
|
||||||
|
"export_failed": "Export fehlgeschlagen."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
||||||
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
||||||
@@ -92,13 +103,18 @@
|
|||||||
"update_title": "Update verfügbar",
|
"update_title": "Update verfügbar",
|
||||||
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
|
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
|
||||||
"update_now": "Jetzt aktualisieren",
|
"update_now": "Jetzt aktualisieren",
|
||||||
"update_reloading": "Wird geladen…"
|
"update_reloading": "Wird geladen…",
|
||||||
|
"storage_persist_hint": "Der Browser kann Offline-Daten löschen. Erlaube dauerhafte Speicherung, damit dein Logbuch geschützt bleibt (in den Browser-Einstellungen oder beim nächsten Hinweis)."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synchronisiert",
|
"status_synced": "Synchronisiert",
|
||||||
"status_syncing": "Synchronisiere…",
|
"status_syncing": "Synchronisiere…",
|
||||||
"status_offline": "Offline-Cache",
|
"status_offline": "Offline-Cache",
|
||||||
"status_unsynced": "Unsynchronisierte Änderungen"
|
"status_unsynced": "Unsynchronisierte Änderungen",
|
||||||
|
"conflict_title": "Synchronisationskonflikt",
|
||||||
|
"conflict_message": "{{count}} Änderung(en) konnten nicht synchronisiert werden (Eintrag {{id}}…). Bitte wähle, welche Version gelten soll.",
|
||||||
|
"conflict_use_server": "Server-Version übernehmen",
|
||||||
|
"conflict_keep_local": "Meine Version behalten"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Schiffs-Stammdaten",
|
"title": "Schiffs-Stammdaten",
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Could not load data.",
|
||||||
|
"save_failed": "Could not save changes.",
|
||||||
|
"delete_failed": "Could not delete.",
|
||||||
|
"export_failed": "Export failed."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Unsaved changes",
|
"unsaved_changes_title": "Unsaved changes",
|
||||||
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
||||||
@@ -92,13 +103,18 @@
|
|||||||
"update_title": "Update available",
|
"update_title": "Update available",
|
||||||
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
|
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
|
||||||
"update_now": "Reload now",
|
"update_now": "Reload now",
|
||||||
"update_reloading": "Reloading…"
|
"update_reloading": "Reloading…",
|
||||||
|
"storage_persist_hint": "Your browser may delete offline data. Allow persistent storage to keep your logbook safe (browser settings or when prompted)."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synced",
|
"status_synced": "Synced",
|
||||||
"status_syncing": "Syncing…",
|
"status_syncing": "Syncing…",
|
||||||
"status_offline": "Offline Cache",
|
"status_offline": "Offline Cache",
|
||||||
"status_unsynced": "Unsynced changes"
|
"status_unsynced": "Unsynced changes",
|
||||||
|
"conflict_title": "Sync conflict",
|
||||||
|
"conflict_message": "{{count}} change(s) could not be synced (entry {{id}}…). Choose which version to keep.",
|
||||||
|
"conflict_use_server": "Use server version",
|
||||||
|
"conflict_keep_local": "Keep my version"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Vessel Master Data",
|
"title": "Vessel Master Data",
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nei"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Data kunne ikke lastes.",
|
||||||
|
"save_failed": "Endringer kunne ikke lagres.",
|
||||||
|
"delete_failed": "Sletting mislyktes.",
|
||||||
|
"export_failed": "Eksport mislyktes."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Ikke-lagrede endringer",
|
"unsaved_changes_title": "Ikke-lagrede endringer",
|
||||||
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
|
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
|
||||||
@@ -92,13 +103,18 @@
|
|||||||
"update_title": "Oppdatering tilgjengelig",
|
"update_title": "Oppdatering tilgjengelig",
|
||||||
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
|
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
|
||||||
"update_now": "Oppdater nå",
|
"update_now": "Oppdater nå",
|
||||||
"update_reloading": "Laster..."
|
"update_reloading": "Laster...",
|
||||||
|
"storage_persist_hint": "Nettleseren kan slette offlinedata. Tillat permanent lagring slik at loggboken din forblir beskyttet."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synkronisert",
|
"status_synced": "Synkronisert",
|
||||||
"status_syncing": "Synkroniser...",
|
"status_syncing": "Synkroniser...",
|
||||||
"status_offline": "Frakoblet hurtigbuffer",
|
"status_offline": "Frakoblet hurtigbuffer",
|
||||||
"status_unsynced": "Usynkroniserte endringer"
|
"status_unsynced": "Usynkroniserte endringer",
|
||||||
|
"conflict_title": "Synkroniseringskonflikt",
|
||||||
|
"conflict_message": "{{count}} endring(er) kunne ikke synkroniseres (post {{id}}…). Velg hvilken versjon som skal gjelde.",
|
||||||
|
"conflict_use_server": "Bruk serverversjon",
|
||||||
|
"conflict_keep_local": "Behold min versjon"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Stamdata for skip",
|
"title": "Stamdata for skip",
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nej"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Data kunde inte laddas.",
|
||||||
|
"save_failed": "Ändringar kunde inte sparas.",
|
||||||
|
"delete_failed": "Radering misslyckades.",
|
||||||
|
"export_failed": "Export misslyckades."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Osparade ändringar",
|
"unsaved_changes_title": "Osparade ändringar",
|
||||||
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
|
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
|
||||||
@@ -92,13 +103,18 @@
|
|||||||
"update_title": "Uppdatering tillgänglig",
|
"update_title": "Uppdatering tillgänglig",
|
||||||
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
|
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
|
||||||
"update_now": "Uppdatering nu",
|
"update_now": "Uppdatering nu",
|
||||||
"update_reloading": "Laddar..."
|
"update_reloading": "Laddar...",
|
||||||
|
"storage_persist_hint": "Webbläsaren kan radera offlinedata. Tillåt permanent lagring så att din loggbok förblir skyddad."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synkroniserad",
|
"status_synced": "Synkroniserad",
|
||||||
"status_syncing": "Synkronisera...",
|
"status_syncing": "Synkronisera...",
|
||||||
"status_offline": "Offline-cache",
|
"status_offline": "Offline-cache",
|
||||||
"status_unsynced": "Osynkroniserade förändringar"
|
"status_unsynced": "Osynkroniserade förändringar",
|
||||||
|
"conflict_title": "Synkroniseringskonflikt",
|
||||||
|
"conflict_message": "{{count}} ändring(ar) kunde inte synkas (post {{id}}…). Välj vilken version som ska gälla.",
|
||||||
|
"conflict_use_server": "Använd serverversion",
|
||||||
|
"conflict_keep_local": "Behåll min version"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Masterdata för fartyg",
|
"title": "Masterdata för fartyg",
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ export interface SyncQueueItem {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EntryDraftRecord {
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
class DaagboxDatabase extends Dexie {
|
class DaagboxDatabase extends Dexie {
|
||||||
logbooks!: Table<LocalLogbook>
|
logbooks!: Table<LocalLogbook>
|
||||||
yachts!: Table<LocalYacht>
|
yachts!: Table<LocalYacht>
|
||||||
@@ -101,6 +110,7 @@ class DaagboxDatabase extends Dexie {
|
|||||||
nmeaArchives!: Table<LocalNmeaArchive>
|
nmeaArchives!: Table<LocalNmeaArchive>
|
||||||
logbookKeys!: Table<LocalLogbookKey>
|
logbookKeys!: Table<LocalLogbookKey>
|
||||||
syncQueue!: Table<SyncQueueItem>
|
syncQueue!: Table<SyncQueueItem>
|
||||||
|
entryDrafts!: Table<EntryDraftRecord, [string, string]>
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('DaagboxDatabase')
|
super('DaagboxDatabase')
|
||||||
@@ -167,6 +177,19 @@ class DaagboxDatabase extends Dexie {
|
|||||||
nmeaArchives: 'entryId, logbookId, updatedAt',
|
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||||
logbookKeys: 'logbookId'
|
logbookKeys: 'logbookId'
|
||||||
})
|
})
|
||||||
|
this.version(7).stores({
|
||||||
|
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
|
||||||
|
yachts: 'logbookId, updatedAt',
|
||||||
|
crews: 'payloadId, logbookId, updatedAt',
|
||||||
|
deviations: 'logbookId, updatedAt',
|
||||||
|
entries: 'payloadId, logbookId, updatedAt',
|
||||||
|
syncQueue: '++id, action, type, payloadId, logbookId',
|
||||||
|
photos: 'payloadId, entryId, logbookId, updatedAt',
|
||||||
|
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||||
|
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||||
|
logbookKeys: 'logbookId',
|
||||||
|
entryDrafts: '[logbookId+entryId], updatedAt'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { db } from './db.js'
|
||||||
|
import { encryptJson, decryptJson } from './crypto.js'
|
||||||
|
import { getActiveMasterKey } from './auth.js'
|
||||||
|
|
||||||
|
export interface EntryDraftRecord {
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEntryDraft(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
payload: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) return
|
||||||
|
|
||||||
|
const { ciphertext, iv, tag } = await encryptJson(payload, masterKey)
|
||||||
|
await db.entryDrafts.put({
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
encryptedData: ciphertext,
|
||||||
|
iv,
|
||||||
|
tag,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEntryDraft<T = unknown>(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string
|
||||||
|
): Promise<T | null> {
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) return null
|
||||||
|
|
||||||
|
const row = await db.entryDrafts.get([logbookId, entryId])
|
||||||
|
if (!row) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as T
|
||||||
|
} catch {
|
||||||
|
await db.entryDrafts.delete([logbookId, entryId])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearEntryDraft(logbookId: string, entryId: string): Promise<void> {
|
||||||
|
await db.entryDrafts.delete([logbookId, entryId])
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@ import { db, type SyncQueueItem } from './db.js'
|
|||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import { apiFetch } from './api.js'
|
import { apiFetch } from './api.js'
|
||||||
import { getLogbookAccess } from './logbookAccess.js'
|
import { getLogbookAccess } from './logbookAccess.js'
|
||||||
|
import {
|
||||||
|
clearSyncConflict,
|
||||||
|
reportSyncConflict,
|
||||||
|
type SyncConflict
|
||||||
|
} from './syncConflicts.js'
|
||||||
|
|
||||||
const API_BASE = '/api/sync'
|
const API_BASE = '/api/sync'
|
||||||
const syncingLogbooks = new Set<string>()
|
const syncingLogbooks = new Set<string>()
|
||||||
@@ -177,10 +182,19 @@ async function pushChanges(logbookId: string): Promise<boolean> {
|
|||||||
const queueItem = pending[i]
|
const queueItem = pending[i]
|
||||||
if (!queueItem) continue
|
if (!queueItem) continue
|
||||||
|
|
||||||
if (res.status === 'success' || res.status === 'conflict') {
|
if (res.status === 'success') {
|
||||||
if (queueItem.id !== undefined) {
|
if (queueItem.id !== undefined) {
|
||||||
await db.syncQueue.delete(queueItem.id)
|
await db.syncQueue.delete(queueItem.id)
|
||||||
}
|
}
|
||||||
|
clearSyncConflict(logbookId, res.payloadId ?? queueItem.payloadId, queueItem.type)
|
||||||
|
} else if (res.status === 'conflict') {
|
||||||
|
reportSyncConflict({
|
||||||
|
logbookId,
|
||||||
|
payloadId: res.payloadId ?? queueItem.payloadId,
|
||||||
|
type: queueItem.type,
|
||||||
|
reason: typeof res.reason === 'string' ? res.reason : 'Server version is newer',
|
||||||
|
queueItemId: queueItem.id
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
console.error(`Sync failed for item ${res.payloadId}:`, res.error)
|
console.error(`Sync failed for item ${res.payloadId}:`, res.error)
|
||||||
}
|
}
|
||||||
@@ -525,3 +539,43 @@ export function stopBackgroundSync() {
|
|||||||
syncIntervalId = null
|
syncIntervalId = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accept server version: pull latest and drop the conflicting queue item. */
|
||||||
|
export async function resolveSyncConflictUseServer(conflict: SyncConflict): Promise<void> {
|
||||||
|
if (conflict.queueItemId !== undefined) {
|
||||||
|
await db.syncQueue.delete(conflict.queueItemId)
|
||||||
|
} else {
|
||||||
|
const pending = await db.syncQueue
|
||||||
|
.where({ logbookId: conflict.logbookId })
|
||||||
|
.filter(
|
||||||
|
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
|
||||||
|
)
|
||||||
|
.toArray()
|
||||||
|
const ids = pending.map((p) => p.id).filter((id): id is number => id !== undefined)
|
||||||
|
if (ids.length > 0) await db.syncQueue.bulkDelete(ids)
|
||||||
|
}
|
||||||
|
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||||
|
await pullChanges(conflict.logbookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep local version: bump queue timestamp and retry push. */
|
||||||
|
export async function resolveSyncConflictKeepLocal(conflict: SyncConflict): Promise<void> {
|
||||||
|
const bump = new Date(Date.now() + 1000).toISOString()
|
||||||
|
if (conflict.queueItemId !== undefined) {
|
||||||
|
await db.syncQueue.update(conflict.queueItemId, { updatedAt: bump })
|
||||||
|
} else {
|
||||||
|
const pending = await db.syncQueue
|
||||||
|
.where({ logbookId: conflict.logbookId })
|
||||||
|
.filter(
|
||||||
|
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
|
||||||
|
)
|
||||||
|
.toArray()
|
||||||
|
for (const item of pending) {
|
||||||
|
if (item.id !== undefined) {
|
||||||
|
await db.syncQueue.update(item.id, { updatedAt: bump })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||||
|
await flushPushQueue(conflict.logbookId)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export interface SyncConflict {
|
||||||
|
logbookId: string
|
||||||
|
payloadId: string
|
||||||
|
type: string
|
||||||
|
reason: string
|
||||||
|
queueItemId?: number
|
||||||
|
detectedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflicts = new Map<string, SyncConflict>()
|
||||||
|
const listeners = new Set<() => void>()
|
||||||
|
|
||||||
|
function conflictKey(logbookId: string, payloadId: string, type: string): string {
|
||||||
|
return `${logbookId}:${type}:${payloadId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSyncConflicts(logbookId?: string): SyncConflict[] {
|
||||||
|
const all = Array.from(conflicts.values())
|
||||||
|
if (!logbookId) return all
|
||||||
|
return all.filter((c) => c.logbookId === logbookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSyncConflicts(logbookId?: string): boolean {
|
||||||
|
return getSyncConflicts(logbookId).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportSyncConflict(conflict: Omit<SyncConflict, 'detectedAt'>): void {
|
||||||
|
const key = conflictKey(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||||
|
conflicts.set(key, { ...conflict, detectedAt: new Date().toISOString() })
|
||||||
|
listeners.forEach((l) => l())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSyncConflict(logbookId: string, payloadId: string, type: string): void {
|
||||||
|
conflicts.delete(conflictKey(logbookId, payloadId, type))
|
||||||
|
listeners.forEach((l) => l())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSyncConflictsForLogbook(logbookId: string): void {
|
||||||
|
for (const key of conflicts.keys()) {
|
||||||
|
if (key.startsWith(`${logbookId}:`)) conflicts.delete(key)
|
||||||
|
}
|
||||||
|
listeners.forEach((l) => l())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeSyncConflicts(listener: () => void): () => void {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => listeners.delete(listener)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Map unknown errors to a user-facing message (i18n key or fallback). */
|
||||||
|
export function getErrorMessage(err: unknown, fallback: string): string {
|
||||||
|
if (err instanceof Error && err.message.trim()) {
|
||||||
|
return err.message
|
||||||
|
}
|
||||||
|
if (typeof err === 'string' && err.trim()) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** Request durable IndexedDB storage (important on iOS Safari). */
|
||||||
|
export async function requestPersistentStorage(): Promise<{
|
||||||
|
persisted: boolean
|
||||||
|
supported: boolean
|
||||||
|
}> {
|
||||||
|
if (!('storage' in navigator) || !navigator.storage.persist) {
|
||||||
|
return { persisted: false, supported: false }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const persisted = await navigator.storage.persisted()
|
||||||
|
if (persisted) return { persisted: true, supported: true }
|
||||||
|
const granted = await navigator.storage.persist()
|
||||||
|
return { persisted: granted, supported: true }
|
||||||
|
} catch {
|
||||||
|
return { persisted: false, supported: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user