diff --git a/client/src/App.css b/client/src/App.css index 68a251b..ca73dc8 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1799,6 +1799,22 @@ html.scheme-dark .themed-select-option.is-selected { 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 { background: var(--app-surface-alt); backdrop-filter: var(--app-backdrop); @@ -1809,18 +1825,61 @@ html.scheme-dark .themed-select-option.is-selected { display: flex; align-items: flex-start; gap: 16px; - cursor: pointer; position: relative; 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); border-color: var(--app-border); box-shadow: var(--app-card-shadow); 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 { border-left: 3px solid #38bdf8; } @@ -2130,9 +2189,65 @@ html.scheme-dark .themed-select-option.is-selected { align-items: start; } +.app-bottom-nav { + display: none; +} + @media (max-width: 768px) { .app-body { 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); } } diff --git a/client/src/App.tsx b/client/src/App.tsx index 070fda1..76f2666 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -53,6 +53,8 @@ import { } from './services/demoLogbook.js' import { fetchLogbooks, parseCollaborationRole } from './services/logbook.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' @@ -71,6 +73,7 @@ function App() { const [isSyncing, setIsSyncing] = useState(false) const [isAcceptingInvite, setIsAcceptingInvite] = useState(false) const [showUserProfile, setShowUserProfile] = useState(false) + const [storagePersistHint, setStoragePersistHint] = useState(false) const tourLogbookRef = useRef<{ id: string; title: string } | null>(null) const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({ id: activeLogbookId, @@ -428,10 +431,19 @@ function App() { return () => navigator.serviceWorker.removeEventListener('message', onSwMessage) }, [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 () => { setIsAuthenticated(true) trackPlausibleEvent(PlausibleEvents.LOGGED_IN) void ensurePushSubscriptionIfEnabled() + void requestPersistentStorage() try { const demo = await seedDemoLogbookIfNeeded() @@ -606,7 +618,7 @@ function App() {

{activeAccessRole && activeAccessRole !== 'OWNER' ? t('dashboard.section_shared_hint') - : `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`} + : t('app.tagline')}

@@ -646,10 +658,28 @@ function App() { + + + {storagePersistHint && ( +
+

{t('pwa.storage_persist_hint')}

+ +
+ )} + {/* Active Workspace */}
{/* Navigation Sidebar */} -
diff --git a/client/src/components/LogEntriesList.tsx b/client/src/components/LogEntriesList.tsx index d0b2870..afb1354 100644 --- a/client/src/components/LogEntriesList.tsx +++ b/client/src/components/LogEntriesList.tsx @@ -8,6 +8,7 @@ import { syncLogbook } from '../services/sync.js' import { downloadCsv, shareCsv } from '../services/csvExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' +import { getErrorMessage } from '../utils/errors.js' import LogEntryEditor from './LogEntryEditor.tsx' import LiveLogView from './LiveLogView.tsx' import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx' @@ -142,7 +143,7 @@ export default function LogEntriesList({ setEntries(list) } catch (err: any) { 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 { setLoading(false) } @@ -176,7 +177,7 @@ export default function LogEntriesList({ trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED) } catch (err: any) { console.error('Failed to download CSV:', err) - setError(err.message || 'Failed to generate CSV export.') + setError(getErrorMessage(err, t('errors.export_failed'))) } finally { setExporting(false) } @@ -204,7 +205,7 @@ export default function LogEntriesList({ setError(t('logs.share_unsupported')) } else { console.error('Failed to share CSV:', err) - setError(err.message || 'Failed to share CSV export.') + setError(getErrorMessage(err, t('errors.export_failed'))) } } finally { setExporting(false) @@ -225,7 +226,7 @@ export default function LogEntriesList({ trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' }) } catch (err: any) { console.error('Failed to download PDF:', err) - setError(err.message || 'Failed to generate PDF export.') + setError(getErrorMessage(err, t('errors.export_failed'))) } finally { setExporting(false) } @@ -317,7 +318,7 @@ export default function LogEntriesList({ syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) } catch (err: any) { console.error('Failed to create entry:', err) - setError(err.message || 'Failed to create new log entry.') + setError(getErrorMessage(err, t('errors.save_failed'))) } finally { setLoading(false) } @@ -347,7 +348,7 @@ export default function LogEntriesList({ syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err)) } catch (err: any) { 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} className="logbook-card glass" data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined} - onClick={() => setSelectedEntryId(item.id)} > + + @@ -492,8 +500,6 @@ export default function LogEntriesList({ )} - - ))} diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 639bf24..d588a8c 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -5,6 +5,8 @@ import { getActiveMasterKey } from '../services/auth.js' import { getLogbookKey } from '../services/logbookKeys.js' import { encryptJson, decryptJson } from '../services/crypto.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 { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react' import PhotoCapture from './PhotoCapture.tsx' @@ -288,6 +290,14 @@ export default function LogEntryEditor({ 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( () => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0), [fuelConsumption, motorHours] @@ -1208,15 +1218,17 @@ export default function LogEntryEditor({ ...signaturesForSave }) + await clearEntryDraft(logbookId, entryId) + setSuccess(true) trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED) setTimeout(() => { setSuccess(false) onBack() }, 1500) - } catch (err: any) { + } catch (err: unknown) { console.error('Failed to save entry details:', err) - setError(err.message || 'Failed to save entry details.') + setError(getErrorMessage(err, t('errors.save_failed'))) } finally { setSaving(false) } diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx index 69dae7c..3a2bb73 100644 --- a/client/src/components/LogbookDashboard.tsx +++ b/client/src/components/LogbookDashboard.tsx @@ -6,6 +6,7 @@ import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type D import LogbookRoleBadge from './LogbookRoleBadge.tsx' import BetaBadge from './BetaBadge.tsx' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' +import { getErrorMessage } from '../utils/errors.js' import { logoutUser } from '../services/auth.js' import { useDialog } from './ModalDialog.tsx' import { BookOpen, Plus, Trash2, LogOut, 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 { const data = await fetchLogbooks() setLogbooks(data) - } catch (err: any) { - setError(err.message || 'Failed to load logbooks') + } catch (err: unknown) { + setError(getErrorMessage(err, t('errors.load_failed'))) } finally { setLoading(false) setRefreshing(false) @@ -121,8 +122,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf setLogbooks((prev) => [created, ...prev]) setNewTitle('') trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED) - } catch (err: any) { - setError(err.message || 'Failed to create logbook') + } catch (err: unknown) { + setError(getErrorMessage(err, t('errors.save_failed'))) } finally { setLoading(false) } @@ -138,7 +139,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf await deleteLogbook(id) setLogbooks((prev) => prev.filter((lb) => lb.id !== id)) } catch (err: any) { - setError(err.message || 'Failed to delete logbook') + setError(getErrorMessage(err, t('errors.delete_failed'))) } finally { setLoading(false) } @@ -182,7 +183,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf ) ) } catch (err: any) { - setError(err.message || 'Failed to update logbook title') + setError(getErrorMessage(err, t('errors.save_failed'))) } finally { setLoading(false) } @@ -226,8 +227,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
onSelectLogbook(lb.id, lb.title)} > +
+ {!lb.isShared && (
diff --git a/client/src/components/ModalDialog.tsx b/client/src/components/ModalDialog.tsx index 8594a23..3e9e2f1 100644 --- a/client/src/components/ModalDialog.tsx +++ b/client/src/components/ModalDialog.tsx @@ -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 { showAlert: (message: string, title?: string, confirmText?: string) => Promise @@ -16,6 +26,11 @@ export function useDialog() { } export function DialogProvider({ children }: { children: React.ReactNode }) { + const { t } = useTranslation() + const titleId = useId() + const messageId = useId() + const confirmRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) const [title, setTitle] = useState('') const [message, setMessage] = useState('') @@ -23,19 +38,20 @@ export function DialogProvider({ children }: { children: React.ReactNode }) { const [confirmLabel, setConfirmLabel] = useState('OK') 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 => { setMessage(msg) setTitle(headerTitle || '') setType('alert') - setConfirmLabel(btnText || 'OK') + setConfirmLabel(btnText || t('dialog.ok')) setIsOpen(true) return new Promise((resolve) => { - resolveRef.current = resolve + alertResolveRef.current = resolve }) - }, []) + }, [t]) const showConfirm = useCallback(( msg: string, @@ -46,31 +62,47 @@ export function DialogProvider({ children }: { children: React.ReactNode }) { setMessage(msg) setTitle(headerTitle || '') setType('confirm') - setConfirmLabel(btnConfirm || 'Yes') - setCancelLabel(btnCancel || 'No') + setConfirmLabel(btnConfirm || t('dialog.yes')) + setCancelLabel(btnCancel || t('dialog.no')) setIsOpen(true) return new Promise((resolve) => { - resolveRef.current = resolve + confirmResolveRef.current = resolve }) - }, []) + }, [t]) const handleConfirm = useCallback(() => { setIsOpen(false) - if (resolveRef.current) { - resolveRef.current(type === 'confirm' ? true : undefined) - resolveRef.current = null + if (type === 'confirm' && confirmResolveRef.current) { + confirmResolveRef.current(true) + confirmResolveRef.current = null + } else if (alertResolveRef.current) { + alertResolveRef.current() + alertResolveRef.current = null } }, [type]) const handleCancel = useCallback(() => { setIsOpen(false) - if (resolveRef.current) { - resolveRef.current(false) - resolveRef.current = null + if (confirmResolveRef.current) { + confirmResolveRef.current(false) + 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( () => ({ showAlert, showConfirm }), [showAlert, showConfirm] @@ -80,17 +112,44 @@ export function DialogProvider({ children }: { children: React.ReactNode }) { {children} {isOpen && ( -
-
e.stopPropagation()}> - {title &&

{title}

} -

{message}

+
+
e.stopPropagation()} + > + {title && ( +

+ {title} +

+ )} +

+ {message} +

{type === 'confirm' && ( - )} -
diff --git a/client/src/components/SyncConflictBanner.tsx b/client/src/components/SyncConflictBanner.tsx new file mode 100644 index 0000000..719ba91 --- /dev/null +++ b/client/src/components/SyncConflictBanner.tsx @@ -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([]) + + useEffect(() => { + const refresh = () => { + setItems(logbookId ? getSyncConflicts(logbookId) : getSyncConflicts()) + } + refresh() + return subscribeSyncConflicts(refresh) + }, [logbookId]) + + if (items.length === 0) return null + + const first = items[0] + + return ( +
+ +
+ {t('sync.conflict_title')} +

+ {t('sync.conflict_message', { + count: items.length, + id: first.payloadId.slice(0, 8) + })} +

+
+ + +
+
+
+ ) +} diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index f9d3c4b..91e028c 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -13,6 +13,17 @@ "sv": "Svenska", "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": { "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.", @@ -92,13 +103,18 @@ "update_title": "Opdatering tilgængelig", "update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.", "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": { "status_synced": "Synkroniseret", "status_syncing": "Synkroniser...", "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": { "title": "Skibets stamdata", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 4c0bec2..404d958 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -13,6 +13,17 @@ "sv": "Svenska", "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": { "unsaved_changes_title": "Ungespeicherte Änderungen", "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_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.", "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": { "status_synced": "Synchronisiert", "status_syncing": "Synchronisiere…", "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": { "title": "Schiffs-Stammdaten", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 89bc4c0..644fb25 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -13,6 +13,17 @@ "sv": "Svenska", "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": { "unsaved_changes_title": "Unsaved changes", "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_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.", "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": { "status_synced": "Synced", "status_syncing": "Syncing…", "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": { "title": "Vessel Master Data", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 65a717f..ddf2a23 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -13,6 +13,17 @@ "sv": "Svenska", "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": { "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.", @@ -92,13 +103,18 @@ "update_title": "Oppdatering tilgjengelig", "update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.", "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": { "status_synced": "Synkronisert", "status_syncing": "Synkroniser...", "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": { "title": "Stamdata for skip", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 19cfdad..cd251d4 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -13,6 +13,17 @@ "sv": "Svenska", "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": { "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.", @@ -92,13 +103,18 @@ "update_title": "Uppdatering tillgänglig", "update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.", "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": { "status_synced": "Synkroniserad", "status_syncing": "Synkronisera...", "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": { "title": "Masterdata för fartyg", diff --git a/client/src/services/db.ts b/client/src/services/db.ts index 8ef4dcc..d6f9fab 100644 --- a/client/src/services/db.ts +++ b/client/src/services/db.ts @@ -90,6 +90,15 @@ export interface SyncQueueItem { updatedAt: string } +export interface EntryDraftRecord { + logbookId: string + entryId: string + encryptedData: string + iv: string + tag: string + updatedAt: string +} + class DaagboxDatabase extends Dexie { logbooks!: Table yachts!: Table @@ -101,6 +110,7 @@ class DaagboxDatabase extends Dexie { nmeaArchives!: Table logbookKeys!: Table syncQueue!: Table + entryDrafts!: Table constructor() { super('DaagboxDatabase') @@ -167,6 +177,19 @@ class DaagboxDatabase extends Dexie { nmeaArchives: 'entryId, logbookId, updatedAt', 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' + }) } } diff --git a/client/src/services/entryDraft.ts b/client/src/services/entryDraft.ts new file mode 100644 index 0000000..24e34bc --- /dev/null +++ b/client/src/services/entryDraft.ts @@ -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 { + 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( + logbookId: string, + entryId: string +): Promise { + 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 { + await db.entryDrafts.delete([logbookId, entryId]) +} diff --git a/client/src/services/sync.ts b/client/src/services/sync.ts index 8449aa7..cabe948 100644 --- a/client/src/services/sync.ts +++ b/client/src/services/sync.ts @@ -2,6 +2,11 @@ import { db, type SyncQueueItem } from './db.js' import { getActiveMasterKey } from './auth.js' import { apiFetch } from './api.js' import { getLogbookAccess } from './logbookAccess.js' +import { + clearSyncConflict, + reportSyncConflict, + type SyncConflict +} from './syncConflicts.js' const API_BASE = '/api/sync' const syncingLogbooks = new Set() @@ -177,10 +182,19 @@ async function pushChanges(logbookId: string): Promise { const queueItem = pending[i] if (!queueItem) continue - if (res.status === 'success' || res.status === 'conflict') { + if (res.status === 'success') { if (queueItem.id !== undefined) { 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 { console.error(`Sync failed for item ${res.payloadId}:`, res.error) } @@ -525,3 +539,43 @@ export function stopBackgroundSync() { syncIntervalId = null } } + +/** Accept server version: pull latest and drop the conflicting queue item. */ +export async function resolveSyncConflictUseServer(conflict: SyncConflict): Promise { + 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 { + 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) +} diff --git a/client/src/services/syncConflicts.ts b/client/src/services/syncConflicts.ts new file mode 100644 index 0000000..7f042cc --- /dev/null +++ b/client/src/services/syncConflicts.ts @@ -0,0 +1,48 @@ +export interface SyncConflict { + logbookId: string + payloadId: string + type: string + reason: string + queueItemId?: number + detectedAt: string +} + +const conflicts = new Map() +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): 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) +} diff --git a/client/src/utils/errors.ts b/client/src/utils/errors.ts new file mode 100644 index 0000000..3017f78 --- /dev/null +++ b/client/src/utils/errors.ts @@ -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 +} diff --git a/client/src/utils/storagePersist.ts b/client/src/utils/storagePersist.ts new file mode 100644 index 0000000..0bc3b6d --- /dev/null +++ b/client/src/utils/storagePersist.ts @@ -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 } + } +}